In-flight visibility — landed evidence

One cumulative record, updated as each node lands. Anchors are stable; the plan's evergreen sections link to them as they collapse.

Declared measures and what each has produced

MeasureEvidenceVerdict
Bounded readsExactly one 65,536-byte read observed on an oversized stream — § crew routespass
Live run visiblePhase classification proven on fixtures; the 3-second latency measure against a real dispatch is still open — § crew routespartial
NavigableThe crew tab renders each run with linked plan and section; the screenshot figure and the in-flight band remain open — § watcher surfacepartial
Visible to agents tooOne pointer produces identical in_flight data on both the plan read and the roadmap row — § in-flight read toolspass
No second daemonThe surface is a tab in the existing SPA on the existing port; no new listener was added — § watcher surfacepartial
Portfolio scan usable2,873 bytes against a 180,224-byte baseline — a 98.4% reduction with all 12 rows intact — § portfolio viewpass
Drift detectableReported on a fixture and repaired behind a flag; two live findings reported without modification — § skill driftpartial
Placeholder rejectedAn em-dash milestone is refused on write and the live plan is cleared — § milestone shapepass
Suite greenopen

Crew routes, with phase derived on read

Commit 7c1c3dereckon/serve.py, tests/test_crew_routes.py; 419 insertions, 2 deletions across 2 files. Gate passed: 7 focused tests, 0 failures, 3.4 s.

GET /crew and GET /crew/reckon each emit one row per mounted live pointer, joining the pointer with the owning repository's ledger for member and role, and adding plan and section, resolved model and effort, elapsed seconds, derived phase, last activity, the gate the run works toward, and direct plan_href and sprint_href links. The route reads live pointers, ledgers, plan discovery and bounded log tails, and persists nothing — so the server remains a reader of run state rather than a second writer of it.

MeasureResult
Phase from a terminal eventA stream ending in turn.completed classifies done
Phase from freshnessA log written inside the 900-second window classifies working; older classifies idle
Read is boundedOn a stream grown past the bound, exactly one 65,536-byte read was observed — the whole file is never loaded
Both routesAggregate and project-filtered routes each returned the fixture pointers
Scoped suitetests/test_crew_routes.py — 7 passed, 0 failed; 7 new tests

Two of this plan's declared measures are only partly discharged by this node and stay open: the 3-second latency figure needs a real dispatch observed through a restarted server, and the navigability measure needs the rendered surface that the watcher-tab node supplies. Both are recorded as partial rather than passed.

Cleanup now refuses a worktree a live run still claims

Commit e96cac1skills/reckon-ship/scripts/worktree_fleet.py, tests/test_worktree_fleet.py; 139 insertions, 4 deletions across 2 files. Gate passed: 82 passed, 0 failed, 7.4 s, 3 new tests.

This node exists because the orchestrator caused the defect during this sprint: a session-wide cleanup removed the worktree of a running analysis node. Cleanup preflighted for a dirty tree and for reachability from the integrated branch, and a node that had not committed yet passed both — so a live read-only worker looked perfectly removable.

BehaviourResult
An active claim refusesA worktree whose run classifies as running is refused, and the result reports the claiming run id under claimed_by_live_runs
Refusals travel togetherActive-claim and dirty-tree refusals are returned in the same result rather than one masking the other
Release permits removalDeleting the pointer allows the same worktree to be removed
A crash cannot strand a treeA dead-pid pointer classifies as abandoned and does not block removal; liveness is refreshed rather than trusted from the stored phase

The last row is what keeps the new check from becoming its own trap. A liveness gate that trusted the stored phase would let any crashed run hold its worktree forever, trading a rare destructive error for a permanent leak — so the check re-reads the process table rather than the record.

The check broke the tool it protects, and then did not

The first implementation reached for reckon.crew to read live pointers. That script is deliberately standalone — the orchestration reference invokes it as python skills/reckon-ship/scripts/worktree_fleet.py … in two places — so the very next cleanup call failed with ModuleNotFoundError: No module named 'reckon'. Its own gate could not see this: pytest runs inside the project environment, where the import succeeds.

