From fb397d5bc12ba332a01b8a3649ec770553b6f266 Mon Sep 17 00:00:00 2001 From: Sree Raman <1271305+sreejithraman@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:10:38 -0500 Subject: [PATCH 01/16] Keep the chief's whole program record in program.json and render it on request Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +- SOURCES.md | 23 +- skills/chief-of-staff/SKILL.md | 20 +- .../chief-of-staff/assets/command-center.html | 2 + .../chief-of-staff/assets/example-state.json | 142 ++++++++++- skills/chief-of-staff/assets/owner-kickoff.md | 10 +- .../chief-of-staff/references/chief-move.md | 23 +- skills/chief-of-staff/references/decisions.md | 23 +- skills/chief-of-staff/references/hosting.md | 50 ++-- skills/chief-of-staff/references/landing.md | 5 +- .../references/program-state.md | 232 +++++++++++------- .../scripts/render_dashboard.py | 149 +++++++++-- .../chief-of-staff/scripts/serve_dashboard.py | 89 +++++++ .../scripts/test_command_center.py | 46 +++- .../scripts/test_render_dashboard.py | 106 +++++++- .../scripts/test_serve_dashboard.py | 133 ++++++++++ 16 files changed, 872 insertions(+), 194 deletions(-) create mode 100644 skills/chief-of-staff/scripts/serve_dashboard.py create mode 100644 skills/chief-of-staff/scripts/test_serve_dashboard.py diff --git a/README.md b/README.md index 75c0a18..3bf6ebb 100644 --- a/README.md +++ b/README.md @@ -272,13 +272,12 @@ to you, with judgment calls ahead of quick picks. Each outcome has its own **Land**, **Try**, or PR finish line: an owner lands its accepted outcome once the chief releases it, the chief lands only sets that span owners, and each **Try** waits for your merge, changes, or scrap choice. Its command center shows -Needs you first and Work second, rendered from a checked snapshot of the program -record: each outcome with its finish line, results to open, layers, provisional -state, and what each decision blocks. Answers go back through chat. Existing -trackers stay authoritative. A hosting guide can serve the page at a stable -private URL through Show Me and Tailscale Serve. Open pages pick up each new -snapshot on their own and keep your place; publishing one in the background -needs explicitly requested host scheduling. +Needs you first and Work second, rendered from the program record's one checked +file on every request: each outcome with its finish line, results to open, +layers, provisional state, and what each decision blocks. Answers go back +through chat. Existing trackers stay authoritative. A small loopback server +serves the page at a stable private URL through Show Me and Tailscale Serve. +Open pages pick up each change on their own and keep your place. [Swarm](skills/swarm/SKILL.md) is a bounded tool any session can use. For example, “Swarm these three possible causes and return evidence” partitions the question diff --git a/SOURCES.md b/SOURCES.md index 6aae717..582512b 100644 --- a/SOURCES.md +++ b/SOURCES.md @@ -450,8 +450,14 @@ base and provisional bases, and linearizes fork-and-join inputs into reviewable stacks. Existing trackers stay authoritative; there is no mandatory wiki, Linear, Slack, cloud runtime, or recurring schedule. -The original command center renders a checked JSON snapshot of the program -record (`scripts/render_dashboard.py`, `assets/command-center.html`) with +The program record is one file, `program.json`: its top-level fields are what +the command center shows, and its `record` object holds what the page never +shows (request, corrections, approvals and directions, landing authority and +releases, owner plumbing, waits, decision history, discoveries, hosting, the +chief, and the next action), with each fact in one field. Owners write only +their own report files. The chief writes the file atomically after +`render_dashboard.py --check` passes on it. The original command center +(`scripts/render_dashboard.py`, `assets/command-center.html`) renders it with Needs you before Work, collapsible sections, per-row observation times, copyable decision prompts that name one recorded option, judgment calls ahead of quick picks with a batch prompt, reopenable chief-resolved decisions, and accepted @@ -462,11 +468,14 @@ Below 900px it stays one column; wider, Needs you is a dual-pane inbox and Work board of stage lanes. It replaces the supplied archive's Claude artifact database and integrations; no archive code or prose is copied. Validation rejects provisional landings, PRs on a Try, stacks on another owner's unmerged layer -without a provisional mark, and terminal rows with active blockers. Delivery is -hosted only; `references/hosting.md` serves the page through Show Me's web -sharing, including Tailscale Serve. A hosted page polls its own URL and swaps in -a newer published digest without a server push, keeping the viewer's place per -URL in session storage; the chief stays the only publisher. +without a provisional mark, terminal rows with active blockers, record entries +for missing rows, and a decision entry that repeats its Need's question or +disposition. Delivery is hosted only: `scripts/serve_dashboard.py` renders the +file on loopback at each request, serving the last good page under an error +banner when it fails to load, and `references/hosting.md` shares it through Show +Me's web sharing, including Tailscale Serve. A hosted page polls its own URL and +swaps in a changed digest without a server push, keeping the viewer's place per +URL in session storage; the chief stays the only writer. ## implement diff --git a/skills/chief-of-staff/SKILL.md b/skills/chief-of-staff/SKILL.md index 48e035b..c1d211b 100644 --- a/skills/chief-of-staff/SKILL.md +++ b/skills/chief-of-staff/SKILL.md @@ -72,9 +72,9 @@ unmerged work: update the record and owners, then reconcile pending landings under [withdraw a release](references/landing.md#withdraw-a-release). Set up the [program record](references/program-state.md) before you start or -adopt owners, so another chief could resume from it. Reuse the project's -tracker; without one, keep a task-owned file record. A coordination request -needs no new tracker. When a small choice will recur, propose a +adopt owners, so another chief could resume from it. Work items stay in the +project's tracker, and a coordination request needs no new one. When a small +choice will recur, propose a [standing approval](references/decisions.md#standing-approvals) with the plan; owners start without waiting for the answer. @@ -212,13 +212,13 @@ user, its requested PR state, or a repair with its owner. ## 5. Show and continue -Keep the [command center](references/program-state.md#command-center) current -and host it through [dashboard hosting](references/hosting.md), never inline. It +Host the [command center](references/program-state.md#command-center) through +[dashboard hosting](references/hosting.md), never inline. It renders +`program.json` each time it is opened, so every record write reaches it. It shows every outcome once, with its finish line, results and how to open them, provisional condition, owner, layers, and what each Need blocks. Results come from owners' Shown reports: link what they showed, say what opening it needs, -and name the gaps. Refresh on meaningful results and decisions, and show -freshness honestly. +and name the gaps. Keep each row's observation time honest. Answers arrive through chat or the host's input tools. Record each under [record an answer](references/decisions.md#record-an-answer), then update the @@ -228,8 +228,8 @@ Choose user updates from the owner reports: what the user can now use, try, decide, or unblock. Keep routine checks, recovery, and unchanged milestones in the record, and answer direct questions in the detail asked. -Done when the hosted view matches the record and shows every result and open -Need. +Done when the hosted page serves the current `program.json` without an error +banner and shows every result and open Need. ## 6. Settle or resume @@ -250,5 +250,5 @@ Before pausing or moving, bring the record current. On resume, reconcile it with current files, threads, and forge state before dispatching. To move the program to another thread or take over from a chief that cannot act, follow [chief move](references/chief-move.md). This skill supplies no background -wakeups: native goal tracking, recurring refreshes, and other automation need an +wakeups: native goal tracking, recurring checks, and other automation need an explicit user request and host support. diff --git a/skills/chief-of-staff/assets/command-center.html b/skills/chief-of-staff/assets/command-center.html index 9d5bf9a..a1f0a39 100644 --- a/skills/chief-of-staff/assets/command-center.html +++ b/skills/chief-of-staff/assets/command-center.html @@ -52,6 +52,8 @@ .icon { inline-size: 1.05em; block-size: 1.05em; flex-shrink: 0; } /* Program header */ +.state-error { display: flex; align-items: flex-start; gap: 8px; margin-block-end: 20px; padding: 12px 14px; border-radius: 12px; background: var(--danger-soft); font-size: 14px; } +.state-error .icon { margin-block-start: 3px; color: var(--danger); } .eyebrow { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-block-end: 14px; color: var(--text-secondary); font-size: 13px; font-weight: 600; letter-spacing: .01em; } .demo { padding: 3px 9px; border-radius: 99px; background: var(--fill); color: var(--text-secondary); font-size: 12px; font-weight: 500; } h1 { font-size: clamp(28px, 6vw, 38px); font-weight: 700; line-height: 1.08; letter-spacing: -.025em; text-wrap: balance; } diff --git a/skills/chief-of-staff/assets/example-state.json b/skills/chief-of-staff/assets/example-state.json index 67717e1..8e4a37b 100644 --- a/skills/chief-of-staff/assets/example-state.json +++ b/skills/chief-of-staff/assets/example-state.json @@ -341,5 +341,145 @@ "revisit": "If support needs per-attempt logs, add an attempt number.", "source": "W0-D1" } - ] + ], + "record": { + "chief": "Chief thread (example)", + "request": { + "text": "Make exports dependable, then let people see and regenerate their export history.", + "sources": [ + "EXP-100", + "EXP-101", + "EXP-102", + "EXP-103", + "Chat message 2026-11-01" + ] + }, + "corrections": [ + { + "date": "2026-11-01", + "source": "Chat message 2026-11-01", + "text": "Export history is a **Try**: show it before anything merges." + } + ], + "requirements": [ + { + "date": "2026-11-01", + "source": "EXP-101", + "text": "A retried export never emails the same file twice." + } + ], + "approvals": [ + { + "id": "SA-1", + "date": "2026-11-01", + "source": "Chat message 2026-11-01", + "text": "Pick the existing component when a new view could reuse one.", + "covers": "Component reuse for new views", + "picks": "The existing component" + } + ], + "directions": [ + { + "date": "2026-11-01", + "source": "Chat message 2026-11-01", + "text": "Hold W6's landing until the support team reviews link expiry.", + "until": "Support confirms the expiry copy" + } + ], + "landingAuthority": { + "date": "2026-11-01", + "source": "Chat message 2026-11-01", + "text": "Land covers W0, W1, W2, and W6 in example/exports. Required checks and review stay the gate." + }, + "landings": [ + { + "id": "L1", + "date": "2026-11-02", + "chief": "Chief thread (example)", + "lander": "Idempotency owner", + "target": "main", + "work": [ + "W0" + ], + "prs": [ + { + "url": "https://example.com/exports/pull/39", + "head": "4f2a91c", + "base": "9be03d1" + } + ], + "state": "landed" + } + ], + "outcomes": { + "W1": { + "sources": [ + "Chat message 2026-11-01" + ], + "scope": "Covers `jobs/retry.py` and its queue wiring. Proof: an interrupted export resumes once. Leaves export history to W5.", + "why": "Land: a contained repair the user asked to ship.", + "acceptance": "Retry proof passed at the current head; burst timing untested." + } + }, + "owners": { + "W1": { + "start": "top-level", + "route": "`t3_thread_send` to the chief", + "report": "reports/W1.md", + "model": "Example model", + "workspace": "Worktree on `retry-export`" + }, + "W5": { + "start": "top-level", + "route": "`t3_thread_send` to the chief", + "report": "reports/W5.md", + "resources": "History preview server on port 8105; stop with `kill` on its recorded PID." + } + }, + "waits": [ + { + "date": "2026-11-02T16:50:00Z", + "work": "W1", + "on": "PR 44 checks", + "event": "Required checks finish", + "watcher": "Retry owner" + } + ], + "decisions": [ + { + "id": "W5-D2", + "date": "2026-11-02", + "source": "History owner report", + "owner": "History owner", + "rationale": "SA-1 settles it; switching components later costs one file." + }, + { + "id": "W2-D1", + "date": "2026-11-02", + "source": "API owner report", + "owner": "API owner", + "question": "Paginate history by cursor or by page?", + "affects": [ + "W2" + ], + "disposition": "Withdrawn: the endpoint returns at most 50 rows." + } + ], + "discoveries": [ + { + "date": "2026-11-02", + "origin": "W2", + "text": "Export filenames repeat across time zones.", + "route": "Saved as snip EX-7" + } + ], + "hosting": { + "url": "https://example-host.example.ts.net:8443/", + "server": "`serve_dashboard.py` PID 4242 on 127.0.0.1:58665, started 2026-11-02T09:00:00Z", + "serve": "`tailscale serve --bg --https=8443 http://127.0.0.1:58665`", + "stop": "`tailscale serve --https=8443 off`, then `kill -TERM 4242` after checking its start time and arguments", + "access": "Tailnet devices only; checked from this machine" + }, + "next": "Release W1's landing once PR 44 is ready." + } } diff --git a/skills/chief-of-staff/assets/owner-kickoff.md b/skills/chief-of-staff/assets/owner-kickoff.md index fdbb5df..8bfd830 100644 --- a/skills/chief-of-staff/assets/owner-kickoff.md +++ b/skills/chief-of-staff/assets/owner-kickoff.md @@ -1,7 +1,7 @@ /implement -Program: . Program record (read-only except your report file): <absolute path>. -Original request and accepted corrections: <link or path>. +Program: <title>. Program record (read-only): <absolute path to program.json>. +Original request and accepted corrections: its `record.request` and `record.corrections`. Outcome <ID>: <title>. Sources: <tickets, snips, PRs, or messages, or none>. Prefix decision request IDs with <ID>. Scope: <the result this outcome delivers, the code it covers, and what it leaves to other outcomes>. @@ -13,11 +13,11 @@ Inputs available at that base: <dependencies>. Uncommitted inputs: <paths, or no Provisional assumptions: <another owner's unmerged branch or PR, or the recommended option of an open decision you build on, with what clears it, or none>. Your own planned layers are not provisional. Provisional work never merges and never takes destructive or outward-facing steps on its own; the user's scrap allows its scoped cleanup per Implement. Land becomes Try, and a requested draft or review-ready PR state can still be reached. Publish such a PR with a Provisional section in its body, per PR's body guide; that section holds its status until the condition clears and proof is refreshed. Finish line: <Land | Try | review-ready PR | draft PR>, picked because <reason>. -Landing authority: <absolute link to the record's entry for this outcome's finish line and the program scope>. Land within that scope is the merge request Merge needs, and it takes effect only on the current chief's recorded landing release for your PR or stack. -Landing: report readiness and end your turn. Land through Merge only on that release. Immediately before submitting, re-read the record: confirm the current chief, your active release, you as its one named lander, and no affecting hold or decision; match the forge's head/base for every PR in the set to the release. Report a mismatch instead of landing. Sets that span several owners are landed by the chief. +Landing authority: `record.landingAuthority` and this outcome's finish line. Land within that scope is the merge request Merge needs, and it takes effect only on the current chief's recorded landing release for your PR or stack. +Landing: report readiness and end your turn. Land through Merge only on that release. Immediately before submitting, re-read the record: confirm `record.chief`, your active release in `record.landings`, you as its one named lander, and no affecting hold or decision; match the forge's head/base for every PR in the set to the release. Report a mismatch instead of landing. Sets that span several owners are landed by the chief. Holds and user gates: <hold, source, release condition, or none>. Delegation allowance: you may start the bounded helpers your workflow needs as your own subagents: implementation, verification, and review helpers, and Swarm workers that fit. <Further limits, or none>. Start no new program owners; send work that needs one to the chief. -Owner report: write it to <record>/reports/<ID>.md. Send a partial report as soon as you hit a missing prerequisite, a human decision, or a pending PR, naming what continues and the event that resumes the rest. +Owner report: write it to <program directory>/reports/<ID>.md. Send a partial report as soon as you hit a missing prerequisite, a human decision, or a pending PR, naming what continues and the event that resumes the rest. Route: <top-level: send it to the chief thread <link> with t3_thread_send; you watch your PR | delegated: end your turn with it; the chief thread watches your PR>. Decision requests and shared blockers use the same route as they arise. In each decision request, say what reversing each option would take and cite any recorded source that settles it. diff --git a/skills/chief-of-staff/references/chief-move.md b/skills/chief-of-staff/references/chief-move.md index 464282e..f0dccc5 100644 --- a/skills/chief-of-staff/references/chief-move.md +++ b/skills/chief-of-staff/references/chief-move.md @@ -6,18 +6,19 @@ from acting on stale state or writing over the record. ## Check the chief Before acting on a request, direction, event, report, or pasted answer, and -before each record write, read the record's current chief. A thread that is not -the chief reads the chief's thread, forwards the item with `t3_thread_send`, -linking the user's message for requests, directions, and answers, and stops. It -unwatches its PRs when the chief asks. A user item that arrives through +before each record write, read `record.chief` in `program.json`. A thread that +is not the chief reads the chief's thread, forwards the item with +`t3_thread_send`, linking the user's message for requests, directions, and +answers, and stops. It unwatches its PRs when the chief asks. A user item that arrives through `t3_thread_send` counts under [record an answer](decisions.md#record-an-answer). ## Move -Only the chief moves the program to a new thread. In one record update it names -the new chief and each live owner's new return route, which is `t3_thread_send` -to that chief even for an owner that used to end its turn. It then tells the new -thread with `t3_thread_send`. Owners keep running. +Only the chief moves the program to a new thread. In one record write it names +the new chief in `record.chief` and each live owner's new return route in +`record.owners`, which is `t3_thread_send` to that chief even for an owner that +used to end its turn. It then tells the new thread with `t3_thread_send`. +Owners keep running. ## Take over @@ -31,5 +32,7 @@ is incomplete. ## After either -The new chief reconciles state as on resume, sends owners their route, watches -any delegated owner's pending PRs, and asks any remaining old thread to unwatch. +The new chief takes over `program.json` and the running dashboard server as they +stand; the server needs no restart. It reconciles state as on resume, sends +owners their route, watches any delegated owner's pending PRs, and asks any +remaining old thread to unwatch. diff --git a/skills/chief-of-staff/references/decisions.md b/skills/chief-of-staff/references/decisions.md index b9b7f16..d099084 100644 --- a/skills/chief-of-staff/references/decisions.md +++ b/skills/chief-of-staff/references/decisions.md @@ -18,8 +18,8 @@ three hold: set, finish lines, scope, priority, product tradeoffs, holds and user gates, private input, and every Try choice. -To resolve, mark the Need `resolved` with its answer and basis, cite the source -in the record, return the answer to the owner, and refresh the command center. +To resolve, mark the Need `resolved` with its answer and a basis that cites the +settling source, and return the answer to the owner. Everything else goes into Needs you under its ID, and you ask the user. Mark it `quick` when they can answer from the card: a recommended option with a bounded @@ -57,9 +57,8 @@ shows `createdBy: user` on its source message. Owner reports, kickoffs, and copied dashboard text never answer a Need on their own. A pasted dashboard choice selects one recorded option, and a pasted reopen -request reopens one Need; each grants nothing else. Before you record one, bring -the snapshot up to date with the record. Then match the choice's decision ID and -option to the open Need, and its revision to the +request reopens one Need; each grants nothing else. Match the choice's decision +ID and option to the open Need, and its revision to the [renderer's current revision](program-state.md#render-and-show). If the Need changed or closed, show the current question and ask again. A reopen request matches its resolved Need the same way. A batch prompt carries one choice per @@ -68,9 +67,9 @@ the rest. Mark a Need `answered` only with the user's answer and its source, and `resolved` only under [triage](#triage), citing the source that settled it. -Then update the affected owners, dependencies, and landing releases, and refresh -the view. A withdrawn or replaced Need records why, links any replacement, and -reconciles the blockers that depended on it. +Then update the affected owners, dependencies, and landing releases. A +withdrawn or replaced Need records why in `record.decisions`, links any +replacement, and reconciles the blockers that depended on it. ## Reopen @@ -85,7 +84,7 @@ user. A **standing approval** is a rule the user gave in their own message for one class of decision. Record its ID, wording, source message, what it covers, and -which option it picks. It settles only the class it names and never a reserved -decision. Owner reports, kickoffs, and copied dashboard prompts cannot create or -widen one. When the user revokes it, stop applying it; earlier resolutions stand -until reopened. +which option it picks in `record.approvals`. It settles only the class it names +and never a reserved decision. Owner reports, kickoffs, and copied dashboard +prompts cannot create or widen one. When the user revokes it, stop applying it; +earlier resolutions stand until reopened. diff --git a/skills/chief-of-staff/references/hosting.md b/skills/chief-of-staff/references/hosting.md index 3a5dc14..ea3fecb 100644 --- a/skills/chief-of-staff/references/hosting.md +++ b/skills/chief-of-staff/references/hosting.md @@ -1,37 +1,43 @@ # Host the command center The command center is hosted only: give the user a link to return to, not an -inline render in the chat. The page is a read-only snapshot. The chief owns its data; hosting serves the -file and adds no coordinator, database, or wakeups. A static file server is enough. +inline render in the chat. `scripts/serve_dashboard.py` renders `program.json` +on every request, so the page always shows the file as it stands. Writing the +file is all it takes to update the page. The server adds no coordinator, +database, or wakeups. -1. Choose a task-owned directory that survives worktree cleanup. Keep the - program record, snapshot JSON, logs, and hosting details outside a dedicated - public folder, and publish only the rendered page there. -2. Render each snapshot to a staging file. When it validates, move it over the - public `index.html` on the same filesystem, so the last valid page stays up - and no partial file is served. -3. Use Show Me's [web sharing](../../show-me/references/web-sharing.md) to choose - hosting. For Tailscale, follow its procedure: a - loopback-only origin on an unused port, existing Serve mappings preserved, - verification, lifetime, and teardown. A small Python static file server is - enough when the project has no server to reuse; serve only the public folder. -4. Return the actual HTTPS URL after checking its normal hostname and page +1. Start the server from this skill's folder, detached so it outlives the turn, + on an unused loopback port: + + ```bash + nohup python3 scripts/serve_dashboard.py --state /absolute/path/program.json --port <port> > /absolute/path/serve.log 2>&1 & + ``` + + It binds `127.0.0.1` and serves only `/`, nothing else from disk. When the + file fails to load, it serves the last good page under an error banner and + logs the error; with no good page since it started, it serves an error page + that retries. Fix the file under + [write the record](program-state.md#write-the-record). +2. Use Show Me's [web sharing](../../show-me/references/web-sharing.md) to share + it. For Tailscale, follow its procedure with this server as the origin: + existing Serve mappings preserved, verification, lifetime, and teardown. +3. Return the actual HTTPS URL after checking its normal hostname and page content, ideally from the user's phone or another tailnet client. A check with an explicit IP or DNS override proves only that path; label the rest unverified. -Record the URL, public path, origin process and start time, Serve mapping, -exact stop commands, and access limits in the program record. The hosting +Record the URL, the server's PID, port, start time, and arguments, the Serve +mapping, exact stop commands, and access limits in `record.hosting`. The hosting machine must stay awake and connected, and viewers need tailnet access. -On each meaningful result or decision, replace the page at the same path. -An open page fetches that URL every 20 seconds while visible and when the user -returns to it, then shows a new snapshot in place with its tab, selection, +An open page fetches its URL every 20 seconds while visible and when the user +returns to it, then shows a changed page in place with its tab, selection, sections, and scroll position kept. Each browser tab keeps that view for the -URL, so a manual refresh returns to it too; a new tab starts at Needs you. -Only the chief's next publish changes what any page shows, and answers still go +URL, so a manual refresh returns to it too; a new tab starts at Needs you. A +write that changes only `record` leaves open pages alone. Answers still go through chat. For teardown, follow the [web sharing](../../show-me/references/web-sharing.md#take-it-down)'s take-down -rules. Keep the state and any page the chief still needs. +rules: stop the Serve mapping, then the server only if its PID, start time, and +arguments still match the record. Keep `program.json` and the reports. diff --git a/skills/chief-of-staff/references/landing.md b/skills/chief-of-staff/references/landing.md index 28decbc..350d950 100644 --- a/skills/chief-of-staff/references/landing.md +++ b/skills/chief-of-staff/references/landing.md @@ -22,8 +22,9 @@ set at those heads. Record exactly one lander for each merge set. - **The owner,** for a set within its own outcome. Record a **landing release** - from the current chief naming the owner, each PR with its accepted head and - base, and any resources dependents still need, then resume the owner with it. + in `record.landings` from the current chief, naming the owner, each PR with + its accepted head and base, and any resources dependents still need, then + resume the owner with it. The record is authoritative. Immediately before submitting a merge, the owner re-reads it to confirm the current chief, its active release, itself as the one lander, and no affecting decision or hold, and matches the forge's head diff --git a/skills/chief-of-staff/references/program-state.md b/skills/chief-of-staff/references/program-state.md index d9c15c0..eaa4f51 100644 --- a/skills/chief-of-staff/references/program-state.md +++ b/skills/chief-of-staff/references/program-state.md @@ -1,67 +1,94 @@ # Program record and command center Specs, tickets, snips, PRs, and user decisions stay where the project already -keeps them. The **program record** links those sources and adds coordination -state. The **command center** is a rendered snapshot of that record, not a second -tracker. +keeps them. The **program record** is one file, `program.json`, that links those +sources and holds the program's coordination state. Its top-level fields are +what the **command center** shows; its `record` object holds everything the page +never shows. Each fact has one home in it. ## Program record -Reuse an established record, or choose a task-owned directory that the chief and -its owners can read and that survives worktree cleanup. Put its absolute path in -the chief's handoff and every owner kickoff. It needs no wiki or committed state -file. +Keep `program.json` and a `reports/` folder in a task-owned directory that the +chief and its owners can read and that survives worktree cleanup. Adopt an +existing program's file rather than starting another. Put its absolute path in +the chief's handoff and every owner kickoff. **One writer:** owners write only their own `reports/<outcome ID>.md`. The -current chief alone writes the rest of the record and the snapshot. Before each -write, confirm the record still names this thread as chief; otherwise follow +current chief alone writes `program.json`. Before each write, confirm +`record.chief` still names this thread; otherwise follow [chief move](chief-move.md). -Keep, for the program: - -- The goal, original request and message sources, accepted corrections, - preserved requirements, the agreed scope that Land's authority covers, and - holds with their source and release condition. -- Standing approvals, accepted tradeoffs with what would prompt revisiting them, - and decision requests with their dispositions. -- Discoveries with origin and destination, and support work with the owners it - affects. -- The current chief thread, the snapshot state and HTML paths, hosting details, - and the next useful action. - -Keep, for each outcome under its stable ID: - -- **Sources:** the ticket, snip, PR, or message IDs it came from. -- **Scope:** the result, the code it covers, its proof, and what it leaves to - other outcomes. -- **Finish line:** the picked line (Land, Try, review-ready PR, or draft PR) and - why, and the effective line while it is provisional. -- **Owner:** identity, thread or task IDs and links, start mode (top-level, - delegated, or adopted), return route, report path, model, and workspace. An - outcome can be planned without one. -- **Inputs:** logical dependencies, branch-order constraints, and any blocks - with what is missing, who can supply it, and what it blocks. -- **Provisional:** the branch, PR, or assumption it rests on, the decision or - landing that clears it, and what may continue meanwhile. -- **Layers:** each PR, branch, or local artifact, with its link, head, base, - and stack parent. -- **Results:** what the owner showed and how to open it, with what that needs. -- **Waits:** pending PRs, live helpers and watchers, and the events they await. -- **Landing:** the merge set, its one lander, target branch, pending submission - or queue state, and the landing release (issuing chief, lander, accepted - head/base per PR, resources dependents still need, active or withdrawn). -- **Disposition:** acceptance evidence, the delivery state, and any stop for the - user. - -Date decisions and cite their sources. Code claims name the repository and -revision they describe. Keep superseded reasoning when it explains a tradeoff, -and recheck current facts at their source before acting on them. - -Each decision request keeps its ID, originating owner and message, exact -question, affected outcomes with the phase each one blocks, and disposition -under Orchestration's [decision requests](../../orchestration/SKILL.md#return). -[Decisions](decisions.md) say when a Need is answered, resolved, reopened, or -withdrawn. +### Write the record + +Record each owner report, answer, direction, and decision in one write, before +you reply or move on: + +1. Write the whole new state to `.program.json.tmp` beside `program.json`, with + `updatedAt` set to the time of this write. +2. Check it from this skill's folder, and fix every error it reports: + + ```bash + python3 scripts/render_dashboard.py --state /absolute/path/.program.json.tmp --check + ``` + +3. `mv` it over `program.json`. + +The hosted page shows the new state on its next request. + +### Where each fact goes + +For the program: + +| Fact | Field | +| --- | --- | +| The goal | `goal` | +| A program-wide finish-line note, such as who decides merges | `finishLine` | +| The original request and its message sources | `record.request` | +| Accepted corrections | `record.corrections` | +| Preserved requirements | `record.requirements` | +| The agreed scope that Land's authority covers, and its gates | `record.landingAuthority` | +| Standing directions, and holds with the condition that releases each | `record.directions`, a hold's release in `until` | +| Standing approvals | `record.approvals` | +| Accepted tradeoffs, with what would prompt revisiting them | `revisits` | +| A decision request's question, options, blocked outcomes and phases, status, answer, and basis | `needs` | +| A decision request's date, originating owner and message, and reasoning, including superseded reasoning | `record.decisions`, under the Need's ID | +| A decision with no Need in the file, such as a withdrawn one or a chief call on the plan | `record.decisions`, with its `question`, `affects`, and `disposition` | +| A discovery that became work | That row's `origin` and `description` | +| Any other discovery, with its origin and route | `record.discoveries` | +| Support work, and the outcomes it unblocks | A `support` row, named in their `blockedBy` or `blocked` | +| The current chief thread | `record.chief` | +| Hosting | `record.hosting` | +| The next useful action | `record.next` | + +For each outcome, under its stable ID: + +| Fact | Field | +| --- | --- | +| Ticket sources | `tickets` | +| Snip and message sources | `record.outcomes[ID].sources` | +| The result it delivers | `description` | +| The code it covers, its proof, and what it leaves to other outcomes | `record.outcomes[ID].scope` | +| The picked and effective finish lines | `finishLine`, `effectiveFinishLine` | +| Why that finish line | `record.outcomes[ID].why` | +| The owner's name and session link | `owner`, `sessionUrl` | +| The owner's task IDs, start mode, return route, report path, model, workspace, servers with their stop actions, and handled follow-up messages | `record.owners[ID]` | +| Logical dependencies | `blockedBy` | +| Branch order | Each layer's `stackedOn` | +| Another block, with what is missing and who can supply it | `blocked`, or a Need's `blocks` | +| What it rests on provisionally, what clears it, and what continues meanwhile | `provisional`, or the Need's `meanwhile` | +| Each PR, branch, or local artifact, with link, head, base, and stack parent | `layers` | +| What the owner showed and what opening it needs | `results` | +| Pending PRs, live helpers and watchers, and the events they await | `record.waits` | +| The merge set, lander, target branch, accepted head and base per PR, submission or queue state, resources dependents still need, and the release state | `record.landings` | +| Acceptance evidence links | `evidence` | +| Blocked or untested assertions in its proof | `record.outcomes[ID].acceptance` | +| The delivery state | `state` | +| A stop for the user | An open Need | + +Date entries and cite their sources. Code claims name the repository and +revision they describe. Recheck current facts at their source before acting on +them. [Decisions](decisions.md) say when a Need is answered, resolved, reopened, +or withdrawn. ### Owner reports @@ -70,22 +97,22 @@ fields; ask no other format. Record, from each: | Report field | Record it under | | --- | --- | -| Outcome and finish line | The outcome's finish line and delivery state | -| Layers | Layers, after checking each head and base at the forge | -| Proof | Acceptance evidence, with every blocked or untested assertion kept visible | -| Shown | Results, with access limits, servers, and stop actions | -| Waits | Waits, and which pending PRs this thread watches | -| Decisions | Needs, with the phase each blocks, and provisional assumptions | -| Follow-ups | Discoveries, each with its disposition | -| Sources | Handled follow-up messages, so none is answered twice | +| Outcome and finish line | `finishLine` and `state` | +| Layers | `layers`, after checking each head and base at the forge | +| Proof | `evidence`, and every blocked or untested assertion in `record.outcomes[ID].acceptance` | +| Shown | `results` with access limits; servers and stop actions in `record.owners[ID].resources` | +| Waits | `record.waits`, naming the pending PRs this thread watches | +| Decisions | `needs` with the phase each blocks, `record.decisions`, and `provisional` assumptions | +| Follow-ups | A work row or `record.discoveries`, each with its disposition | +| Sources | `record.owners[ID].handled`, so no message is answered twice | -Also record task IDs an owner returns when it resumes. +Also record task IDs an owner returns when it resumes in `record.owners[ID].tasks`. ## Command center -Before each refresh, read the relevant owner reports, user direction, tickets, +Before each write, read the relevant owner reports, user direction, tickets, and live forge state. Each work row carries the time its state was last -observed, separate from the snapshot's assembly time. Keep an unchecked row's +observed, separate from the file's `updatedAt`. Keep an unchecked row's old observation time; use `unknown` with `unknownReason` when its state cannot be read. @@ -133,26 +160,27 @@ continue, so a shown Try waiting for its choice or a provisional build carries an open Need without being blocked. Put what continues meanwhile in `meanwhile`. -Every snapshot carries all open and resolved Needs, so each resolved one stays +The file carries all open and resolved Needs, so each resolved one stays reopenable; answered ones may remain. Omit a withdrawn or superseded Need only after recording its disposition. If work lands while a Need about it is open, keep the landed row and point the Need's `blocks` at a follow-up row for any change the answer requires. -### Snapshot format +### File format -The renderer reads one JSON object. Fields without `?` are required. Times are +`program.json` is one JSON object. Fields without `?` are required. Times are ISO timestamps with a timezone, such as `2026-11-02T17:00:00Z`. Omit absent optional fields rather than sending empty strings. IDs are unique within each collection, and layer IDs are unique across the program. ```ts type Link = { label: string; url: string }; -type Snapshot = { +type ProgramState = Snapshot & { record: ProgramRecord }; +type Snapshot = { // what the page shows title: string; goal: string; finishLine?: string; // a program-wide note, such as who decides merges - updatedAt: string; // snapshot assembly time + updatedAt: string; // the time of the last write demo?: boolean; // true only for an illustrative example needs: Need[]; work: Work[]; @@ -204,16 +232,56 @@ type Revisit = { id: string; title: string; tradeoff: string; revisit: string; source?: string; // decision ID or source reference }; + +// What the page never shows. Dates are YYYY-MM-DD or timestamps; prose is markdown. +type Entry = { date: string; source: string; text: string }; +type ProgramRecord = { + chief: string; // the current chief thread + request: { text: string; sources?: string[] }; + corrections?: Entry[]; + requirements?: Entry[]; + approvals?: (Entry & { id: string; covers: string; picks: string })[]; + directions?: (Entry & { until?: string })[]; // until: what releases a hold + landingAuthority?: Entry; + landings?: Landing[]; + outcomes?: { [workId: string]: { sources?: string[]; scope?: string; why?: string; acceptance?: string } }; + owners?: { [workId: string]: Owner }; + waits?: { date: string; on: string; event: string; work?: string; watcher?: string }[]; + decisions?: Decision[]; + discoveries?: { date: string; origin: string; text: string; route: string }[]; + hosting?: { url: string; server?: string; serve?: string; stop?: string; access?: string }; + next: string; +}; +type Owner = { + start: "top-level" | "delegated" | "adopted"; route: string; report: string; + tasks?: string[]; model?: string; workspace?: string; + resources?: string; // servers, ports, and databases, with their stop actions + handled?: string[]; // follow-up messages already handled +}; +type Landing = { + id: string; date: string; chief: string; lander: string; target: string; + work: string[]; prs: { url: string; head: string; base: string }[]; + state: "released" | "submitted" | "landed" | "withdrawn"; + keep?: string; // resources dependents still need + note?: string; // such as why it was withdrawn +}; +type Decision = { + id: string; date: string; source: string; owner?: string; rationale?: string; + question?: string; affects?: string[]; disposition?: string; // only when no Need has this ID +}; ``` -The renderer rejects a snapshot that claims more than the record supports: +The renderer rejects a file whose page claims more than the record supports: provisional work that merged, keeps Land, or claims ready on any finish line; a Try finish line with a published PR; a shown Try with nothing to open; a ready outcome with a layer short of its finish line; a landed outcome with an unmerged layer; active work with no owner; a layer on another owner's unmerged layer without a provisional mark; active work on a dead base, or a live layer on a closed one, without a halting Need; a `start` Need on work that started; and -dangling or cyclic edges, including work blocked by itself. +dangling or cyclic edges, including work blocked by itself. In `record`, it +checks each field's shape, that per-outcome entries, waits, and landings name +existing rows, and that a decision whose Need is in the file leaves the +question, affects, and disposition to that Need. Write each `description` from the outcome's scope, not its title or the owner's next step. Store returned T3 thread links verbatim and use only real ticket, PR, @@ -221,16 +289,12 @@ result, and artifact links. ### Render and show -From this skill's folder, with Python 3 and no third-party packages: - -```bash -python3 scripts/render_dashboard.py --state /absolute/path/program.json --output /absolute/path/command-center.html -``` - -The renderer validates the snapshot, escapes content, and writes standalone -HTML stamped with a digest of the page, which open pages compare to spot a new -publication. It makes no tracker, forge, or session changes. The -[example snapshot](../assets/example-state.json) uses fictional work. +The scripts need Python 3 and no third-party packages. `--check` (under +[write the record](#write-the-record)) and the +[dashboard server](hosting.md) render with the same code: it validates the file, +escapes content, and stamps the page with a digest that open pages compare to +spot a change. It makes no tracker, forge, or session changes. The +[example file](../assets/example-state.json) uses fictional work. Each copied choice or reopen request names its decision's revision, a digest of the whole Need. Two or more open quick picks add a batch prompt that takes every @@ -240,5 +304,5 @@ recommendation. Print a Need's current revision with: python3 scripts/render_dashboard.py --state /absolute/path/program.json --revision <decision ID> ``` -Deliver the page through [dashboard hosting](hosting.md). Do not render it -inline in T3 or another chat surface. +Deliver the page through [dashboard hosting](hosting.md), never inline in T3 or +another chat surface. diff --git a/skills/chief-of-staff/scripts/render_dashboard.py b/skills/chief-of-staff/scripts/render_dashboard.py index 52e8431..3c86b08 100644 --- a/skills/chief-of-staff/scripts/render_dashboard.py +++ b/skills/chief-of-staff/scripts/render_dashboard.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Render a checked program snapshot as a command center.""" +"""Check a program state file and render its command center.""" import argparse from datetime import datetime @@ -28,6 +28,10 @@ } PHASES = ("start", "access", "proof", "acceptance", "landing") HALTING = {"start", "access", "proof"} # The others let building continue. +DAY = re.compile(r"\d{4}-\d{2}-\d{2}") +ENTRY = ("date", "source", "text") +START_MODES = ("top-level", "delegated", "adopted") +LANDING_STATES = ("released", "submitted", "landed", "withdrawn") ISO_TIME = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)") @@ -47,6 +51,17 @@ def timestamp(value, path): raise ValueError(f"{path} must be an ISO timestamp with a timezone, such as 2026-11-02T17:00:00Z") from None +def date(value, path): + if isinstance(value, str) and DAY.fullmatch(value): + try: + datetime.strptime(value, "%Y-%m-%d") + except ValueError: + raise ValueError(f"{path} must be a date, such as 2026-11-02") from None + else: + timestamp(value, path) + return value + + def url(value, path): text(value, path) candidate = value[1:-1] if value.startswith("<") and value.endswith(">") else value @@ -78,6 +93,24 @@ def links(value, path): url(item.get("url"), f"{path}[{index}].url") +def obj(value, path): + if not isinstance(value, dict): + raise ValueError(f"{path} must be an object") + return value + + +def fields(row, path, required=(), optional=()): + for key in (*required, *(key for key in optional if key in row)): + (date if key == "date" else text)(row.get(key), f"{path}.{key}") + return row + + +def unique_ids(rows, path): + ids = [text(row.get("id"), f"{path}[{index}].id") for index, row in enumerate(rows)] + if len(ids) != len(set(ids)): + raise ValueError(f"{path} contains duplicate IDs") + + def one_of(value, choices, path): text(value, path) if value not in choices: @@ -329,9 +362,7 @@ def validate(state): needs = records(state.get("needs"), "needs") revisits = records(state.get("revisits", []), "revisits") for name, rows in (("work", work), ("needs", needs), ("revisits", revisits)): - ids = [text(row.get("id"), f"{name}[{i}].id") for i, row in enumerate(rows)] - if len(ids) != len(set(ids)): - raise ValueError(f"{name} contains duplicate IDs") + unique_ids(rows, name) by_id = {row["id"]: row for row in work} layers = {} for row in work: @@ -344,9 +375,84 @@ def validate(state): text(row.get(key), f"revisits[{row['id']}].{key}") if "source" in row: text(row["source"], f"revisits[{row['id']}].source") + validate_record(state.get("record"), by_id, {row["id"] for row in needs}) return state +def per_work(value, path, by_id): + for identity, row in obj(value, path).items(): + if identity not in by_id: + raise ValueError(f"{path} names missing work {identity}") + yield f"{path}[{identity}]", obj(row, f"{path}[{identity}]") + + +def known_work(identities, path, by_id): + for identity in strings(identities, path): + if identity not in by_id: + raise ValueError(f"{path} names missing work {identity}") + + +def validate_record(record, by_id, need_ids): + """The record holds what the page does not show; check its shape and its links to the page's rows.""" + record = obj(record, "record") + fields(record, "record", ("chief", "next")) + request = fields(obj(record.get("request"), "record.request"), "record.request", ("text",)) + strings(request.get("sources", []), "record.request.sources") + for name, required, optional in ( + ("corrections", ENTRY, ()), ("requirements", ENTRY, ()), + ("approvals", (*ENTRY, "id", "covers", "picks"), ()), ("directions", ENTRY, ("until",)), + ("waits", ("date", "on", "event"), ("work", "watcher")), + ("discoveries", ("date", "origin", "text", "route"), ()), + ): + for index, row in enumerate(records(record.get(name, []), f"record.{name}")): + fields(row, f"record.{name}[{index}]", required, optional) + if "work" in row and row["work"] not in by_id: + raise ValueError(f"record.{name}[{index}].work names missing work {row['work']}") + unique_ids(record.get("approvals", []), "record.approvals") + if "landingAuthority" in record: + fields(obj(record["landingAuthority"], "record.landingAuthority"), "record.landingAuthority", ENTRY) + + for path, row in per_work(record.get("outcomes", {}), "record.outcomes", by_id): + fields(row, path, (), ("scope", "why", "acceptance")) + strings(row.get("sources", []), f"{path}.sources") + for path, row in per_work(record.get("owners", {}), "record.owners", by_id): + fields(row, path, ("route", "report"), ("model", "workspace", "resources")) + one_of(row.get("start"), START_MODES, f"{path}.start") + for key in ("tasks", "handled"): + strings(row.get(key, []), f"{path}.{key}") + + landings = records(record.get("landings", []), "record.landings") + unique_ids(landings, "record.landings") + for row in landings: + path = f"record.landings[{row['id']}]" + fields(row, path, ("date", "chief", "lander", "target"), ("keep", "note")) + one_of(row.get("state"), LANDING_STATES, f"{path}.state") + if not row.get("work") or not row.get("prs"): + raise ValueError(f"{path} needs its work and PRs") + known_work(row["work"], f"{path}.work", by_id) + for index, pr in enumerate(records(row["prs"], f"{path}.prs")): + url(pr.get("url"), f"{path}.prs[{index}].url") + fields(pr, f"{path}.prs[{index}]", ("head", "base")) + + decisions = records(record.get("decisions", []), "record.decisions") + unique_ids(decisions, "record.decisions") + for row in decisions: + path = f"record.decisions[{row['id']}]" + fields(row, path, ("date", "source"), ("owner", "rationale")) + if row["id"] in need_ids: + for key in ("question", "affects", "disposition"): + if key in row: + raise ValueError(f"{path}.{key}: the Need {row['id']} holds it") + else: + fields(row, path, ("question", "disposition")) + strings(row.get("affects", []), f"{path}.affects") + + if "hosting" in record: + hosting = obj(record["hosting"], "record.hosting") + url(hosting.get("url"), "record.hosting.url") + fields(hosting, "record.hosting", (), ("server", "serve", "stop", "access")) + + STAGE_ORDER = ("planned", "building", "shown", "in-review", "landed", "done", "unknown", "scrapped") ICONS = { "planned": "circle-dashed", "building": "hammer", "shown": "hand", "in-review": "scan-eye", @@ -725,7 +831,8 @@ def overview(work): ) -def render(state): +def render(state, error=None): + """Render the page; error adds a banner saying the page shows this older state.""" validate(state) open_needs = [row for row in state["needs"] if row["status"] == "open"] judgment = [row for row in open_needs if row.get("weight") != "quick"] @@ -751,7 +858,11 @@ def render(state): facts += f'<p class="fact">{icon("finish")}<span><span class="fact-label">Finish line</span> {e(state["finishLine"])}</span></p>' facts += f'<p class="fact">{icon("snapshot")}<span><span class="fact-label">Snapshot</span> {when(state["updatedAt"], age=True)}</span></p>' demo = '<span class="demo" title="Fictional work, not live project status.">Illustrative example</span>' if state.get("demo") else "" - content = f'<header class="masthead"><p class="eyebrow"><span>Chief of Staff</span>{demo}</p><h1>{e(state["title"])}</h1><p class="goal">{e(state["goal"])}</p><div class="facts">{facts}</div></header>' + content = "" + if error: + content += (f'<p class="state-error" role="alert">{icon("blocked")}<span><strong>Showing the last good state.</strong> ' + f'The program file failed to load: {e(error)}</span></p>') + content += f'<header class="masthead"><p class="eyebrow"><span>Chief of Staff</span>{demo}</p><h1>{e(state["title"])}</h1><p class="goal">{e(state["goal"])}</p><div class="facts">{facts}</div></header>' needs_badge = f'<span class="count-pill{" attention" if open_needs else ""}">{len(open_needs)}</span>' content += ( '<nav class="navigation" aria-label="Program views"><div class="tabs" role="tablist" aria-label="Command center">' @@ -770,27 +881,21 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--state", required=True, type=Path) action = parser.add_mutually_exclusive_group(required=True) - action.add_argument("--output", type=Path) + action.add_argument("--check", action="store_true", help="Validate the state and render it in memory; write nothing") action.add_argument("--revision", metavar="DECISION_ID", help="Print a decision's current revision") args = parser.parse_args() - if args.revision is not None: - try: - state = validate(json.loads(args.state.read_text())) - print(revision(next(row for row in state["needs"] if row["id"] == args.revision))) - except StopIteration: - parser.error(f"no decision {args.revision}") - except (OSError, ValueError) as error: - parser.error(str(error)) - return - if args.state.resolve() == args.output.resolve(): - parser.error("--output must differ from --state") try: - result = render(json.loads(args.state.read_text())) - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(result) + state = json.loads(args.state.read_text()) + if args.check: + render(state) + print(f"{args.state}: valid") + return + validate(state) + print(revision(next(row for row in state["needs"] if row["id"] == args.revision))) + except StopIteration: + parser.error(f"no decision {args.revision}") except (OSError, ValueError) as error: parser.error(str(error)) - print(args.output.resolve()) if __name__ == "__main__": diff --git a/skills/chief-of-staff/scripts/serve_dashboard.py b/skills/chief-of-staff/scripts/serve_dashboard.py new file mode 100644 index 0000000..744d082 --- /dev/null +++ b/skills/chief-of-staff/scripts/serve_dashboard.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Serve the command center on loopback, rendering the program state file on each request.""" + +import argparse +from html import escape +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import sys +from urllib.parse import urlsplit + +from render_dashboard import render + + +ERROR_PAGE = """<!doctype html> +<html lang="en"><head><meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<meta name="color-scheme" content="light dark"> +<meta http-equiv="refresh" content="20"> +<title>Command center unavailable + +

Command center unavailable

+

The program file failed to load, and there is no earlier good state to show. This page retries every 20 seconds.

+

{error}

+ +""" + + +class DashboardServer(ThreadingHTTPServer): + def __init__(self, state_path, port=0): + self.state_path = Path(state_path) + self.last_good = None + self.last_error = None + super().__init__(("127.0.0.1", port), DashboardHandler) + self.page() + + def page(self): + """Return (status, html) for the current file, falling back to the last state that rendered.""" + try: + state = json.loads(self.state_path.read_text()) + page = render(state) + except Exception as error: # Any failure to read or render keeps the last good page up. + message = f"{type(error).__name__}: {error}" if not isinstance(error, ValueError) else str(error) + if message != self.last_error: + print(f"{self.state_path}: {message}", file=sys.stderr, flush=True) + self.last_error = message + if self.last_good is None: + return HTTPStatus.SERVICE_UNAVAILABLE, ERROR_PAGE.format(error=escape(message)) + return HTTPStatus.OK, render(self.last_good, error=message) + self.last_good, self.last_error = state, None + return HTTPStatus.OK, page + + +class DashboardHandler(BaseHTTPRequestHandler): + def do_GET(self): + if urlsplit(self.path).path != "/": + self.send_error(HTTPStatus.NOT_FOUND) + return + status, page = self.server.page() + body = page.encode() + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state", required=True, type=Path, help="The program state file, such as program.json") + parser.add_argument("--port", type=int, default=0, help="Loopback port; 0 picks an unused one") + args = parser.parse_args() + server = DashboardServer(args.state.resolve(), args.port) + print(f"http://127.0.0.1:{server.server_port}/", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/skills/chief-of-staff/scripts/test_command_center.py b/skills/chief-of-staff/scripts/test_command_center.py index 0b2dd72..3733fab 100644 --- a/skills/chief-of-staff/scripts/test_command_center.py +++ b/skills/chief-of-staff/scripts/test_command_center.py @@ -1,12 +1,14 @@ -"""Drive the hosted command center in headless Chrome against a static file server. +"""Drive the hosted command center in headless Chrome. Chrome's debugging pipe needs no client package. A clock installed before the page's script runs its timers on demand; everything else is the real page, browser, and network. Without a usable Chrome the tests skip locally and fail in CI. """ +import contextlib import copy import functools +import io import http.server import json import os @@ -21,9 +23,10 @@ from urllib.parse import urlsplit from render_dashboard import render +from serve_dashboard import DashboardServer +from test_render_dashboard import page_state -EXAMPLE = Path(__file__).parents[1] / "assets" / "example-state.json" CHECK_INTERVAL = 20_000 DESKTOP = (1280, 800) PHONE = (390, 844) @@ -272,7 +275,7 @@ def identity(self): class Host(http.server.ThreadingHTTPServer): - """The documented hosting: a static file server over a folder whose index.html is replaced atomically.""" + """A scriptable stand-in for the dashboard server: faults, stale navigations, and template changes.""" def __init__(self, folder): self.page_requests = 0 @@ -377,7 +380,7 @@ def tearDownModule(): class CommandCenterCase(unittest.TestCase): def setUp(self): - self.state = json.loads(EXAMPLE.read_text()) + self.state = page_state() self.folder = tempfile.TemporaryDirectory() public = Path(self.folder.name) / "public" public.mkdir() @@ -532,6 +535,41 @@ def test_reload_that_returns_the_old_template_keeps_the_old_page_without_looping EVIDENCE = "#toggle-evidence-" + "W1".encode().hex() +class ServedPageTests(CommandCenterCase): + """The open page against serve_dashboard.py, where writing program.json is the whole publish.""" + + def setUp(self): + self.state = page_state() + folder = tempfile.TemporaryDirectory() + self.addCleanup(folder.cleanup) + self.program = Path(folder.name) / "program.json" + self.write(self.state) + self.host = DashboardServer(self.program) + self.addCleanup(self.host.server_close) + threading.Thread(target=self.host.serve_forever, args=(.01,), daemon=True).start() + self.addCleanup(self.host.shutdown) + + def write(self, state): + staging = self.program.with_name(".program.json.tmp") + staging.write_text(json.dumps(state)) + os.replace(staging, self.program) + + def test_open_page_follows_each_write_and_flags_a_bad_one_until_it_is_fixed(self): + page = self.open_page() + self.write(self.changed(lambda state: state.update(title="Written once"))) + page.advance(CHECK_INTERVAL + 500) + self.assertIn("Written once", page.text()) + self.program.write_text("{broken") + with contextlib.redirect_stderr(io.StringIO()): + page.advance(CHECK_INTERVAL) + self.assertIn("Showing the last good state.", page.text()) + self.assertIn("Written once", page.text()) + self.write(self.changed(lambda state: state.update(title="Written again"))) + page.advance(CHECK_INTERVAL) + self.assertIn("Written again", page.text()) + self.assertNotIn("Showing the last good state.", page.text()) + + class ViewTests(CommandCenterCase): def prepare_view(self, page): page.click("#work-tab") diff --git a/skills/chief-of-staff/scripts/test_render_dashboard.py b/skills/chief-of-staff/scripts/test_render_dashboard.py index 8faf06d..a5e204f 100644 --- a/skills/chief-of-staff/scripts/test_render_dashboard.py +++ b/skills/chief-of-staff/scripts/test_render_dashboard.py @@ -15,6 +15,12 @@ SCRIPT = Path(__file__).with_name("render_dashboard.py") EXAMPLE = SCRIPT.parents[1] / "assets" / "example-state.json" +# Page tests reshape rows freely, so they carry a record that references none. +BARE_RECORD = {"chief": "Chief thread", "request": {"text": "Carry the example program."}, "next": "Wait for owner reports."} + + +def page_state(): + return json.loads(EXAMPLE.read_text()) | {"record": copy.deepcopy(BARE_RECORD)} class Elements(HTMLParser): @@ -83,7 +89,7 @@ def card_html(html, identity): class DashboardTests(unittest.TestCase): def setUp(self): - self.state = json.loads(EXAMPLE.read_text()) + self.state = page_state() def work(self, identity, state=None): return next(row for row in (state or self.state)['work'] if row['id'] == identity) @@ -267,17 +273,31 @@ def test_unknown_and_empty_snapshots_remain_explicit(self): self.assertIn("Nothing needs you in this snapshot", html) self.assertIn("No work recorded in this snapshot", html) - def test_cli_renders_and_rejects_input_overwrite(self): + def test_check_passes_a_valid_file_and_fails_an_invalid_one_without_writing(self): with tempfile.TemporaryDirectory() as directory: - source, output = Path(directory) / "state.json", Path(directory) / "dashboard.html" + source = Path(directory) / "program.json" source.write_text(json.dumps(self.state)) - command = [sys.executable, str(SCRIPT), "--state", str(source), "--output"] - good = subprocess.run([*command, str(output)], capture_output=True, text=True) + command = [sys.executable, str(SCRIPT), "--state", str(source), "--check"] + good = subprocess.run(command, capture_output=True, text=True) self.assertEqual(good.returncode, 0, good.stderr) - self.assertTrue(output.read_text().startswith("")) - bad = subprocess.run([*command, str(source)], capture_output=True, text=True) + self.work("W1")["state"] = "finished" + source.write_text(json.dumps(self.state)) + bad = subprocess.run(command, capture_output=True, text=True) self.assertNotEqual(bad.returncode, 0) - self.assertEqual(json.loads(source.read_text()), self.state) + self.assertIn("work[W1].state must be one of", bad.stderr) + source.write_text("{not json") + self.assertNotEqual(subprocess.run(command, capture_output=True, text=True).returncode, 0) + self.assertEqual([path.name for path in Path(directory).iterdir()], ["program.json"]) + + def test_error_banner_leads_the_page_escaped_and_changes_its_version(self): + plain = render(self.state) + flagged = render(self.state, error="record.next must be nonempty") + main = flagged.split("
")[1] + self.assertTrue(main.startswith('
")[1]) + + def test_without_a_good_state_it_serves_an_error_page(self): + self.program.write_text("{not json") + self.start() + status, _, page = self.get() + self.assertEqual(status, 503) + self.assertIn("Command center unavailable", page) + self.assertIn("Expecting property name", page) + self.write(self.state) + self.assertEqual(self.get()[0], 200) + + def test_serves_only_the_page(self): + self.write(self.state) + (self.folder / "secret.txt").write_text("private") + self.start() + for path in ("/program.json", "/secret.txt", "/index.html", "/../program.json", "//program.json", "/reports/W1.md"): + with self.subTest(path=path): + status, _, body = self.get(path) + self.assertEqual(status, 404) + self.assertNotIn("private", body) + self.assertNotIn('"record"', body) + self.assertEqual(self.get("/?tab=work")[0], 200) + self.assertEqual(self.get(method="POST")[0], 501) + + def test_cli_prints_its_loopback_url_and_serves_the_file(self): + self.write(self.changed("From the command line")) + process = subprocess.Popen([sys.executable, str(SCRIPT), "--state", str(self.program)], stdout=subprocess.PIPE, text=True) + self.addCleanup(process.wait, 10) + self.addCleanup(process.terminate) + address = process.stdout.readline().strip() + self.assertRegex(address, r"^http://127\.0\.0\.1:\d+/$") + self.port = int(address.rsplit(":", 1)[1].rstrip("/")) + self.assertEqual(self.title(self.get()[2]), "From the command line") + + +if __name__ == "__main__": + unittest.main() From a1d4ff63643b3f495b16c24519c28af3a414a987 Mon Sep 17 00:00:00 2001 From: Sree Raman <1271305+sreejithraman@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:15:53 -0500 Subject: [PATCH 02/16] Prefer Playwright's Chromium in command center tests to stop leaking Chrome clones Co-Authored-By: Claude Opus 5.5 --- .../scripts/test_command_center.py | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/skills/chief-of-staff/scripts/test_command_center.py b/skills/chief-of-staff/scripts/test_command_center.py index 3733fab..1a31353 100644 --- a/skills/chief-of-staff/scripts/test_command_center.py +++ b/skills/chief-of-staff/scripts/test_command_center.py @@ -16,6 +16,7 @@ import select import shutil import subprocess +import sys import tempfile import threading import time @@ -34,7 +35,14 @@ HELD_LIMIT = 120_000 VIEW_KEY_PREFIX = "command-center-view:" +# Each launch of branded macOS Chrome copies its signed app into a code_sign_clone +# folder under /var/folders, and the copy outlives the process, so it comes last. MAC_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" +PLAYWRIGHT_CHROMES = ( + "chromium-*/chrome-mac*/*.app/Contents/MacOS/*", + "chromium-*/chrome-linux*/chrome", + "chromium_headless_shell-*/chrome-headless-shell-*/chrome-headless-shell", +) PAGE_HARNESS = """ (() => { const realSetTimeout = setTimeout.bind(window); @@ -117,9 +125,19 @@ def next_template(html): return html.replace('data-template="', 'data-template="next').replace('data-snapshot="', 'data-snapshot="next') +def playwright_chromes(): + """Playwright's cached Chromium builds, Chrome for Testing first, newest first.""" + default = "~/Library/Caches" if sys.platform == "darwin" else os.environ.get("XDG_CACHE_HOME", "~/.cache") + root = Path(os.environ.get("PLAYWRIGHT_BROWSERS_PATH") or f"{default}/ms-playwright").expanduser() + for pattern in PLAYWRIGHT_CHROMES: + yield from sorted(root.glob(pattern), key=lambda path: int(path.relative_to(root).parts[0].rpartition("-")[2]), + reverse=True) + + def chrome_path(): - names = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", MAC_CHROME) - return next(filter(None, map(shutil.which, names)), None) + candidates = (os.environ.get("COMMAND_CENTER_CHROME"), *map(str, playwright_chromes()), + "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", MAC_CHROME) + return next(filter(None, (shutil.which(candidate) for candidate in candidates if candidate)), None) class Chrome: @@ -362,6 +380,7 @@ def setUpModule(): if required: raise RuntimeError("CI needs Chrome for the command center tests") raise unittest.SkipTest("Chrome is not installed") + print(f"command center tests use {executable}", file=sys.stderr) browser_profile = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) browser = Chrome(executable, browser_profile.name) try: From dd3ee4acd59575e516df39eee309680e9facb0dd Mon Sep 17 00:00:00 2001 From: Sree Raman <1271305+sreejithraman@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:58:06 -0500 Subject: [PATCH 03/16] Keep the chief's served page and record writes consistent under failure - serve_dashboard.py caches the last rendered HTML and, on any read or render failure, serves it under an escaped banner with a new digest, without rendering again. One lock serializes read, render, and cache, so an older request can't replace a newer page. - updatedAt now means the last write that changed what the page shows; record-only writes keep it, so open pages stay put. - A program.json that fails --check is repaired by the thread whose own last write named it chief; other threads report the break. - Browser tests run on the real DashboardServer with a small handler hook for faults; COMMAND_CENTER_CHROME is gone; render_dashboard.py reuses obj() and one missing-work check. Co-Authored-By: Claude Opus 5.5 --- .../chief-of-staff/references/chief-move.md | 5 + skills/chief-of-staff/references/hosting.md | 8 +- .../references/program-state.md | 12 +- .../scripts/render_dashboard.py | 47 +++--- .../chief-of-staff/scripts/serve_dashboard.py | 38 +++-- .../scripts/test_command_center.py | 152 +++++++----------- .../scripts/test_render_dashboard.py | 16 +- .../scripts/test_serve_dashboard.py | 67 +++++++- 8 files changed, 196 insertions(+), 149 deletions(-) diff --git a/skills/chief-of-staff/references/chief-move.md b/skills/chief-of-staff/references/chief-move.md index f0dccc5..2eb6433 100644 --- a/skills/chief-of-staff/references/chief-move.md +++ b/skills/chief-of-staff/references/chief-move.md @@ -12,6 +12,11 @@ is not the chief reads the chief's thread, forwards the item with answers, and stops. It unwatches its PRs when the chief asks. A user item that arrives through `t3_thread_send` counts under [record an answer](decisions.md#record-an-answer). +When `program.json` fails `--check`, only the thread whose own last write named +it chief writes: it repairs the file through +[write the record](program-state.md#write-the-record), then resumes the check. +Any other thread reports the break to the user and writes nothing. + ## Move Only the chief moves the program to a new thread. In one record write it names diff --git a/skills/chief-of-staff/references/hosting.md b/skills/chief-of-staff/references/hosting.md index ea3fecb..43ca557 100644 --- a/skills/chief-of-staff/references/hosting.md +++ b/skills/chief-of-staff/references/hosting.md @@ -16,8 +16,8 @@ database, or wakeups. It binds `127.0.0.1` and serves only `/`, nothing else from disk. When the file fails to load, it serves the last good page under an error banner and logs the error; with no good page since it started, it serves an error page - that retries. Fix the file under - [write the record](program-state.md#write-the-record). + that retries. Repair the file as [check the chief](chief-move.md#check-the-chief) + says. 2. Use Show Me's [web sharing](../../show-me/references/web-sharing.md) to share it. For Tailscale, follow its procedure with this server as the origin: existing Serve mappings preserved, verification, lifetime, and teardown. @@ -34,8 +34,8 @@ An open page fetches its URL every 20 seconds while visible and when the user returns to it, then shows a changed page in place with its tab, selection, sections, and scroll position kept. Each browser tab keeps that view for the URL, so a manual refresh returns to it too; a new tab starts at Needs you. A -write that changes only `record` leaves open pages alone. Answers still go -through chat. +write that changes only `record` keeps `updatedAt` and leaves open pages alone. +Answers still go through chat. For teardown, follow the [web sharing](../../show-me/references/web-sharing.md#take-it-down)'s take-down diff --git a/skills/chief-of-staff/references/program-state.md b/skills/chief-of-staff/references/program-state.md index eaa4f51..fbbc7bc 100644 --- a/skills/chief-of-staff/references/program-state.md +++ b/skills/chief-of-staff/references/program-state.md @@ -15,16 +15,18 @@ the chief's handoff and every owner kickoff. **One writer:** owners write only their own `reports/.md`. The current chief alone writes `program.json`. Before each write, confirm -`record.chief` still names this thread; otherwise follow -[chief move](chief-move.md). +`record.chief` still names this thread; otherwise, or if the file fails to load, +follow [check the chief](chief-move.md#check-the-chief). ### Write the record Record each owner report, answer, direction, and decision in one write, before you reply or move on: -1. Write the whole new state to `.program.json.tmp` beside `program.json`, with - `updatedAt` set to the time of this write. +1. Write the whole new state to `.program.json.tmp` beside `program.json`. Set + `updatedAt` to the time of this write if it changes anything outside + `record`; a write that changes only `record` keeps `updatedAt`, so open + pages stay put. 2. Check it from this skill's folder, and fix every error it reports: ```bash @@ -180,7 +182,7 @@ type Snapshot = { // what the page shows title: string; goal: string; finishLine?: string; // a program-wide note, such as who decides merges - updatedAt: string; // the time of the last write + updatedAt: string; // the last write that changed what the page shows demo?: boolean; // true only for an illustrative example needs: Need[]; work: Work[]; diff --git a/skills/chief-of-staff/scripts/render_dashboard.py b/skills/chief-of-staff/scripts/render_dashboard.py index 3c86b08..d711587 100644 --- a/skills/chief-of-staff/scripts/render_dashboard.py +++ b/skills/chief-of-staff/scripts/render_dashboard.py @@ -187,9 +187,7 @@ def validate_work(row, layers): if state == "done": raise ValueError(f"{path}: an outcome ends landed, at its shown Try, or at its PR state, not done") if "provisional" in row: - provisional = row["provisional"] - if not isinstance(provisional, dict): - raise ValueError(f"{path}.provisional must be an object") + provisional = obj(row["provisional"], f"{path}.provisional") for key in ("basis", "clears"): text(provisional.get(key), f"{path}.provisional.{key}") strings(provisional.get("on", []), f"{path}.provisional.on") @@ -349,8 +347,7 @@ def validate_need(row, by_id): def validate(state): - if not isinstance(state, dict): - raise ValueError("state must be an object") + obj(state, "state") for key in ("title", "goal"): text(state.get(key), key) timestamp(state.get("updatedAt"), "updatedAt") @@ -379,19 +376,17 @@ def validate(state): return state +def known(identity, path, by_id): + if identity not in by_id: + raise ValueError(f"{path} names missing work {identity}") + + def per_work(value, path, by_id): for identity, row in obj(value, path).items(): - if identity not in by_id: - raise ValueError(f"{path} names missing work {identity}") + known(identity, path, by_id) yield f"{path}[{identity}]", obj(row, f"{path}[{identity}]") -def known_work(identities, path, by_id): - for identity in strings(identities, path): - if identity not in by_id: - raise ValueError(f"{path} names missing work {identity}") - - def validate_record(record, by_id, need_ids): """The record holds what the page does not show; check its shape and its links to the page's rows.""" record = obj(record, "record") @@ -406,8 +401,8 @@ def validate_record(record, by_id, need_ids): ): for index, row in enumerate(records(record.get(name, []), f"record.{name}")): fields(row, f"record.{name}[{index}]", required, optional) - if "work" in row and row["work"] not in by_id: - raise ValueError(f"record.{name}[{index}].work names missing work {row['work']}") + if "work" in row: + known(row["work"], f"record.{name}[{index}].work", by_id) unique_ids(record.get("approvals", []), "record.approvals") if "landingAuthority" in record: fields(obj(record["landingAuthority"], "record.landingAuthority"), "record.landingAuthority", ENTRY) @@ -429,7 +424,8 @@ def validate_record(record, by_id, need_ids): one_of(row.get("state"), LANDING_STATES, f"{path}.state") if not row.get("work") or not row.get("prs"): raise ValueError(f"{path} needs its work and PRs") - known_work(row["work"], f"{path}.work", by_id) + for identity in strings(row["work"], f"{path}.work"): + known(identity, f"{path}.work", by_id) for index, pr in enumerate(records(row["prs"], f"{path}.prs")): url(pr.get("url"), f"{path}.prs[{index}].url") fields(pr, f"{path}.prs[{index}]", ("head", "base")) @@ -831,8 +827,8 @@ def overview(work): ) -def render(state, error=None): - """Render the page; error adds a banner saying the page shows this older state.""" +def render(state): + """Validate the state and render its page.""" validate(state) open_needs = [row for row in state["needs"] if row["status"] == "open"] judgment = [row for row in open_needs if row.get("weight") != "quick"] @@ -858,11 +854,7 @@ def render(state, error=None): facts += f'

{icon("finish")}Finish line {e(state["finishLine"])}

' facts += f'

{icon("snapshot")}Snapshot {when(state["updatedAt"], age=True)}

' demo = 'Illustrative example' if state.get("demo") else "" - content = "" - if error: - content += (f'') - content += f'

Chief of Staff{demo}

{e(state["title"])}

{e(state["goal"])}

{facts}
' + content = f'

Chief of Staff{demo}

{e(state["title"])}

{e(state["goal"])}

{facts}
' needs_badge = f'{len(open_needs)}' content += ( '