diff --git a/.custodian-brief.md b/.custodian-brief.md index 8814598..e08d6c0 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,18 +2,19 @@ # Custodian Brief — state-hub **Domain:** infotech -**Last synced:** 2026-10-13 21:26 UTC +**Last synced:** 2026-10-14 00:49 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams ### Supply State Hub inputs that hub-core receipt tasks wait on -Progress: 2/5 done | workplan_id: `0c68970e-295b-57fc-ac70-e472ee3a7bc4` +Progress: 3/5 done | workplan_id: `0c68970e-295b-57fc-ac70-e472ee3a7bc4` **Open tasks:** -- ► A4 work/repo UI caller inventory and owner asks `e6ef3381` -- · Named single reader and parity window `077797c8` -- · Implement the inbox change feed `14c66646` +- ! A4 work/repo UI caller inventory and owner asks `e6ef3381` + *(wait: Owner map delivered and switch-plan requests sent 2026-10-13 to hub-core (5e2f749b), activity-core (13cff5ff), repo-manager (0c49c989), sbom-nexus (63ce4ead) and policy-nexus (d5dda84d); waiting for their read smokes and reversible switch plans.)* +- ! Named single reader and parity window `077797c8` + *(wait: The first reader is chosen once hub-core implements the accepted feed contract (HUB-WP-0011-T01) and the feed is deployed. Per D3, choose a reader that does not rely on broadcast delivery stamps.)* ### Route human gates and expose actionable owner returns Progress: 3/5 done | workplan_id: `2c4e96a3-ca0d-5003-b759-880976600ecc` @@ -22,7 +23,7 @@ Progress: 3/5 done | workplan_id: `2c4e96a3-ca0d-5003-b759-880976600ecc` - ! Integrate deduplicated owner wakeups with the supported runner `cd73e265` *(wait: Memo 2 plan-only and submit trials wait for a live owner return; C-25 found zero findings across 13 public repos on 2026-10-04)* - ! Prepare approved receiving promotion and assess graph freshness (P6) `ec0acd06` - *(wait: Memo 3 apply trials need a read-only Forgejo token for the Activity Core worker (draft CCR-2026-0034 in railiance-platform); then memo 4 schedule and 48 h freshness evidence)* + *(wait: Memo 3 apply trials need a read-only Forgejo token for the Activity Core worker; then memo 4 schedule and 48 h freshness evidence. 2026-10-14: CCR-2026-0034 in railiance-platform is the keycape operator reader (applied), not this token; no Forgejo-read CCR for the Activity Core worker found.)* ### State Hub retirement strangler and disposition execution Progress: 6/9 done | workplan_id: `ed077b62-7048-5752-bf65-f90471f45854` diff --git a/Makefile b/Makefile index 7925592..a374201 100644 --- a/Makefile +++ b/Makefile @@ -515,3 +515,9 @@ register-from-classification-all: clean: $(COMPOSE) down -v + +# Explicit read-model brief rendering; never stages or commits files. +.PHONY: brief-generate brief-check +brief-generate brief-check: + @test -n "$(BRIEF_REPO_PATH)" -a -n "$(BRIEF_REPO_SLUG)" || { echo 'Set BRIEF_REPO_PATH and BRIEF_REPO_SLUG'; exit 1; } + $(PYTHON) scripts/generate_custodian_brief.py --repo-path "$(BRIEF_REPO_PATH)" --repo-slug "$(BRIEF_REPO_SLUG)" --api-base "$(STATE_HUB_API_BASE)" $(if $(filter brief-check,$@),--check,) diff --git a/SCOPE.md b/SCOPE.md index 87155e2..00c085e 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -14,6 +14,9 @@ repository files remain authoritative for repository-owned work records. - FastMCP server and tool reference - Observable dashboard - repo registration (classification-driven) and consistency synchronization +- read-model orientation brief rendering without Git operations (`make brief-generate` / + `brief-check`, explicit repo path and slug); rejects unavailable or malformed reads + and preserves existing briefs on failure. See `docs/brief-generation.md`. - repo classification spine (14 market domains, `.repo-classification.yaml`) - task-flow engine and flow definitions - SBOM, contribution, capability, demand-weighted suggestion backlog, TPSC, DoI, diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index e554984..0b3678a 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -17,6 +17,7 @@ | workplan | STATE-WP-ADHOC-2026-09-22 | finished | — | workplans/ADHOC-2026-09-22.md | | workplan | STATE-WP-ADHOC-2026-10-06 | finished | — | workplans/ADHOC-2026-10-06.md | | workplan | STATE-WP-ADHOC-2026-10-13 | finished | — | workplans/ADHOC-2026-10-13.md | +| workplan | STATE-WP-ADHOC-2026-10-19 | finished | — | workplans/ADHOC-2026-10-19.md | | workplan | CUST-WP-0003 | finished | — | workplans/CUST-WP-0003-whi-kpi-card.md | | workplan | CUST-WP-0012 | finished | — | workplans/CUST-WP-0012-multi-user-onboarding.md | | workplan | CUST-WP-0038 | archived | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md | @@ -73,7 +74,7 @@ | workplan | STATE-WP-0095 | finished | — | workplans/STATE-WP-0095-historical-source-receiving.md | | workplan | STATE-WP-0096 | finished | — | workplans/STATE-WP-0096-private-decision-dispatch.md | | workplan | STATE-WP-0097 | finished | — | workplans/STATE-WP-0097-unlinked-decision-supersession.md | -| workplan | STATE-WP-0098 | active | — | workplans/STATE-WP-0098-hub-core-receipt-inputs.md | +| workplan | STATE-WP-0098 | blocked | — | workplans/STATE-WP-0098-hub-core-receipt-inputs.md | | task | STATE-WP-ADHOC-2026-06-04-T01 | done | — | workplans/ADHOC-2026-06-04.md | | task | STATE-WP-ADHOC-2026-07-01-T01 | done | — | workplans/ADHOC-2026-07-01.md | | task | STATE-WP-ADHOC-2026-07-01-T02 | done | — | workplans/ADHOC-2026-07-01.md | @@ -90,6 +91,7 @@ | task | STATE-WP-ADHOC-2026-10-06-T02 | done | — | workplans/ADHOC-2026-10-06.md | | task | STATE-WP-ADHOC-2026-10-06-T03 | done | — | workplans/ADHOC-2026-10-06.md | | task | STATE-WP-ADHOC-2026-10-13-T01 | done | — | workplans/ADHOC-2026-10-13.md | +| task | STATE-WP-ADHOC-2026-10-19-T01 | done | — | workplans/ADHOC-2026-10-19.md | | task | CUST-WP-0003-T01 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | | task | CUST-WP-0003-T02 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | | task | CUST-WP-0003-T03 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | @@ -394,13 +396,19 @@ | task | STATE-WP-0095-T02 | done | — | workplans/STATE-WP-0095-historical-source-receiving.md | | task | STATE-WP-0095-T03 | done | — | workplans/STATE-WP-0095-historical-source-receiving.md | | task | STATE-WP-0095-T04 | done | — | workplans/STATE-WP-0095-historical-source-receiving.md | +| task | STATE-WP-0095-T05 | done | — | workplans/STATE-WP-0095-historical-source-receiving.md | +| task | STATE-WP-0095-T06 | done | — | workplans/STATE-WP-0095-historical-source-receiving.md | +| task | STATE-WP-0095-T07 | done | — | workplans/STATE-WP-0095-historical-source-receiving.md | +| task | STATE-WP-0095-T08 | done | — | workplans/STATE-WP-0095-historical-source-receiving.md | +| task | STATE-WP-0095-T09 | done | — | workplans/STATE-WP-0095-historical-source-receiving.md | +| task | STATE-WP-0095-T10 | done | — | workplans/STATE-WP-0095-historical-source-receiving.md | | task | STATE-WP-0096-T01 | done | — | workplans/STATE-WP-0096-private-decision-dispatch.md | | task | STATE-WP-0096-T02 | done | — | workplans/STATE-WP-0096-private-decision-dispatch.md | | task | STATE-WP-0096-T03 | done | — | workplans/STATE-WP-0096-private-decision-dispatch.md | | task | STATE-WP-0097-T01 | done | — | workplans/STATE-WP-0097-unlinked-decision-supersession.md | | task | STATE-WP-0097-T02 | done | — | workplans/STATE-WP-0097-unlinked-decision-supersession.md | | task | STATE-WP-0098-T01 | done | — | workplans/STATE-WP-0098-hub-core-receipt-inputs.md | -| task | STATE-WP-0098-T02 | progress | — | workplans/STATE-WP-0098-hub-core-receipt-inputs.md | +| task | STATE-WP-0098-T02 | wait | — | workplans/STATE-WP-0098-hub-core-receipt-inputs.md | | task | STATE-WP-0098-T03 | done | — | workplans/STATE-WP-0098-hub-core-receipt-inputs.md | -| task | STATE-WP-0098-T04 | todo | — | workplans/STATE-WP-0098-hub-core-receipt-inputs.md | -| task | STATE-WP-0098-T05 | todo | — | workplans/STATE-WP-0098-hub-core-receipt-inputs.md | +| task | STATE-WP-0098-T04 | wait | — | workplans/STATE-WP-0098-hub-core-receipt-inputs.md | +| task | STATE-WP-0098-T05 | done | — | workplans/STATE-WP-0098-hub-core-receipt-inputs.md | diff --git a/docs/brief-generation.md b/docs/brief-generation.md new file mode 100644 index 0000000..cfe1c91 --- /dev/null +++ b/docs/brief-generation.md @@ -0,0 +1,24 @@ +# Render orientation briefs without Git operations + +Use the existing State Hub renderer independently of consistency writeback: + +```sh +make brief-generate BRIEF_REPO_PATH=/path/to/repo BRIEF_REPO_SLUG=canonical-slug +make brief-check BRIEF_REPO_PATH=/path/to/repo BRIEF_REPO_SLUG=canonical-slug +``` + +The Python entry point is `scripts/generate_custodian_brief.py`; pass `--api-base` +or set Make's `STATE_HUB_API_BASE` for another endpoint. It performs only API reads +and writes `.custodian-brief.md` in generation mode. It never stages, commits, +pushes, or writes to the hub. Check mode exits nonzero on drift without writing. +Timestamp-only changes are ignored. + +The brief describes the current hub read model, not the Git index. Sync authored +work records before expecting their changes in the brief. API requests are not +an atomic snapshot; inspect output if concurrent work is changing the hub. +Missing or malformed endpoint responses stop generation before writing. Symlink +targets are refused. Preserve any existing local changes before refreshing. + +Review and stage the brief alongside the substantive source change that needs it. +The generated-only commit guard remains in force. This command does not install +an automatic brief hook or change existing consistency writeback defaults. diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index de7b47d..ade37fe 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -2847,8 +2847,9 @@ def _summarise_inbox_message(msg: dict[str, Any], *, now: datetime) -> dict[str, } -def collect_inbox_hygiene(api_base: str, repo_slug: str) -> dict[str, Any]: - messages = _api_get( +def collect_inbox_hygiene(api_base: str, repo_slug: str, *, reader=None) -> dict[str, Any]: + reader = reader or _api_get + messages = reader( api_base, "/messages", {"to_agent": repo_slug, "unread_only": True, "limit": 100}, @@ -3392,7 +3393,19 @@ def _commit_working_memory_notes(repo_slug: str, repo_path: str) -> bool: return False -def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> bool: +def _strict_brief_get(api_base: str, path: str, params=None): + """Never turn an incomplete read-model snapshot into an empty brief.""" + value = _api_get(api_base, path, params, return_error=True) + singular = path.startswith(("/repos/", "/topics/")) + valid = (isinstance(value, dict) and bool(value) and "_error" not in value + if singular else isinstance(value, list) and all(isinstance(row, dict) for row in value)) + if not valid: + raise ValueError("brief read model unavailable or malformed at " + path) + return value + + +def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str, *, + commit: bool = True, strict: bool = False, check: bool = False) -> bool: """Generate .custodian-brief.md at the repo root and git-commit if changed. The brief gives any agent — including subagents without MCP access and @@ -3402,7 +3415,8 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo import datetime as _dt from datetime import timezone as _tz - repo = _api_get(api_base, f"/repos/{repo_slug}") + reader = _strict_brief_get if strict else _api_get + repo = reader(api_base, f"/repos/{repo_slug}") if not repo: return False @@ -3413,21 +3427,21 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo # lookup only for compatibility with older API responses that omitted it. workstreams: list[dict] = [] for status in OPEN_WORKSTREAM_STATUSES: - rows = _api_get(api_base, "/workplans", {"repo_id": repo_id, "status": status}) or [] + rows = reader(api_base, "/workplans", {"repo_id": repo_id, "status": status}) or [] if isinstance(rows, list): workstreams.extend(rows) _ws_for_domain = workstreams if workstreams else [] if not _ws_for_domain: - all_ws = _api_get(api_base, "/workplans", {"repo_id": repo_id}) or [] + all_ws = reader(api_base, "/workplans", {"repo_id": repo_id}) or [] _ws_for_domain = all_ws if isinstance(all_ws, list) else [] if not domain_slug and _ws_for_domain: - topic = _api_get(api_base, f"/topics/{_ws_for_domain[0].get('topic_id', '')}") + topic = reader(api_base, f"/topics/{_ws_for_domain[0].get('topic_id', '')}") if topic: domain_slug = topic.get("domain_slug", "") # Active repo goal (first active one if multiple) goal_text = "" - goals = _api_get(api_base, "/repo-goals", {"repo_slug": repo_slug}) or [] + goals = reader(api_base, "/repo-goals", {"repo_slug": repo_slug}) or [] if isinstance(goals, list): active_goals = [g for g in goals if g.get("status") == "active"] if active_goals: @@ -3455,7 +3469,7 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo for ws in workstreams: ws_title = ws.get("title", ws.get("slug", "?")) ws_id = ws["id"] - tasks = _api_get(api_base, "/tasks", {"workplan_id": ws_id}) or [] + tasks = reader(api_base, "/tasks", {"workplan_id": ws_id}) or [] if not isinstance(tasks, list): tasks = [] @@ -3502,7 +3516,8 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo else: lines += ["## Active Workstreams", "", "*(none — repo may need first-session setup)*"] - hygiene = collect_inbox_hygiene(api_base, repo_slug) + hygiene = (collect_inbox_hygiene(api_base, repo_slug, reader=reader) if strict + else collect_inbox_hygiene(api_base, repo_slug)) if hygiene["stale_unread_count"] or hygiene["missing_thread"] or hygiene["work_requests_unpromoted"]: lines += ["", "## Inbox Hygiene", ""] if hygiene["stale_unread_count"]: @@ -3539,7 +3554,7 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo counterpart = parse_blocked_on(blocked_on) if not counterpart: continue - messages = _api_get( + messages = reader( api_base, "/messages", { @@ -3621,14 +3636,19 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo if _strip_ts(content) == _strip_ts(existing): return False # no meaningful change + if brief_path.is_symlink(): + raise ValueError("refuse redirected brief") + if check: + raise ValueError("brief differs from current read model") brief_path.write_text(content, encoding="utf-8") # Commit the brief so remote workers can pull it - _git_commit_writeback( - repo_path, - brief_path, - [f"update .custodian-brief.md for {repo_slug}"], - ) + if commit: + _git_commit_writeback( + repo_path, + brief_path, + [f"update .custodian-brief.md for {repo_slug}"], + ) return True diff --git a/scripts/generate_custodian_brief.py b/scripts/generate_custodian_brief.py new file mode 100644 index 0000000..0e5b146 --- /dev/null +++ b/scripts/generate_custodian_brief.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Render a current State Hub orientation brief without Git operations or hub writes.""" +import argparse +import json +from pathlib import Path +import re + +from consistency_check import _write_custodian_brief + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--repo-path', type=Path, required=True) + parser.add_argument('--repo-slug', required=True) + parser.add_argument('--api-base', default='http://127.0.0.1:8000') + parser.add_argument('--check', action='store_true') + args = parser.parse_args() + try: + if not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', args.repo_slug): + raise ValueError('canonical repository slug required') + if not args.repo_path.is_dir(): + raise ValueError('repository path must exist') + if (args.repo_path / '.custodian-brief.md').is_symlink(): + raise ValueError('refuse redirected brief') + changed = _write_custodian_brief(args.api_base, args.repo_slug, str(args.repo_path), + commit=False, strict=True, check=args.check) + except (OSError, ValueError, KeyError, TypeError) as exc: + parser.exit(1, f'Brief generation refused: {exc}\n') + print(json.dumps({'repo': args.repo_slug, 'status': 'updated' if changed else 'current', + 'source': 'state-hub-read-model', 'git_operations': False})) + + +if __name__ == '__main__': + main() diff --git a/tests/test_brief_generation.py b/tests/test_brief_generation.py new file mode 100644 index 0000000..1297d36 --- /dev/null +++ b/tests/test_brief_generation.py @@ -0,0 +1,49 @@ +"""Rendering-only briefs must preserve existing data on incomplete API snapshots.""" +from unittest.mock import Mock + +import pytest +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import consistency_check as owner + + +def responses(base, path, params=None, **kwargs): + if path.startswith('/repos/'): + return {'id': 'repo-1', 'domain_slug': 'infotech'} + return [] + + +def test_render_does_not_commit_and_timestamp_only_retry_is_unchanged(tmp_path, monkeypatch): + monkeypatch.setattr(owner, '_api_get', responses) + commit = Mock(side_effect=AssertionError('Git operations forbidden')) + monkeypatch.setattr(owner, '_git_commit_writeback', commit) + assert owner._write_custodian_brief('unused', 'demo', str(tmp_path), commit=False, strict=True) + before = (tmp_path / '.custodian-brief.md').read_bytes() + assert not owner._write_custodian_brief('unused', 'demo', str(tmp_path), commit=False, strict=True) + assert (tmp_path / '.custodian-brief.md').read_bytes() == before + commit.assert_not_called() + + +@pytest.mark.parametrize('failed_path', ['/repos/demo', '/workplans', '/repo-goals', '/messages', '/tasks']) +@pytest.mark.parametrize('failure', [None, {'_error': 'offline'}, 'malformed']) +def test_partial_snapshot_preserves_existing_brief(tmp_path, monkeypatch, failed_path, failure): + def read(base, path, params=None, **kwargs): + if path == failed_path: + return failure + if path == '/workplans': + return [{'id': 'wp-1', 'title': 'Waiting'}] + return responses(base, path, params, **kwargs) + monkeypatch.setattr(owner, '_api_get', read) + path = tmp_path / '.custodian-brief.md'; path.write_text('preserved') + with pytest.raises(ValueError, match='unavailable or malformed'): + owner._write_custodian_brief('unused', 'demo', str(tmp_path), commit=False, strict=True) + assert path.read_text() == 'preserved' + + +def test_check_has_no_write(tmp_path, monkeypatch): + monkeypatch.setattr(owner, '_api_get', responses) + path = tmp_path / '.custodian-brief.md'; path.write_text('preserved') + with pytest.raises(ValueError, match='differs'): + owner._write_custodian_brief('unused', 'demo', str(tmp_path), commit=False, strict=True, check=True) + assert path.read_text() == 'preserved' diff --git a/workplans/ADHOC-2026-10-19.md b/workplans/ADHOC-2026-10-19.md new file mode 100644 index 0000000..fb82b22 --- /dev/null +++ b/workplans/ADHOC-2026-10-19.md @@ -0,0 +1,28 @@ +--- +id: STATE-WP-ADHOC-2026-10-19 +type: workplan +title: "Small source maintenance for 2026-10-19" +domain: infotech +repo: state-hub +status: finished +owner: codex +created: "2026-10-19" +updated: "2026-10-19" +flavor: implementation +depends_on: [] +--- + +## Render briefs without implicit Git commits + +```task +id: STATE-WP-ADHOC-2026-10-19-T01 +status: done +priority: medium +``` + +Expose the existing brief renderer through an explicit no-Git CLI and Make targets. +Keep strict API failures from overwriting existing briefs, support read-only drift +checks, document the read-model boundary in SCOPE. Supports CUST-WP-0079 provider +adoption without generated-only guard bypass. Seventeen focused tests pass and +Custodian live read-model check passes. Broader consistency validation recorded +in the session progress receipt; no runtime deployment or scheduler activation.