@staix/agent-hub 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +35 -8
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +18 -3
  7. package/docs/agent-notes/tasks.md +12 -4
  8. package/docs/agent-notes/tests.md +3 -1
  9. package/docs/dogfood.md +16 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +264 -14
  12. package/docs/profiles.md +2 -0
  13. package/docs/quickstart.md +23 -6
  14. package/docs/security.md +198 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
  16. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  17. package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
  18. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  19. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  20. package/docs/verified.json +45 -17
  21. package/package.json +4 -3
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +5294 -4797
  24. package/src/adapters/channel-peer.ts +4 -2
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  27. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  28. package/src/adapters/definitions/qwen.ts +2 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry-types.ts +2 -0
  31. package/src/adapters/registry.ts +2 -1
  32. package/src/cli/acon-register.ts +29 -0
  33. package/src/cli/conductor-recovery.ts +3 -2
  34. package/src/cli/handoff-args.ts +27 -0
  35. package/src/cli/handoff-output.ts +25 -0
  36. package/src/cli/help.ts +11 -8
  37. package/src/cli/identity.ts +7 -1
  38. package/src/cli/main.ts +138 -30
  39. package/src/cli/output.ts +8 -5
  40. package/src/cli/recovery-package.ts +94 -4
  41. package/src/cli/reset.ts +14 -4
  42. package/src/cli/runtime-version-warning.ts +28 -0
  43. package/src/cli/schedule-output.ts +8 -4
  44. package/src/cli/start-agents-run.ts +125 -0
  45. package/src/cli/study-output.ts +71 -0
  46. package/src/cli/study.ts +58 -0
  47. package/src/cli/task-output.ts +6 -0
  48. package/src/cli/upgrade-interactive.ts +4 -4
  49. package/src/cli/upgrade-runtime.ts +105 -57
  50. package/src/cli/upgrade.ts +90 -28
  51. package/src/hub/autonomy-defaults.ts +32 -7
  52. package/src/hub/board.ts +993 -8
  53. package/src/hub/budget.ts +3 -4
  54. package/src/hub/channel-project.ts +61 -0
  55. package/src/hub/child-process.ts +4 -3
  56. package/src/hub/conductor.ts +3 -4
  57. package/src/hub/config-apply.ts +13 -2
  58. package/src/hub/config-edit-host.ts +1 -0
  59. package/src/hub/config-tier.ts +3 -0
  60. package/src/hub/config-trust.ts +10 -2
  61. package/src/hub/control-client.ts +39 -10
  62. package/src/hub/daemon.ts +326 -25
  63. package/src/hub/db-schema.ts +143 -0
  64. package/src/hub/delivery-journal.ts +3 -4
  65. package/src/hub/envelope.ts +2 -0
  66. package/src/hub/events.ts +4 -1
  67. package/src/hub/execution-budget.ts +15 -4
  68. package/src/hub/folder-trigger.ts +3 -4
  69. package/src/hub/handoff.ts +126 -0
  70. package/src/hub/hub-tools.ts +12 -2
  71. package/src/hub/invocations.ts +3 -4
  72. package/src/hub/lifecycle.ts +87 -29
  73. package/src/hub/manager.ts +110 -87
  74. package/src/hub/no-writes.ts +1 -1
  75. package/src/hub/orca-env.ts +19 -0
  76. package/src/hub/path-policy.ts +12 -8
  77. package/src/hub/project-runtime-state.ts +139 -0
  78. package/src/hub/project-runtime.ts +62 -0
  79. package/src/hub/project.ts +14 -5
  80. package/src/hub/protocol-window.ts +19 -0
  81. package/src/hub/provenance.ts +30 -0
  82. package/src/hub/recovery-store.ts +102 -12
  83. package/src/hub/registry.ts +5 -0
  84. package/src/hub/research-campaign.ts +265 -0
  85. package/src/hub/research-experiments.ts +474 -0
  86. package/src/hub/research-runner.ts +172 -0
  87. package/src/hub/restart.ts +35 -0
  88. package/src/hub/runtime-capture-prune.ts +415 -0
  89. package/src/hub/runtime-prune-health.ts +112 -0
  90. package/src/hub/schedules.ts +213 -13
  91. package/src/hub/sdlc-merge.ts +7 -0
  92. package/src/hub/sdlc-queue.ts +13 -1
  93. package/src/hub/session-identity.ts +49 -0
  94. package/src/hub/settings.ts +4 -0
  95. package/src/hub/snapshots.ts +3 -4
  96. package/src/hub/start-agents.ts +44 -0
  97. package/src/hub/task-approval.ts +23 -0
  98. package/src/hub/tasks.ts +1005 -34
  99. package/src/hub/ui.ts +15 -6
  100. package/src/hub/usage-rollup.ts +3 -4
  101. package/src/memory/capture.ts +2 -2
  102. package/src/pi/tool-receipts.ts +3 -4
  103. package/src/ui/index.html +5 -3
  104. package/src/ui/office-space.ts +118 -0
  105. package/src/ui/plugin.ts +293 -0
  106. package/templates/AGENTS.block.md +1 -0
  107. package/templates/profiles/general.json +1 -1
  108. package/templates/profiles/research.json +4 -4
  109. package/templates/profiles/writing.json +1 -1
  110. package/templates/roles/research/analyst.md +1 -0
  111. package/templates/roles/research/fact-checker.md +1 -0
  112. package/templates/roles/research/research-lead.md +1 -0
  113. package/templates/roles/research/researcher.md +1 -0
  114. package/templates/roles/research/statistician.md +1 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ Issue and pull request numbers in the entries for 0.7.7 and earlier refer to the
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.26.0
8
+
9
+ - Retain each project's verified runtime across stop/start, preserve other projects when upgrading one, prune unreferenced source captures with reference checks, and report stopped projects before source preservation (#1039 follow-up). Runtime selection protection begins with the 0.26.0 resolver.
10
+ - Bind signed external runner receipts to research attempts (#1018). Keep runner-attested evidence separate, show current key revocation status, preserve idempotent replay, and guard first-attestation format-2 storage and recovery. Registration is person-only; the hub runs no external job.
11
+ - Re-review approved, unmerged tasks at a new owner-reported head, or let the reviewer withdraw approval (#1047). Keep each verdict bound to its head, show stale approvals, and block stale-head merges.
12
+ - Upgrade one project while other projects keep working (#1039, #1045): negotiate protocol 17/16, use per-project recovery holds and verified versioned daemon captures, and update the global CLI and shared plugin separately. Doctor reports older shared tools.
13
+ - Refuse incompatible `hub.db` formats before daemon effects or database writes, with a named required-version message. Preserve legacy recovery contracts and require guard-aware targets for new controlled replacements.
14
+ - `ahub up` now also starts a default set of agents (task 608, #1040): `claude` and `codex` unless chosen, each through the hub's own start (its start mode, the `terminal.open` or Orca terminal, `already attached` for an attached peer), one line per agent, and a refusal names the command to run by hand without stopping the hub or the others. The set is the new machine-local setting `start.agents` (ignored from a committed `config.json`, a Start-group settings row, an empty list starts none); `ahub setup` asks once (`--agents <a,b>`, `--yes`, no terminal keeps the default) and writes `~/.agenthub/defaults.json`, which `ahub init` copies into a new project; `ahub up --no-agents` skips it for one run. The dashboard's Start counts as `ahub up`. A daemon restart, recovery, config reload or upgrade relaunch starts none from it, and qwen, a declared instance or a channel extra cannot be listed.
15
+ - A test run or `scripts/check.sh` started from an Orca pane no longer opens real Orca tabs and agents there (task 613, #1048). The test preload and the gate now remove every `ORCA_*` variable they inherit, `personEnv` drops the whole prefix, and the launcher's four names live in one list (`src/hub/orca-env.ts`). A test that needs an `ORCA_*` value sets it itself. `test/orca-env-guard.test.ts` runs a real `ahub up` with fake `ORCA_*` values and a stub Orca CLI and fails if the stub is called.
16
+
17
+ ## 0.25.0
18
+
19
+ - Pin `@staix/agent-mem` 0.18.0 (was 0.17.0), which adds forward-tolerant amem hook-spool handling, an amem console Recap panel and screened tool-input capture. The hub continues to use the library API and does not call the hook spool.
20
+ - Pin `@staix/agent-mem` 0.17.0 (was 0.15.0). agent-mem 0.16.0 requires every amem writer on one memory home, hub pins included, to run 0.16.0 or later: a 0.15.0 drain deletes hook-spool records that 0.16.0 staged without reading them. Upgrade the hub together with a global `amem` of 0.16.0 or later. 0.17.0 adds the acon console plugin (`@staix/agent-mem/ui-plugin`) and `CandidateStore.reopen`. The hub does not pass `forgetNearby`, so the changed `amem forget --json` output does not reach it. Recall, capture, briefs and memory homes are otherwise unchanged.
21
+ - acon console plugin (#935, D1 and D2): `@staix/agent-hub/ui-plugin` is an agent-console api 1 plugin. Its panels are the dashboard's views (`DASHBOARD_VIEWS`). The provider uses the dashboard manager's own project listing and forwarding (`projectConsole`, lifted out of `startManager` unchanged), so every action passes the manager's whitelist and every view passes `dashboardView`; there is no protocol change. Its inbox lists pending approvals (Allow once / Deny, answered with the hub's own `permit`) and in-review tasks whose reviewer is you. Outward or destructive actions (permit, approve, deny, send, stop, setting changes, config apply, peer start, split) answer `needsConfirm` first. The panel is the `ahub ui` page itself: `renderDashboard()` returns the injected page, `ahub ui` still serves it inlined and hashed (byte-identical, same CSP), and the plugin builds its panel module from the same blocks with `post()` routed to the console host. `ahub setup` writes `~/.agenthub/console/plugins/ahub.json` with the module's sha256.
22
+ - acon Space (#1012 Part 2): the plugin declares `capabilities.space` and answers `snapshot({ panel: "space" })` with a SpaceScene built from the dashboard's office model (`src/ui/office-space.ts`). Peers are actors (state from condition, approvals and review, with marks), the office rooms are queue, work, review and done zones, tasks are cards, overlap/conflict links and message arcs are links. Ids are project-prefixed, scenes from all running projects are merged within acon's caps (32 actors, 200 cards, 64 links), and only labels reach the scene: no bodies, details, approval titles or reasons.
23
+ - Research campaigns (#930, #932; #1024, #1027, #1028, #1029, #1031, #1041): `ahub study draft|revise|approve|show|close` and the `hub_study_draft`, `hub_study_revise`, `hub_study_show` and `hub_study_handoff` tools keep a bounded multi-unit study contract on an approved parent task. Planners store drafts only; only the person's `ahub study approve` creates the unit tasks, as Backlog cards. Before a unit task is offered, assigned, pulled or handed over, the hub rechecks the latest approved revision, the deadline, the stop policy and an optional execution budget `hardCap`, and holds it otherwise (held owner offers survive a restart). Unit owners record evidence with `hub_study_handoff`, and `ahub study show` reports coverage per unit and source. Experiment protocols, comparisons and attempts are stored with explicit provenance, and `ahub study approve-protocol <hash>` approves one exact protocol and evaluator; no tool records experiments yet. Private campaigns show ids, counts and states only, study tool text stays out of memory capture, and a restart, upgrade or recovery onto a release without the `research_*` features is refused while their rows exist. See [operations](docs/operations.md#research-campaigns-ahub-study-930).
24
+ - Calendar schedules (#1017, #1022): a schedule in `config.local.json` takes a five-field `cron` expression with an optional IANA `tz` instead of `every`. While one does, a restart, upgrade or recovery onto a release without `calendar_schedules` is refused.
25
+ - An owner may call `hub_task_done` again during review with a different full commit SHA (#1000, #1021): the reviewer gets a fresh request for the new head, the CI watch and stale merge evidence are replaced, and open findings and the rejection count stay. The same head, a malformed SHA or another owner refuses without effect.
26
+ - `ahub doctor` reads its `memory capture: codex|kimi` rows from `amem setup <tool> --json` (a preview that writes nothing) instead of `dot ai memory status`; a missing or refusing `amem` reads as unknown, never ready (#1008, #1025).
27
+ - Cross-project task handoff (#1030, #1043): `ahub task handoff --project <root|id> [<class>] <title...>` (the person) and `hub_task_handoff` (a configured planner or the conductor) put work onto another running agent-hub project's board on this machine as an ownerless proposal, where that project's own roles plan, implement and review it. Accepted, in review, approved, declined and cancelled come back to the requesting peer as one task message with an event name, a PR number and a SHA, never the target's text, and a restarted origin asks running targets for its open handoffs. `hub_task_handoffs` and `ahub board --handoffs` list them. Unregistered, same-project, stopped or older targets, PII turns and tasks, oversized or malformed requests and callers who are not the person, a planner or the conductor are refused by name, with no row on either board.
28
+ - Distinguishable sessions and sender provenance, not authentication (#1036, #1037): a connection that names a peer but is not that peer's current session is bound to a daemon-minted id such as `claude~a1b2c3`. It may `send`, read `status` and read tasks, holds none of the peer's roles, receives no deliveries and replaces nobody; everything else is refused naming the session. Message headers name the sender as it is. A channel plugin uses an inherited hub only when its recorded root matches the working directory's. No connection that works today is refused.
29
+
7
30
  ## 0.24.0
8
31
 
9
32
  - Autonomy preset (#999 T1 and T2, tasks 581 and 583, #1009, #1010): `ahub autonomy on [--all-projects] [--yes] | off [--all-projects] | status [--json]`, person only. Agent shells, delegated live tests, hub tools and the conductor are refused. `on` previews the settings and their apply timing, then takes one confirmation. It applies `templates/presets/autonomous.json` to this project's untracked `config.local.json`: task flow, supported permission and start defaults, and `action_policy.floor`. It also writes the machine opt-in `~/.agenthub/defaults.json` (owner-only 0600; an unsafe file is ignored with a notice). `--all-projects` applies to every registered project after one combined preview: live hubs through their console, stopped ones through the same validated writer. `off` removes the fixed preset keys, including hand-set values of those keys, and the retained consent; unrelated keys stay. Nothing changes until the person opts in, and a machine without the file keeps the shipped ask defaults.
package/README.md CHANGED
@@ -5,7 +5,7 @@ Code, Codex, Kimi Code, Pi and a hub-owned local worker (plus Qwen Code, Jcode a
5
5
  Antigravity if you want them) share a message bus, a task board and a memory, each
6
6
  connected through its own native interface.
7
7
 
8
- Status: 0.24.0, control protocol 16. Bun + TypeScript, one loopback daemon per
8
+ Status: 0.26.0, control protocol 17 (accepts 16). Bun + TypeScript, one loopback daemon per
9
9
  project. Site: [agent-hub.staix.net](https://agent-hub.staix.net).
10
10
 
11
11
  ## Quick start
@@ -14,10 +14,12 @@ You need [Bun](https://bun.sh) 1.4.2 or later on PATH.
14
14
 
15
15
  ```bash
16
16
  bun add -g @staix/agent-hub && ahub setup
17
- cd <your project> && ahub init && ahub up # daemon plus the operator console
17
+ cd <your project> && ahub init && ahub up # daemon, claude and codex, then the operator console
18
18
  ```
19
19
 
20
- Then start the agents you use, one terminal each, and talk to them:
20
+ `ahub up` opens the default agents (claude and codex; choose them at `ahub setup`,
21
+ skip them with `--no-agents`) through `terminal.open` or Orca. Where it cannot, it
22
+ names the command; run the agents you use by hand, one terminal each, and talk to them:
21
23
 
22
24
  ```bash
23
25
  ahub claude # Claude Code with the hub channel
@@ -26,6 +28,15 @@ ahub say "Each of you: reply with your name."
26
28
  ahub ui # the web dashboard
27
29
  ```
28
30
 
31
+ `ahub setup` also registers agent-hub as a plugin of the family console
32
+ ([agent-console](https://github.com/STAIxBWLB/agent-console), `acon ui`): the same dashboard
33
+ appears as a panel for every registered project, and pending approvals and reviews assigned
34
+ to you reach the console inbox. The plugin is `@staix/agent-hub/ui-plugin`; it uses the
35
+ dashboard manager's own listing and forwarding, so it adds no protocol and no action.
36
+ The plugin also feeds the console's Space view (Flat, Room and 3D): agents become actors
37
+ at their desks, the task board's columns become zones, and task links and recent messages
38
+ become links. The scene carries labels and states only, never message bodies or details.
39
+
29
40
  `ahub init --dry-run --json` shows what init would write. The
30
41
  [Quickstart](docs/quickstart.md) walks through a first session and a working day.
31
42
 
@@ -45,7 +56,15 @@ ahub ui # the web dashboard
45
56
  Claude Code instances can implement in their own worktree, in a terminal or
46
57
  headless with one session per task. `ahub sdlc metrics` reads lead times,
47
58
  rework and stalls from board history, and
48
- optional control bands turn a breach into a backlog card.
59
+ optional control bands turn a breach into a backlog card. Work that belongs to
60
+ another project running agent-hub on this machine goes onto that project's board
61
+ with `ahub task handoff` (agents: `hub_task_handoff`), and its state changes come
62
+ back as task messages.
63
+ - **Research campaigns.** `ahub study` keeps a multi-unit study contract as a draft
64
+ (a planner drafts it with `hub_study_draft`); only the person approves it, which
65
+ creates one task per unit, and `ahub study show` reads coverage per unit and
66
+ source. A unit is covered by its owner's `hub_study_handoff` with evidence, not by
67
+ a done task.
49
68
  - **Shared memory.** Decisions, contracts, fails and findings are kept through the
50
69
  first-party [`@staix/agent-mem`](https://github.com/STAIxBWLB/agent-mem) library,
51
70
  in process with no claude-mem worker (short- and long-term tiers, a typed graph,
@@ -62,11 +81,16 @@ ahub ui # the web dashboard
62
81
  (`action_policy.floor`, which `ahub autonomy on` sets) lets a push of a non-default
63
82
  branch to `origin` and `gh pr create|comment|review|merge` in the project's own
64
83
  repository run without asking; force, default-branch and tag pushes and releases
65
- still ask. Budgets pause an
84
+ still ask. Every message records its sender's origin, and a session that is not
85
+ the project's own peer appears as `<peer>~<origin>` with none of that peer's
86
+ roles: sessions are distinguishable, which is provenance, not authentication.
87
+ Budgets pause an
66
88
  agent before it runs out of quota, and recovery never replays an
67
89
  effect whose outcome is unknown.
68
90
  - **Operations.** A terminal console and a local dashboard, one hub per repository or
69
- worktree with a machine-wide view, live settings reload, a one-confirmation autonomy
91
+ worktree with a machine-wide view, live settings reload, machine-local schedules
92
+ that propose tasks by interval or by a `cron` expression in a time zone
93
+ (`ahub schedule list`), a one-confirmation autonomy
70
94
  preset (`ahub autonomy on|off|status`), structural config
71
95
  (instances, review, lifecycle) edited with a validated diff and a backup from the
72
96
  console or a dashboard settings session, token usage and quota
@@ -75,7 +99,10 @@ ahub ui # the web dashboard
75
99
 
76
100
  ## Upgrading
77
101
 
78
- Run `ahub upgrade` while your hubs are running. Read the notes for your version in
102
+ Run `ahub upgrade` in a project while its hub is running (`--project` selects
103
+ another); other projects keep their version. Update the global CLI
104
+ (`bun add -g @staix/agent-hub@<version>`) and run `ahub setup` for the plugin
105
+ yourself. Read the notes for your version in
79
106
  the [Quickstart](docs/quickstart.md) ("Upgrading to ...") and the
80
107
  [Changelog](CHANGELOG.md) first: some releases need a one-time step, such as
81
108
  refreshing an agent's MCP entry.
@@ -99,7 +126,7 @@ uses the home must run a version that understands the new layout.
99
126
  [#258](https://github.com/STAIxBWLB/agent-hub/issues/258)).
100
127
 
101
128
  Live checks that still need a real account or a person are listed in the
102
- [release verification report](docs/verification/2026-10-31-0.24.0.md) and the
129
+ [release verification report](docs/verification/2026-11-01-0.26.0.md) and the
103
130
  [smoke checklist](docs/smoke.md).
104
131
 
105
132
  ## Documentation
@@ -65,3 +65,15 @@ Background moved out of the area notes for 0.21.0:
65
65
  - Web fetch transport (#553, tests.md): the daemon test's answer waited on iana.org for 99 percent of its time and failed about 3 of 100 runs at 30 parallel on a 4 s bound; a longer wait would only have measured the network.
66
66
  - Jcode usage (#801, budget.md): Jcode's `totalTokens` adds the cached read on top of an input that already holds it for OpenAI-style providers, and `jcode usage --json` names its limit with free text and no window length.
67
67
  - Claim verdicts (#901, tasks.md): review of #937 found `factCheck.agreement` counting person-verdict frequencies, which is why a person verdict alone is never an agreement.
68
+
69
+ - Registered runner integration (#600, daemon.md): the frozen runner contract narrowed issue #1018 from person-or-conductor registration to person-only trust-root changes. Generic machine-local stripping would silently ignore shared/tracked runner keys instead of refusing them, and recursive declaration discovery omitted whole-map scoped replacements after registration existed. The config/recovery fixtures exercise explicit source refusal, atomic whole-block publication, pending raw edits versus accepted revocation, and the separate recovery floor and integrity binding.
70
+
71
+ Issue #1047 records approved tasks that could not accept a new head or a withdrawn verdict on hub 0.23.1. The prior in-review-only rule left the board bound to an older commit while reviewers handled later PR heads in comments. The approval reopening rules in tasks.md replace that restriction; fixture coverage includes a legacy receipt-only approval, a configured check without a prior reviewer, and a PR merged while a screen is pending.
72
+
73
+ Task 610 follows the staged-upgrade review: ordinary startup forgot the scoped target, scoped refresh replaced the shared manager, verified captures accumulated, and an empty plan reached source preservation before its not-running refusal. Runtime selection now starts at the first resolver release (0.26.0); earlier published binaries do not read the pointer, so that ceiling is documented rather than enforced by a broader database floor.
74
+
75
+ Review of PR #1050 found that including accepted runner configuration and raw-key presence in release-equality integrity made pending config edits and legacy no-receipt snapshots fail the release step. Runner configuration is mutable startup policy; receipt rows are retained board state. The runner integrity rule now separates those roles, with two-daemon prepare/commit/release fixtures covering both transitions.
76
+
77
+ Review of PR #1052 found that full-index trust sweeps and hashing every retained capture under the registry writer lock made ordinary startup refuse large repositories or starve other project writers. Per-start scoped trust and bounded quarantine transactions separate execution authority and deletion safety from maintenance cost.
78
+
79
+ The ledger review of PR #1052 found that applying the startup admission wait to worker completion could strand the ledger in running state after contention or a crash. Signed process ownership, an atomic dead-owner replacement, a normal worker wait and read-only doctor paths keep deferred maintenance recoverable and visible.
@@ -8,7 +8,7 @@ Scope: the Codex, ACP and Pi adapters and the Pi extension (turn `generation` an
8
8
 
9
9
  - Keep per-kind native policy in `src/adapters/definitions/` and consume it through the registry. Private CLI implementations accept only fixed host-bound ports; keep identity, audit, lifecycle admission and effect ordering in the host. Except for the verified person-owned A4 startup-module contract, do not add config-selected callbacks or runtime imports from registry leaves into eagerly initialized hub/CLI modules; use type-only ports for existing host planners.
10
10
  - Adapter registry (#451): kind-specific policy lives in that peer's `AdapterDefinition` under `src/adapters/definitions/` (runtime definitions `NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN` in `builtin-metadata.ts` plus `qwen.ts`; the daemon constructs every native driver through `start.create` and `DAEMON_PORTS`). Outside `src/adapters/`, read a semantic field of `definitionOf(id)`: never a raw peer-id comparison, a resolved kind (`kindOf`, `definitionOf(...).kind`) or a constant alias compared with a native name, a vendor-keyed object, a peer-name array, or a closed-list import from `peer-catalog.ts`, `start-mode.ts` or `permission-mode.ts`; the guard resolves aliases and import/re-export bindings. `test/registry-guard.baseline.json` is `[]` and stays empty: a new site moves into a definition, never into the baseline. The only exceptions are the cross-release literals `KNOWN_PEERS`, `OPTIONAL_PEERS` and `START_MODE_PEERS`, which `test/registry-guard.test.ts` holds equal to the derived lists. `LaunchContext.nativeCommand` carries only validated config argv (or its redacted preview), never model input; a definition's `permission.grants` text is what `permissionGrant` shows, ahead of the built-in wording. Keep native mode-id maps in `definitions/*-native.ts`, free of registry factories (`test/adapter-import-order.test.ts`).
11
- - Registry load runs `assertDaemonEngine` and `assertNativeCapabilityScope` before publication. A definition that is not a fixed builtin (object identity in `fixedBuiltinDefinitions`, never a kind name) may not declare `start.acpCommand`, `attach.admission`, `budget.resetSoftTokensOnResume`, `session.crashIdentity`, `session.nativeRevert`, a `pi-rpc` or `claude-implementer` daemon port, `identity.currentChild`, `routing`, `projection`, `reviewerVariant`, `headlessVariant`, `reviewer`, `hooks.factsHook`, `cli.entry`, `cli.preview`, `cli.usage` or `instances.modelSelect`. Only `definitionOf(id, instances)` picks a kind's `reviewerVariant`, for a declared extra with `start_mode: "headless"`, roles exactly `["reviewer"]`, and either a variant whose `reviewer.declaration` is `"automatic"` (Claude, agy) or `review_mode: "fresh"` (Codex, Kimi); without that opt-in a reviewer-only Codex or Kimi instance keeps its native adapter. It picks a kind's `headlessVariant` (Claude, #525-4) for a declared headless extra whose roles include `implementer` and hold only `implementer` and `verifier`. Metadata-only reviewer variants never establish native peer support; `isSupportedPeer` shares the registry runtime/validated-instance predicate with the catalog and daemon. A new field that depends on fixed-builtin assumptions or host-global native state joins that list (`test/adapter-capability-scope.test.ts`). `trust.onPrem` admits PII work (rule: `tasks.md`).
11
+ - Registry load runs `assertDaemonEngine` and `assertNativeCapabilityScope` before publication. A definition that is not a fixed builtin (object identity in `fixedBuiltinDefinitions`, never a kind name) may not declare `start.acpCommand`, `start.upStart`, `attach.admission`, `budget.resetSoftTokensOnResume`, `session.crashIdentity`, `session.nativeRevert`, a `pi-rpc` or `claude-implementer` daemon port, `identity.currentChild`, `routing`, `projection`, `reviewerVariant`, `headlessVariant`, `reviewer`, `hooks.factsHook`, `cli.entry`, `cli.preview`, `cli.usage` or `instances.modelSelect`. Only `definitionOf(id, instances)` picks a kind's `reviewerVariant`, for a declared extra with `start_mode: "headless"`, roles exactly `["reviewer"]`, and either a variant whose `reviewer.declaration` is `"automatic"` (Claude, agy) or `review_mode: "fresh"` (Codex, Kimi); without that opt-in a reviewer-only Codex or Kimi instance keeps its native adapter. It picks a kind's `headlessVariant` (Claude, #525-4) for a declared headless extra whose roles include `implementer` and hold only `implementer` and `verifier`. Metadata-only reviewer variants never establish native peer support; `isSupportedPeer` shares the registry runtime/validated-instance predicate with the catalog and daemon. A new field that depends on fixed-builtin assumptions or host-global native state joins that list (`test/adapter-capability-scope.test.ts`). `trust.onPrem` admits PII work (rule: `tasks.md`).
12
12
  - Native branch ratchet (#625): `test/daemon-native-branches.test.ts` counts every call of a native class predicate (an `is*Peer` imported from an adapter module other than `registry.ts`) per enclosing function in `src/hub/daemon.ts` and `src/hub/claude-launch-proof.ts`, and the counts must equal `test/daemon-native-branches.json` (`predicate`, `function`, `count`, `reason`). A PR that adds, moves or removes such a call edits its row by hand with a concrete reason; never regenerate the table from a test. Bind a predicate alias with `const` only and never call one through a computed namespace key: the scan refuses both.
13
13
  - Native proof state (`frontendProxy`, builtin `statusNotice`, `revertTurn` with `session.nativeRevert`, the Claude launch proof and terminal execution root) is read in the daemon only after `isFixedBuiltinDefinition(definitionOf(peer))`: a same-name foreign definition gets none of it. A product of a definition that is not a fixed builtin may not have a `frontendProxy`, `statusNotice` or `revertTurn` member at all, even undefined (`assertNativeProofPorts`, run by `createFromDefinition` and the conformance kit). Use `frontendProxyState` for the daemon proxy reads: absence keeps the generic path, present undefined refuses with `frontend proxy state unknown`. Keep approval policies and restoration debt inside CodexPeer. The remaining Codex `actionEnforcement`/`actionGated` predicate is intentional until a separately specified action-policy port; never add it to `FrontendProxyPort`.
14
14
  - Adding a native peer: declare its `AdapterDefinition` under `src/adapters/definitions/` (or its own directory, as agy) and list it in `ADAPTERS` (`src/adapters/registry.ts`). `KNOWN_PEERS`, `OPTIONAL_PEERS`, `SUPPORTED_PEERS`, `START_MODE_PEERS` (a kind with both a TUI and a headless form), `ROLE_ORDER`, `HELP_ORDER` and `PLANNER_PEERS` are derived from it by `deriveAdapterLists`: set `optional`, `projection` (`roleRank`, `startRank`, `helpRank`, `plannerEligible`; a configured or external definition cannot) and, for an `ahub <kind>` launcher, `cli.entry`, `cli.usage`, `cli.preview` and `cli.startWords`, never a list edit (`builtinCliEntries(definitions)` projects the entries). The literal cross-release copies `KNOWN_PEERS` and `OPTIONAL_PEERS` in `src/hub/peer-catalog.ts` and `START_MODE_PEERS` in `src/hub/start-mode.ts` stay hand-kept for older coordinators: `test/registry-guard.test.ts` fails when one differs from the derived list. Adding a peer is the one change that updates their probe counts and `VENDORS` in `test/helpers/registry-inventory.ts` (`REGISTRY_COMPATIBILITY_PROBES`), the probe literal in `test/adapter-registry-a2.test.ts`, the `ADAPTERS` order in `test/adapter-registry.test.ts` and the literal in `test/adapter-catalog-a2.test.ts`; say so in the PR as an expected test change. `isSupportedPeer` answers from `ADAPTERS`, and for a declared instance from its kind's runtime definition unless the instance resolves to an unavailable reviewer variant; `isKnownPeer` and `defaultPeerRoles` answer from `definitionOf`; `STARTABLE_PEERS` reuses `SUPPORTED_PEERS`. It also adds the kind's `kinds` entry and its rows to `templates/presets/confirmation-matrix.json` (#999 T0): `test/confirmation-matrix.test.ts` fails without a tool-approval and a classed-action row per environment, and when the entry's forms, pull-only forms, conductor start or hub-mode fact differ from the definition. Add the same kind to `kinds` in `templates/presets/autonomous.json`, with a permission per form (`null` where the hub mode is `unmanaged`, or for `tui` under `unmanaged-in-tui`) and a start per environment: `test/autonomy.test.ts` fails when the preset and matrix kinds differ or a mode is unsupported.
@@ -65,6 +65,7 @@ Scope: the Codex, ACP and Pi adapters and the Pi extension (turn `generation` an
65
65
  - `stop(reason?)` (`src/hub/peers.ts`): a reason marks a person's requested stop (`peer_stop`, rule: `daemon.md`). Fence before the first await (the adapter's stopping flag, or `offline`) so `deliver()` and buffered native callbacks do nothing more, hold the in-flight delivery as `needs_review` with that reason, and end `offline`. For ACP without a journal, return ordinary in-flight envelopes once through `onFailed` before a requested stop invalidates the turn; never return the watchdog-cancelled original prompt. A requested stop never completes, escalates or reports the turn it cut (`AcpPeer` bumps `turn`; `PiPeer.fail` skips the failure message and `onTurnFailure` while `requestedStopReason` is set). `PiPeer.stop()` waits for aborted tools only up to `stopGraceMs`: a tool past it never blocks the teardown, and the stop throws `PiToolSettlementFailure` once the owner is down.
66
66
  - A terminal the hub opens for a person (`src/hub/terminal-open.ts`, #269) gets `personEnv`, which strips every agent marker; never `peerChildEnv`, which sets one.
67
67
  - Spawn a native peer with `peerChildEnv(peer, source)` so its own `AGENTHUB_PEER_ID` replaces inherited agent markers. Sandbox tool environments carry their executing peer too. Preserve the native shell environment policy; Codex's `CODEX_THREAD_ID` supplies the fallback after filtering. Before changing marker detection or launcher environments, read `docs/verification/2026-10-09-agent-shell-t0.md` for pinned source and live-probe limits.
68
+ - Launch ids and peer ids (agent-hub#1036): `peerChildEnv` gives a spawned peer `AGENTHUB_PEER_ID`; a Claude session's launcher also puts `AGENTHUB_LAUNCH_ID` into the environment every shell and MCP helper it spawns inherits, and a tools hello that names a Claude channel peer is a distinct session `<peer>~<origin>` only when it PRESENTS a launch id that is not the current launch record's (`sessionHello`). A hello with NO id stays the peer: the native facts hook, a helper and a person's shell connect that way and must keep working (#636, #525-5), so `ControlClient` never injects an inherited id. Nothing is refused: a new adapter that gives its tools socket a peer id keeps its marker admission, and adds its own launch id and `sessionHello` branch only when it wants the same distinction. The daemon labels an envelope an adapter publishes itself with the launch read by shape from `managedLaunchId` (channel) or `ownedLaunchId` (Jcode) (rule: `daemon.md` "Sender provenance"): keep those names, and never admit or authorize anything by them. This makes sessions distinguishable, it does not authenticate them: a same-user process can always act as the user.
68
69
  - Spawn with `peerChildEnv(<instance id>)`, not the kind name. An ACP `kind` comes only from the host declaration, never from the id suffix or agent text.
69
70
 
70
71
  ## Pi
@@ -14,6 +14,7 @@ Scope: delivery, digests and condensation, reply addressing, priority and limits
14
14
  - Invocations (#445 C2, #986): an `invocation` or `invocation_result` (`isInvocationKind`) goes alone in `take` and `takeBudget`, is never condensed, and is never steered into: both steer sites and `steerQueued` ask `invocationTurnOpen` first. In `onMessage`, a peer's answer goes to `onAnswer` before `parseMarker` and before any publish, when it names the request (`inReplyTo` in the handed delivery) or names nothing while the invocation turn is open. A taken answer, or a throwing hook, publishes, stores and emits nothing.
15
15
  - An invocation is never replayed through ordinary retry (#991). `retryDelivery` refuses a row that holds one. An uncertain or thrown delivery holds the peer (`recoveryHeldPeers`) and calls `onInvocationUncertain` instead of `failed()`. `failed()` allows at most one automatic retry, and only when `canRetryInvocation` returns true; unknown or a throw refuses.
16
16
  - Add an envelope kind only to `KINDS` (`envelope.ts`); the journal, restart reader, deferred-hub check and held hints read that one list. Test room provenance with `remoteEnvelope(env)`, never `from.includes("@")`: a relayed room invocation arrives `from: hub` with `refs.invocationRemote` (#992).
17
+ - `Envelope.session` (agent-hub#1036) marks a sender that is a distinct session, `from` being its minted `<peer>~<origin>` id (rules: `daemon.md`). It grants nothing; keep it on queued and journaled originals, because the `session_identity` recovery guard reads it from unsettled deliveries.
17
18
  - A condensed digest is sent by `digest`, not `hub`: `replyParent()` skips only the hub's `presence` item, and a reply to a digest must keep the highest hop of what it replaced. On a `failed_safe` delivery or a `retry` resolution the bus puts back the originals, never the digest.
18
19
  - Limits admit what is sent: the envelope `newEnvelope` builds (a reply inherits its parent's sender, `digest` resolves to the originals, `capPriority` applies), never the raw `to` and priority. Admit first and build later, and implicit replies all count as broadcasts.
19
20
  - A reply is addressed, never broadcast. An adapter that answers a delivery passes `to: replyAudience(envs)`; anything else with an `inReplyTo` inherits that envelope's sender. Leaving `to` out or empty fans the message out to every peer. The control `send` takes a nonempty string `to` as that one peer and refuses any other non-array shape or bad item before publishing (#963); never turn a malformed `to` into a broadcast.
@@ -18,7 +18,7 @@
18
18
 
19
19
  - Console attention navigation selects a delivery only by an exact visible row id. A missing id or row prints read-only guidance; never select by peer or fall back to the first Queue row.
20
20
 
21
- Scope: the operator console, control WS and its protocol, state files, the dashboard listener and settings, the Claude channel's reconnect, `ahub setup`, git snapshots, upgrade and recovery. Read before editing `src/adapters/definitions/native-recovery*.ts`, `src/adapters/definitions/native-policy-aux.ts`, `src/adapters/definitions/native-config-*.ts`, `src/adapters/definitions/native-cli-*.ts`, `src/cli/console.ts`, `src/cli/console-state.ts`, `src/cli/attention.ts`, `src/ui/needs.ts`, `src/hub/daemon.ts`, `src/hub/ui.ts`, `src/hub/settings.ts`, `src/hub/control-client.ts`, `src/adapters/claude-channel.ts`, `src/hub/restart.ts`, `src/hub/recovery-store.ts`, `src/hub/snapshots.ts`, `src/hub/manager.ts`, `src/hub/lifecycle.ts`, `src/hub/crash.ts`, `src/hub/start-mode.ts`, `src/hub/terminal-open.ts`, `src/hub/recovery-error.ts`, `src/hub/db-timeout.ts`, `src/hub/config-reload.ts`, `src/hub/config-apply.ts`, `src/hub/config-tier.ts`, `src/hub/config-edit.ts`, `src/hub/config-edit-host.ts`, `src/hub/claude-launch-proof.ts`, `src/hub/recovery-continuity.ts`, `src/cli/recovery-presentation-options.ts`, `src/cli/setup.ts`, `src/cli/upgrade*.ts`, `src/cli/terminal-recovery.ts`, `src/cli/recovery-package.ts`, `src/cli/facts-hook.ts`, `src/hub/usage.ts`, `src/hub/memory-profile.ts`, the memory code modules (`src/hub/memory-symbols.ts`, `src/hub/memory-code-overlay.ts`, `src/hub/memory-code-head.ts`, `src/hub/memory-stale-anchors.ts`, `src/cli/memory.ts`), the task brief host (`src/memory/brief.ts`, `src/hub/memory-plan-guard.ts`), `src/hub/native-hook-launch.ts`, `src/cli/output.ts`, `src/ui/index.html`, `src/hub/task-code-group.ts` (dashboard Code group), the agent drift sweep (`src/hub/agent-drift.ts`, `src/cli/agents.ts`) or the instance modules (`src/hub/instances.ts`, `src/hub/instance-config.ts`, `src/hub/instance-execution.ts`), or before adding a native peer.
21
+ Scope: the operator console, control WS and its protocol, state files, the dashboard listener and settings, the Claude channel's reconnect, `ahub setup`, git snapshots, upgrade and recovery. Read before editing `src/adapters/definitions/native-recovery*.ts`, `src/adapters/definitions/native-policy-aux.ts`, `src/adapters/definitions/native-config-*.ts`, `src/adapters/definitions/native-cli-*.ts`, `src/cli/console.ts`, `src/cli/console-state.ts`, `src/cli/attention.ts`, `src/ui/needs.ts`, `src/hub/daemon.ts`, `src/hub/ui.ts`, `src/hub/settings.ts`, `src/hub/control-client.ts`, `src/adapters/claude-channel.ts`, `src/hub/restart.ts`, `src/hub/recovery-store.ts`, `src/hub/snapshots.ts`, `src/hub/manager.ts`, `src/hub/lifecycle.ts`, the runtime selection and retention modules (`src/hub/project-runtime-state.ts`, `src/hub/project-runtime.ts`, `src/hub/runtime-capture-prune.ts`, `src/hub/runtime-prune-health.ts`, `src/hub/registry.ts`), `src/hub/crash.ts`, `src/hub/start-mode.ts`, `src/hub/terminal-open.ts`, `src/hub/recovery-error.ts`, `src/hub/db-timeout.ts`, `src/hub/db-schema.ts`, `src/hub/protocol-window.ts`, `src/hub/config-reload.ts`, `src/hub/config-apply.ts`, `src/hub/config-tier.ts`, `src/hub/config-edit.ts`, `src/hub/config-edit-host.ts`, `src/hub/claude-launch-proof.ts`, the session and provenance modules (`src/hub/session-identity.ts`, `src/hub/provenance.ts`, `src/hub/channel-project.ts`), `src/hub/recovery-continuity.ts`, `src/cli/recovery-presentation-options.ts`, `src/cli/setup.ts`, the default agent set (`src/hub/start-agents.ts`, `src/cli/start-agents-run.ts`), `src/cli/upgrade*.ts`, `src/cli/runtime-version-warning.ts`, `src/cli/terminal-recovery.ts`, `src/cli/recovery-package.ts`, `src/cli/facts-hook.ts`, `src/hub/usage.ts`, `src/hub/memory-profile.ts`, the memory code modules (`src/hub/memory-symbols.ts`, `src/hub/memory-code-overlay.ts`, `src/hub/memory-code-head.ts`, `src/hub/memory-stale-anchors.ts`, `src/cli/memory.ts`), the task brief host (`src/memory/brief.ts`, `src/hub/memory-plan-guard.ts`), `src/hub/native-hook-launch.ts`, `src/cli/output.ts`, `src/ui/index.html`, the acon plugin (`src/ui/plugin.ts`, `src/ui/office-space.ts`, `src/cli/acon-register.ts`), `ahub study` (`src/cli/study.ts`, `src/cli/study-output.ts`), `src/hub/task-code-group.ts` (dashboard Code group), the agent drift sweep (`src/hub/agent-drift.ts`, `src/cli/agents.ts`) or the instance modules (`src/hub/instances.ts`, `src/hub/instance-config.ts`, `src/hub/instance-execution.ts`), or before adding a native peer.
22
22
 
23
23
  - The plugin bundle is installed apart from the daemon. Breaking a control WS message contract bumps `PROTOCOL` in `control-client.ts`; a new request answered as unknown or an optional field preserves it, as does additive optional public task metadata in `ui_snapshot` that older clients ignore (such as `deps` in #246).
24
24
  - `hub_invoke` over the control WS (#445, #991): the daemon binds each admission to its socket and `rid` with an `AbortController` (`invocationRequests`, at most 64 per connection; a duplicate `rid` refuses). `request_cancel` and socket close abort it, and every check after an await confirms the socket is open and still maps that `rid` to the same controller before any effect. Clients go through `ControlClient.request`, whose `controlRequestTimeout` raises a `hub_invoke` wait to `INVOCATION_CONTROL_TIMEOUT_MS`; never call it with a shorter bound.
@@ -28,6 +28,7 @@ Scope: the operator console, control WS and its protocol, state files, the dashb
28
28
  - Console Queue `D` (#713) only prefills `queue discard --peer <selected row's peer> --reason `; Enter refuses a missing peer or reason and arms a `y` confirm that names the count, then runs the person's `ahub queue discard ... --yes`. Beyond `list`, the `queue` op is the console's or an admitted conductor's (`admitOperational`, `show`/`resolve`/`discard` only); `discard` without `confirmed` only counts, and a confirmed one needs a reason, is refused during recovery or shutdown and discards row by row through `resolveDelivery` (rule: `bus.md`).
29
29
  - Console Usage panel (#683 U3): tab `7`, text from `src/cli/console-usage.ts` (pure strings, no terminal or state import), rendered in `console-state.ts`, fetched in `console.ts` `refresh()` only while panel 7 is open. It reads the `usage` op and nothing else: no event-log read, no board field beyond the task id selected in Tasks. A refusal shows `usage unavailable`, never empty usage; an unknown counter is `unknown`, never 0; a private task is `#id [pii]` from the op's `taskPii`, never a title. The `usage` op folds with `catchUp(250)` and answers `behind: true` when it is not done: never answer from one pass of a big log. Keep Left/Right from 7 wrapping like Settings so the `1`..`5` cycle (#309) stays pinned, and keep PAUSE and 7D out of the width drop list.
30
30
  - Dashboard snapshot `view` (#683 U4): the daemon's `ui_snapshot` and the manager's forward pass a request's `view` only through `dashboardView` (`DASHBOARD_VIEWS` in `src/hub/ui.ts`); keep that list equal to the page's `DASHBOARD_VIEWS` in `src/ui/index.html`. Any other value is dropped, never forwarded.
31
+ - acon plugin (#935): `src/ui/plugin.ts` never bypasses `uiAction`'s whitelist or `DASHBOARD_VIEWS`. It reaches project hubs only through `projectConsole` (`src/hub/manager.ts`), the same listing and forwarding `ahub ui --all` uses; widen neither for the plugin. Its inbox verbs map to the hub's own `permit` with an option the request offers. Its panel is built from `renderDashboard()` by exact rewrites that throw when a target line moves: when you edit a rewritten line in `src/ui/index.html` (post, the cookie helper, `$`, the theme `apply`, the view key listener, the title update or the landmark tags), update `panelAssets()` in the same PR. `lifecycle.ts` is loaded lazily there, because it reaches the daemon and CLI files acon's import scan reads. `ahub setup` registers it only through `registerAconPlugin` (`src/cli/acon-register.ts`): `console/plugins/ahub.json` under the hub home names the module and its sha256 (directories 0700, written to a temp file and renamed); the package export `./ui-plugin` is pinned by `scripts/check-package.mjs`. The Space panel (#1012, `src/ui/office-space.ts`) maps only the #264-allowlisted `officeModel` scene, pinned by the #264 marker fixture in `test/acon-plugin.test.ts`; never feed it an envelope body, task detail, path or symbol. A Space snapshot answers within acon's 5 s: the listing gets half of `SPACE_BUDGET_MS` and falls back to the last listing (a late one still replaces it), projects are read concurrently and a late one is left out of that poll, the cached office model is keyed by the daemon's `instanceId` (a restarted daemon restarts its ui sequence at zero), and `pollMs` stays under acon's 3000 ms link gap.
31
32
  - Dashboard Usage view (#683 U4): `uiSnapshot` builds `usage` only when the request's view is `agents`, never on a hidden view's poll. It folds with `catchUp(100)` and sets `behind: true` when that is not done, so a partial figure is never shown as the whole. A failed read answers `{ unavailable: true }`, never `undefined`, which the page reads as a hidden view and keeps the old figures. The snapshot carries task ids, a `pii` flag and numbers only, never a title; the page shows a private task as `#id [pii]`. Daily figures come only from `UsageRollup.daily` (rule: `budget.md`).
32
33
  - Dashboard Code group (#684 2c): `codeGroup` (`src/hub/task-code-group.ts`) is the only dashboard classifier of changed symbols against `plan.symbols`, called in `uiSnapshot` with the public `codeSymbols`, `tasks.publicMetadataText` and `tasks.isPii`; the page (`codeGroupSection`) draws it and never matches names. A private task gets no `codeGroup`. A stored receipt (one with `repositoryKey`), a missing or an unknown one is `state: "unknown"`. A malformed or screened-out plan name is counted in `planWithheld` and marks the group incomplete, never dropped. Lists stop at `CODE_GROUP_MAX` (40) with the rest in `more`, and `counts` are totals. The memory view reads a `node` of up to 512 characters only when it starts with `symbol:` (200 otherwise).
33
34
  - Agent CLI drift (#843 AC3): the daemon's `sweepAgentDrift` (once after start, then `agentDriftEveryMs`, default one day, on its own timer apart from the task sweep) is the only caller of `noticeDrift` (`src/hub/agent-drift.ts`). It reads versions only through `agentVersionsAsync` (`src/cli/agents.ts`), which shares `probeTarget` and `probeRow` with the `ahub agents` probe: never add a second probe path, a network read or an install. The first probe is owed until one has run (`agentDriftOwed`): a daemon that starts inside a controlled recovery skips it and the 1 s tick pays it once `recoveryActive()` ends, so a restart or upgrade never waits a day for the first notice. `startDaemon` probes only when `agentDrift` is set (`lifecycle.ts` sets `true`; tests inject a function), so a test daemon never spawns a real CLI. Drift is an installed executable with a readable version whose state is not `verified`; a missing or unreadable one is silent. The notice carries id, version, state and the receipt path only, is marked per (id, version, planner) in `agent_drift_notices` before it is published to that planner alone (and forgotten if it cannot be queued, so only that planner is retried), and goes to `plannerPeers()` that are rostered; with none, nothing is recorded. `agentVersionsAsync` reads stdout as a stream with the 64 KiB cap enforced as it arrives (kill and cancel on overflow) and cancels the reader at its deadline and a short grace after the probe exits, so a descendant holding the pipe cannot hang it. Never put CLI output in it.
@@ -43,6 +44,7 @@ Scope: the operator console, control WS and its protocol, state files, the dashb
43
44
  - `pet` is console-local presentation (#483): validate it only through `validatePetConfig`, which both `loadConfig` and the reload schema call, and keep it applied live with no authority (`authorizeConfigLeaf`).
44
45
  - Start modes (#269): every start the hub makes itself goes through `hubStart` (`src/hub/daemon.ts`): the dashboard's `start_peer`, the console-role `peer_start`, the conductor's `start` hook and `pi.auto_start`. It takes the mode from `startMode(peer)` and nothing else; never add a caller-chosen mode, flag or command to it, and never fall back to headless when a terminal cannot be opened (refuse with the manual command). The words a terminal runs are assembled there from `process.execPath`, the CLI entry, the project root and `startWords(peer)`. The only continuity exception is a validated Pi bookmark on `pi.auto_start`: append its recorded session file (or its session id when no file was recorded), backend and model; never pass both session selectors or accept caller-written replacements. Recovery and `startPeer` callers that pass a recorded mode keep it. A launcher with no `--mode` takes the daemon's accepted start mode from `status.json` `startModes` (`readControl`) before the file's value. A TUI start of an `owned-acp` peer (Jcode) from the console's `peer_start` opens its terminal even while the ACP owner is online: that terminal is a frontend on the owner. The conductor and the dashboard answer `already` for an attached one (#559). The real terminal providers are wired only in `src/hub/lifecycle.ts` (`DaemonOptions.terminal`): a daemon built without them, as in every test, opens nothing; a test injects a recording provider and never the real one.
45
46
  - A headless start that `hubStart` made for a peer with `capabilities.watch` (Kimi, Qwen) also opens `ahub watch <peer>` through `openWatchPane` (#286 AC10): the same provider, fixed words from `process.execPath`, `CLI_ENTRY`, the project root and the peer id, one per peer per `PEER_START_RETRY_MS`. It runs only after the start succeeded and never fails it: no provider or a failed open reports `watch.command` in the result and a notice, with the provider's own text in the log only. Never open a pane for an `already` result or a declared instance.
47
+ - Default agent set (#608): `start.agents` is read only by the CLI at a person's `ahub up` and by the dashboard manager's start (`afterStart` in `src/hub/manager.ts`), through the console `peer_start` and so through `hubStart` (`src/cli/start-agents-run.ts`). The daemon never reads it: do not add a start from it to daemon startup, restart, recovery, `reloadConfig` or upgrade, and keep the `AGENTHUB_RECOVERY_OPERATION`, `--live-test` and console-caller guards on the `up` body together (each overlaps another, and removing one is caught only because the test removes them all). It is in `ALL_MACHINE_LOCAL` with an empty list kept as a value (`EMPTY_IS_VALUE` in `config-trust.ts`): an empty list means start none, never the default. `src/hub/start-agents.ts` and `config-apply.ts` stay free of file-system imports because the schema reaches the Claude channel bundle. `defaults.json` gained `start.agents`; `writeAutonomyDefaults` and `writeMachineStartAgents` each keep the other half, and any other key still makes the whole file untrusted.
46
48
  - `ahub setup` reads Claude Code's state from `--json` listings and takes one step at a time, re-reading after each; never match paths or names by substring.
47
49
  - A copy of the git index keeps the original's mtime (`snapshot()`): git's racy-entry check compares entries with the index file's time in whole seconds, and a fresh copy makes a same-size edit look clean.
48
50
  - State files that clients read (`status.json`, `control-token`) are written after the port is bound, and `status.json` via temp file + rename.
@@ -55,7 +57,7 @@ Scope: the operator console, control WS and its protocol, state files, the dashb
55
57
  - Adding a native peer requires testing the command emitted by the actual recovery driver, not only its inspection or launch helpers. Never let a generic non-Codex branch treat a new peer as Claude.
56
58
  - Cross-version recovery must use the authenticated source protocol for prepare/commit/abort and the target protocol after startup. Prove the transition against a real prior release before claiming compatibility.
57
59
  - Preserve `task_cancel` target-feature preflight before source holds, capture or commit. Reuse stored target features only after verifying the same retained root and digest; legacy receipts still capable of commit must obtain feature proof. Do not apply pre-commit compatibility checks to sources already past commit. Every prepare, including the lease renewal before a terminal close and after a resume, sends the operation's stored `targetFeatures` (#478).
58
- - A recovery target-feature floor (`task_cancel`, `invocation` while the invocation ledger has an open (`queued` or `delivered`) row, `peer_instances`, `role_stages` while a declared Claude-kind instance holds `implementer`, `task_planners` while a list/instance planner is configured or `Board.hasPlannerState()` finds a task/release binding or plan-review authorship marker, `release_trains` while a configured train is active or frozen, `reviewer_pool` while a fresh reviewer or `review.pool` is configured, `configured_acp` while the project declares `adapters`, `external_adapter_modules` while external module definitions are loaded, `external_adapter_instances` with `external_adapter_modules` and `peer_instances` while an external module-kind instance is declared, `conductor_recovery` while `Board.hasConductorManualState()` finds open conductor manual-assignment intent, `project_autonomy` while the daemon holds retained autonomy consent or a raw `config.json` or `config.local.json` has a top-level `_autonomy` key (an unreadable file refuses as unknown), `action_policy_floor` while the raw `config.json` or `config.local.json` names `action_policy.floor`, true or false, even where the key is ignored or normalized away: an older target parses the raw key and rejects it before any trust rule, so check raw file presence, never the effective config (#582, seen twice)) lives in `incompatibleRecoveryTarget`, which every existing check site calls, and its name in `DAEMON_FEATURES`; never check a floor at one site only.
60
+ - A recovery target-feature floor (`task_cancel`, `invocation` while the invocation ledger has an open (`queued` or `delivered`) row, `peer_instances`, `role_stages` while a declared Claude-kind instance holds `implementer`, `task_planners` while a list/instance planner is configured or `Board.hasPlannerState()` finds a task/release binding or plan-review authorship marker, `release_trains` while a configured train is active or frozen, `reviewer_pool` while a fresh reviewer or `review.pool` is configured, `configured_acp` while the project declares `adapters`, `external_adapter_modules` while external module definitions are loaded, `external_adapter_instances` with `external_adapter_modules` and `peer_instances` while an external module-kind instance is declared, `conductor_recovery` while `Board.hasConductorManualState()` finds open conductor manual-assignment intent, `project_autonomy` while the daemon holds retained autonomy consent or a raw `config.json` or `config.local.json` has a top-level `_autonomy` key (an unreadable file refuses as unknown), `calendar_schedules` while a raw `schedules` entry has a `cron` or `tz` key (#1017: an older target refuses the key at load), `research_records` while `Board.hasResearchState()` or a raw `config.json` or `config.local.json` `research` object names `runners` (unreadable refuses as unknown), `research_dispatch`, `research_dropped_offers`, `research_experiments` and `research_comparisons` while `Board.hasResearchDispatchState()`, `hasResearchDroppedOffers()`, `hasResearchExperimentState()` and `hasResearchComparisonState()` find rows (each research digest joins `integrity()` only when its rows exist), `research_runners` under the runner registration rule below, `action_policy_floor` while the raw `config.json` or `config.local.json` names `action_policy.floor`, true or false, even where the key is ignored or normalized away: an older target parses the raw key and rejects it before any trust rule, so check raw file presence, never the effective config (#582, seen twice)) lives in `incompatibleRecoveryTarget`, which every existing check site calls, and its name in `DAEMON_FEATURES`; never check a floor at one site only.
59
61
  - Project autonomy (`autonomyCommand` in `src/hub/daemon.ts`, `writeAutonomyPreset` in `src/hub/settings.ts`): the `autonomy` control message is console-only; its operations come only from `planAutonomy` and `autonomyRemovalKeys`, never from the request; `off` removes the fixed key list and clears consent whatever `_autonomy` says. `_autonomy` is inert metadata, never approval provenance. Retained consent is created only by `createAutonomyConsent(...).confirm` inside the daemon (console `on`, or startup under a trusted `readAutonomyDefaults` opt-in with an exact plan match), never by a CLI or offline writer; a config `never-ask` takes effect only for a peer in `autonomyConfirmed`.
60
62
  - Defer hub notices only for an authenticated recovery `prepare` or `commit` whose target features include `recovery_deferred_hub`, or for a restored daemon. Otherwise `setRecoveryHold(true)` moves the deferred notices into queues before commit records expected integrity, and a bare `fenceRecovery` defers nothing. Abort and lease expiry release the bus hold before clearing the operation; if that release fails, keep the operation and let expiry retry on its 1 s timer (#478).
61
63
  - Resume a prepared `commitSent` source that is unavailable or database-busy inside the existing bounded shutdown poll; never re-send commit, re-prepare or close terminals again on that path. Require confirmed `stopped` before target startup, refuse a replacement instance, and retain the prepared receipt on timeout.
@@ -66,7 +68,7 @@ Scope: the operator console, control WS and its protocol, state files, the dashb
66
68
  - A restored daemon accepts a changed native session id (Kimi and local aside: they restart as fresh workers) in two ways only: the #64 rule for a Claude session that never persisted a transcript, and the operation-fenced `recovery-waivers.json`, which the coordinator writes wherever it accepts a session other than the planned one: a reconnect-only Claude (#206), an operator-chosen fresh session, the plan's `freshStart` of a Codex thread the hub saw start with no turn, or a zero-turn Claude it accepted (#215). Never waive on any other evidence. Re-preparation after an expired lease keeps every effect receipt, and `restored:<peer>` = `failed` never counts as restored. Judge a restoration launcher by its `terminal-recovery.json` record and process signature (and a `terminal_handle_stale` answer), never by the terminal's exit; an unreadable record is unknown, never "none".
67
69
  - A target that stopped after it started is restarted only through `recordRestart` (`src/cli/upgrade.ts`), recorded before the new one starts and at most once per project per resume: each done or waiting `restored:<peer>` binding becomes `retired:<peer>`, whose terminal is closed (`closedRetired:<peer>`) or found absent before that peer is relaunched, and is never revalidated against the new instance. Keep repaired-session provenance across every target restart, disabling automatic fresh/zero-turn replacement. A person may explicitly accept loss of a repaired Codex/Claude conversation only on typed positive missing transcript/rollout evidence and a disposal-time recheck; refuse live-test repaired overrides after read-only receipt classification and before runner/lock or coordinator mutation, fail closed on unclassifiable scope, and repeat in core before lock and after under-claim read, preserving ordinary #408 delegation; record the one-use grant and freshOverride evidence, and consume the grant on exact attachment. Pi stays excluded: positively missing accepted Pi files get stop-and-archive plus the normal project-scoped ahub pi command; bookmark retirement never falls back fresh in the same start. Clear the repair fence only for a done or waiting binding retired from that target; an uncertain creation without a durable binding keeps its fence. Launcher evidence is read for every instance in `restarts`, and a session attached without an id is unknown, never gone (#225).
68
70
  - After a managed terminal is created, persist its binding before waiting up to `recovery.reconnect_wait_s` (#489 H4, read by `readRecoveryWaits` at the start of the wait; unset 600 s) for authenticated attachment to the expected target instance, operation and conversation (#414). Resume waits on the saved binding without creating another terminal. Count only attached, non-offline session ids; when the chosen terminal has no current launch record, a confirmed-ended row for another handle or an id-less attachment retains the bounded wait after older-launcher checks. A late current record must match the saved terminal; unknown or live replacement evidence still blocks. Timeout keeps the receipt blocked and records only terminal/hub metadata; a last native connect attempt that was never recorded is unknown, never inferred from terminal readiness or offline state. Final identity and preservation verification stays strict. `createTerminal` re-reads the launch record and Orca binding of an idle replacement every `MAP_POLL_MS` until it maps to the original peer and session, a known identity or session contradicts it, or the deadline passes; only then does it report `ambiguous-create`. Missing identity alone is never a mapping (#599).
69
- - An operation receipt stays `schema: 1`. Only `abandoned()` writes 2, on an operation with a recorded `disposition` whose coordinator predates #215: in the same write as the disposition, or, for a disposition 0.12.20 recorded at 1, by the next command that holds the runner claim. `validateReceipt` accepts 2 only with a disposition (#227). Never write 2 on an operation that can still be resumed. Disposal clears `error` and `failure`, so each `stop-and-archive` audit entry keeps the operation's `error` at that moment as `cause` (#776): only text the receipt already held, never task or message text.
71
+ - An unscoped operation receipt stays `schema: 1` (a scoped one is written at `schema: 3` and never moves; Staged upgrades below). Only `abandoned()` writes 2, on an operation with a recorded `disposition` whose coordinator predates #215: in the same write as the disposition, or, for a disposition 0.12.20 recorded at 1, by the next command that holds the runner claim. `validateReceipt` accepts 2 only with a disposition (#227). Never write 2 on an operation that can still be resumed. Disposal clears `error` and `failure`, so each `stop-and-archive` audit entry keeps the operation's `error` at that moment as `cause` (#776): only text the receipt already held, never task or message text.
70
72
  - Four checks read another release's source text for an exact line: `coordinatorCurrent` (`export async function disposeRecovery` in `src/cli/upgrade.ts`), `targetReadsWaivers` (`export function readRecoveryWaivers` in `src/hub/restart.ts`), `coordinatorRestarts` (`export function recordRestart` in `src/cli/upgrade.ts`) and `supportsAction` (`export async function reattachRecovery` in `src/cli/upgrade.ts`). Renaming, moving or un-exporting one makes this release read as an older one: keep those lines until a capability list replaces the checks. The reattach CLI dispatch must use the same `supportsAction` check as its printed command; never redirect a fallback command back to a coordinator without that action. Recovery also reads a target's literal `OPTIONAL_PEERS` (`src/hub/peer-catalog.ts`, through `legacyTargetSupport`) and `START_MODE_PEERS` (`src/hub/start-mode.ts`, through `legacyHeadlessArgv`), declared in `src/adapters/definitions/native-recovery.ts`; keep both literal declarations until a published capability replaces them.
71
73
  - Publish terminal ownership only after the current native owner is proven through `terminal_owner` (#482); capture and start acknowledgement are pending evidence only. Preserve unrelated and invalid rows, scope current evidence to project/instance/peer, and recheck the same native generation after asynchronous Orca reads. Keep Claude's hook record pre-spawn. Compatibility and retention ceilings are described in Operations, controlled recovery.
72
74
  - `reattachRecovery` (`src/cli/upgrade.ts`, #463) relaunches only the peer a blocked receipt's `blockedReattachment` names, once per its `reattachments` marker, after the lock, the runner claim and a re-read, and only against the same held target instance and version. The terminal proof is `inspectSavedTerminal` (`src/cli/terminal-recovery.ts`): only an `orphaned` pane without positive connected evidence or `missing` (a typed stale handle and a complete, untruncated inventory) counts as gone; `unknown`, `changed`, an orphaned-but-connected pane or a disconnected-only reading never authorizes a terminal. It counts as reattached only on a `done` restore receipt with the exact recorded session id, session file and project root, then leaves the operation blocked for `recovery resume`. It is person-only: `classifyPeerCommand` and `liveTestRefusal` refuse it from an agent shell.
@@ -79,6 +81,9 @@ Scope: the operator console, control WS and its protocol, state files, the dashb
79
81
  - `ahub claude` (`src/cli/launch.ts`, #270) publishes `claude-launch.json` (`claude-launch.<id>.json` for an `--as` instance) for every launch against a running hub, atomically and without a lock: hooks, permission status, session observation and recovery read its `launchId`, so never make it wait for native-proof state. The native child's pid and signature go only into the private `claude-native-<binding hash>.json`, keyed by the exact launch record, written once under `claude-launch.lock` and removed at that child's own exit; never put them in `claude-launch.json`, where a late callback of an earlier launch could replace a newer record. Crash cleanup of a previous launch's settings file (`cleanupStaleClaudeSettings`) needs the launcher gone by `processLiveness` and that native proof, and for a `direct-child` proof the native pid absent from an available `processTable`: a direct child may exec in place, so its signature certifies nothing. A script or interpreter gets no proof and keeps its settings file.
80
82
  - Read a `claude-launch*.json` record in the daemon only through `readClaudeLaunchProof` (`src/hub/claude-launch-proof.ts`: no-follow, 16 KiB cap, this instance only), never by parsing the file. An `--as` launch records the `executionCwd` its entry got from `resolveExecutionCwd`; the entry never chooses or creates a worktree.
81
83
  - Terminal Claude implementer roots (#525-5): bind `executionRoots`/`executionOwners` only at a hello where the resolved worktree is not the project root, the private launch record names this hello's `launchId` and that root, and the channel's reported `cwd` resolves to it; bind it to that socket and session generation. `currentExecutionOwner` rechecks the record and the worktree in each event-loop turn (reuse only within synchronous code), and `revokeTerminalExecution` drops the proof on a mismatch, detach or new claim. Status (`ownerIsolation`, `ownerIsolationNotes`, `tasks.stalled({ ownerIsolation })`) reads only `ownerIsolationProjection`, the retained binding plus owner, generation and socket checks: never add a terminal-root Git or filesystem validation to the status path, and never authorize admission, facts grants or recovery from that projection. Missing or unknown evidence keeps the channel attached but never grants isolation; the channel omits `cwd` when it cannot resolve its own.
84
+ - Distinguishable sessions (agent-hub#1036, `src/hub/session-identity.ts`): a tools hello that asks to be a session (`session`, sent by a channel plugin that has no project of its own and inherited another project's state dir) or that names a Claude channel peer while PRESENTING a launch id that is not that peer's current one (`sessionHello`; an ABSENT id stays the peer, so the native facts hook and a person's shell keep working; a missing launch record, another daemon's record, codex, kimi, qwen, owned ACP peers and the implementer child are untouched) is bound to an id the daemon mints, `<peer>~<origin>`. The id has no `@`, so it never reaches the remote-room paths (`isRemotePeerId`, `membershipAllowed`, `remoteEnvelope`, `Bus.emit`'s body withholding); `PEER_ID` is not widened, and the ids live in the daemon-owned `sessionIds` set. A session is never a bus peer: no delivery, no push, no roster. `sessionGate` runs before the dispatch switch and allows only `send`, `status` and the read task ops in `SESSION_READ_OPS`; every other message is refused with `this session is <id>, not this project's <peer>`, so a new control message is refused to a session until it is listed. Its envelopes carry `session: true`, the header shows the minted id as is, and its `attach` and envelope events carry `session: true` and the id, never a path or text. A label that is not already a plain lowercase token is dropped (never cleaned up into one) and the hub mints a short id. A plugin never asks for a session from a hub whose `status.json` lacks `session_identity`: that hub would bind the real peer id. `session_identity` is its own recovery label: a target without it is refused while an unsettled delivery holds an envelope with `session: true`. Nothing is refused to a connection that worked before. Limit: this makes sessions distinguishable and traceable, it does not authenticate them.
85
+ - Channel plugin project (agent-hub#1036, `src/hub/channel-project.ts`): the plugin picks its hub with `channelProject`, which shares `inheritedStateDir` (`src/hub/project.ts`) with the CLI's `projectContext`; keep that ownership rule in the one function. An inherited `AGENTHUB_STATE_DIR` is used as the peer only when its recorded root is this cwd's project root (or, unrecorded, `AGENTHUB_PROJECT_DIR` names it), or for a declared extra instance whose cwd is its managed worktree. A process with a project of its own (a linked worktree with its config) keeps that project's hub; one with no project of its own joins the inherited hub as a session with an empty label, so no directory name reaches a header; with nothing to join the plugin connects nowhere and says so. A tools-mode helper (`AGENTHUB_MODE=tools` with both variables set: the implementer child, an external-module instance) trusts the pair, because the daemon checks its launch id at the hello.
86
+ - Sender provenance (`src/hub/provenance.ts`): `originOf` is pure and keeps only role, a peer-shaped id, and a launch or instance id that matches its id pattern; never add a path, cwd, root, body or token, and drop a malformed value rather than copy it. Each admitted peer or tools hello writes one `attach` event after admission (a refused hello writes none). An envelope event's `origin` is the sending connection's, recorded at `send` in the bounded `envelopeOrigins` map and taken once when the event is logged, else `adapterOrigin` for an envelope an adapter published itself; the hub's own writers (`hub`, `user`, `digest`) and room ids get none. `adapterOrigin` reads `managedLaunchId`/`ownedLaunchId` by shape, never through `isWsPeer`: it only labels the log, and an `isWsPeer` call there counts as a native branch in `test/daemon-native-branches.json`.
82
87
  - A headless Claude implementer's tools socket (#525-4) is admitted only with the launch id of that peer's current child (`ownedTools`) and a verified worktree root, and bound as `toolsOwner`: every request and every reply rechecks `currentImplementerTools` and fails closed. Its `task` ops are limited to `IMPLEMENTER_HUB_TOOL_NAMES` and run inside `childToolAuthority`, so `Tasks.requestCheck` refuses before an effect once the child changed. Its `inbox` refuses, before `bus.pull`, a batch holding a private item or one attributed to another task. Its person `permission_request` path is `classed: true`, offers allow-once and deny only, and denies on expiry, abort or a changed child. Automatic Bash permission uses only the fixed host-wrapper contract in `adapters.md`, including the explicit client protocol capability; a raw native allow is never an automatic grant. Its facts get `permission` only for the current child's launch id, session id and this instance.
83
88
 
84
89
  - Keep SQLite recovery contention in the existing blocked receipt as retryable `database_busy`, retaining bounded throw-site frames and the ordinary `ok: false` rid reply. Use a neutral control error; the receipt's next actions name resume or the partway disposition. Clear a superseded failure on abort, disposal, fresh-session choice or a refused resume of a disposition. After a commit request, treat busy inspection as unconfirmed and keep polling only inside the existing shutdown bound.
@@ -130,3 +135,13 @@ Scope: the operator console, control WS and its protocol, state files, the dashb
130
135
  - Anything the daemon starts on a timer and registers only with `startupCleanup` is not stopped by a normal `stop()`: `startupCleanup` runs on a failed startup only. Clear the timer and close the store in `stopOnce`, and wait for a pass in flight before the board closes (`schedulesPass`, #512a). A console op that runs the same writer on demand (`schedule_run`, #335) joins that wait through `manualSchedulePasses`; add any new on-demand entry to it, or `stopOnce` closes the board and the store under it.
131
136
  - Corpus (#510a, `src/hub/corpus.ts`, `corpus-config.ts`): `corpus.paths` is machine-local and `corpus` is never editable from settings. Keep `corpus-config.ts` free of file-system imports, because the config schema (`config-apply.ts`) reaches the Claude channel bundle. A refusal of a root is a reason string, never a thrown error and never a partial read: evidence the check cannot read refuses, and only a completed read certifies a folder. The vault check is `.obsidian` at the real root, in every ancestor and anywhere below (any entry of that name), always on the `realPath`. A folder the pii, deny or local.deny rules protect is never listed, so it cannot be shown vault-free: the root that holds it is refused, never certified with that subtree skipped (a protected file is only skipped).
132
137
  - Domain packs (#513a, `src/hub/packs.ts`): `ahub init --profile` resolves a shipped profile name first, then a pack through `loadPacks` (shipped: `templates/packs/` listed in `src/hub/shipped-packs.ts`; a project pack in `.agenthub/roles/<name>.pack.json` replaces a shipped one of the same name); keep `parsePack` closed (exact fields, bounded, plain ASCII, ids through `criterionId` from `src/hub/workflow.ts`) and never give a pack a field that carries a command, a path or an authority. The preview names a pack by ids only, never rubric or check text, and the apply goes through `packOverrides`, which `resolveProfile` validates. Reads use `lstat` and `O_NOFOLLOW` and refuse a hardlinked file, as the role catalog does. A pack whose roles are not in `loadRoleCatalog` is refused whole.
138
+
139
+ - Campaign dispatch (#597): keep `research_dispatch` additive to `research_records`; any stored campaign requires it, because old campaign stop policies also need current dispatch enforcement. Preserve the conditional dispatch metadata/handoff integrity digest and every other recovery feature guard. A study-dropped owner offer (#603) is a `research_dropped_offers` row written through `Board.recordResearchDroppedOffer` from `Tasks.relevant`, never an in-memory map: a restart between the drop and the approval must still re-offer. It joins `researchDispatchIntegrity` only when rows exist (so a project without one keeps its digest), requires a `research_units` row for its task and a peer-shaped owner, and `studyReoffer` clears it only after the offer is published or the record is stale.
140
+
141
+ - Experiment storage and runner registration (#598, #600): preserve `research_experiments`, its conditional integrity digest and every earlier research/calendar/autonomy guard. Read check evidence only through this project's host reader; no record input creates a measured field. Accept `research.runners` only in untracked `.agenthub/config.local.json`; refuse shared or tracked raw keys even when empty. Keep registration person-only, with whole-block tier2 structural edits and atomic scoped reload; never admit conductor writes. Consume registrations through `TasksDeps.researchRunners`, revalidating the active accepted configuration and current source trust each call; never adopt raw disk edits through that getter. While stored runner state or an active/raw runner key exists (empty counts), require `research_runners`. Include `researchRunners` in release-equality integrity only when stored receipts exist, using their count and stored-row digest alone; never bind accepted registrations or raw config presence to board-state equality across restart.
142
+ - `ahub study` (#601, `src/cli/study.ts`, `src/cli/study-output.ts`): `study.ts` is pure argv parsing (ids `rc-` and twelve hex digits, revisions as positive integers, `--reason` takes the rest of the words) and runs before any connection; `main.ts` reads the contract file against `invokedFrom` with a 64 KiB bound and sends it to the hub, which validates it. `show` is `allowed` in `classifyPeerCommand` through `peerReadForms`; draft, revise, approve, approve-protocol and close are `console`, and the audit label names at most the subcommand (approve-protocol is labelled `study`; a campaign id, a protocol hash and a contract path are the person's text). The default output is for a person and `--json` prints the hub's text byte for byte (`test/output-cli.test.ts`); a new `study` form needs its `--json` branch and a renderer, never a `TEXT_DEBT` entry.
143
+
144
+ - Experiment comparisons (#599): preserve the separate conditional `research_comparisons` floor and integrity for plans, exact protocol/evaluator approvals and causal protocol-context rows. Never backfill legacy order from timestamps or carry approval to a changed hash. Keep the approval op under the console-only `study_` dispatch guard.
145
+ - Doctor memory capture (#1008, #1025): the `memory capture: <tool>` rows come only from `amem setup <tool> --json`, a preview that writes nothing (`readMemorySetup` in `src/cli/main.ts`: no shell, 10 s, 256 KiB), judged by the pure `nativeMemoryCaptureRow` (`src/adapters/definitions/native-cli-doctor.ts`). A plan not in the documented shape, a non-zero exit, a timeout or unparsable output is unknown, never ready; never read dotfiles text or a client's config, and do not scrub the caller's identity before the call.
146
+ - Handoff transport (#604): `handoffOut` connects to the target as a `console` role through `ControlClient` (same token file and machine user as every control message; the hello checks the target's project root and instance), and the target answers every handoff message, an unknown one included. `handoffNotifyOrigin` hangs off `board.onChange` inside its own try, so a task move never waits for, or fails because of, another project's daemon. A target is judged by its own `status.json` features (`checkTarget`), never by a guess from its version. The inbound `task_handoff` is refused while recovery is active, counts in `taskOpsInFlight` so a recovery commit waits for it, and hands `handoffIn` a fence (recovery or `stopping`) that it asks again right before the board write, after the PII screen's await. Keep `task_handoff` in `DAEMON_FEATURES` and its recovery guard beside `research_experiments`; no `PROTOCOL` bump, because a new message type is answered as unknown by an older hub and an optional field is ignored.
147
+ - Staged upgrades (#1039): retain `status.protocol` at the compatibility floor and publish `protocolMin`/`protocolMax`; negotiate the highest common control version and preserve each attached channel's actual negotiated protocol in recovery metadata. New scoped recovery receipts use schema 3 and per-project holds; keep legacy unscoped receipt behavior. Start ordinary project daemons from verified versioned release captures. Check `hub.db` with `db-schema.ts` before startup effects and every writable opener; advance its marker only with an incompatible format migration, never merely on opening with a newer binary. Before source replacement, controlled targets require `schema_guard`; preserve the retained target evidence of fully committed legacy receipts. Unguarded published binaries remain outside the bootstrap guarantee. Keep project runtime selection in bounded project-bound `runtime.json`; refuse Git-tracked or indeterminate repository state before using or replacing that authority. Updated 0.26+ up/manager/dashboard paths use its verified capture or a named version refusal. Preserve the explicit pre-0.26 published-CLI ceiling with a `ponytail:` note; never raise a DB/bootstrap floor for this selection. Compute path-scoped Git trust once per startup outside the writer lock; reuse its project-bound access for runtime reads and writes. Record selection and synchronous launch under the same registry lock used for capture publication and prune quarantine; confirm the unchanged selection and owner at readiness, and require the internal daemon to execute from that exact root. Scoped recovery must not refresh the shared manager. Prune only verified unreferenced versioned source captures, keep newest three/version, and defer on active recovery, unknown reference inventory or exhausted budgets. Schedule rate-limited maintenance after startup returns; keep only bounded metadata validation and atomic quarantine inside the registry writer lock, with candidate hashing and recursive deletion outside it. Refuse managed execution from reserved `.runtime-prune-*` paths; leave changed quarantine contents undeleted. Keep the 1 ms ledger wait on startup admission only; worker statements use the normal bounded wait. Record PID plus process signature and replace only a proven-gone running owner atomically under the admission transaction, with id/phase-fenced completion. Doctor names uncertain ledger state and leftover quarantine paths without repairing or deleting them. Reject empty recovery plans before source preservation.
@@ -1,6 +1,6 @@
1
1
  # Task board and hub tools
2
2
 
3
- Scope: the task flow, assignment, the state machine and the hub's MCP tools. Read before editing `src/hub/tasks.ts`, `src/hub/board.ts`, `src/hub/routing.ts`, `src/hub/task-sweep.ts`, `src/hub/settled-roles.ts`, `src/hub/conductor.ts`, `src/hub/supervision.ts`, `src/hub/tool-refusal.ts`, `src/hub/hub-tools.ts`, `src/hub/review-brief.ts`, `src/hub/reviewer-pool.ts`, `src/hub/reviewer-pool-config.ts`, `src/hub/release-admission.ts`, `src/hub/main-checkout.ts`, `src/hub/checks.ts`, `src/hub/task-repository.ts`, `src/hub/review-findings.ts`, `src/hub/review-scope.ts`, `src/hub/plan-conformance.ts`, `src/hub/sdlc-metrics.ts`, `src/hub/sdlc-merge.ts`, `src/hub/sdlc-mirror.ts`, `src/hub/no-writes.ts`, `src/hub/workflow.ts`, `src/hub/deliverables.ts`, `src/hub/deliverable-files.ts` or `src/hub/ci-watch.ts`.
3
+ Scope: the task flow, assignment, the state machine and the hub's MCP tools. Read before editing `src/hub/tasks.ts`, `src/hub/board.ts`, `src/hub/routing.ts`, `src/hub/task-sweep.ts`, `src/hub/settled-roles.ts`, `src/hub/conductor.ts`, `src/hub/supervision.ts`, `src/hub/tool-refusal.ts`, `src/hub/hub-tools.ts`, `src/hub/review-brief.ts`, `src/hub/reviewer-pool.ts`, `src/hub/reviewer-pool-config.ts`, `src/hub/release-admission.ts`, `src/hub/main-checkout.ts`, `src/hub/checks.ts`, `src/hub/task-repository.ts`, `src/hub/review-findings.ts`, `src/hub/review-scope.ts`, `src/hub/plan-conformance.ts`, `src/hub/sdlc-metrics.ts`, `src/hub/sdlc-merge.ts`, `src/hub/sdlc-mirror.ts`, `src/hub/no-writes.ts`, `src/hub/workflow.ts`, `src/hub/deliverables.ts`, `src/hub/deliverable-files.ts`, `src/hub/research-campaign.ts`, `src/hub/research-experiments.ts`, `src/hub/research-runner.ts`, `src/hub/task-approval.ts`, `src/hub/ci-watch.ts` or the cross-project handoff (`src/hub/handoff.ts`, `src/cli/handoff-args.ts`, `src/cli/handoff-output.ts`).
4
4
 
5
5
  - Scope every task-place comparison through `TaskRepositories` (`src/hub/task-repository.ts`, #476): explicit `refs.repo`, else a common outside-absolute-ref repository or parent, else this project. Preserve relative display spelling within that key and gate overlap, contradiction, replay and held evidence by equal keys. Warm/cache repository identity at task mutation and startup; never repeat Git reads for unchanged overlap inputs. Set `refs.repo` for work in another repository; repo-only accept metadata never grants execution or file access.
6
6
  - Tool callers are models: MCP `inputSchema` is not enforced on the way in. Normalize at the boundary (`cleanRefs`) before anything reaches the board, and never throw after a board write: a publish or notify that follows a `review()` or `done()` board write goes through the one `releaseNotice` guard, which turns a failed publish into a console notice, drops a failed notify and never retries (#231, #243). An assignment's delivery once its board write is saved (#276, #297): the move is fact, the failed publish is not retried in place (the journal's own recovery covers a storage error, and a retry may duplicate a publish that actually left), and a person looks for a queued or needs_review delivery with `ahub queue list --peer <owner>` (then `ahub queue show <id>`, or the accepted entry in `ahub task show <id>` if the journal is down) before re-sending with `ahub task assign <id> <owner>`. The distinction is made once, in `assignOwner`: a failure after the write prints the one console notice from `undeliveredNotice` right there, once, and throws `AssignmentUndeliveredError` carrying the saved task; callers (`review()`'s repeated rejection, `escalate` and its callers, the ready offer, both relays, the idle sweep, rescreen) decide control flow by the type and print nothing more, and `propose`, `assignTo` and `decline` additionally let the error reach the caller in its turn, which is a person or peer that can act. Two exceptions get no notice, by construction: a row superseded meanwhile, meaning a later history entry that moves or settles the task (another assignment, an unassign, the owner's accept, done, approval, close), while entries that do neither (the hub's quiet bookkeeping: answers, reviewer changes, idle sweeps, check results) leave the assignment undelivered and the notice stands, so the original error goes to the caller as before only on supersession; and a claim's own accepted write and "claimed by" line stay outside the delivery rule (a claim is its own hand-over; its overlap and routing announcements to earlier owners are deliveries and share the wrapper, so a failure there does read as one). A failure before the write stays a plain `Error` (#254), a failed write leaves the ready offer unconsumed (#231), and the notices before the write (waits, no-owner, the reserved-owner line) go through `releaseNotice` too, so those pre-write notices cannot fail their caller.
@@ -11,7 +11,7 @@ Scope: the task flow, assignment, the state machine and the hub's MCP tools. Rea
11
11
  - `assign()` never defaults to the task's current owner, or a decline can only come back to the decliner. A reservation (#207) is the same risk: the reserved owner goes first only through `blocked()`, and a decline or an escalation for reason `rejections` or `manual` records the owner it left in the history entry's `from`, which `excluded()` reads, so no later reroute hands the task back through its reservation.
12
12
  - A named owner that `assign()` refuses is never a silent no-op (#601): `assignTo` passes `refuseNamed` so `assignOwner` throws with the routing refusal reason before any write (pre-effect for tool callers, a plain `Error` for the console), and a proposal's unmet named owner is reported in the propose result through `namedOwnerNote`, read from the stall the proposal recorded. The task stays where it was; never deliver to an offline peer to make the name stick.
13
13
  - Write-class owners (#735): `ownerRoleOfClass` (`src/hub/routing.ts`) is the one derivation of the role that owns a class (`plan` planner, `review` reviewer, everything else implementer); the role ceiling and the owner-admission gate both use it, so never test a fixed list of class names; `explain` and `assign` stay the same function.
14
- - The state machine allows `in_progress -> approved` only for classes without a reviewer; `review()` checks `in_review` itself.
14
+ - The state machine allows `in_progress -> approved` only for classes without a reviewer. Reopening an approval follows the owner/reviewer rules below.
15
15
  - Treat `cancelled` as closed through `isClosedTaskState`, but keep dependency release restricted to approval. Cancellation goes through `Tasks.cancel`, preserves the row and roles, clears its reservation/cohort, sends revision-bound stop notices and writes no outcome memory; never map it to approval or cancel a native turn or worktree. Planner cancellation stays limited to its own proposed/backlog work with no execution evidence (`taskHasWorkEvidence`, the `hub_backlog_return_untouched` test, #812 AC5): an accepted-then-declined untouched task is cancellable, anything with a branch, commit, PR, local check, done, verdict or reopen is person-only and keeps the refusal text. The configured planner may `hub_task_show` any non-backlog task through the conductor's public view (`ConductorHooks.isPlanner`); a staged backlog row stays hidden from agent task tools (#263) and a peer holding both roles takes the audited conductor path. That read grants no hold or assignment.
16
16
  - Capture `TaskOffer.pullOnly` and the exact offer envelope id at publication. Keep unread-offer attention independent of attachment and restart until acceptance, decline, supersession or `offerRead` evidence; never replace that evidence with a generic inbox timestamp or completion state.
17
17
  - Use the daemon's settled role map (`resolveSettledRoles`, `src/hub/settled-roles.ts`, the same resolver reload compares holders with) for planner authority and notice recipients. An explicit planner designation is exclusive; automatic fallback remains runtime-only. Keep ready-unassigned sweep admission consistent with person-unassignment suppression, planner-pull reservation/WIP limits and ordinary routing guards; persist each ladder step before its notice.
@@ -54,7 +54,7 @@ Scope: the task flow, assignment, the state machine and the hub's MCP tools. Rea
54
54
 
55
55
  - Resolve any id that may be an extra instance or a configured kind through the startup-bound project catalog: `this.d.adapterCatalog` with `this.d.instances?.()` in Tasks, `opts.adapterCatalog` in `assign()` and `relayCandidates`, and the `adapterCatalog` hook in `Conductor`/`conductorStart`; a bare `definitionOf(id)` answers builtins only. Only a kind's routing preference expands to its instances; manual ids, reservations and nominations stay exact. Reviewer independence compares families: take the own family only when no other is eligible, record it through `assignmentNote` (`sameFamilyFallback=true`), explicit nominations included; persist the `instance`/`family` refusal codes as `policy`.
56
56
  - Tool publication (`projectToolList`, `projectConductorTools`, `configuredToolContext` in `src/hub/hub-tools.ts`) only narrows the list or adds start targets, using authenticated status metadata (`configuredAdapters`, `startablePeers`, `readOnlyPeers`); in a configured project, malformed or incomplete metadata publishes the read-only set and adds no start target. Publication is never authority: `taskOp` refuses read-only mutations independently, and MCP task and conductor refusals keep their text and set `isError: true`.
57
- - SDLC metrics and bands (#481 S7): `sdlc-metrics.ts` is pure (history in, numbers out, no clock, no I/O); every metric a task cannot derive is `null`/`unknown`, never 0, and a private task is only counted as withheld. Stale-head re-reviews need both heads on a round (`reviewCost.head` on the done, `reviewFindings.head` on the verdict): never derive them from event order, the board cannot produce an `approved` followed by a `done`. `Tasks.sdlcMetrics` is the one read (planner or person, refused during a private turn) and `Tasks.proposeFromBands` the one writer. It is held like every host writer (`sweepHeld`, `bus.isRecoveryHeld`, `bus.storageError`), keys a breach by its episode (`BandResult.episode`, derived from history so a restart finds the same key), writes nothing while the metric has an open band card, and records a log notice in `band_notices` so it is announced once. Card text is fixed words, numbers and task ids, screened through `signalsFor` and `candidateTextAllowed` like the recurring-finding card; never put task titles, notes or PR text in it, and never open a GitHub issue. Drift honours `min_delta` like every tier. `[bands]` is validated by `bandsConfig` with field-level paths; do not add a tier or a metric without a test that fires it. Merge-time figures (#442) come from `MergeRecord.cost`, written once by `guardedMerge` after it observes the merge (`mergeCostFrom`, `cleanMergeCost` in `cost-governance.ts`): re-validate a stored record with `cleanMergeCost` before use, never let that read touch the merge result, and keep a missing or partial read `null`, never 0. `hotfix_rate` and `security_open_alerts` are external series (`EXTERNAL_METRICS`, `Tasks.externalSeries`) whose points are releases and scans, so a card names its `subject`; a legacy release with no train history is left out of the series, never a 0. The security scan (`security-baseline.ts`, `Tasks.runSecurityScan`) reads GitHub's own open-alert counts, runs no scanner, schedules nothing without `[security_scan]`, is held like every host writer, re-judges its schedule after the awaited read and records counts only; a source it cannot read is an unknown total, never zero.
57
+ - SDLC metrics and bands (#481 S7): `sdlc-metrics.ts` is pure (history in, numbers out, no clock, no I/O); every metric a task cannot derive is `null`/`unknown`, never 0, and a private task is only counted as withheld. Stale-head re-reviews need both heads on a round (`reviewCost.head` on the done, `reviewFindings.head` on the verdict): never derive them from event order. `Tasks.sdlcMetrics` is the one read (planner or person, refused during a private turn) and `Tasks.proposeFromBands` the one writer. It is held like every host writer (`sweepHeld`, `bus.isRecoveryHeld`, `bus.storageError`), keys a breach by its episode (`BandResult.episode`, derived from history so a restart finds the same key), writes nothing while the metric has an open band card, and records a log notice in `band_notices` so it is announced once. Card text is fixed words, numbers and task ids, screened through `signalsFor` and `candidateTextAllowed` like the recurring-finding card; never put task titles, notes or PR text in it, and never open a GitHub issue. Drift honours `min_delta` like every tier. `[bands]` is validated by `bandsConfig` with field-level paths; do not add a tier or a metric without a test that fires it. Merge-time figures (#442) come from `MergeRecord.cost`, written once by `guardedMerge` after it observes the merge (`mergeCostFrom`, `cleanMergeCost` in `cost-governance.ts`): re-validate a stored record with `cleanMergeCost` before use, never let that read touch the merge result, and keep a missing or partial read `null`, never 0. `hotfix_rate` and `security_open_alerts` are external series (`EXTERNAL_METRICS`, `Tasks.externalSeries`) whose points are releases and scans, so a card names its `subject`; a legacy release with no train history is left out of the series, never a 0. The security scan (`security-baseline.ts`, `Tasks.runSecurityScan`) reads GitHub's own open-alert counts, runs no scanner, schedules nothing without `[security_scan]`, is held like every host writer, re-judges its schedule after the awaited read and records counts only; a source it cannot read is an unknown total, never zero.
58
58
  - Admit a declared extra (a person-started `ahub claude --as` instance and a headless Claude implementer included, #742, #747) as owner of any class whose owner role is implementer (`ownerRoleOfClass`) only with the current implementer role and `ownerIsolation[id] === true`, the daemon's proof that the live adapter generation started in `executionRoots[id]` outside the canonical cwd. Never derive it from the declaration's `worktree`, its roles or a reload. Only the status path passes a display `ownerIsolation` projection (`stalled(projection)`); every admission and effect caller uses fresh `TasksDeps.ownerIsolation`.
59
59
 
60
60
  - Lifecycle identity (#694): the hub links `sdlc.repo`/`issue` only through `Tasks.linkIssue` (the one issue `planIssueIdentity` resolves) at propose and accept (`linkOnWrite`, #723: a local parse, never a read, never a refusal, advise and enforce only), plan review, done and the groom backfill (`backfillIssueLinks`: first in `groomingDigest`, 32 links and 1 s per call, before the slow metadata reads), and stages a done's PR through `Tasks.identityLink`/`withLink` (the link carries the repository and the commit or branch it was read for, and is dropped when the done's effective repository or head changed during the read; the issue is re-resolved at apply time; `reviewRequestAdmission` compares the board copy through the same `stage` projection) (`openPrFor`: the one open PR whose head is the done's full commit, or with no commit its branch; a full 100-row page or a malformed row is unknown). Persist a done's link only in the admitted done update or `done (checking)`, never before a gate that can still throw `PreEffectToolRefusal` (#254); reapply it after every re-read in `complete()`. Write a first link with `linkedReceipt`, never `sdlcReceipt` (its relink rule would drop red-first and local-gate records); never relink an existing or foreign identity, never parse agent text for a PR, skip PII tasks before any read, and run the groom backfill after its authority checks with its own bound.
@@ -66,6 +66,7 @@ Scope: the task flow, assignment, the state machine and the hub's MCP tools. Rea
66
66
 
67
67
  - `Tasks.review` captures `reviewAuthority` once and rechecks it in `valid()` after every await, right before each write; a headless reviewer's verdict, and its `hub_task_show` (checked before and after the read), holds only while the same child runs for that task at its bound envelope head and review revision (`Tasks.reviewBindingCurrent`, host-only: `in_review`, same reviewer, non-PII, matching `reviewRequestRevision` and review head). Never cache the answer (#223, #533).
68
68
  - Review scope (#852, `src/hub/review-scope.ts`): a reviewer reads `full` (code, affected tests, CI) or `code` (code only). The default is data on the fixed reviewer variants (`AdapterDefinition.reviewer.scope`, `code` for the sandboxed codex and kimi variants, fixed-builtin-only like `instances.modelSelect`); a fresh-reviewer instance may override it with `review_scope` from `config.local.json` only (tier 3, never from a tracked file or the dashboard). `TasksDeps.reviewScope(peer)` is the one read. `sendReview` replaces the Hub-verified, check-result and no-check lines with `CODE_SCOPE_LINE` for a `code` reviewer, which requires every code defect as a structured finding with an id. Reserve findings for defects in the reviewed code, docs or tests; missing check/CI/test-run/PR-or-branch identity evidence goes in `unmet`, never `findings` (Amendment 2). The verdict rule is structural (amended AC3): a `code` reviewer's `changes_requested` is always recorded, never refused, and never classified by reading its unmet text; never add a text classifier (background: README). If its structured `findings` carry no item with disposition `open`, the history entry is `evidenceOnly: true`: it adds nothing to `rejections` (so it cannot escalate), records no failed outcome, contradicts no earlier approval, writes no recurring-finding candidate, is not a rework round in the SDLC metrics, still returns the task to its owner as usual, and sends the configured conductor one ids-only task notice (`evidenceOnlyNotice`; with no conductor, one console notice). A verdict with an open finding counts as today. A person's and a `full` reviewer's verdict are never evidence-only. A `code` reviewer's verdict history entry carries `reviewScope: "code"` and a `full` reviewer's carries nothing, so old rows are unchanged. Merge rule: a task approved by a `code` reviewer is merged by the conductor or the person only after exact-head CI is green and no review thread is unresolved, and the merge note names the CI run.
69
+ - Owner review re-file (#1000, #1047): admit a replacement `hub_task_done` while `in_review` or approved and unmerged only for a different full lowercase SHA through the existing owner, privacy, lifecycle and completion-check gates. Fence the original review revision through every await; same-head, active-check and superseded submissions refuse before effects. Clear stale artifact-head evidence without minting reviewer consent, retain current-head local evidence, open Important findings and rejection counts, and publish a fresh request. A configured check returns the task to `in_progress` until completion. Every verdict pins its initial review request revision across awaits, including off mode with no explicit head. On an approved unmerged task, allow only the reviewer/person to withdraw approval with `changes_requested`; retain Important findings and leave that state for the owner. Persist each verdict head in history and preserve a legacy receipt-only approval head before dropping stale evidence. Refuse merged/cancelled reopens and unknown linked-PR merge state; re-read merge state after other awaits before the reopening write. Require a reviewer after a re-file, including configured-check completion. Project approval binding through `task-approval.ts`; merge queue, decision, dispatch and cleanup must compare it with the latest reported head.
69
70
  - Keep structured review findings in verdict history, bound to the reviewed head. Validate bounded reports and person-only accepted dispositions before effects; recheck authority, privacy and head identity after awaits. A supplied `head` that differs from the task's resolved head (`findingHead`) is refused pre-effect, and the resolved head is part of the identity rechecked after the screen (#657). Key findings by `id` alone, never by pass (`findingsForHead`): a nit or open reclassification never discharges an Important, only an explicit disposition does. Omitted IDs never settle findings; carry open Important across heads until explicitly dispositioned, while nits stay head-local. Use the shared valid-head resolver for projections. Project text through publicView, and deduplicate recurring public pass/id pairs into backlog proposals through durable `Review finding key:` markers (a pair recurs only across separate work: tasks sharing a PR, a branch in one repository or a supersession count once, `workUnits`, #736; a task counts only where a review that is not withheld recorded the finding's id, whatever its pass, as open or accepted, never where it is only recorded fixed, #328) read only from hub-proposed `plan` cards (first history entry by `hub`), without editing instruction files.
70
71
  - Recurring unmet criteria (#514a, `src/hub/learning.ts`): grouping is pure (rows in, groups out); `Tasks.proposeCriterionCandidates` is the only caller and runs beside `proposeRecurringFindings`. It files a backlog card with ids and counts and the `profile_overrides` fragment as text, never applies it, and counts only rows that `candidateTextAllowed` passes (no PII). Its dedupe is the `Criterion candidate key:` line; keep it first in the detail. `ahub learn` (#514b) is the person's verdict on those cards: `parseCandidateCard` reads only that first line (and the hub-authored `plan` card, and the title that must match it), the task count and the line are recomputed from the board (`learnEvidence`, `overridesDiff`), and the card closes through `Tasks.cancel` only after `settingsSet` wrote the one managed key `LEARN_CHECKLIST` (never before, never by a direct board write); every `Tasks.learn*` method refuses any `by` but the person, and a new learn op never joins `CONDUCTOR_TASK_OPS`. Rewording the card's key line or title changes the parser in the same change.
71
72
  - Plan conformance (#481 S2, `src/hub/plan-conformance.ts`): compare only literal repository-relative `plan.paths` entries (file or directory prefix, no glob) with complete PR file metadata; `completePlanInventory` requires a full head, `pathsComplete` and `typesComplete` (false on any rename or copy). Root, absolute-unverified or unsafe entries, a missing plan, a truncated inventory and a failed read are `unknown`, never covered; never read patches or the filesystem. An untouched planned path is advice; a changed path outside the plan is a departure, and `planConformanceBlockers` blocks enforce approval on `unknown` and on an unaccepted departure. Plan `proof` and `risks` (32 strings of 512 characters) cover no file. `Board` records `planRevision` only while `trackPlanRevisions` (mode not off), separate from backlog `planningRevision`.
@@ -106,10 +107,17 @@ Scope: the task flow, assignment, the state machine and the hub's MCP tools. Rea
106
107
  - Review request CI evidence (#824, `Tasks.hubVerifiedBlock`, `ReviewReadiness.checks`): `sendReview` renders one `Hub-verified at <head>` block from the `admission` it already carries and never makes a `gh` call of its own (the AC5 test pins the admission path to its three reads: a fourth call fails it). Its words come only from the hub's readiness read and recorded `check passed` events, never the builder's summary or a `hub_sdlc_record` claim; a PII task and a request with no admission get no block. A producer of `ReviewReadiness` (`readReviewReadiness`, `reviewReadinessFromEvidence`) must fill `checks` or the block names no checks. The checklist's `{check}` line drops "run the affected tests yourself" only for a ready admission with no `check passed` event; keep the #223 R2 withheld summary.
107
108
  - Verdict mirror (#786 T3, `src/hub/sdlc-mirror.ts`, `Tasks.mirrorVerdict`): one `COMMENT` review per (task, head, verdict), called after the approved and changes_requested writes in `Tasks.review`, never awaited and never throwing (a failure is a number-only notice). The only review write is `gh api repos/<repo>/pulls/<pr>/reviews -f event=COMMENT -f commit_id=<head> -f body=<text>`, pinned by `assertMirrorArgv`: never `APPROVE`, `REQUEST_CHANGES`, another endpoint or a method flag, and keep the `test/sdlc-mirror.test.ts` argv test. Idempotency lives on GitHub (list the PR's reviews for the headline at that head), not on the task, so it adds no board or receipt field and survives a restart; an unknown read posts nothing. The note goes out only when public (`!isPii`, not withheld, not a private turn, `publicMetadataText`); a PII task mirrors the headline alone. `mirrorVerdict` reads the PR head and state again AFTER the review listing and judges on that last read with nothing awaited before the post (a head checked before an await is not trusted after it; the same invariant as the guarded merge's dispatch): a new read in that module means a new re-read after it and a fake-gh test that moves GitHub during it. An approval's mirror settles before `autoMerge` starts (`autoMerge(task, after)`), so keep that order. It follows `auto_merge` through `mergeModeFor`.
108
109
  - Guarded merge (#786 T1, `src/hub/sdlc-merge.ts`, `Tasks.guardedMerge`): the hub's only merge method is `gh pr merge <pr> --repo <repo> --squash --match-head-commit <head>`, built by `mergeArgv` and checked by `assertMergeArgv` right before the call; never add `--admin`, `--auto` (until the repository allows it), another merge method or a ref update used to merge, and keep `test/sdlc-merge.test.ts` AC5 over the recorded argv. `decideMerge` names every failing gate and treats unknown evidence as a failure, and judges the non-head gates (checks including the `[repos]` table's and the eval gate's, review requests, threads, draft, change request) on the first read AND a last read taken after the queue (`evidenceGates`), because `--match-head-commit` pins only the head, and compares every board-side input again in one synchronous dispatch segment (`dispatchGates` on `MergeInput.fresh`, then the `run` call, with no await between them: state, repo/PR/reviewed head, privacy, dependency edges and states, open Important findings, release stage, merge mode, required checks, authority and the sweep, recovery and storage holds). The invariant: a gate checked before an await is never trusted after it, so a new input to the decision is a new field in `FreshBoard`, a new line in `dispatchGates` and a new entry in the `flips` table of `test/sdlc-merge.test.ts`; a new gate is a new refusal string plus a test line. The approval trigger (`autoMerge` after `releaseDependents` in `review()`) is fire-and-forget through `Promise.resolve().then(...).catch(...)`: it never awaits, never throws and never changes a verdict. Notices and results carry the task number and gates only (no `publicTitle`), and a PII task is refused before any read. Success is GitHub reporting `MERGED` on a readback, not the exit code. The control op (`sdlc` / `merge`) answers every request and admits the console user or the one configured conductor; post-run result recording and cleanup go through `Tasks`. After verified MERGED plus merge commit, the only permitted ref write is the same-repository/nondefault/unprotected PR head CAS deletion in #786 AC5; never delete a local worktree or branch.
109
- - Schedules (#512a, `src/hub/schedules.ts`, `Tasks.proposeFromSchedules`): the hub proposes a scheduled task only through `Tasks.propose` as `HUB`, never `Board.propose`, so the PII screen, class validation, triage and routing apply. "Its previous task is open" is read from the board (`openScheduled`: first history entry by the hub, detail starting `Schedule key: <id>`, state not closed), never from `schedule_runs`. A run restarts the period at `now`; a hold or an open task counts a skip and proposes nothing, so a long hold ends in one task. Status rows carry ids, times and a state, never the title or detail.
110
+ - Schedules (#512a, `src/hub/schedules.ts`, `Tasks.proposeFromSchedules`): the hub proposes a scheduled task only through `Tasks.propose` as `HUB`, never `Board.propose`, so the PII screen, class validation, triage and routing apply. "Its previous task is open" is read from the board (`openScheduled`: first history entry by the hub, detail starting `Schedule key: <id>`, state not closed), never from `schedule_runs`. A run restarts the period at `now`; a hold or an open task counts a skip and proposes nothing, so a long hold ends in one task. Status rows carry ids, times and a state, never the title or detail. A schedule is `every` or `cron` (+ `tz`), never both (#1017): every consumer of its period goes through `scheduleNext(entry, anchor)` and `scheduleZone`, never `parseEvery(entry.every)` (`every` is absent on a cron entry), and a view carries `cron` and `zone` instead of `every`. The cron core is pure and takes its time as an argument; keep the clock and the machine zone out of it, and keep the daylight-saving rule (a skipped time runs at the next valid minute, a repeated one once) in `instantOf` alone. No third-party cron or time-zone library.
111
+ - Research campaign dispatch (#930, #597; `docs/specs/2026-11-01-research-campaigns-design.md` "T2 dispatch and coverage implementation boundary"): a task bound to a campaign unit reaches an owner only through the paths that call `studyDispatchReason` / `studyDispatchCheck` (offer, assign, planner pull, owner change, acceptance, and a queued owner offer re-checked at delivery). Each reads the stored campaign, its deadline and stop limits, and for a hard cap the named budget, synchronously, and `assignOwner` asks again immediately before the owner write with nothing awaited between that read and the write; a new owner write asks the same read there rather than checking earlier. An explicit request refuses before any effect; the hub's own offer holds with `holdReady`. Budget enforcement is the named budget by id (`ExecutionBudget.covers`), never `applies` (any budget), and it only reads: a campaign never resets, copies or writes a counter, and a `kind: "run"` budget stays peer-wide. A read that fails holds, never dispatches, and a hold never touches a native turn already running. `studyShow` returns ids, enums, counts and times, and strips source ids from a private campaign. A later slice that exposes a new dispatch or wire requirement adds its own `DAEMON_FEATURES` label and recovery guard.
112
+ - Campaign storage (#930 T1, #596; spec "T1 implementation boundary"): every study write runs inside `studyWrite` (counted by `studiesPending`, which `recoveryReady` and the shutdown drain read; `stopStudies` fences new ones) and asks `requestCheck(by, () => this.studyHold())` again after every await and right before the board write. `studyApprove` screens and builds units outside any transaction, re-reads the latest hash and references, then writes once through `Board.activateCampaign`; never move a screen, source lookup or file read into that transaction. A replay creates nothing (it may only tighten privacy), a stable unit id keeps its task only for an identical definition (changed work takes a new id), and a private campaign never retains a public task. `research-campaign.ts` validates only inert plain data (plain prototype, no proxy, accessor or symbol key; a caller's `toJSON` never runs) before canonical hashing. A receipt carries ids, revision, status and counts; no raw contract or close reason enters parent history or memory.
113
+ - Experiments (#932, #598, #599; spec "Experiment T1 implementation boundary" and "T2 comparison implementation boundary"): `Tasks.experimentRecord` and `experimentShow` have no hub tool or CLI yet; `study_approve_protocol` (`experimentApproveProtocol`, person only) is the one wired op, and a new tool or form needs its own admission and recovery review. A writer is the current in-progress owner of a unit task in the protocol's current approved campaign revision (`Board.researchDispatchBinding`), rechecked after every screen, receipt read and hash. A host measurement is its own `research_attempt_observations` row, never a rewrite of a declared event, and no path upgrades a provenance level. Output-path privacy uses `matchesPolicy` with `failClosedOnDiscoveryLimit` (`experimentOutputPrivate`); keep that opt-in to experiments, never the default for other callers. Approval binds the exact protocol hash and evaluator hash, never carries to a changed hash, and a comparison never ranks.
114
+ - Registered runner receipts (#1018): admit a new signed receipt only through `Tasks.experimentRecord`, with current task/source/campaign/privacy authority and fresh trusted registrations rechecked after screening. Keep declared, host-observed and runner-attested evidence separate; a deliverable protocol never derives a code match from a signed commit claim. Identical canonical receipt/signature replay returns stored proof without registration verification or writes, even after revocation; changed bytes or a new revoked/unknown-key receipt refuse. Validate the whole linked attestation event and absence of host observations on every stored receipt read, including paged views that omit that event. Store receipt/event and advance format 2 in the same transaction; keep new/unused databases at `HUB_DB_BASE_SCHEMA` (1). Preserve task, privacy and source fences and show current key status separately from as-of-signing provenance. No public recording surface runs or contacts a runner; keep relay arguments/results out of memory capture.
115
+ - Study readback (#930 T3, #601): `ahub study draft|revise|approve|close` and the hub tools are thin callers of `Tasks.study*` and add no board write. The daemon's person ops are `study_draft|revise|approve|approve_protocol|close` (`taskOpBody`), refused on any non-console connection by an explicit `study_` check in the `task` control case before `Tasks` is asked; agents use `hub_study_draft` and `hub_study_revise` (configured planner through `planningAuthority`, never approve), `hub_study_show` (read-only, also in `READ_ONLY_HUB_TOOLS`) and `hub_study_handoff` (the bound in-progress owner). Pass the tool's arguments to `Tasks` whole: `studyFields` refuses an unknown field, and re-picking fields in the daemon would silently drop it (mutation-checked in `test/study-daemon.test.ts`). `STUDY_TOOLS` keeps the four tools' arguments and results out of memory capture. An implementer child gets `hub_study_show` and `hub_study_handoff` only. `Tasks.studyList` returns one page `{ campaigns, next? }` of receipts (ids, revision, status, counts), never question, scope or sources, at most 50 per call: the bound is the `LIMIT` in `Board.researchCampaignPage` (observed on its own SQL in `test/research-campaign-projection.test.ts`), `after` is the cursor, and a caller cannot raise the page size. `studyShow` blanks source ids for a private campaign and carries `budgetNamed`, so a named budget keeps every resource row with `-` for an absent limit: do not add text to either. `liveTestRefusal` refuses every `study` form except `show`, so an agent shell cannot delegate a study write to the person through `--live-test`; the contract file is read through one opened regular-file descriptor, at most 64 KiB plus one byte.
110
116
  - Folder triggers (#512c, `src/hub/folder-trigger.ts`, `Tasks.runFolderTriggers`): the folder pass shares `schedulesRunning` with the schedules pass and `ahub schedule run`, so it is serialized with them and counted by `schedulesPending`; keep it so, and never start it from a path that does not take the guard. A file is recorded in `folder_seen` only AFTER its task exists, and the pass stops at the first failure so order is kept; do not record first (a failure would lose the file) or continue past a failure. A task made for a file names the source id and never the file's name or path, because both can be private. A trigger's baseline (the files there when it starts, possibly over several polls) proposes nothing, and a refused folder never begins the trigger; do not make either propose. The scan must keep no file's bytes (it hashes in chunks under a file and byte budget and never cuts the listing); `registerScanned` re-reads the one file being registered and refuses it unless it still hashes to what the scan saw. Ask `isNew` for each file inside the loop, not only when building the list, and `observe` (never skip) every file whose content is already known, in the list and in the loop, so it is found by `known` next time and cannot starve a new file of the hash budget. `folder_triggers` stays in `MACHINE_LOCAL` and `NEVER_EDITABLE`.
111
117
  - A scheduled run is fenced twice (#512a): `proposeFromSchedules` checks the hold before it starts and passes `propose` a `fence` that `requestCheck` asks again right before the board write, because the PII screen and triage are awaited in between and `requestAuthority` only fences child generations. Any other host writer that awaits a screen before `Board.propose` needs the same fresh admission at the write. A recovery commit also waits for a pass in flight (`Tasks.schedulesPending`, read by `recoveryReady()` in the daemon): every host writer that can write across an await gets a pending count there, next to `checksPending` and `autoPullsPending`. A person's schedule command that changes what a run in flight should do (`pause`, `resume`, #335) must reach that run's fence too: `ScheduleStore.epoch` is bumped by `setPaused` and `runSchedule`'s fence compares it, so the acknowledged command wins over an admitted run; `ScheduleStore.ran` never moves the anchor back. Any manual entry into the same writer (`Tasks.runScheduleNow`) takes the same `schedulesRunning` guard and is registered with the shutdown drain (`manualSchedulePasses` in `startDaemon`), never called around it.
112
118
 
113
119
  - Person-or-conductor writers (#933): `importGitHub`, `approveIntake`, `runScheduleNow`, `setSchedulePaused` and external intake in `propose` admit only the person or a peer for which `TasksDeps.operational` answers true, and record that peer as the actor, never `USER`; `runScheduleNow` asks the same check again in the hold its board-write fence reads, so authority that ended during the PII screen writes nothing. `setRole` admits the person or the task's owner, and only the person while the task is `in_review`. An agent's `ahub task cancel` reaches `Tasks.cancel` unchanged; never add a second own-task rule for it. `task_label_check` (the daemon's answer before an agent's CLI writes a research label) admits only the task's owner of the current board row, so the CLI refuses an agent's `--created` (an earlier task with that id) before asking; an agent's label record carries `by`, and every reader of labels goes through `effectiveLabels` (`src/hub/research.ts`), where a person's label always wins.
114
120
 
115
121
  - Claim verdicts and fact_check provenance (#901, `Tasks.recordClaimVerdict`, `parseClaimVerdict`, `claimListId`): only the console user records a verdict (`ahub task claim`; an agent tool never does), the claim is named by its 1-based slot and its text is never typed, stored or printed, and every field is parsed closed. A verdict entry carries `list`, the `claimListId` of the plan's ordered claims, and counts only while that id equals the plan's current one, so a replaced or reordered list leaves the slot unknown. `claim verdict` is in `QUIET_EVENTS`. The hub stamps `checkKey` and `missingKeys` on its own check entries from the lookup that chose the command, never from output beyond the example check's exact last line. In `dogfood-profile.ts`, `factCheck.agreement` counts per label only the slots whose model verdict was entered and equals the person's: a slot without the model's verdict is unknown, one that differs counts for no label, and a person verdict alone is never an agreement. Every one of these rows is null, never zero, when its source is missing.
122
+
123
+ - Cross-project handoff (#604, agent-hub#1030, `src/hub/handoff.ts`, `Tasks.handoff*`, `Board.handoff*`): the pure core (`parseHandoff`, `resolveTarget`, `checkTarget`, `handoffEventFor`, `followBackLine`) takes no clock, file or socket; the daemon's `handoffOut` is the one origin path (parse closed, `handoffOutAuthority`: the person, a configured planner or the conductor, refused in a private turn and for a `[pii]` own task; then registry, the target's own status descriptor, the target's reply) and a refusal at any step leaves no row in either board. The target's `Tasks.handoffIn` parses again (a control message is data from another process), sanitizes, refuses a PII pattern or an uncleared screen, asks the daemon's recovery fence before and after the screen's await, and proposes through `board.propose(HUB, ...)`: the origin peer is a record in `task_handoff_origin` and never an actor, a roster peer or a `PeerId` that `Tasks` admits. The spec `issue` is kept as a line of the task's detail. Once the task and its origin row are written nothing fails the handoff (a link, notice or assignment failure becomes the reply's `note`), so the origin always learns the task id. `handoffStatesFor` answers only for tasks whose recorded origin is the asking project, with the fields a live event carries. The inbound `task_handoff`, `task_handoff_event` and `task_handoff_states` control messages are console-role only (the daemon answers an agent connection with a reason, and an unknown one with the usual "does not know"). Follow-back is read from the history entry's event and the task's state (`handoffEventFor`: observed names `accepted`, `done` (in_review, or approved for a class with no reviewer), `approved`, `declined`, `cancelled`), carries only an event name, a PR number and a full SHA, and never throws after a board write; the origin's outbound row is idempotent and a start-up catch-up (`handoffCatchUp`) asks each running target for the open ones. The tables are additive and `task_handoff` is its own recovery label while either holds a row (`Board.hasHandoffState`). `HANDOFF_TOOLS` keep the arguments and results out of memory capture. Never add text from the target to the follow-back line.
@@ -10,6 +10,8 @@ Scope: tests, fakes and the test gate. Read before adding or changing a test or
10
10
  - A test timeout that fires while `Bun.spawnSync` runs can start the next test inside spawnSync's own event loop (Bun 1.3.14 and 1.4.2; background: README). `scripts/check.sh` runs tests with a 20 s timeout and under `scripts/hang-watch.sh`; a test that spawns and reads the process table gets a timeout of its own, never above 20 s: an explicit larger timeout overrides the gate's `--timeout 20000`, so shorten the scenario instead.
11
11
  - Sandboxed commands and the MLX server get a fixed environment (`PATH`, `HOME` and their own keys), so test knobs for their fakes travel in a wrapper script or argv, not in `process.env`; native peer children inherit the hub's environment minus the agent markers (`peerChildEnv`).
12
12
  - Bind agent identity markers explicitly in native/CLI fixture children. The full gate starts the test runner without inherited agent markers (including `AGENTHUB_DELIVERY_ONLY`) and without an `ahub claude` session's hub variables (`AGENTHUB_PROJECT_DIR`, `AGENTHUB_STATE_DIR`, `AGENTHUB_INSTANCE_ID`, `AGENTHUB_LAUNCH_ID`); a new variable a native session exports and a fixture child reads joins that `env -u` list in `scripts/check.sh`. Never weaken production marker detection to make them pass.
13
+ - A fixture that spawns the real channel plugin (`src/adapters/claude-channel.ts` or `plugins/agent-hub/server.js`) to act as a peer runs it with `cwd` at the hub's project root, made with `realPath` (the macOS temp dir is a symlink). The plugin chooses its hub through `channelProject` (rule: `daemon.md`), so from another cwd it joins the wrong hub, joins as a distinct session or connects nowhere. Only tools mode with both `AGENTHUB_STATE_DIR` and `AGENTHUB_PROJECT_DIR` set skips that check.
14
+ - Orca environment (#613, #1048): the `test/hub-home.ts` preload also deletes every inherited `ORCA_*` variable, and `scripts/check.sh` unsets them before `bun test`. A run from an Orca pane otherwise reaches the person's Orca through `ORCA_WORKTREE_ID`, `ORCA_TERMINAL_HANDLE` and `ORCA_CLI_COMMAND` (the launcher's four names are `ORCA_LAUNCHER_ENV` in `src/hub/orca-env.ts`, the one list; `personEnv` drops the whole prefix) and opens real tabs and agents there. A test that needs one sets it itself after the preload and restores it; a test of the launcher uses a stub `ORCA_CLI_COMMAND` and never the real one. `test/orca-env-guard.test.ts` runs a real `ahub up` with fake values and a recording stub and must see zero calls.
13
15
  - `bunfig.toml` preloads `test/hub-home.ts`, which first clears inherited `AMEM_HOME`, gives every test run (the gate and `bun test <file>` alike) a temp `AGENTHUB_HOME` unless one is set, and always replaces `KIMI_CODE_HOME` with a temp dir, an inherited value included (#285); scoped tests may set their own `AMEM_HOME` or `KIMI_CODE_HOME` after preload. Hub memory (#258) is on with any project config, and no test may write the person's `~/.agenthub`. Do not remove it; a test that needs its own home sets and restores the variable. The preload keeps an inherited `AGENTHUB_HOME`, which an agent shell exports as the person's hub home and `scripts/check.sh` does not unset: run `bun test` and the gate with `env -u AGENTHUB_HOME`.
14
16
  - `test/registry-guard.test.ts` statically scans `src/` against the committed `test/registry-guard.baseline.json` (#451); a test never writes, regenerates or repairs that baseline, and a failing guard is fixed in the source by moving the policy into a definition (rule: `docs/agent-notes/adapters.md`); the baseline stays `[]`. Scanner behavior is pinned on inline sources in `test/registry-guard-fixtures.test.ts`, never on edits to real files.
15
17
  - A fixture that needs a port takes it from a free-port probe, never a fixed number, which collides with whatever holds it on a shared runner (#548). The probe binds `127.0.0.1`, matching the loopback server that takes the port (#232).
@@ -37,7 +39,7 @@ Scope: tests, fakes and the test gate. Read before adding or changing a test or
37
39
 
38
40
  - A daemon test that writes a config file and then calls `settings_reload` passes `configWatch: false` (`DaemonOptions`, #546), unless the watcher is what it tests. The daemon's file watcher (100 ms poll, 100 ms debounce) publishes the same bytes, so whichever of the two runs second reports `applied: []`, and a watcher pass between a preview and its confirm replaces the confirmation token. Never lengthen a sleep or loosen `applied` for it; the watcher has its own tests in `test/settings-reload-daemon.test.ts`.
39
41
  - A daemon test of a tool that reaches a public host passes the host-only transport seam (`DaemonOptions.webFetchTransport`, #553) and asserts what the daemon handed the tool, never a real round trip or a longer wait. The tool, the proxy URL and the allowlist stay the daemon's own; only the request is replaced. The proxy's own behaviour has its tests in `test/proxy.test.ts` and the web-proxy block of `test/web-fetch.test.ts`.
40
- - The preload replaces neither `HOME` nor `CLAUDE_CONFIG_DIR`. A test that spawns `ahub setup`, `ahub autonomy` or `ahub init` under machine defaults, or reaches `discoverClaudeHomes` or the profile hook, sets `HOME`, `CLAUDE_CONFIG_DIR`, `AGENTHUB_HOME` and `AMEM_HOME` to temp directories, as `test/autonomy-machine-cli.test.ts` does; unit tests pass `{ home, env }` instead of `process.env`.
42
+ - The preload replaces neither `HOME` nor `CLAUDE_CONFIG_DIR`. A test that spawns `ahub setup`, `ahub autonomy` or `ahub init` under machine defaults, or reaches `discoverClaudeHomes` or the profile hook, sets `HOME`, `CLAUDE_CONFIG_DIR`, `AGENTHUB_HOME` and `AMEM_HOME` to temp directories, as `test/autonomy-machine-cli.test.ts` does; unit tests pass `{ home, env }` instead of `process.env`. A test that runs `ahub doctor` through the CLI also sets `CODEX_HOME` to a temp directory and puts a fake `amem` first on PATH, because doctor runs `amem setup <tool> --json` (#1025, `test/doctor-memory-capture.test.ts`).
41
43
  - Raising the `@staix/agent-mem` exact pin moves its version literal in `test/memory.test.ts`, `test/memory-profile.test.ts`, `test/memory-profile-daemon.test.ts` and `test/doctor-model-aliases.test.ts` in the same PR; that is the only test edit a pin raise makes.
42
44
  - Keep CI's Bun version sourced from `.bun-version` through the pinned setup-bun action; update the version file rather than adding per-job version literals.
43
45
  - Release verification requires both OS checks and the aggregate `seeded guards` success for an identical tree, a tag matching `package.json` on `origin/main`, and fresh ancestor-bound documentation stamps. Preserve the separate npm OIDC publish job and GitHub Release write job; neither job gets the other's credential authority.