@staix/agent-hub 0.29.0 → 0.31.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.
- package/CHANGELOG.md +51 -0
- package/README.md +15 -7
- package/docs/agent-notes/README.md +13 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +10 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +10 -4
- package/docs/agent-notes/tests.md +8 -4
- package/docs/design/adapters.md +108 -0
- package/docs/design/agent-hub-architecture.md +58 -0
- package/docs/design/bus.md +147 -1
- package/docs/design/task-board.md +100 -0
- package/docs/dogfood.md +29 -8
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +410 -86
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +25 -5
- package/docs/sdlc.md +1 -1
- package/docs/security.md +182 -62
- package/docs/smoke.md +13 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/specs/2026-10-12-office-view-design.md +86 -3
- package/docs/verification/2026-11-03-0.30.0.md +54 -0
- package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
- package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
- package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
- package/docs/verification/2026-11-03-pi-title-api.md +26 -0
- package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
- package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verification/dogfood/2026-11-03.md +3095 -0
- package/docs/verification/pi-150-model-free.ts +57 -0
- package/docs/verification/pi-150-native.mjs +21 -0
- package/docs/verification/pi-150-title-api.mjs +31 -0
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4565 -3617
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +13 -13
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +82 -23
- package/src/adapters/pi.ts +140 -16
- package/src/adapters/registry-types.ts +16 -0
- package/src/adapters/registry.ts +4 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/autonomy.ts +66 -1
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +82 -17
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +10 -5
- package/src/cli/identity.ts +21 -4
- package/src/cli/main.ts +167 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +396 -20
- package/src/hub/bus.ts +497 -57
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/conductor.ts +4 -3
- package/src/hub/config-apply.ts +8 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1795 -142
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/dogfood-read.ts +62 -9
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +66 -11
- package/src/hub/manager.ts +67 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peer-doing.ts +33 -3
- package/src/hub/peers.ts +32 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/settings.ts +3 -0
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +607 -71
- package/src/hub/ui.ts +2 -0
- package/src/hub/worktree-prs.ts +100 -0
- package/src/hub/worktree-shelf.ts +246 -0
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/pi/extension.ts +43 -3
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +271 -17
- package/src/ui/needs.ts +10 -4
- package/src/ui/office-model.ts +64 -8
- package/src/ui/office-space.ts +1 -0
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +219 -28
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
- Session/recovery dispatch (#625 slice 2c) reads `PeerAdapter.nativeSession`, never `definition.session.currentId`: local and reviewer context/memory provenance stays unbound. Keep Claude's session/claim proof in the daemon and `lastCodexThreads` as the resume-command fallback. An absent `recoveryReady` is settled; a present false or undefined value refuses. Call an idle owner's `captureResume` before recovery inspect/prepare/commit; preserve snapshot and protocol shapes.
|
|
8
8
|
|
|
9
9
|
- For explicitly user-authorized live verification, use the per-invocation CLI delegation in `docs/security.md` §Agent CLI identity and conductor authority; keep caller markers and record native evidence separately from fixtures.
|
|
10
|
+
- Every new person-only CLI command or subcommand that mutates state (a receipt, an answer, a verdict, a stamp, a confirm) is added to `liveTestRefusal` in `src/cli/identity.ts` in the same PR, with a delegated-form test: a delegated `--live-test` run from an agent shell is promoted to console identity unless it is refused there, so a missing entry lets an agent act as the person. Test the delegated forms (`--live-test <command> ...`, with `=` and separate-operand spellings) and the ordinary agent-shell forms, and assert the person-attributed-write refusal text, because a missing audit outbox also exits 1 and hides the gap. A plain listing may stay allowed. Seen twice on 2026-11-04: `ahub results --read|--read-all|--unread-last` (#1054, PR #1186) and `ahub answer` (#1053, PR #1183).
|
|
10
11
|
- Keep `export const LIVE_TEST_SUPPORTED = true;` in `src/cli/identity.ts`: delegated recovery staging reads this declaration before any source effect.
|
|
11
12
|
- A delegated non-recovery command runs only after `recordDelegatedCliAudit` committed `delegated` against a hub whose `features` list `cli_audit_delegated`, that instance's console `cli_audit_ack` confirmed retention, and `ControlClient.pinInstance` pinned the invocation to it; a refused feature, failed ack or changed consumer executes nothing (#410). Upgrade, restart and recovery record delegation only in the operation receipt (`delegatedAudits` through `createOperation` or `recordRecoveryDelegation`). The daemon counts an audit retained only after `event()` returned true.
|
|
12
13
|
- For conductor recovery (#900), keep source-issued private continuity separate from public actor/audit metadata; recheck the same initiator and every affected project at effect boundaries. Compare candidate config and routing with the running accepted policy. Verify and use the same captured startup inputs before any adapter import; never publish a later raw-file reread. Keep captured TUI restoration and unknown source/target evidence person-only. Derive target feature floors from verified target bytes, never a request or receipt claim (`docs/operations.md`, "Conductor-bound restart and recovery continuation").
|
|
13
14
|
- Conductor operational refusals (#812, #839): throw `OperationalAuthorityError` (`src/hub/operational-authority.ts`) for an authority refusal, never a plain `Error`. Only it sets `authorityRefused: true` and its scope on the reply, which the control `reply` wrapper audits once as a refused `agent_cli` event; any other failure of a labelled request keeps its own error with `authorityRefused: false` and is never audited as refused, and the CLI's `fail` audits only when `authorityRefused` is not `false`. An unreachable or dead hub is "authorization unavailable", never a missing conductor role. `publicOperationalView` is cached on the board status revision, config revision, routing and the private-path policy: a new input to that projection joins the cache key.
|
|
14
15
|
|
|
15
|
-
- Peer DOING (#878): `observeDoing` is the only writer and `doingRows` the only reader (`src/hub/daemon.ts`, helpers in `src/hub/peer-doing.ts`). Decide privacy at observation (`contextPii` or a refusing or throwing `tasks.publicMetadataText` latches a stub) and again at display (an open PII task owned or reviewed,
|
|
16
|
+
- Peer DOING (#878): `observeDoing` is the only writer and `doingRows` the only reader (`src/hub/daemon.ts`, helpers in `src/hub/peer-doing.ts`). Decide privacy at observation (`contextPii`, held review input (`reviewObserverHeld`) or a last delivery holding an `isReviewObserverExcluded` envelope, or a refusing or throwing `tasks.publicMetadataText` latches a stub) and again at display (an open PII task owned or reviewed, a private turn, or the same review-input screens); reuse those screens, never add one. `status()` adds it only with `includeDoing` (the `status` reply and the dashboard snapshot), so `writeStatus` never writes it to `status.json`; the conductor's `hub_status` gets the same rows, each non-stub value framed by `publicConductorStatus` as untrusted (`sanitize`d text, `untrusted: true`, and `from` with `doingNote`, or `note: INCOMING_LABEL_NOTE` for a `label` row); never give an agent DOING text without that frame. A `label` row (`unnamedSessionLabel`: an unnamed `title: "hub"` session's first acknowledged incoming delivery, #1055) reaches only the console, the dashboard and the conductor (`includeIncomingLabels`); every other status reader gets `unnamed`. The console `title` op and `hub_set_title` both go through `setSessionTitle` (writer rule: `adapters.md`), and `session-title-inputs.json` keeps only session, delivery and envelope ids (`sessionTitleBindings`, `titlePersistence: "native"` peers only), never a name, body or label. Claude's title comes from the transcript path the facts hook reported and `claudeTranscript` validated, read through `readAiTitle` (1 MiB per read, lines over 64 KiB skipped); its current item only from PreToolUse input, never the transcript; every cached TaskUpdate subject, current or not, is screened when it is learned (`contextPii` or `tasks.publicMetadataText` refusing or throwing, via `ClaudeTodoSource`), keeps that verdict and passes it to `observeDoing` as `learnedPrivate`. A doing failure never changes a facts reply.
|
|
16
17
|
- Crash-recovery lines (#1111): `recoverAfterCrash` adds a line only through its `report(line, notice)`. Tag a line that asks something of a peer's owner with `peer`; it clears when that peer attaches. Tag the headline with `inFlight`; its count is read live from `crashLostIds`. A line about what recovery found or did gets neither and stays. Status shows only `crashView()` (`openCrashNotices`), never the raw list. `crashReported`, which the Jcode TUI hint reads, never clears.
|
|
17
18
|
|
|
18
19
|
- Conductor console ops (#933): a tools caller's `CONDUCTOR_TASK_OPS` request (`github_import`, `intake_approve`, `schedule_run|pause|resume`, `task_unassign`) and `goal_link` are admitted by `admitOperational` in `onMessage` before Tasks sees them, and the task op runs inside `operationalContext`, which `TasksDeps.operational` reads; never let `taskOpBody` admit a non-person caller to a console op any other way. `conductor_operational` `admit` audits a `CONDUCTOR_LOCAL_OPERATIONS` form once; `authorize` only rechecks the role. `task_catalog_role` from a tools caller passes the peer to `Tasks.setRole` (never `USER`), and is refused for a child, external, read-only or PII-context caller.
|
|
@@ -31,9 +32,10 @@ Scope: the operator console, control WS and its protocol, state files, the dashb
|
|
|
31
32
|
- 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`. Without `--state` the confirm counts the peer's queued and unsettled failed rows; the console sends that form only when the hub's `features` list `queue_discard_states`, otherwise it sends `--state queued` (#1103). 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`).
|
|
32
33
|
- 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.
|
|
33
34
|
- 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.
|
|
34
|
-
- 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.
|
|
35
|
+
- 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. A question's `answer` and a result's receipts (`result_seen`, `results_read_all`, `results_unread_last`, #1054 T6) are the exceptions, and none goes through `projectConsole(...).act` or the `FORWARDED` list from the plugin (the receipts use the `receipt` port beside `answer`, with `receiptEvidence` in `src/ui/plugin-results.ts` checking the same console person meta; the page of `ahub ui` reaches the same three writes as dashboard actions, refused for a live-test session by both `ui.ts` and the daemon). The answer's route is (#1053 T5): `action()` checks the console's person `meta` first, then calls the dedicated `projectConsole(...).answer` port, which sends `answer { surface: "acon", person }` as a console-role control message. `answer` is advertised in `capabilities.actions` (`QUESTION_ACTIONS`) so acon lets the page send it, and is never in `FORWARDED`; widening the dashboard whitelist for it would let a dashboard page answer without person evidence. Acon types are type-only imports from the exact-pinned `@staix/agent-console/contract` entry, never hand-copied. 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. The inbox snapshot (#727, #1196) answers within 1 s at p95: the project listing is the one in-flight listing Space also joins (`runningWithin`, half of `inboxProjectBudgetMs`, falling back to the last listing), the running projects' `agents` snapshots are asked at once, each within `inboxProjectBudgetMs` (a project that is late keeps its last rows and their `at`) and joined while pending in the same action generation and instance (`projectFlights`), so a slow project is asked once, not once per poll; concurrent polls join one computation, a result under `inboxFreshMs` is served as it is, one under `inboxStaleMs` is served while one refresh runs (nothing runs while nobody polls), and `action()` bumps a generation before and after it so a computation that started earlier never becomes the cache. The reply holds at most `INBOX_MAX_ITEMS` (50) items, decisions first, with `more`; the `seen` bookkeeping runs before the cut, so a cut item keeps its `at`. Never put a per-request hub call back into `inbox()` outside that single flight.
|
|
35
36
|
- 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`).
|
|
36
37
|
- 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).
|
|
38
|
+
- Worktree shelf (#1057, `worktreeShelf` and `worktreesView` in `src/hub/daemon.ts`; spec `docs/design/task-board.md`): never add a worktree or branch removal to the daemon (its only one stays the empty hub-made task worktree in `src/hub/task-worktree.ts`); shelf removal exists only in the person's `ahub worktrees tidy --confirm` (`src/cli/worktree-tidy.ts`), which `daemon.ts`, `worktree-shelf.ts`, `board.ts`, `ui.ts` and `plugin.ts` never import. GitHub is read only by `worktrees_refresh_prs` (`refreshPrStates`); a snapshot, `ahub worktrees` and the inbox alert never reach it, and `worktreesView` answers from its 30 s cache without awaiting git. The `worktrees` read is open to a tools caller outside a private turn; override set/clear and the refresh are console-only and refused for a live-test dashboard session. After the awaited git reads, ask the recovery/stopping fence again, then read and write the board with nothing awaited between. The board keeps only the person's Park/Pin (`worktree_override`), never a derived settled state, and accepts one only for a path the shelf just listed, never the main checkout or an instance worktree.
|
|
37
39
|
- 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.
|
|
38
40
|
- SDLC band timer (#481 S7): the daemon's band interval (`bandsEveryMs`, default 5 min) is the only caller of `tasks.proposeFromBands`. It skips while stopping or in recovery, when `tasks.bandsState()` is not ready or `bandsHeld` holds, and when `board.statusRevision()` with the band signature is unchanged since the last pass that ran; it records that key after the write. A failure is one sanitized `hub.log` line. Writer rules: `tasks.md`.
|
|
39
41
|
- `ahub board` (#724): `renderBoard` prints one line per task only with a terminal width and without `--full`; `--full`, `ahub task show` and output without a width keep the full table and SDLC advice. `STAGE_CODES` (`src/cli/output.ts`) keys on blocker-text prefixes from `src/hub/sdlc.ts`: rewording a blocker there updates its code in the same PR. TITLE is cut, never wrapped; STAGE is never dropped (it falls back to the meter and the code); a PII row keeps its `(ahub task show N)` hint while 8 title columns remain.
|
|
@@ -44,7 +46,7 @@ Scope: the operator console, control WS and its protocol, state files, the dashb
|
|
|
44
46
|
- Planner setting (#755): `planner` is one id or a list of one to eight distinct eligible ids, validated only by the `planner` row of `projectSettings` (eligible declared instances join its `values`) and `checkSettingValue`, in `loadConductorPolicy` and `validateRawConfig` alike. Every `resolveSettledRoles` call (`settlePlanner`, `roleHolders`) passes the same `plannerEligible` predicate built from that row, so reload guards compare holders the way the running map settles them.
|
|
45
47
|
- Retained configuration (#544): a key the reload refused, or a file edit outside a scoped write that still differs from the accepted value, is listed in `retainedConfig` (key and sanitized reason only, never a value) in status, `status.json`, the settings view and the console snapshot until a reload accepts or reverts it (`retainedConfigAfterReload`). A config leaf that selects a peer's native launch belongs in that definition's `cli.launchConfigKeys`: the `start` and `peer_start` replies then carry `retainedLaunchConfig` (the running values), the launcher starts with them and prints `retainedLaunchWarning` before its start request; when a retained value cannot launch (Kimi TUI `kimi_cmd` with no TUI form), the error names the running hub as its source and never echoes the words (#654).
|
|
46
48
|
- `action_policy` (#636) is machine-local only: `stripUntrusted` drops it from any file but `config.local.json`, and `authorizeConfigLeaf` refuses an `action_policy` leaf whose `sourceFile` is not an untracked `config.local.json` before any other check. It applies live (`actionBudgets.policyChanged`); Claude's PreToolUse hook, hub-owned ACP peers in `ask`, the local worker and Pi (`classedToolPermission`, #750) enforce it, and a headless Claude implementer only while a current child generation is busy. Codex's approval RPCs are gated but Codex stays not enforceable, because its native policy can run a command without a request. Keep `actionEnforcement` (`src/hub/daemon.ts`) honest: a peer is enforceable only where the hub sees the call before it runs, in the mode it runs in now, and every refusal names its reason (rules: `adapters.md`, Always-ask).
|
|
47
|
-
- `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`).
|
|
49
|
+
- `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`). The floating window is retired (#1012): strip a legacy `pet.window` only through `stripLegacyPetWindow` (in `loadConfig`, `parseConfigSources` and `validatePetConfig`, keeping the raw value for the trust layer). It is reported once in `retired`, refused as a config leaf and never published, and `ahub pet --window|--palette` only print `PET_WINDOW_RETIRED_NOTICE`.
|
|
48
50
|
- 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.
|
|
49
51
|
- 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.
|
|
50
52
|
- 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.
|
|
@@ -57,10 +59,12 @@ Scope: the operator console, control WS and its protocol, state files, the dashb
|
|
|
57
59
|
- The channel's reconnect loop stops on closes a retry cannot fix (`TERMINAL_CLOSES` in `claude-channel.ts`); a new daemon close code that means "do not come back" belongs there, or two clients fight over it forever.
|
|
58
60
|
- The channel advertises `tools.listChanged`; build its tool list only through `toolListFor` and record `listedTools` from each `ListTools` reply, so a reconnect sends `sendToolListChanged` when the whole definitions differ or the lifecycle stays unreadable (#490).
|
|
59
61
|
- Close 4000 is the one close that ends on its own, so it is not in `TERMINAL_CLOSES`: the session stands by and reconnects only once `status.json` reports the peer offline and not `claiming`. One exception (#205, `peerHeld` in `claude-channel.ts`): a session with channel pushes also reconnects when the holder is reported `toolsOnly` and not `claiming`, and takes the peer; the daemon refuses the reverse with 4000. Reconnecting blind would evict whoever holds the id now, and the two would trade it forever. `status.json` is advisory: a reconnecting standby sends `standby: true`, and the daemon refuses it with 4000 while `standbyHeld` holds (a claim, or an open socket; only a channel-capable standby may replace a pull-only holder). A hello whose `launchId` differs from this instance's current `claude-launch` record closes with 4430, which is in `TERMINAL_CLOSES`; no launch id or no record keeps the old admission (#551). `claim()` happens at hello and `attach()` only after the preface, so the peer reads as offline in between: the daemon writes the status file at the claim and the flag covers that window.
|
|
62
|
+
- Peer takeover (#1174): `peer_takeover` is console-role only, with closed fields (`peer`, optional `standbyId` of at most 64 characters, no control or format characters), and `ahub peer takeover` is in `liveTestRefusal`. It refuses without detaching the owner when no eligible standby remains, and answers `outcome: "requested"`; attachment is confirmed only by status. A connection detached by the person (`detachedByPerson`) refuses every peer request with the fixed detached text, and the plugin stops reconnecting on 4405. Never treat a standby id or reported PID as session, launch or permission evidence.
|
|
63
|
+
- Channel push evidence (#1171): the plugin pushes only when `currentClaudeChannelEvidence()` (`src/adapters/claude-channel-evidence.ts`) reads `development-channel-launch` from the direct parent's bounded process row. The inherited `AGENTHUB_CHANNEL` marker alone, or unknown, malformed or changed-parent evidence, keeps it tools-only. The hello's `channelEvidence` is a closed, display-only label: an unlisted value becomes `unknown`, a missing one `unreported`. Never admit, deliver or authorize from it, and never walk ancestors.
|
|
60
64
|
- 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.
|
|
61
65
|
- 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.
|
|
62
66
|
- 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).
|
|
63
|
-
- 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 `
|
|
67
|
+
- 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`, `research_comparisons` and `research_evidence` while `Board.hasResearchDispatchState()`, `hasResearchDroppedOffers()`, `hasResearchExperimentState()`, `hasResearchComparisonState()` and `hasResearchEvidenceState()` find rows (each research digest joins `integrity()` only when its rows exist), `research_runners` under the runner registration rule below, `review_comments` while `Board.hasReviewCommentsState()` finds stored comments or packages (its `reviewComments` digest joins `integrity()` only then), `review_observer_input_state` while the journal (`hasReviewInputState`) or a live or recovery-snapshot peer holds review input (unreadable refuses as unknown), `accepted_delivery_settlement` while `Bus.hasAcceptedTimeoutHolds()` finds a timeout hold (unreadable 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.
|
|
64
68
|
- 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`.
|
|
65
69
|
- 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).
|
|
66
70
|
- 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.
|
|
@@ -77,7 +81,7 @@ Scope: the operator console, control WS and its protocol, state files, the dashb
|
|
|
77
81
|
- `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.
|
|
78
82
|
- The interactive screens (`src/cli/upgrade-interactive.ts`, #272) change an operation only by running an existing command (`recovery resume|abort|dispose|reattach`, `reset`) through `io.run`, and build their menu from `nextChoices`, whose text is `nextActions`: never call `abortRecovery`, `disposeRecovery` or a driver from a screen, and never offer a choice `next` does not have. The one addition is `cancellableWait` (a runner still at `prepare` with no effect): read the receipt again at the key press, stop only a runner `signedRunner` names (a claim without a matching process signature is never signalled), then run `recovery abort`, which decides again. Agents are ended (`endPlannedPeer`) only before an operation holds the lock, and only after the lock, the hub instance, the attached session and the terminal's handle and incarnation were read again: never on the plan as it was drawn. Ctrl+C leaves the screens and the terminal is not read after it; a prompt answers an interrupt with null, never text. What a hub says it waits for (`recovery.waiting`, the daemon's `blockers`) is shown, never decided on.
|
|
79
83
|
- Recovery presentation (#466, `src/cli/upgrade-presentation*.ts`) only renders, and only when `humanRecovery` holds (a TTY and no `--json`); `--json` stays the raw document. Recognise `--json`, `--verbose` and `--color=` only through `recoveryPresentationFlag` (`src/cli/recovery-presentation-options.ts`), which `liveTestRefusal` and `classifyPeerCommand` share, so a presentation flag cannot hide reattach or a repaired dispose from authorization. A screen's Enter default (`preferred`) is read-only (refresh, follow the runner, read the receipt); apply, resume, reattach and destructive choices need their own key, and an item with a `disabledReason` never runs. The receipt's `waits` and `presentationCause` describe the current attempt only: written best-effort (a failed timing save warns, never fails the operation or replaces the wait's own error) and cleared at each resume. Without them show `timing unknown` or an unknown cause; never substitute `updatedAt` or guess a cause from error text. With no typed cause, `renderBlocked` may show only the first recorded error line through `terminalText`, controls stripped, capped at 512 code points and labelled `typed cause unknown`, with the uniform action; never classify it or choose an option from its text (#478). `endInspection` (the completion and stop-and-archive summary) only inspects, and a failed reading is `runtime unknown`.
|
|
80
|
-
- `upgrade()` (`src/cli/main.ts`, #272) never coordinates a target newer than its own `VERSION`: it hands over to that release's coordinator (`bun x --package @staix/agent-hub@<to> ahub upgrade --to <to>` with the same flags), and `AGENTHUB_UPGRADE_HANDOVER=1` keeps the process it started from handing over again. `--to` passes `exactVersion` before it reaches a package spec or the registry. `latestRelease` supplies it only for a terminal run or a dry run: `--yes`, and an apply through a pipe, still name the release.
|
|
84
|
+
- `upgrade()` (`src/cli/main.ts`, #272) never coordinates a target newer than its own `VERSION`: it hands over to that release's coordinator (`bun x --package @staix/agent-hub@<to> ahub upgrade --to <to>` with the same flags), and `AGENTHUB_UPGRADE_HANDOVER=1` keeps the process it started from handing over again. `--to` passes `exactVersion` before it reaches a package spec or the registry. `latestRelease` supplies it only for a terminal run or a dry run: `--yes`, and an apply through a pipe, still name the release. `ahub upgrade` never updates the global CLI or the shared Claude plugin (#1039), so after a completed or scheduled upgrade it prints the step (#718, #1179): `globalUpdateAdvice` (`src/cli/runtime-version-warning.ts`) reuses `runtimeVersionWarnings`, the comparison `ahub doctor` prints, with the plugin read from the same `claude plugin list --json` probe, and speaks only when the CLI or the plugin is older than the daemon the upgrade installs. A handover sets `AGENTHUB_UPGRADE_HANDOVER=1`, and the target's coordinator then speaks for the older CLI that started it, which it cannot see. An unknown plugin (no `claude`, a failing or unparsable probe) is never a claim, a failure of the advice never fails an upgrade, a restart prints nothing, and `--json` stays the raw document. The sentence for the release notes is `globalUpdateStep` in `scripts/lib/release-plan.ts`: `release-prepare` writes it into every new `## Upgrading to <v>` section of `docs/quickstart.md` and `tagPreflight` refuses a tag whose section lacks it or still has a `TODO(agent)`.
|
|
81
85
|
- Recovery judges the registry claim as it is now (`inspectRecovery` re-reads `registeredProjects()`), never the plan's frozen copy; a registry that cannot be read is not inspected, never stopped (#226).
|
|
82
86
|
- `inspectRecovery` catches only `RecoveryDeferredHubError` from `readRestartSnapshot`: keep the stopped state and return the static `snapshotError` blocker without a `snapshot` binding. Never echo rejected content, repair the snapshot or offer resume from it (#478).
|
|
83
87
|
- The managed Claude hook (`src/cli/facts-hook.ts`) may add `permissionDecision: "allow"` on `PreToolUse` (for `ask-when-needed` only what `projectFileTool` accepts) and, since #636, `permissionDecision: "ask"` for a call the hub classed (`actionClasses` on its `facts` reply, in every permission mode, and an `ask` replaces any `allow`); it never adds `deny`, and a value that is present but unreadable asks. A hub that is down, slow or refuses decides nothing, so Claude's own policy stays in force. The daemon puts `permission` in a `facts` reply only for the hook of this instance's recorded `ahub claude` launch (the same `launchId`, not `--unattended`) or of a headless Claude implementer's current child (rule below). For a declared instance with a `worktree` it answers `ok: false` without a current verified owner root (`ownerWriteRoot`), never a canonical-tree fallback, and that root is the reply's `projectRoot`. A permission-only hook (`hookPurpose`) never drives busy state (#242). With `AGENTHUB_HOOK_EVENT` set, the hook serves only the peer definition's `hooks.factsHook` port: input over 1 MiB or refused by its `decode` decides nothing, it sends `native_facts`, and it outputs only the port's `encode` (agy: an ephemeral message on SessionStart/PreInvocation, never a permission decision). The daemon refuses plain `facts` from a peer whose definition has `factsHook`.
|
|
@@ -109,6 +113,8 @@ Scope: the operator console, control WS and its protocol, state files, the dashb
|
|
|
109
113
|
|
|
110
114
|
- Observe memory counters only through the machine-local candidate sidecar. Bind delivered counters to a positive journal acknowledgement and the original render context; skip private envelopes and current PII tasks/turns. Leave missing telemetry unknown, and never pass candidates or counters to evidence consumers. Require memory_correct for correction proposals, refuse PII turns, and keep project/identity authority server-side.
|
|
111
115
|
|
|
116
|
+
- Review observer input (#1056; contract in `docs/design/task-board.md` "Anchored review packages (#1056)"): while a peer holds review-scoped input (`reviewObserverHeld`, true on any throw) or its last delivery holds an `isReviewObserverExcluded` envelope, keep its capture, progress, DOING, session title and incoming label, memory counters and shared-memory writes held. Decide it only through those two predicates and the bus ports wired in `startDaemon` (`beforeReviewInputHandoff`, `reviewObserverScope`, `reviewObserverInputExcluded`, `onPulled`), never from message text. Durable state is the journal's (`reviewInputState`, `markReviewInputIncomplete`, `clearReviewInputIncomplete`), keyed by peer and stable native session hash. A sessionless peer still gets the delivery, held as incomplete. Only a confirmed ordinary next-turn input or a confirmed native session change releases a hold; presence-only traffic or more input in the same turn never does. Unknown provenance is held.
|
|
117
|
+
|
|
112
118
|
- Keep full memory home, generation ID, selector revision and malformed-row totals in authenticated person status/CLI receipts, never disk status.json or agent CLI output; low-privilege receipts use a generation prefix and omit home, selector revision and malformed-row totals. Emit typed unbound disabled/unavailable receipts with no profile when no memory handle opened; profile-check may treat an authenticated same-home generation-capable unbound daemon as memory-quiesced, never a stale bound handle. Scope profile-check readiness to its named registry inventory; old, unknown or unreadable receipts refuse readiness, and other custom registries need separate checks.
|
|
113
119
|
|
|
114
120
|
- The memory client (#752) is the in-process amem `MemoryClient` built once in `startDaemon` from the alias-resolved `memoryProject`, the bound store and candidates, `screenMemory` and the capture binding; its `check` rechecks that binding and the project before each call. `DaemonOptions.memoryClient` is a host-only seam, never read from configuration or a tool; `memory.worker_url` is ignored legacy configuration, never a destination. Local capture takes `memoryProject`, never the project chain. Shutdown aborts `syncStop`, awaits `syncRun`, then closes `memory`, the candidates and the store, in that order.
|
|
@@ -133,8 +139,8 @@ Scope: the operator console, control WS and its protocol, state files, the dashb
|
|
|
133
139
|
|
|
134
140
|
- Share one monotonic 250 ms snapshot Git budget until the next event-loop I/O turn. Repository/HEAD probes, capture and end diffs consume the same remaining deadline; exhaustion or unreadable diff evidence records unknown trees and no attribution/undo, with one diagnostic per burst. Never substitute a stale baseline or apply a late result (#576). `DaemonOptions.snapshotClock` is host-only (tests hold it, rule: `tests.md`): never read it from configuration or a tool, production leaves it unset (`performance.now`), and the share stays 250 ms with `snapshotBurstMs` still clamped to it. Every deadline comparison goes through `budgetNow(budget)`, never `performance.now()` against `budget.deadline`.
|
|
135
141
|
|
|
136
|
-
- Channel tool-result hints (#493, #221): `withPendingHint` (`src/adapters/claude-channel.ts`) appends fresh `delivery_holds` counts, and drops the "call hub_inbox" advice whenever the same reply carries a held-delivery hint, because `bus.pull` refuses under that hold and the advice would send the session into a refused read. A `hub_inbox` result that already says how many messages remain unread adds only the important count, never a second queued count. Do not add a Claude client-version gate on steer without a probe of the older client and an optional hello field that carries the version (background: README).
|
|
137
|
-
- Memory sync (#438): stop an in-flight `syncMemory` only through `syncStop` (`src/hub/daemon.ts`), and await `syncRun` before the memory store closes, as `stopOnce` does. agent-mem runs the sync's git in its own process group, so a process that ends without `daemon.stop()` leaves that git and its transport child holding the memory repository. A signal the hub exits on goes through lifecycle stop (`STOP_SIGNALS` in `src/hub/lifecycle.ts`: SIGINT, SIGTERM, SIGHUP, SIGQUIT), never the default action; SIGQUIT in particular must not reach its default core dump (Bun honours it: agent-mem #435 measured a 5.9 GB core file and a delayed exit). The forced exit after the shutdown deadline stays `process.exit`. A sync result is logged as a failure only when `quietSyncError` is false: a missing remote in either agent-mem wording (`no sync remote`, `no generation sync remote configured`) and a stop are not failures (#401); a new agent-mem wording for them extends that one predicate.
|
|
142
|
+
- Channel tool-result hints (#493, #221): `withPendingHint` (`src/adapters/claude-channel.ts`) appends fresh `delivery_holds` counts, and drops the "call hub_inbox" advice whenever the same reply carries a held-delivery hint, because `bus.pull` refuses under that hold and the advice would send the session into a refused read. A `hub_inbox` result that already says how many messages remain unread adds only the important count, never a second queued count. A `hub_inbox` result that renders its own held or accepted metadata (`HELD_HINT_INCLUDED`) gets no second held hint and no polling advice, and unavailable metadata adds the inspect step instead of "Nothing is waiting" (#1172). Do not add a Claude client-version gate on steer without a probe of the older client and an optional hello field that carries the version (background: README).
|
|
143
|
+
- Memory sync (#438): stop an in-flight `syncMemory` only through `syncStop` (`src/hub/daemon.ts`), and await `syncRun` before the memory store closes, as `stopOnce` does. agent-mem runs the sync's git in its own process group, so a process that ends without `daemon.stop()` leaves that git and its transport child holding the memory repository. A signal the hub exits on goes through lifecycle stop (`STOP_SIGNALS` in `src/hub/lifecycle.ts`: SIGINT, SIGTERM, SIGHUP, SIGQUIT), never the default action; SIGQUIT in particular must not reach its default core dump (Bun honours it: agent-mem #435 measured a 5.9 GB core file and a delayed exit). The forced exit after the shutdown deadline stays `process.exit`. A sync result is logged as a failure only when `quietSyncError` is false: a missing remote in either agent-mem wording (`no sync remote`, `no generation sync remote configured`) and a stop are not failures (#401); a new agent-mem wording for them extends that one predicate. Every pass gets the host screen and policy version (`currentMemorySyncPolicy`, `memorySyncPolicyVersion`, #1173); a version change during the pass records `binding`, never success. Record outcomes only through `reduceMemorySync` and publish them only as `memorySyncView` (`sync` in memory status: closed cause, counts, next step). The log line names the closed cause (`classifyMemorySyncFailure`), never the raw error text. `DaemonOptions.memorySyncTrigger` is a host-only fixture seam.
|
|
138
144
|
- Schedules timer (#512a): `schedulesTimer` in `startDaemon` is its own interval, apart from the task sweep's, armed whenever `config.schedules` or `config.folder_triggers` is non-empty and re-armed in `reloadConfig` on a change to either; never gate it on `task_sweep.enabled`. `schedules` is machine-local (`MACHINE_LOCAL`) and never editable from settings (`NEVER_EDITABLE`): a committed file or a settings write must not be able to make the hub create tasks. The daemon status carries `schedules` (`scheduleViews`) only when some are configured.
|
|
139
145
|
- 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.
|
|
140
146
|
- 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).
|
|
@@ -149,5 +155,7 @@ Scope: the operator console, control WS and its protocol, state files, the dashb
|
|
|
149
155
|
|
|
150
156
|
- 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.
|
|
151
157
|
- 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.
|
|
152
|
-
- 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.
|
|
158
|
+
- 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. Handoff diagnostics (#1203 T3): a connection failure to a target is classified by `handoffConnectFailure` from the error's `code` (4426, 4404, `AHUB_CONNECT_TIMEOUT`, anything else), never from its message, and every handoff failure log goes through `handoffLog`, which keeps the last cause per target (`handoffFailures`, read by `handoffListView`) and rate-limits the line with `handoffLogGate`; a new silent `catch` in the handoff block is a defect. `handoffListView` opens no connection (registry and descriptor files only), so `--list` never waits on a target. `hub_task_handoff_sync` is a control op and not in `hub-tools.ts`: an agent never sees it, and `Tasks.handoffSyncAuthority` refuses anyone but the person or the conductor. `handoffCatchUp(only, wait)` with `wait` waits for the running pass and then runs its own, so a sync answers after a pass that began when it was asked, but never for longer than `handoffSyncBoundMs` (20 s, below the CLI's 30 s control timeout): a slower pass makes it answer `syncing: true` with the list, and a sync must never end in the generic "no answer from the hub". Handoff sequence (#1203 T2): `recordHandoffOutbound` applies a write with a `seq` only when it is newer, inside the one statement; `handoffEvent` returns `recorded: false` for a stale one before it publishes, so never publish a follow-back before the write says `applied`. `handoffCatchUp` reads `handoffOutboundOpen` (no cap; `handoffOutbound` is the capped display list, never the catch-up's input), runs one pass at a time and queues a request that arrives meanwhile (the targets it names, or all) to be drained when the pass ends, so the guard never drops a watcher's request; `handoffWatch` asks once for the whole changed set; it asks in chunks of `HANDOFF_STATES_CHUNK`, and checks `stopping` between requests; its timers go in `intervals`. A new handoff event goes only to an origin that advertises `task_handoff_seq`. Handoff key (#1203 T1): `handoffOut` sends a key only to a target whose status descriptor lists `task_handoff_key`; a timeout or closed connection (`handoffOutcomeUnknown`, which reads the client's fixed wording and anchors it) is resent once with the same key, then asked by key (`task_handoff_by_key`), then `outcome_unknown`, never `invalid`. A refusal of the resend never settles an unknown first attempt (its PII screen may still file the task): it is kept as a note and the by-key lookup still runs. The caller's deadline covers the whole recovery: `controlRequestTimeout` gives `hub_task_handoff` `HANDOFF_CONTROL_TIMEOUT_MS`, built from the same `HANDOFF_*` budgets `handoffOut` spends, so the CLI and the MCP path (both ask for 30 s) receive `outcome_unknown` with the key instead of a generic timeout; change a budget there and the deadline moves with it. Never write the outbound row before an answer, and never reset an existing one (`handoffSent`). The follow-back envelope carries `refs.handoff` and no `refs.task`: a new envelope field that names a task in another project must not reuse `refs.task`, which the bus judges against this board.
|
|
153
159
|
- 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.
|
|
160
|
+
- Questions (#1053, `src/hub/questions.ts`, `src/hub/question-desk.ts`): the store owns the row and its ONE terminal transition (`UPDATE ... WHERE state = 'open'`; of concurrent answer, stop and expiry exactly one sees `changes === 1`), the desk owns the waiting call and the hub-built envelope, and the daemon only supplies ports. `answer` and `questions` are console-role control messages that always reply (rid); a tools or peer connection never reaches `questionDesk.answer`, and nothing a peer sends (a `hub_send` body, a tool result, an envelope) is parsed as an answer. A late answer is `kind: "answer"`, `from: user`, id `answer-<set>` (so a republish after a crash is dropped by the bus's own dedupe) with `refs.question`; an expiry notice is `kind: "status"` from the hub, never an answer. Supersede keys on `from === USER && kind !== "answer"` in the bus tap, never on the body, and never on the hub's own answer envelope. Question and answer text reach nothing but `hub.db` and the envelope to the asking peer: the tap's log line, `redactBusEvent` and the `question` event all stub them, `QUESTION_TOOLS` skips capture and the progress observer skips `hub_ask`. `hub_ask` holds its control request open (`controlRequestTimeout`, `request_cancel` through the shared `invocationRequests` map, so a closed socket or an aborted turn cancels the row) and Pi's bridge lifts its idle timeout for that one call. A new peer kind gets `questionWaitMs` on its definition (fixed builtins only, `assertNativeCapabilityScope`), never a wait chosen by the caller. The `questions` feature label is a recovery label: an older target ignores the table, so keep it in `DAEMON_FEATURES`. Start repair runs once after `ready`'s locals exist (`questionDesk.repair()` just before `ready = true`), and the sweep timer is pushed only after the desk exists.
|
|
161
|
+
- Completions (#1054 T2/T3, `src/hub/completions.ts`, `src/hub/completions-store.ts`): a run opens at the `turn_start` that consumes a `from: user` delivery (never the hub's own `answer`), only for a peer whose definition declares `settle` and that is not pull-only. `settleCheck(peer)` is the ONE settle check and never throws; call it from every place a pending-work fact can change (turn end, `bus.onQueues`, a permission or question closing, an isolated check ending, `Tasks.checkEnded`). The bus takes a delivery out of its queue before the peer's idle reaches the state tap, so `queuedContinuations` also counts `dispatching` rows and `deliveredAwaitingTurn` (bounded to 10 s, `ponytail:` in the code); drop either and a continuation reads as nothing pending. Claude settles only on the verified native Stop (a lost channel or watchdog carries no id and never settles). A `limit` or budget pause ends the run with no completion (`budget.record`), an explicit stop calls `quietStop`, and `completions` is a recovery feature label (`hasPending`: an open run or an unread completion). The store keys a completion by the run's first turn id (`UNIQUE`), so repeated restarts yield one `interrupted` completion; never reconstruct from `events.jsonl`, which cannot say who started a turn. Receipts (#1054 T4/T5): `completion_receipts` has a UNIQUE index on `completion_id`, so `markSeen` is one compare-and-set and a mark-unread has exactly one row to delete; `result_seen` and `results` are console-role control cases (rid always) placed before `case "kill"`, and nothing else writes a receipt: never mark read from a snapshot, a status read, a listing or an envelope. The `result` need is level `watch` and is not in the `waiting`/`blocked` lists in `needs()`; keep it that way so a result never reads as something to act on. Reconstruction drops an open run whose peer has a budget pause record before it would settle it `interrupted` (persist before remove, per run), and `CompletionStore.prune` bounds the table to the newest 500 read completions at start; never prune an unread completion or an open run. The title of a result is computed at display (`resultView`), a stub when private, and no prompt or reply text is ever stored.
|
|
@@ -17,6 +17,7 @@ Scope: the hub-native local worker, its tools, path guard, denylist and seatbelt
|
|
|
17
17
|
- A worker turn builds its messages in a local array and joins the history only as a whole. Never push to `history` mid-turn: one tool call without its result poisons every later request.
|
|
18
18
|
- After a tool with side effects ran, a failed turn is reported, never redelivered.
|
|
19
19
|
- A turn with any remote envelope (`remoteEnvelope(env)`: `from` containing `@`, or a hub `invocation`/`invocation_result` carrying `refs.invocationRemote`; rooms #941, #992; never test `from` alone) never joins `history` and never reaches `capture.observe`, like a PII turn, and every failure it reports (answer, delivery reason, log line) says only `remote turn failed`. Keep all three when you touch the turn's settlement or the tool loop.
|
|
20
|
+
- A review turn (any envelope `isReviewObserverExcluded` accepts, or an anchored `hub_review` call, which latches the exclusion for the rest of the turn; both in `src/hub/memory-policy.ts`) is handled like a remote turn: it never joins `history`, never reaches `capture.observe`, `capture.summarize` or `observeTool`, and every failure it reports (answer, delivery reason, log line) says only `review turn failed`. While latched, `hub_remember` and `hub_task_propose` are refused before they reach `taskTool`. A throwing `beforeInput` settles the delivery `failed_safe` (`review input provenance unavailable`) before the turn starts. Keep all of these when you touch the turn's settlement or the tool loop.
|
|
20
21
|
- `ctx.permit` answers a `ToolApproval` (`true`, `false`, `"expired"`, `"aborted"`, #253): only `true` permits an effect, so never test it for truthiness. A tool returns `approvalError`'s text for the rest: a denial, the expiry (which tells the model not to retry) or the withdrawal. A turn that two unanswered approvals ended (`ApprovalWaitStop`; counting rule: `adapters.md`) completes its missing tool results, settles its delivery `completed` with the reason and is never handed back for replay, also when no tool had a side effect.
|
|
21
22
|
- `Facts` (`src/hub/facts.ts`, #428) names a large file whose ctime is under `RACY_MS` by its git blob id: in `load()` and `observe()` only through `racyBlob()` (per-file `RACY_HASH_MAX_BYTES`, per-store `RACY_BURST_BYTES` per event-loop turn, memo cleared at `boundary()`), past which it keeps the stat-only stamp and makes no claim; `atHead()` keeps its own `HASHED_BYTES` bound, `tree()` reads large files by stat only, and `largeBlob()` caches only settled stamps.
|
|
22
23
|
- Hub tool calls (`hub_send`, `TASK_TOOL_NAMES`, `CONDUCTOR_TOOL_NAMES`) are never passed to `capture.observe`; `Tasks` alone decides what hub tool text reaches memory (#230). Every live `capture.observe` passes a host `sourceId` (`<session>.<turn>.<step>.<n>`, #752); a new host caller supplies one too. Capture stores `paths` only for `read`/`write`/`edit` and only project-relative ones: command argv is never path metadata.
|
|
@@ -19,10 +19,12 @@ Scope: the task flow, assignment, the state machine and the hub's MCP tools. Rea
|
|
|
19
19
|
- Capture the coordination preference once per task at creation (`coordinationModes` from `TasksDeps.coordination`, in `propose` and for each split child, #439). `formCohort` joins only overlaps with the same captured mode and forms a silent cohort only for a turn-free one; a reload never silences an existing task or lifts an existing silent cohort. A daemon restart re-pins retained rows to the startup mode (a compatibility ceiling, not task metadata). `configureSweep` takes effect at the next pass: a running `sweep` keeps the config it started with.
|
|
20
20
|
- The planner "board changed" notice (#758, only with the lifecycle on) is built from what was added to a card's history since the last scan (`Tasks.groomNotices`, `groomScan`), never from the status revision alone: skip the peer whose own entry it was (`entry.by`), tell only the card's planners (`taskPlanner`, #525-2), send a planning revision or dependency change (a `planning` entry, or `deps` that differ from the last scan whatever wrote them: a propose with `after`, a split) and a card's creation (a peer's propose, a split child, a generated card) at task weight, since new work and its dependencies are what a plan review consolidates, and replace an unsettled copy (`sendGroomNotice` withdraws it by the `The board changed` prefix) so a planner meets the latest revision once, and send state moves, reviews and assignments at `fyi` weight (recorded, no turn), at most once per planner per current `tasks.groom_fyi_window_min` (default `GROOM_FYI_WINDOW_MS`, 10 minutes); read the host config at each notice flush, never capture it at construction. The hub's own bookkeeping entries (`by` is `hub` with no `planning`: lifecycle links, idle sweeps, holds, plan observations) tell nobody and spend no window; a deps change with no new entry has no known author, so no planner is skipped for it. Scan every configured planner, not only the attached ones, so a change made while one was offline is kept for it. The first notice a planner gets after a hub start is task weight. Keep the scan out of the status revision and never read history that is not new.
|
|
21
21
|
- Planner work-ready notices (#785 P1a, `writePlannerNotices`): a write that can free a reserved owner's WIP or release backlog (done, approval, decline, cancel, backlog return or pull, untouched return, pull undo) takes `capacityBefore` (the `wip()` of the task's owner and reservation) before its board write and calls `writePlannerNotices` after it. Each rostered planner of the task (`taskPlanner`) gets one ids-only `task` envelope starting `Planner work ready:`: `capacity_released: <peer> WIP <n>; reserved ready #<id>|none` for a peer whose WIP dropped, and, when the write approved the task, `backlog_ready: #<id>` for each backlog dependent with nothing left to wait for. Merge the lines of that planner's unsettled copies, read back from `bus.pendingEnvelopes` (they survive a restart; never keep them in memory), withdraw them, then publish, so at most one stays queued per planner; all of it runs inside `releaseNotice`. The notice only wakes the planner: never pull, reserve or assign from it, and never put a title or note in it.
|
|
22
|
+
- Person-run cards (#1176, `needsPerson` / column `needs_person`): the mark is the person's alone (`planBacklog` refuses an agent that sets or clears it or plans a marked card), and every way an agent could be offered the card checks it: `topReservedReady` (auto pull and the planner notice), the one pull gate in `pullPlanned`, `offerReady`, `releaseReady` (not a stall), `assign()` (pure, before any routing, so `route explain` agrees), `nextSweep`, and the two `plannerWip` queries; and the other doors: `splitBacklog` (agent refused in `Tasks` before and after the screen and in the board transaction; the person's children inherit the mark), `accept` and `acceptReviewed`, `assignTo` for the owner role, `relevant` (drops a queued offer), and `planBacklog` (refuses a mark on an owned card). A new way to offer a card to an agent must check `needsPerson` too; `test/person-run-cards.test.ts` has a mutation-checked test per gate.
|
|
22
23
|
- Opt-in auto pull (#785 P1c, `tasks.auto_pull`, `Tasks.queueAutoPulls`, `autoPull`, `topReservedReady`): `writePlannerNotices` collects the peers whose WIP this write lowered and, only when `TasksDeps.autoPull()` is true (read at each release, so a reload applies to the next one), queues one `autoPull(peer)` per peer on a promise chain, so two releases never pick the same card and each job re-reads the board. It takes the top backlog card explicitly reserved for that peer (`topReservedReady(peer, undefined, true)`, the rule the notice's `reserved ready #id` line also uses; never an unreserved card, never PII, which `topReservedReady` skips for a pull), finds the card's own planner of record (`taskPlanner(card)[0]`, which must be rostered) and calls `pullPlanned(HUB, input, { onBehalfOf: planner })`. `onBehalfOf` is accepted only with `by === HUB`; the planner's authority is what `planningAuthority` checks, the actor in history and the planning telemetry stays `hub`, and the planner binding records the planner it acted for. Every other check is `pullPlanned`'s own, unchanged, and a refusal is one console notice with the public title (never detail). A held or recovering hub, a storage error or the setting off pulls nothing, and that is checked twice: at entry and again by `pullPlanned`'s `opts.guard` (`autoPullAdmitted`) wherever authority is rechecked, which includes right before the assignment or board write after the awaited PII screen and `planReviewGap`, so turning the setting off or starting recovery or shutdown mid-admission writes nothing and reports nothing. `autoPullsPending()` counts queued and running pulls and `recoveryReady` waits for it (also named in the recovery view's blockers), as it does for `checksPending`. The key is in `ALL_MACHINE_LOCAL` (`config-trust.ts`) so a committed config is ignored for it, and it is not in `CONDUCTOR_SETTING_KEYS`: an agent never turns it on. Never add a timer or sweep that pulls: the only trigger is the write that freed the slot.
|
|
23
24
|
- Planned start (#1092): `plannedStartPending` (`src/hub/task-sweep.ts`) is the only test that a card's `plannedStart` is still ahead. Every automatic path skips such a card: dependent readiness, `backlog_ready` notices, the reserved-ready pick, auto pull and its `guard`, a hub-actor (`by === HUB`) offer, pull or assign of an ownerless backlog or proposed card, and the idle sweep. The planner digest shows it as `scheduled`. A new automatic path asks it too.
|
|
24
25
|
- Per-head CI watcher (#785 P1b, `src/hub/ci-watch.ts`, `Tasks.watchCi`, `ciCompleted`, `reofferHeldReview`): when a `done` leaves a review whose admission was not ready at the task's exact full-commit head (or a review is held as premature in `sendReview`), `CiWatchers` starts at most one watcher per task and head, with chained unref'd `setTimeout`s and no interval. Each poll is one `gh pr view` through the `sdlc.gh` port (`pollCi`); it ends on a passed result for the required checks, on `CI_WATCH_DEADLINE_MS`, on `stopCiWatchers` (daemon `stopOnce`), or silently when `ciWatchCurrent` fails (the task left `in_review`, its head or PR changed, the lifecycle is off, it turned private). `complete`, `review` (both verdict writes) and `cancel` cancel it. Each (head, outcome) is told once (`settled`), and only `passed` settles a head for good (#540): after `failed` or `incomplete` the watcher keeps reading for a rerun of that head (the first pending read after each newly told result is a new run attempt and renews the deadline once; later pending or unreadable reads and an unchanged failed result never renew it, so the watcher always ends; a deadline after a told result ends silently), and a later `hub_task_done` at that head starts one again. A newer head replaces the watcher. The result is one ids-only `ci_completed:` task envelope at `status` weight to the owner and the reviewer (`fyi` is never delivered to a peer, so it would not reach them), a console notice for `user`, and for a deadline a console notice only. A passed or incomplete result re-offers a review held as premature once (`heldReviews`, keyed by task, head, reviewer and review revision) after a fresh `reviewRequestAdmission` says ready; a failed one re-offers nothing. No task text goes in the notice, no PII task is watched, and nothing is restored after a hub restart (the next `hub_task_done` starts one). Never add a second periodic gh read outside this watcher.
|
|
25
26
|
|
|
27
|
+
- Handoff idempotency (#1203 T1): the target writes a handed-off task only through `Board.proposeHandoff` (the proposal and `task_handoff_origin` row, key and request digest in one `atomic` transaction); `handoffIn` asks `replay()` before the duplicate check and again after the PII screen's await, so a retry of a key whose first attempt is still screening files nothing twice. Never `propose` then `setHandoffOrigin` for a handoff, and never compare a key to anything but this origin project's own rows.
|
|
26
28
|
- Completed-change notices (#713, one per recipient per done, accepted work only, superseding by `completedFor`): rule in `bus.md`.
|
|
27
29
|
- Plan-symbol overlap (#682 1b) compares through `symbolsOverlap` (`src/hub/symbol-normalizer.ts`): arguments stripped, `section `/`source ` keys exact, two qualified names equal, otherwise last segments equal. `places`, `overlapHits`, `contradict`, the completed-notice replay, `plannerGrooming` and `planSymbolMatches` all use it; never compare plan symbols with `includes` or set membership.
|
|
28
30
|
- Task code symbols (#684 2a) are host-derived: stored only in the `task_code_symbols` table and read through `TASK_SELECT` (`src/hub/board.ts`), so a `SELECT * FROM tasks` reader never exports them. `Board.update` writes `codeSymbols` only with `HOST_CODE_SYMBOLS` and drops it when the head (`refs.commit`, else `sdlc.head`, else `sdlc.localHead`), `refs.repo`, `refs.paths` or `sdlc.repo` changes or the task turns private. Tasks observes them only inside an admitted `done (checking)` or `done` write (`observedCodeSymbols`: non-PII, a full commit, the project repository); a failed or mismatched observation is unknown, never a refusal. Optional code facts (`prepareReviewBrief`) skip PII tasks, never refuse a review, and the actor guard runs again after them.
|
|
@@ -47,7 +49,7 @@ Scope: the task flow, assignment, the state machine and the hub's MCP tools. Rea
|
|
|
47
49
|
|
|
48
50
|
- Memory read tools (`hub_memory_search`, `hub_memory_show`, `hub_memory_graph`, #412) take their project only from the daemon. Validate UTF-8 input caps before logs or storage, require `memory_read`, refuse PII turns and exclude arguments/results from capture. Screen complete rows and graph metadata before counting or truncating; keep whole-result byte caps, complete id/provenance/kind headers and one neutral unavailable-id refusal.
|
|
49
51
|
|
|
50
|
-
- Compose structured `hub_remember` fail fields through `composeRememberText` before screening or writes; reject over 4000 UTF-8 bytes without truncation. Treat plan-fail matches as screened advisory priority only, never a state-transition guard; skip all plan-memory work for PII tasks.
|
|
52
|
+
- Compose structured `hub_remember` fail fields through `composeRememberText` before screening or writes; reject over 4000 UTF-8 bytes without truncation. Treat plan-fail matches as screened advisory priority only, never a state-transition guard; skip all plan-memory work for PII tasks. A long-term note (`horizon: "long"`, or an approved task's promoted kind) passes `memorySyncPolicy().screen` (`src/hub/memory-sync-policy.ts` plus the hub's PII patterns and private paths) before `store.note`. A decline, or a policy that is unavailable, writes nothing: `hub_remember` refuses pre-effect and names horizon `short`, and a hub-written note becomes a console notice. Never fall back to writing it long-term (#1189).
|
|
51
53
|
|
|
52
54
|
- Keep candidate production in Tasks: screen review/correction payloads and current task/turn privacy before CandidateStore writes; recheck after asynchronous screening. Never make candidate failures roll back a saved review, or let a peer accept a candidate. Keep candidate tool arguments/results out of capture.
|
|
53
55
|
|
|
@@ -55,6 +57,8 @@ Scope: the task flow, assignment, the state machine and the hub's MCP tools. Rea
|
|
|
55
57
|
|
|
56
58
|
- 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`.
|
|
57
59
|
- 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`.
|
|
60
|
+
- `hub_set_title` (`SESSION_TITLE_TOOL`, #1055) is in `TASK_TOOL_NAMES` but never in `TASK_TOOLS`; its publication rule is in `adapters.md`, and its `taskOpBody` case accepts only `title`.
|
|
61
|
+
- Plan sources (#1144): `propose`, `acceptReviewed`, `accept` and `splitBacklog` (every part's cleaned plan, planner split and the hub's intake split alike, #1161) call `validatePlanSources` on the plan they will keep (the new one, else the stored one) before any board write or notice; an id the source registry (`TasksDeps.intake.sourceKnown`) does not know is a `PreEffectToolRefusal` naming `ahub source list`. A new path that admits a plan calls it too.
|
|
58
62
|
- 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.
|
|
59
63
|
- 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`.
|
|
60
64
|
|
|
@@ -70,6 +74,7 @@ Scope: the task flow, assignment, the state machine and the hub's MCP tools. Rea
|
|
|
70
74
|
- 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.
|
|
71
75
|
- Stale heads on re-file (#1121): a re-filed `hub_task_done` after a rejection or re-accept must name the new full SHA in `refs.commit`; summary text is never head proof. A linked PR whose head moved, or whose head cannot be read (bounded head-only read over `SdlcGh`), refuses the re-file and an approval at the old head, naming both SHAs. Owner-submitted full refs outrank host-derived PR receipt heads; revalidate role, task and head after every async read.
|
|
72
76
|
- 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.
|
|
77
|
+
- Person review comments and anchored findings (#1056, #1181, #1192, #1205; `src/hub/review-comments.ts`, `src/hub/review-anchor.ts`, `src/hub/review-material.ts`, `Tasks.reviewComment*`): only the console person writes a comment (`reviewPerson`), and authorship comes only from the server-built `ActionMeta`. A send binds immutable anchors and bodies into one package, appends one count-only history entry and attempts one envelope; it is never retried automatically, and only an explicit Send with `packageId` recovers that saved package. Placement is computed at read and send and never persisted over the stored anchor. Dashboard snapshots never read comment material. PII comments stay board-local and count-only. Comment writes count in `reviewCommentsPending`, which recovery and shutdown wait for. A finding `anchor` must agree with its `path`. Bounds and the full contract are in `docs/design/task-board.md` "Anchored review packages (#1056)"; read it before changing these paths.
|
|
73
78
|
- 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.
|
|
74
79
|
- 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`.
|
|
75
80
|
- Departure consent is written only by `Tasks.acceptPlanDeparture` for the person (`ahub sdlc accept-departure <task> <head> <reason...>`; no peer tool) on a currently known departure at the current PR head, never on `unknown`, and never for a PII task. It binds repo, PR, head, plan revision and fingerprint, the inventory, `planScopeIdentity` and this host's `departureGeneration`, so a restart or a change to any of them invalidates it (`validPlanDepartures`). Status and merge-queue reads never write the board: they only note observations and invalidations in memory (`observedPlans`, `invalidatedPlanDepartures`), which `planObservation()` attaches to the next owner or reviewer transition write. Off captures no observation or revision; a plan or refs change still invalidates an existing acceptance.
|
|
@@ -82,13 +87,13 @@ Scope: the task flow, assignment, the state machine and the hub's MCP tools. Rea
|
|
|
82
87
|
- `Tasks.retryReviewer` is host-only (the daemon's reviewer `onUnavailable`), never a tool or CLI path. It acts only while the failed peer is still the reviewer of an `in_review`, non-PII task, with the same review head, the request's `reviewRequestRevision` and a current child generation; `valid()` rechecks all of it after every await and right before the write, which also passes the flow revision. A manually named reviewer's `no_verdict` and a no-verdict with no declared pool go only to the person; other manually named failures keep explicit person control. Confirm fallback only after accepted reviewer publication or a person offer and a fresh binding/generation check; failed publication or notification never confirms settlement. Each failed peer is tried once per head and round (`reviewerFailure` history). `reviewRequestRevision` counts only role events (`done`, `reviewer changed`, `reviewer unavailable`, ownership), so bookkeeping never fences a request; keep `refs.reviewRevision` and `refs.commit` on the review envelope.
|
|
83
88
|
- Release trains (#481 S11): change trains only through `Tasks.newRelease`, `configureRelease`, `assignRelease` and `cutRelease` (planner or person), and refuse every `hub_release_*` in a PII turn. `assignRelease` writes only `release` and refuses a frozen, shipped or dropped release on either side; private or pinned scope is person-only. One active or frozen train at a time. A cut freezes only through `Board.freezeRelease` after a fresh `releaseWindow` (host lease from `readReleaseHostAdmission`, quota, `current()`, at most 5 s old); unknown evidence defers, and the window is closed in `finally`. A cut never publishes, tags or upgrades, and upgrade and recovery never consult `releaseWindow`. Keep two of `RELEASE_TRAIN_HISTORY_LIMIT` slots for cut and ship; a configuration no-op appends nothing. An S9 plan receipt survives only contiguous planning revisions that changed exactly `["release"]`.
|
|
84
89
|
- Release authorization (#481 S6, `src/hub/sdlc-release-authorization.ts`): the console user or the one configured conductor through `sdlc authorize-release` creates one under the same admission as `sdlc merge` (tools role, matching configured conductor, no `toolsOwner`, private turn or caller PII context). Keep the actual asker in receipt `by` and the audit, and the cooperative explicit-person-instruction rule in AGENTS.md Releasing. Keep consent bound to the current HEAD commit, its committed package version, the origin identity and a fresh, error-free docs verdict, all reread after the docs check. It lives in daemon memory only: a restart revokes it and the event is audit, never restored. `release-guard` and `release-guard-hook` are refused in a private turn; a tag push needs a matching receipt, and npm publish also a clean tracked worktree and the same docs digest. Never accept authorization from a tool, a task receipt or a file.
|
|
85
|
-
- `Board.update`'s untouched-return exception is the only way owned or accepted work returns to backlog: keep every condition (exact event, owner and reviewer null, state, `expectedFlowRevision`, non-empty note, no work evidence) and reach it only through `Tasks.returnUntouchedToBacklog` (#481 S9).
|
|
90
|
+
- `Board.update`'s untouched-return exception is the only way owned or accepted work returns to backlog: keep every condition (exact event, owner and reviewer null, state, `expectedFlowRevision`, non-empty note, no work evidence) and reach it only through `Tasks.returnUntouchedToBacklog` (#481 S9). Optional return `fields` accepts only `reserved`, follows the existing reservation authority and target-admission rules before and after screening, and saves the reservation with Board-derived planning history and revision in the same state write before capacity notices or auto-pull; omitted fields retain the plan (#1154).
|
|
86
91
|
- The main-checkout note (`mainCheckoutWarning`, `src/hub/main-checkout.ts`, #572) is advisory text in the `hub_task_done` result, the review request and status, never a refusal. A branch name or other metadata the hub reads from outside the board reaches task text only after `Tasks.publicMetadataText` (PII patterns, secret shapes, private paths) and a plain ref-name check; otherwise use the generic sentence that carries none of it.
|
|
87
92
|
|
|
88
93
|
- No-write advisories (#498): count only host-captured, completed owner turns bound to the current acceptance of an `implement` or `bulk_edit` task. Require a known empty snapshot diff, unchanged HEAD/shared branch refs and no task-tool effect; changes, effects, pending/unknown effects or unknown captures break the streak. Treat empty canonical-root captures as unknown when an owned task records branch/commit, worktree paths or SDLC PR/head evidence. Require exclusive Git worktree scope at both boundaries and no `.worktrees` directory before trusting negative canonical evidence; uncommitted and detached worktrees otherwise remain unknown. Keep builtin-owner D1 inert while `.worktrees` exists; narrow this deliberate false-negative ceiling only after binding each owner to its real execution worktree. Keep `task_sweep.no_writes_turns` independent of the time sweep (default 2, 0 disables), and route structured `no-writes` stalls through the existing public-title planner notice and grooming paths.
|
|
89
94
|
- At done, add `no writes recorded since acceptance` to review only when the daemon proves no owner touches since the first acceptance after the current ownership boundary and an empty acceptance-to-now diff, with no branch, review head or PR evidence. Preserve that baseline and earlier touches across plan-revising re-acceptance. Require a canonical-root D2 result to pass the same elsewhere-hint and exclusive-current-worktree checks before treating it as negative evidence. Bind extra-instance evidence to its current verified execution owner/root; missing, private, disabled, expired-budget or restart-lost baselines stay unknown. Capture through the guarded `TasksDeps.ownerAccepted` callback for explicit accept/self-claim only; an implicit done acceptance has no pre-work baseline and remains unknown. Do not capture private work for this advisory, block done, or accept evidence flags from models.
|
|
90
95
|
|
|
91
|
-
- `ahub report --dogfood` (#687, `src/hub/dogfood.ts`, `src/hub/dogfood-read.ts`) opens `hub.db` read-only with byte and row caps; an unavailable or incomplete source reports `null`, never 0, and per-PR GitHub reads run only when the
|
|
96
|
+
- `ahub report --dogfood` (#687, `src/hub/dogfood.ts`, `src/hub/dogfood-read.ts`) opens `hub.db` read-only with byte and row caps; an unavailable or incomplete source reports `null`, never 0, and per-PR GitHub reads run only when the PII inventory is complete. That inventory (`dogfoodPiiInventory`, #1147: ids and PR refs of PII tasks, no text, 2000-task cap) is read from the whole board, while `dogfoodTasks` takes a `{since, until}` window and the PR numbers of the merged PRs, so a board over 500 tasks still reads complete and every sibling task of an in-window PR is read whatever its update time (a partial sibling set would give wrong lead times, so overflow makes the source incomplete); `dogfoodPrs` without an inventory argument still uses the task list as the inventory.
|
|
92
97
|
- Qualified titles (#723): `planIssueIdentity` links a sibling `repo#N` only when `refs.repo` names that repository (`siblingTitleRefs`, `spec-read.ts`), and an `owner/repo#N` only when it is `refs.repo`, else this project's repository; a sibling shorthand beside a bare `#N` is two identities and links neither. A title that names another repository with no `refs.repo` does not link, and `refsRepoHint` tells the proposer to set it (advice in the `hub_task_propose` result, never a refusal; none in mode off, for a PII task or once `refs.repo` or an issue is set). There is no retitle path on the board: a title is fixed at propose.
|
|
93
98
|
|
|
94
99
|
- Planner walk and reviewer independence (#525-3): `plannerWalk` and `implementers` in `src/hub/routing.ts` are pure; the planner is chosen only inside `assign()` (`planners`, `namedPlanner`, `keptPlanner` in, `Assignment.planner`/`plannerReason` out), and `Tasks` never picks one outside it. `Tasks.weights()` is the one place `planners` is built (rostered holders of `d.roles`, sorted), so every `assign()` path and `explain` agree. Pass a bound task as `keptPlanner` to that same walk: its explanation names the binding as `kept`; drafts and legacy null bindings still rotate, and explanation never writes a binding or history. Rotation is `task.id mod n` over every holder and is never renumbered by availability; a named planner is exact and never replaced. I-2 (`implemented this task`) lives in `blocked()` for the reviewer role as its last capability/independence check before queue admission, so the legacy, named and pool paths share it and it only ever removes an otherwise eligible candidate; when I-2 removed every otherwise eligible reviewer, the person reviews with `reviewBlocked`, never an implementer.
|
|
@@ -119,6 +124,7 @@ Scope: the task flow, assignment, the state machine and the hub's MCP tools. Rea
|
|
|
119
124
|
- 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.
|
|
120
125
|
- Experiments (#932, #598, #599; spec "Experiment T1 implementation boundary" and "T2 comparison implementation boundary"): `hub_experiment_record` and `hub_experiment_show` are thin callers of `Tasks.experimentRecord` and `experimentShow`; the person alone approves through `study_approve_protocol`. Forward the complete record envelope and authenticated actor unchanged, keep record calls out of read-only publication, refuse all PII-turn contexts, and exclude their arguments/results from capture. Only the existing receipt kind admits a person relay; ordinary records keep the current in-progress unit-owner check. New forms need their 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.
|
|
121
126
|
- 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.
|
|
127
|
+
- Claim evidence links (#1169, `hub_evidence_record`, `EVIDENCE_RECORD_TOOL`, `Tasks.evidenceRecord`, `src/hub/research-evidence.ts`): published only for the `research` profile (in `TASK_TOOL_NAMES` and `IMPLEMENTER_HUB_TOOL_NAMES`, never in `TASK_TOOLS`), refused in a private turn or PII context, and kept out of memory capture. The writer is the local in-progress owner of a public research task. A link binds the current #901 `claimId`/`claimRevision` and a registered public source whose raw hash matches. It runs inside `studyWrite` and rechecks `requestCheck`, the task/claim/source context, public metadata and the revision sequence after the screen's await, right before `Board.recordEvidenceLink`. Revisions only append: the next number only, an identical replay returns the stored one, and changed content is refused. It records declared provenance only; never resolve a locator or derive a #901 verdict from it.
|
|
122
128
|
- 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.
|
|
123
129
|
- 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`.
|
|
124
130
|
- 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.
|
|
@@ -127,4 +133,4 @@ Scope: the task flow, assignment, the state machine and the hub's MCP tools. Rea
|
|
|
127
133
|
|
|
128
134
|
- 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.
|
|
129
135
|
|
|
130
|
-
- 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 `
|
|
136
|
+
- 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.proposeHandoff(HUB, ...)` (the handoff idempotency rule above): 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`, `task_handoff_states` and `task_handoff_by_key` 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`, and `changes_requested` and `reopened` (#1203 T2, sent only to an origin that advertises `task_handoff_seq`)), carries only an event name, a PR number, a full SHA and `seq` (the target task's flow revision; an event not newer than the stored `seq` changes nothing), and never throws after a board write; the origin's outbound row is idempotent, and the catch-up (`handoffCatchUp`) asks each running target at start, every 5 minutes and when its control descriptor changes, over every row `handoffCatchUpRow` keeps (open, or approved within `HANDOFF_APPROVED_GRACE_MS`); transport, queueing and key-retry rules are in `daemon.md`. 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. Duplicate handoffs (#1175): `duplicateIssueTasks` (pure, `src/hub/handoff.ts`) is the one finder and `Tasks.handoffIn` runs it after its last await, right before `board.proposeHandoff`, over the board rows with every `[pii]` row left out, so a refusal writes nothing and names only ids; a sender's `duplicate_ok` (bounded, kept as a detail line) skips it, and a handoff with no `issue` is never refused. Never move the check before the PII screen's await, never name a title in the refusal, and never offer or escalate on a task the check refused. The conductor's cancel of an unaccepted handed-off task is `Tasks.conductorHandoffCancel`, asked inside both `validate()` runs of `Tasks.cancel` (the same `configuredManualConductor` and roster check the unassign path uses), limited to `proposed` or `backlog` with `board.handoffOrigin` set and no `taskHasStarted` or `taskHasWorkEvidence`; the board write records the conductor as the actor, and the follow-back is unchanged.
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Scope: tests, fakes and the test gate. Read before adding or changing a test or fake under `test/` or `src/adapters/conformance/`, or editing `scripts/check.sh`, `scripts/hang-watch.sh`, `scripts/progress-tee.sh`, `scripts/seeded-check.ts`, `scripts/seeds.json`, `scripts/lib/ts-ast.ts`, `scripts/changed-tests.mjs`, `scripts/check-closing-refs.mjs`, `.github/workflows/closing-refs.yml`, `scripts/release-prepare.ts`, `scripts/release-tag.ts` or `scripts/lib/release-plan.ts`.
|
|
4
4
|
|
|
5
5
|
- A fake's gated action that never sees its gate skips the action; never fall through to a late, unsynchronized action. Owned-group tests inject `AcpOwnedGroupPorts`; a test never signals a process group it did not spawn.
|
|
6
|
-
- When
|
|
6
|
+
- When a production closure gains a dependency, find its VM fixtures by the closure and its callers as well as the new dependency name. Bind every transitive host port and actual imported policy helper, including asynchronous read paths, and run each affected extracted closure. Keep native identity checks on real unstarted instances; never replace a policy predicate with an always-true stub or weaken existing assertions to accommodate a moved dependency.
|
|
7
|
+
- On Bun 1.4.2, preserve original fields before `toMatchObject` with asymmetric matchers, or assert primitive fields and strings separately; never use a matcher-mutated value for a later content/canary assertion. Recheck this restriction when upgrading Bun.
|
|
7
8
|
- A daemon fixture that routes write-class work (`implement`, `bulk_edit`) to a hub-sandboxed peer (Pi, `local`) runs in a throwaway repository made with `gitProject()` (`test/fakes/git-project.ts`), never in `ROOT`: the agent-hub checkout carries the machine's git config (a hosted runner's checkout has an `includeIf` credentials entry the hub refuses), so such a test passes on one machine and fails on another, and it would make a hub worktree and `task/*` branch inside the checkout under test (#1129). Before calling a hosted-only failure a flake, reproduce the runner's condition in a scratch clone.
|
|
8
9
|
- A fixture that walks a policy table (a `COMPAT` entry list, a registry) derives its per-entry values from the table, never a fixed list of keys or versions a planned entry is not in: the compat watch test builds every served version and the ahead/unknown row from `COMPAT[id]`, so an added entry (`pi`, #843 AC2) is covered without editing the test.
|
|
9
10
|
- Tests that are not about batching build the bus with `batchMs: 0`; with the default 15 s window a lone status envelope looks like a lost message.
|
|
@@ -11,7 +12,7 @@ Scope: tests, fakes and the test gate. Read before adding or changing a test or
|
|
|
11
12
|
- 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.
|
|
12
13
|
- 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`. Daemon-owned peer children use `peerChildEnv`, which drops `ORCA_*` and replaces agent identity markers; a person-owned native launch preserves only its own pane's `ORCA_*` context while dropping inherited agent identity.
|
|
13
14
|
- 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.
|
|
14
|
-
- 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.
|
|
15
|
+
- 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. A fixture whose plugin must push as a channel (not fall back to tools mode) injects the parent row with `syntheticClaudeParentEvidenceSetup()` (`test/fakes/claude-channel-parent.ts`, #1171), which replaces only `claudeParentCommandPort.read`; the production marker and classifier still run, so never stub `classifyClaudeParentCommand` or skip the evidence check. A second opted-in session attached while a fixture owner holds the peer stays a standby (#1174): close the owner and wait for offline before attaching the session under test (`test/channel-inbox-daemon.test.ts`).
|
|
15
16
|
- 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.
|
|
16
17
|
- `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`.
|
|
17
18
|
- `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. A new table, hint or flag list in generic code that names a peer (`"pi"`, `isJcodePeer`) is a new group: derive it from the definitions instead (`ADAPTERS.filter(...)`, a port such as `statusNotice`). Missed twice on 2026-11-02 (PR #1093 `jcodeTuiHint`, PR #1100 `INIT_SETTINGS`), both found only by CI's shard 3; run `scripts/check.sh --changed` or `bun test --timeout 20000 test/registry-guard.test.ts` before pushing a new peer-shaped table (a bare `bun test` times out at 5 s on this file).
|
|
@@ -19,7 +20,7 @@ Scope: tests, fakes and the test gate. Read before adding or changing a test or
|
|
|
19
20
|
- 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).
|
|
20
21
|
- `ahub upgrade` without `--to` asks the registry (`npm view`) and hands a target newer than the CLI to `bun x` (#272): a test that reaches either puts a fake `npm` first on `PATH` and a recording stand-in for `process.execPath`, as `test/upgrade-cli.test.ts` does. The interactive screens are tested through a scripted `ScreenIO`; a CLI test that needs them marks `process.stdin` and `process.stdout` as terminals in a wrapper script.
|
|
21
22
|
- Cross-process fixtures wait for the consuming client to process its handshake (the channel connected diagnostic), not merely the daemon's attached/idle state. Deadline-routing fixtures hold and release responses at the fallback decision; do not place a fixed sleep between deadline fractions and assume scheduler headroom (#287). Receipt-order fixtures use the normal watchdog for ordinary I/O, trigger expiry after observed native acceptance, and await a consumed transport barrier before asserting on late results.
|
|
22
|
-
- A bounded polling wait must assert its condition after the last attempt, or document why giving up is intentional. Append-only event histories wait for a correlated closing event, not removal of the opening event. Before changing a slow wait, identify the unreached state and repair its cause (#296).
|
|
23
|
+
- A bounded polling wait must assert its condition after the last attempt, or document why giving up is intentional. Bound it by time (a deadline with short sleeps), never by a count of event-loop turns (`setImmediate`), which a loaded runner spends before a socket frame or child output arrives (#1163, `untilSeen` in `test/control-client.test.ts`). Append-only event histories wait for a correlated closing event, not removal of the opening event. Before changing a slow wait, identify the unreached state and repair its cause (#296).
|
|
23
24
|
- A test of turn or notice behavior that is not about the snapshot budget yields an I/O turn (`await new Promise(r => setImmediate(r))`) between captures: captures in one synchronous burst share the 250 ms budget and may correctly record unknown evidence. A daemon test whose assertion needs a turn's snapshot or end evidence passes `snapshotClock: () => 0` (`DaemonOptions.snapshotClock`, #661) so a slow runner cannot spend that budget; never lengthen the budget instead (#550). A wait on concurrent-edit evidence correlates the expected turn ids and paths (#617).
|
|
24
25
|
- Holding the snapshot budget clock (`snapshotClock`, #661) holds the deadline and, since #554, the git child's wall-clock timeout (the 10 s ceiling instead of the remaining budget). The rule lives in the pure `childTimeoutMs` (`src/hub/snapshots.ts`: production keeps min(10000, remaining), only a budget carrying `now` gets the ceiling). Reproduce a slow git with a deliberately slow `git` first on PATH (`slowGit` in `test/snapshots.test.ts`), never with host-wide load, and never run a high-parallel loop while another gate shares the dgx02 host.
|
|
25
26
|
- Both OS CI legs use four shards (#548): `check (ubuntu-latest) shard i/4` and `check (macos-latest) shard i/4` runs `AHUB_CHECK_SHARD=i/4 scripts/check.sh` (`bun test --shard --timings=scripts/test-timings.json`), and each stable job name `check (ubuntu-latest)` / `check (macos-latest)` is the aggregator over its OS shards; `scripts/ci-gates.mjs`, branch protection and the reuse proof read that name, so never rename it or turn it back into a matrix job without reading them. `gateJob()` in `scripts/ci-gates.mjs` counts the shard names (`check (ubuntu-latest) shard i/N` and `check (macos-latest) shard i/N`) as gate jobs, so a run with either OS and the seeded shards done and only the other OS shards running reads as a launched full gate and not as unknown scope (`test/ci-reuse.test.ts` pins every mid-run window). A new test file needs no timing entry (it gets the median), but a shard that grows past about 15 minutes means the timings are stale: regenerate them with `scripts/make-test-timings.ts`. A shard starts the suite cold, so `scripts/check.sh` execs the system git once (`AHUB_CHECK_WARM_GIT`, default `/usr/bin/git`) before the tests, because tests that give it `PATH=/usr/bin:/bin` and a 1000 ms timeout (`read-only-shell-proof`, `task-shell-default`) are killed by a cold first exec; a failing or absent warm-up never fails the shard. `check.sh` unsets `AHUB_CHECK_SHARD` after building the test arguments, so the runner and every scratch `check.sh` a test spawns (`test/check-lock.test.ts` copies `process.env`) see an unsharded environment; keep that unset. A test must not depend on running in the same process as another file (shards split files across machines), and anything that has to happen once per gate belongs behind the shard-1 condition in `scripts/check.sh`, which `test/macos-shard.test.ts` pins.
|
|
@@ -29,6 +30,7 @@ Scope: tests, fakes and the test gate. Read before adding or changing a test or
|
|
|
29
30
|
- Run `bun scripts/seeded-check.ts` before reporting the seeded-guard gate complete. After changing seeded guard logic, run its affected seeds even when search-anchor counts are unchanged. Put asynchronous calls inside `resolves`/`rejects` expectations so a mutated refusal fails a named assertion. Keep the `scripts/seeds.json` cases sequential; require the same named test green before its seeded assertion fails. Seed rot, survivors, setup/compiler failures, timeout/watchdog termination and process leaks fail the gate. A change that moves or rewrites a line a `scripts/seeds.json` case searches for updates that case's search and replacement in the same PR (seed rot fails the gate), keeping its mutant effect and named test. `scripts/check.sh`, `--changed` included, never runs the seeds, so rot shows only in CI's `seeded guards` job (the `profile-cli-dry-flags` anchor needed a search-text update in both #1100 and #1110, 2026-11-02). Before pushing a change to a file `scripts/seeds.json` names, confirm every anchor still matches once: `bun -e 'const f=require("fs");for(const x of JSON.parse(f.readFileSync("scripts/seeds.json","utf8")).seeds){const n=f.readFileSync(x.file,"utf8").split(x.search).length-1;if(n!==1)console.log("seed rot:",x.id,n)}'` prints nothing when every anchor is intact. Never run the full seed runner from a unit test or mutate the source checkout; inspect preserved failed fixtures before removing them. CI runs it as four sequential Linux shards (`--shard i/4`) beside the ordinary check, separately from `scripts/check.sh`; the required `seeded guards` job passes only when every shard passed, or when the decision job reused a verified identical-tree full gate (`admission == 'reuse'`). Keep that job name: branch protection and `scripts/ci-gates.mjs` read it (#457). On seed rot the checker names the anchor's symbol at a base commit and where it is now (`seedRotContext`): `--base <commit>` sets that base (CI passes the PR base from a `fetch-depth: 0` checkout; keep both), and without it the base is `merge-base origin/main HEAD`. The context is evidence only: it never relaxes the one exact match, and an unavailable base says so instead of guessing.
|
|
30
31
|
- `scripts/lib/ts-ast.ts` is the one TypeScript AST helper for scripts and source-scanning tests (`parseSource`, `callSites`, `symbolSpans`, `enclosingSymbol`, `findSymbol`; inline HTML scripts keep their offsets). It gives lexical locations only, never resolved call edges, and stays dev tooling: never import it from `src/`.
|
|
31
32
|
- The native branch ratchet (`test/daemon-native-branches.json`) is reviewed data, never regenerated by a test (rule: `adapters.md`). `test/daemon-native-proof-gates.test.ts` runs daemon code cut out of `src/hub/daemon.ts` by its text, from ` function frontendProxyState(` to ` const manualPaused =` and from ` async function changePermission(` to ` async function confirmPermissionDefault(`, and the ` const isSupportedPeer =` and ` const readOnlyPeer =` bindings: renaming, reordering or reindenting them updates the test in the same PR. Test Claude launch permission proof through `createClaudeLaunchProof` in `src/hub/claude-launch-proof.ts`; never restore the removed daemon wrapper just to slice it in a fixture.
|
|
33
|
+
- The #1057 worktree tests cut source by its text: `test/worktree-shelf.test.ts` reads `src/hub/daemon.ts` from `case "worktrees":` to `// #604 (agent-hub#1030)`; `test/worktree-panel.test.ts` reads it from `async function worktreeShelf(` and `function worktreesView(` to `async function uiMemorySnapshot(`, `src/ui/index.html` from `function renderWorktrees(shelf)` to `function renderBench(bench)` (plus the helper lines it names) and `src/hub/sdlc.ts` from `export async function readSdlcPr(` to `export async function readSdlcPrHead(`. Moving, renaming or reordering those lines updates the test in the same PR. The removal-module lists pinned there and in `test/worktree-tidy.test.ts` (`src/hub/code-facts.ts`, `src/hub/task-worktree.ts`, `src/cli/worktree-tidy.ts`) are never extended to let a new removal pass (rule: `daemon.md`).
|
|
32
34
|
|
|
33
35
|
- `scripts/check.sh` pins the test environment (#610): `GIT_CONFIG_GLOBAL=/dev/null`, `NO_COLOR` unset, `TERM=xterm-256color`, temp root under the OS per-user temp dir, agent markers cleared. A test that needs a person's git config or color settings sets them itself. `--changed` selects tests with `scripts/changed-tests.mjs`: changed tests (nested suites included) plus every test that names a changed file directly; docs need none; any other changed file no test names, and any gate, package or shared-fixture change, runs everything.
|
|
34
36
|
- `scripts/check.sh` holds the repo's heavy-job slot (`<git common dir>/ahub-heavy.lock`, #398) for the whole run, so a test never runs the real gate; `test/check-lock.test.ts` runs a copy in a scratch repo.
|
|
@@ -43,15 +45,17 @@ Scope: tests, fakes and the test gate. Read before adding or changing a test or
|
|
|
43
45
|
- 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`.
|
|
44
46
|
- The preload leaves `HOME` unchanged and clears inherited `CLAUDE_CONFIG_DIR` and `CODEX_HOME`; tests that need fixture selectors set them after preload. 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`).
|
|
45
47
|
- 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.
|
|
48
|
+
- `test/fixtures/release-n-1` and `test/fixtures/protocol-v16` are separately exact-locked published packages (own `bun.lock`, installed by `scripts/check.sh`), never root dependencies, and `PUBLISHED_FIXTURES` in `test/published-protocol-compat.test.ts` pins their versions. Retarget release N-1 only in a minor release PR together with the version bump (AGENTS.md Releasing step 1, procedure in `docs/operations.md` #1164); the guard requires the fixture minor to be the package minor minus one. protocol-v16 stays at its version while protocol 16 is promised (#1168).
|
|
46
49
|
- 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.
|
|
47
50
|
- 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.
|
|
48
51
|
- `test/conformance/` runs real adapters against fake transports. The shared kit and the transport fakes live in `src/adapters/conformance/` (public `@staix/agent-hub/conformance`); `test/conformance/kit.ts` and the matching `test/fakes/*.ts` only re-export them, so change the packaged file, and `--changed` runs everything for it; `test/conformance/enrollment.ts` enrolls every `ADAPTERS` definition, each `:reviewer` variant and each `:implementer` (`headlessVariant`) variant. Record a case that does not apply in its `UNSUPPORTED` or `UNSUPPORTED_LEGS` map with a concrete reason naming the dedicated coverage, never `test.skip` or synthetic success, and never change native production behavior to satisfy a generic case.
|
|
49
52
|
|
|
50
53
|
- Keep the CI admission lock around the whole controller workflow, not just its decision job, in its per-PR (`check-pr-<number>`, `queue: single`: a newer pending run replaces the older) or main (`check-main`, `queue: max`) group, never with `cancel-in-progress: true` beside `queue: max` (GitHub refuses it) (#609); two groups admitting one tree at once is an accepted duplicate run, never a reason to fail closed on another tree. Preserve successful identical-tree full proof and required job names; a skipped job, pending controller or unavailable active inventory is never a successful full gate. In `readFullGateAdmission`, lost successful proof (404, malformed proof, a per-read timeout) only means a fresh gate runs; unknown active coverage and the global read ceilings (`GateReadLimitError`) stay unknown and refuse. A GitHub 429 or 5xx on one read is retried at most twice after a short backoff (#872), only while the shared deadline leaves room for the backoff, and every retry counts against the 240-read budget; never extend retries to any other status, a timeout or malformed data. CI checks out the PR head (`pull_request.head.sha`), not a merge with main: after a fix lands on main, merge main into the branch before reading CI as proof. CI reuse tests use fixed metadata fixtures and never dispatch real duplicate workflows.
|
|
51
54
|
|
|
52
|
-
- Headless-reviewer tests run `test/fakes/fake-claude-print.ts` and headless Claude implementer tests run `test/fakes/fake-claude-implementer.ts` through `command` or a PATH stand-in (the implementer with `HOME` and `CLAUDE_CONFIG_DIR` in a temp dir), and fresh Codex reviewer tests run `test/fakes/fake-codex-reviewer.ts` through `codex_bin`; a test that reaches the daemon's reviewer or implementer start must never run the real `claude` or `codex`. The fake's sandbox markers test host admission only, never native isolation. Fresh Kimi reviewer tests set and restore `KIMI_CODE_HOME` to a fixture native home (the backend links that home's `credentials/`, `oauth/` and `device_id`, else the person's `~/.kimi-code`) and run `test/fakes/fake-kimi-reviewer.ts` through `command` or a PATH stand-in, never the real `kimi`. Qwen TUI tests run `test/fakes/qwen-dual.ts` (scripted by submit text) as the launched Qwen or the daemon's `qwen_cmd` (an adapter unit test also passes its own `table`), never the real `qwen`. agy tests put a fake `agy` first on PATH and set `HOME` to a temp dir before any transcript readback (`readAgyTranscript` reads `$HOME/.gemini`). Code-facts tests build `new CodeFacts({ env })` with a PATH whose first `ripwire` is a wrapper around `test/fakes/fake-ripwire.ts` (`FAKE_RIPWIRE_MODE` selects its failures), or inject Tasks' `prepareReviewBrief`; never rely on an installed ripwire, which CI does not have.
|
|
55
|
+
- Headless-reviewer tests run `test/fakes/fake-claude-print.ts` and headless Claude implementer tests run `test/fakes/fake-claude-implementer.ts` through `command` or a PATH stand-in (the implementer with `HOME` and `CLAUDE_CONFIG_DIR` in a temp dir), and fresh Codex reviewer tests run `test/fakes/fake-codex-reviewer.ts` through `codex_bin`; a test that reaches the daemon's reviewer or implementer start must never run the real `claude` or `codex`. The fake's sandbox markers test host admission only, never native isolation. Fresh Kimi reviewer tests set and restore `KIMI_CODE_HOME` to a fixture native home (the backend links that home's `credentials/`, `oauth/` and `device_id`, else the person's `~/.kimi-code`) and run `test/fakes/fake-kimi-reviewer.ts` through `command` or a PATH stand-in, never the real `kimi`. Qwen TUI tests run `test/fakes/qwen-dual.ts` (scripted by submit text) as the launched Qwen or the daemon's `qwen_cmd` (an adapter unit test also passes its own `table`), never the real `qwen`. agy tests put a fake `agy` first on PATH and set `HOME` to a temp dir before any transcript readback (`readAgyTranscript` reads `$HOME/.gemini`). Code-facts tests build `new CodeFacts({ env })` with a PATH whose first `ripwire` is a wrapper around `test/fakes/fake-ripwire.ts` (`FAKE_RIPWIRE_MODE` selects its failures) or an inline fake installed with `installFakeRipwire` (`test/helpers/ripwire-probe.ts`, #1197), or inject Tasks' `prepareReviewBrief`; never rely on an installed ripwire, which CI does not have. `installFakeRipwire` runs the shim once outside the measured region, and `withProbe` and the probe's `watch` append where each call spent its time when a test fails; read that before calling a fake-ripwire failure a flake, and never widen the 5 s per-call cap or the 20 s timeout for it.
|
|
53
56
|
- A Pi fake the daemon starts through `createPi` answers `--version` with a version `COMPAT.pi` verifies, as `src/adapters/conformance/fakes/pi-rpc.ts` and `test/fakes/pi-data-policy.ts` do: the factory's preflight (`piVersionAsync`, #847) refuses the start otherwise.
|
|
54
57
|
- A daemon test that declares a headless Claude implementer in a repository whose `origin` is on GitHub passes `shellDefaultBranchProbe` (`DaemonOptions`, #845), or the start runs `git ls-remote` against github.com; a test of `readTaskShellDefaultBranch` itself puts a fake `git` first on the `PATH` it passes.
|
|
55
58
|
- A test of `scripts/release-prepare.ts` or `scripts/release-tag.ts` runs the script with its root in a scratch repository whose only remote is a local bare directory: set `AHUB_RELEASE_ROOT` to it, or copy the script into it as `test/release-tag-daemon.test.ts` does. Without that the script takes its root from its own location and fetches and runs `ls-remote` against this checkout's real origin. Consent comes from a fake port, a stand-in `AHUB_CLI` or a scratch daemon, never the person's hub.
|
|
56
59
|
- Production `MemoryClient` (`src/memory/client.ts`) is the in-process amem store and never opens `memory.worker_url` (#752). A test that needs the legacy worker shape injects `FakeMemoryClient` (`test/fakes/mem-worker.ts`) as `DaemonOptions.memoryClient` or in place of the client; never add an HTTP path to the production client for a test. An amem-backed test sets its own temp `AMEM_HOME` and reads only its own fixture memory directory.
|
|
57
60
|
- CLI print guards (#814, `test/cli-json-guard.test.ts`): two scans over every file in `src/cli`. One finds `console.log(JSON.stringify(...))` and passes only behind a `--json` branch (`selectsJson`) or a named `DATA_FORMAT` entry (`NOT_YET_CONVERTED` is empty since #899; keep it empty). The other finds a print of a hub tool's text (`console.log(await taskOp(<op>))`, or a variable bound from one) and passes only behind `--json`, for a named `SENTENCE_OPS` op (checked against the daemon's case), or named debt (`TEXT_DEBT`, `CONDUCTOR_DEBT`, `REFUSAL_FALLBACK`). A list entry with no matching site fails, so the lists only shrink. Add a command's `--json` branch or renderer; never add a list entry to make a new print pass. Per-command output tests (`test/task-output.test.ts`, `test/sdlc-output.test.ts`) run the real CLI against a fake control server and assert default not JSON, `--json` byte equality, 80 and 200 columns and `--color=never`.
|
|
61
|
+
- Hard-coded values inventory (#719, #1180, `scripts/inventory-hardcoded-values.ts --check`, a once-only step of `scripts/check.sh`): a constant or inline literal you add to `src/` or `scripts/` is an inventory entry, and the gate fails until it is classified in `scripts/inventory-hardcoded-values.classifications.json` (`operational`, `safety floor`, `contract` or `test seam`, by the reasons of `docs/verification/2026-10-30-operational-settings-closure.md`). Never add a key to `scripts/inventory-hardcoded-values.baseline.json`: it is the list of keys that were unclassified when the check began and it only shrinks. Classify a baseline key by adding it to the classifications file and deleting it from the baseline in the same commit; the check refuses either half alone. The classification rules and their calibration are in `docs/verification/2026-11-05-hardcoded-values-classification-rules.md` (#738): a baseline key is labelled by a mechanical rule the committed file agrees with on at least 94% of its decided keys, never by reading the code against a guess, and a shape the file labels inconsistently (length bounds and truncations in `src/hub`, timeout clamps, default-parameter timeouts) stays in the baseline until #739 settles its convention. Do not classify keys in bulk from reading the code: a blind pass agreed with the committed file on 58% of a sample, because the file counts hub-internal caps, injected `opts.x ?? N` defaults and script deadlines as operational (`docs/verification/2026-11-05-hardcoded-values-ratchet.md`). A line moved inside a file changes no key; a changed value or name does.
|