Commit d595280 — 193 insertions, 10 deletions across the same 2 files; 85 passed, 0 failed, 3 further new tests, ruff clean. The script now reads <config-home>/crew/live/*.json directly and checks PIDs against the OS, importing no reckon module at all. Under an interpreter hook that rejects every reckon import: create exited 0, cleanup-session exited 0, a live pointer still produced the visible claimed-worktree refusal with exit 2, and deleting that pointer let cleanup exit 0. An AST inspection asserts the absence of the import, so the regression cannot return quietly.

Two choices are worth stating because the tempting alternatives were both worse. The docs were not edited to match the regression — the script is documented correctly and was wrong. And the import was not wrapped in a try that skips the check when unavailable: a liveness guard that silently does nothing precisely when invoked the documented way would be worse than the defect it was written to fix.

A plan and a sprint show which of their own work is in flight

Commit 3f7c6d3docs/ui/plan.jsx, docs/ui/sprint.jsx, tests/test_build.py; 139 insertions, 16 deletions across 3 files. Gate passed: 15 passed, 0 failed, 2.7 s, 1 new test function with 9 assertions; reckon build exit 0.

Both views poll the project-scoped crew route every 3 seconds and persist nothing. A plan filters the response to runs whose plan equals its slug and renders one accessible band listing each run id, member and section; a sprint groups runs by plan slug and badges each matching card with an accessible label counting the runs and a title naming their members and sections. No match renders no band and no badge — the surfaces are silent when nothing is in flight rather than showing an empty container.

This is the question the lead actually asks. Not "what is the crew doing", which the watcher tab answers, but "is this being worked" — asked while already looking at the plan. The three-entry-point drift test stayed active and passed, so the new components are registered consistently across the live renderer, the static build and the checked-in index.

The tools agents already call now say a node is taken

Commit 2f1bb23reckon/mcp_views.py, reckon/roadmap.py, tests/test_mcp_views.py; 233 insertions, 20 deletions across 3 files. Gate passed: 53 passed, 0 failed, 1.0 s, 6 new test functions.

Typed plan reads derive an optional in_flight list from live pointers across the summary, detail, history and raw views, each record carrying run_id, member, section and started_at. Roadmap pending-work rows use the same shared projection, so one pointer produces identical data on both surfaces rather than two implementations that can disagree.

PropertyResult
Both surfaces agreeOne pointer, identical data on the plan read and the roadmap row
Silent when idleUnmatched plans, non-plan resources, archived plans and roadmap rows without pointers all omit the key entirely
Read-onlyThe path calls crew.list_live and neither persists nor mutates state
DiscoverableThe response schema advertises the optional projection
Non-breakingThe legacy untyped read shape is unchanged

This is the cheapest available guard against double-dispatch: a second orchestrator — or a worker reading its own plan — sees the node is taken without needing to know the crew tool exists. For a node holding write scope, a duplicate dispatch risks a conflicting second commit, which is the concrete failure this closes.

The portfolio scan fits, at 1.6% of its former size

Commit 1d4543areckon/mcp.py, reckon/mcp_views.py, tests/test_mcp_roadmap.py; 199 insertions, 9 deletions across 3 files. Gate passed: 12 passed, 0 failed, 5 new test functions.

project="*" now defaults to view=summary. Measured against the live 12-project mount set, the default response is 2,873 bytes against a 180,224-byte baseline — a 98.4% reduction, and 1.6% of the figure that made this call unusable through MCP — while comfortably inside the tested 32 KiB ceiling.

The reduction keeps what a portfolio scan is for. The payload still reports 12 mounted projects, 361 plans, 177 completed, 86 ready, 50 blocked, 11 deferred and 82 findings, with per-project completion, ready, blocked, deferred and finding totals — all 12 rows retained rather than truncated. Explicit detail gives cursor-paginated findings for portfolio and single-project calls, explicit raw wraps the lossless legacy report, and a single-project call with no view keeps its existing shape, so no caller changes behaviour without asking to.

A copied skill directory standing among symlinks is now reported

Commit fb95f4freckon/cli.py, tests/test_doctor.py; 122 insertions, 5 deletions across 2 files. Gate passed: 9 passed, 0 failed, 3 new tests, ruff clean.

reckon install-skills infers the expected root from sibling symlinks and reports a real directory standing among them, naming both the offending skill and the exact expected target. Repair is behind an explicit --repair flag; the default path reports and changes nothing. A runtime whose skills are consistently copied produces no finding, which is what keeps the check from firing on a deliberately different installation style.

RuntimeFinding
~/.claude/skillsreckon-roadmap copied where linked
~/.agents/skillsreckon-roadmap copied where linked
~/.codex/skillsNone — all seven skills consistently copied

The live count is two, not the one the plan anticipated: the same skill is a real directory in two separate runtimes. Both were left untouched, with identical mtime_ns before and after inspection — roughly a dozen concurrent agent sessions read those directories, and converting one to a symlink underneath a running session is a change to take when the fleet is quiet. The measure is therefore recorded partial: detection is proven, the live repair is an outstanding coordinated action.

Recovery reads the manifest instead of inferring delivery from a turn ending

Commit e2fb788reckon/crew.py, tests/test_crew.py; 193 insertions, 14 deletions across 2 files. Gate passed: 72 passed, 0 failed, 4.5 s, 6 new tests, ruff clean.

The vocabulary grew from three classes to five — running, completed_unpromoted, blocked, failed, abandoned — because three could not express the states that were actually occurring. status: complete is now the only manifest status that produces promote advice.

CaseResult
Terminal phase, no manifestClassifies abandoned, and the next action names stderr.log — the one file a failed launch writes its reason to
Manifest says blockedClassifies blocked; no promote advice
Manifest says failedClassifies failed; no promote advice
Manifest says completeThe only status producing crew complete advice, with the manifest's own commits reflected in it
Both readers agreeThe MCP crew live view and CLI crew recover returned identical classification, manifest_status and next_action for the same pointer
No fabricated promotionDirect, MCP and recovery paths all classify a terminal run with no manifest and no commits as abandoned

The last row closes the specific hazard that produced this section: an orchestrator following the old advice would have written a success with a nonexistent commit into the committed ledger, which later calibration reads as ground truth. The defect was found by hitting it, on the run whose launch the read-only sandbox rejected.

A milestone is an identifier or nothing

Commit 9b48397reckon/_schema.py, docs/_shared/plan.schema.json, tests/test_schema.py, docs/plans/agent-plan-authoring.html; 54 insertions, 4 deletions across 4 files. Gate passed: 1,102 passed, 0 failed, 4 new validation cases.

Strict write validation refuses milestone="—" with an error naming the field, while "M2" and "" both remain accepted. The published schema carries the matching constraint as ^(?:[A-Za-z0-9][A-Za-z0-9._-]*)?$ with an empty default, and the em-dash that had been sitting in agent-plan-authoring is cleared.

The split between strict and lenient is the part worth keeping: writes refuse the placeholder, but reads stay lenient, so an existing document carrying one is still readable rather than becoming unopenable the moment the constraint lands. A validation tightening that made old documents unreadable would be a worse defect than the one it fixed.

The watcher is a tab in the existing SPA, and three entry points now agree

Commit 1cfe5cadocs/ui/crew.jsx, docs/ui/shell.jsx, docs/ui/glyphs.jsx, docs/index.html, reckon/serve.py, reckon/cli.py, tests/test_build.py; 185 insertions, 2 deletions across 7 files. Gate passed: 12 passed, 0 failed, 1.9 s, and reckon build exit 0. Scope was widened mid-flight, so this run is excluded from calibration.

A fifth glyph tab hash-routes as #crew consistently with the existing four and survives project navigation. crew.jsx polls GET /crew every 3 seconds and renders one tbody row per run over ten fields — member, role, linked plan, linked section, model, effort, elapsed time, phase, last activity and gate — as a semantic table, because rows of labels and values are a table and not a card grid.

The node's most useful output was an audit it produced before writing anything. A component cannot render if no entry point loads it, and this SPA has three independent entry points that each carry their own JSX script list: the live renderer in reckon/serve.py, the static-build renderer in reckon/cli.py, and the checked-in docs/index.html. All three loaded graph.jsx immediately before shell.jsx, and none mentioned crew.jsx. Adding one component therefore means editing three files that must agree, with nothing enforcing agreement — the same latent-drift shape as the copied-where-linked skill this plan already carries.

So the repair went beyond registration: tests/test_build.py now asserts that the normalised JSX sets loaded by the live renderer, the static-build renderer and the checked-in index are identical. The hand audit that found this becomes a standing check, and a component registered in two of three places now fails a test instead of silently failing to render. The static build copied 17 UI files and 5 shared files and emitted _ui/crew.jsx in its index.

Two measures move to partial rather than pass. No new listener or port was introduced, which is most of the no-second-daemon claim, but the declared evidence is ss -ltnp output with the watcher in use and that needs the restart below. Navigability has its links but not yet its banked screenshot or its in-flight band.

A restart is outstanding. reckon/serve.py and reckon/cli.py both changed, and server, parse and render code is not hot: the running server and every connected MCP stdio process hold the old code until restarted. Until that is coordinated, the crew route and the tab exist in the repository and not in the live instance.