@staix/agent-hub 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +11 -3
  3. package/docs/agent-notes/adapters.md +4 -2
  4. package/docs/agent-notes/bus.md +4 -4
  5. package/docs/agent-notes/daemon.md +9 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/tasks.md +7 -0
  8. package/docs/agent-notes/tests.md +7 -5
  9. package/docs/design/adapters.md +76 -0
  10. package/docs/design/agent-hub-architecture.md +67 -0
  11. package/docs/design/agent-hub.md +52 -0
  12. package/docs/design/bus.md +48 -0
  13. package/docs/design/task-board.md +86 -0
  14. package/docs/dogfood.md +21 -3
  15. package/docs/operations.md +370 -12
  16. package/docs/profiles.md +1 -0
  17. package/docs/quickstart.md +19 -3
  18. package/docs/sdlc.md +2 -2
  19. package/docs/security.md +24 -4
  20. package/docs/smoke.md +2 -0
  21. package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
  22. package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-11-02-0.28.0.md +54 -0
  24. package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
  25. package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
  26. package/docs/verification/2026-11-02-control-write-after.json +139 -0
  27. package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
  28. package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
  29. package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
  30. package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
  31. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  32. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  33. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  34. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  35. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  36. package/docs/verified.json +14 -14
  37. package/package.json +2 -2
  38. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  39. package/plugins/agent-hub/server.js +857 -125
  40. package/src/adapters/claude-channel.ts +12 -2
  41. package/src/adapters/compat.ts +1 -1
  42. package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
  43. package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
  44. package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
  45. package/src/adapters/jcode.ts +37 -4
  46. package/src/adapters/qwen-dual.ts +3 -2
  47. package/src/cli/agents.ts +4 -2
  48. package/src/cli/bench.ts +4 -2
  49. package/src/cli/board-read.ts +25 -0
  50. package/src/cli/claude-homes.ts +2 -2
  51. package/src/cli/console-state.ts +8 -3
  52. package/src/cli/help.ts +6 -5
  53. package/src/cli/identity.ts +9 -1
  54. package/src/cli/init-prompt.ts +179 -0
  55. package/src/cli/init-settings.ts +229 -0
  56. package/src/cli/init.ts +102 -9
  57. package/src/cli/launch.ts +3 -1
  58. package/src/cli/main.ts +106 -35
  59. package/src/cli/output.ts +19 -0
  60. package/src/cli/recovery-package.ts +9 -0
  61. package/src/cli/setup-homes.ts +18 -3
  62. package/src/cli/specs.ts +150 -0
  63. package/src/cli/terminal-recovery.ts +35 -11
  64. package/src/hub/board.ts +59 -5
  65. package/src/hub/bus.ts +54 -7
  66. package/src/hub/child-process.ts +6 -4
  67. package/src/hub/config-apply.ts +4 -1
  68. package/src/hub/config-edit.ts +1 -1
  69. package/src/hub/config-tier.ts +1 -0
  70. package/src/hub/control-client.ts +43 -4
  71. package/src/hub/control-write-trace.ts +409 -0
  72. package/src/hub/cost-governance.ts +22 -6
  73. package/src/hub/crash.ts +20 -0
  74. package/src/hub/daemon.ts +470 -98
  75. package/src/hub/delivery-hints.ts +6 -3
  76. package/src/hub/delivery-journal.ts +14 -3
  77. package/src/hub/hub-tools.ts +10 -6
  78. package/src/hub/peers.ts +1 -1
  79. package/src/hub/review-brief.ts +18 -0
  80. package/src/hub/routing.ts +14 -3
  81. package/src/hub/runtime-capture-prune.ts +4 -1
  82. package/src/hub/sdlc-release-authorization.ts +13 -4
  83. package/src/hub/sdlc-release.ts +23 -10
  84. package/src/hub/sdlc.ts +15 -2
  85. package/src/hub/settings.ts +2 -0
  86. package/src/hub/specs-config.ts +43 -0
  87. package/src/hub/specs-task.ts +108 -0
  88. package/src/hub/specs.ts +1008 -0
  89. package/src/hub/task-list-page.ts +75 -0
  90. package/src/hub/task-sweep.ts +10 -1
  91. package/src/hub/task-worktree.ts +331 -0
  92. package/src/hub/tasks.ts +606 -53
  93. package/src/hub/terminal-open.ts +10 -5
  94. package/src/hub/tool-request.ts +131 -0
  95. package/src/local/deny.ts +49 -0
  96. package/src/local/tools.ts +4 -2
  97. package/src/memory/brief.ts +5 -0
  98. package/src/memory/client.ts +5 -2
  99. package/src/ui/office-model.ts +17 -1
  100. package/src/ui/office-space.ts +1 -1
  101. package/templates/config.json +1 -0
  102. package/templates/profiles/dev.json +1 -0
  103. package/templates/profiles/general.json +1 -0
  104. package/templates/profiles/research.json +1 -0
  105. package/templates/profiles/writing.json +1 -0
  106. package/templates/specs.block.md +5 -0
@@ -225,7 +225,7 @@ function runnerFailure(argv: readonly string[], result: CommandResult): Recovery
225
225
  async function run(runner: CommandRunner, argv: readonly string[]): Promise<unknown> {
226
226
  const result = await runner(argv);
227
227
  if (commandStatus(result) !== 0) {
228
- // Orca 1.4.223 answers a handle whose terminal was removed with `{"ok":false,"error":{"code":"terminal_handle_stale"}}`.
228
+ // Orca 1.4.223 and, rechecked read-only, 1.4.245 (docs/verification/2026-11-02-orca-1.4.245-cli-contract.md) answer a handle whose terminal was removed with `{"ok":false,"error":{"code":"terminal_handle_stale"}}`.
229
229
  if (errorCode(result) === "terminal_handle_stale") {
230
230
  const handle = argv.includes("--terminal") ? argv[argv.indexOf("--terminal") + 1] : undefined;
231
231
  throw new OrcaCommandError({ code: "terminal-gone", message: `the Orca terminal${handle ? ` ${handle}` : ""} no longer exists` });
@@ -300,17 +300,41 @@ function defaultRunner(executable: string): CommandRunner {
300
300
  export const orcaExecutable = (): string => resolveOrcaExecutable();
301
301
 
302
302
  /**
303
- * #269: the Orca worktree a terminal for this project can be opened in: the one this process runs in, or the one the
304
- * last recorded `ahub <peer>` launch of this project used. Undefined when there is none or the record does not read.
305
- * ponytail: the environment's worktree is taken as this project's; a hub started from another project's Orca terminal
306
- * (`ahub --project`, the dashboard manager's start, an upgrade or restart that brings several projects up from one
307
- * terminal) opens the terminal there and `ahub <peer>` then fails its root readback. Ask Orca for the worktree's path
308
- * and compare it if that case shows up.
303
+ * #269: whether Orca is in play for this project: this process runs in an Orca terminal, or an earlier `ahub <peer>`
304
+ * launch of this project recorded a worktree. It is only a hint for a synchronous "could a start open a terminal"
305
+ * answer. It is never the worktree a terminal is opened in: the environment names the worktree of whatever terminal
306
+ * this process was started from, which is another project's when `ahub --project`, the dashboard manager or an upgrade
307
+ * brought this project up (#1089). `resolveStartTerminalWorktree` names the worktree.
309
308
  */
310
- export function startTerminalWorktree(projectRoot: string, stateDir: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
311
- if (env.ORCA_WORKTREE_ID) return env.ORCA_WORKTREE_ID;
309
+ export function startTerminalCandidate(projectRoot: string, stateDir: string, env: NodeJS.ProcessEnv = process.env): boolean {
310
+ if (env.ORCA_WORKTREE_ID) return true;
312
311
  const records = launchRecords(stateDir);
313
- return records === "unreadable" ? undefined : records.rows.filter((row) => row.projectRoot === projectRoot).at(-1)?.worktreeId;
312
+ return records !== "unreadable" && records.rows.some((row) => row.projectRoot === projectRoot && typeof row.worktreeId === "string");
313
+ }
314
+
315
+ /** #1089: how many worktrees one `orca worktree list` asks for; a longer list is reported as truncated and never read as "none". */
316
+ const WORKTREE_LIST_LIMIT = 500;
317
+
318
+ /**
319
+ * #1089: the Orca worktree whose root is exactly this project's root, asked of Orca and never taken from the caller's
320
+ * environment. The match is exact because `ahub <peer>` compares the terminal's worktree root with the project root by
321
+ * equality and refuses on a difference: a terminal opened in any other worktree would only fail later. The refusal names
322
+ * the cause and the next step and carries no path; Orca's own words go in `detail` for the hub log.
323
+ */
324
+ export async function resolveStartTerminalWorktree(projectRoot: string, options?: CommandRunner | TerminalRecoveryOptions): Promise<{ ok: true; worktreeId: string } | { ok: false; why: string; detail?: string }> {
325
+ const config = normalizeOptions(options);
326
+ let value: unknown;
327
+ try { value = await run(config.runner, ["worktree", "list", "--limit", String(WORKTREE_LIST_LIMIT), "--json"]); }
328
+ catch (error) {
329
+ return { ok: false, why: "Orca could not list its worktrees, so no terminal was opened. Check that Orca is running (orca worktree list), then start the peer again, or set terminal.open", detail: (error as Error).message };
330
+ }
331
+ const payload = responseResult(value);
332
+ const rows = Array.isArray(payload.worktrees) ? payload.worktrees.map(object) : [];
333
+ const matches = rows.filter((row) => row.isArchived !== true && rootMatches(row.path, projectRoot) && typeof row.id === "string" && row.id.length > 0);
334
+ if (matches.length === 1) return { ok: true, worktreeId: String(matches[0]!.id) };
335
+ if (matches.length > 1) return { ok: false, why: "more than one Orca worktree has this project's root, so no terminal was opened. Archive the extra entry in Orca, or set terminal.open" };
336
+ if (payload.truncated === true) return { ok: false, why: `Orca's worktree list was cut off at ${WORKTREE_LIST_LIMIT} rows before one with this project's root appeared, so no terminal was opened. Archive worktrees you no longer use in Orca, or set terminal.open` };
337
+ return { ok: false, why: "Orca has no worktree whose root is this project's root, so no terminal was opened. Open this project in Orca (add it as a repository or worktree), then start the peer again, or set terminal.open" };
314
338
  }
315
339
 
316
340
  /** #269: a new Orca terminal running `command` (a complete, already quoted command line); the terminal's handle. */
@@ -791,7 +815,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
791
815
  }
792
816
 
793
817
  /**
794
- * One bounded Orca wait. Orca 1.4.223 answers a wait that timed out with exit 1 and `{"ok":false,"error":{"code":"timeout"}}`:
818
+ * One bounded Orca wait. Orca 1.4.223 and, rechecked read-only, 1.4.245 (docs/verification/2026-11-02-orca-1.4.245-cli-contract.md) answer a wait that timed out with exit 1 and `{"ok":false,"error":{"code":"timeout"}}`:
795
819
  * that is "not satisfied", not a failure. Any other failure still throws.
796
820
  */
797
821
  async function waitSatisfied(runner: CommandRunner, handle: string, timeoutMs: number): Promise<boolean> {
package/src/hub/board.ts CHANGED
@@ -11,6 +11,7 @@ import type { ReviewerPoolReason } from "./reviewer-pool.ts";
11
11
  import type { GitHubSource } from "./github-import.ts";
12
12
  import type { Database } from "./db-schema.ts";
13
13
  import { openHubDatabase, advanceHubDbSchema } from "./db-schema.ts";
14
+ import { controlWriteSpan } from "./control-write-trace.ts";
14
15
  import { planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
15
16
  import { HUB, USER, type PeerId } from "./envelope.ts";
16
17
  import type { SweepRecord } from "./task-sweep.ts";
@@ -18,6 +19,8 @@ import type { SdlcReceipt } from "./sdlc.ts";
18
19
  import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
19
20
  import { parseDeliverableRecords, type DeliverableRecord } from "./deliverables.ts";
20
21
  import type { CostPlan, ReviewCost } from "./cost-governance.ts";
22
+ import type { SpecRef, SpecTaskReport } from "./specs-task.ts";
23
+ export type { SpecAck, SpecRef, SpecTaskReport } from "./specs-task.ts";
21
24
  import { campaignHash, parseResearchCampaign, parseResearchHandoff, type ResearchCampaignContract, type ResearchCampaignStatus, type ResearchUnit, type ResearchHandoffV1, type ResearchHandoffRecord } from "./research-campaign.ts";
22
25
 
23
26
  export interface ResearchCampaignRecord {
@@ -133,7 +136,7 @@ export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "
133
136
  export const isClosedTaskState = (state: string): boolean => state === "approved" || state === "cancelled";
134
137
  export interface TaskOffer { envelopeId: string; owner: PeerId; assignmentFlowRevision: number; pullOnly?: boolean }
135
138
  export interface TaskCancellation { reason: string; supersededBy?: string; flowRevision: number; withheld?: true }
136
- export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "no-writes", "queue-count", "queue-age", "queue-age-unknown"] as const;
139
+ export const TASK_STALL_CODES = ["tests", "spec", "network", "worktree", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "no-writes", "queue-count", "queue-age", "queue-age-unknown"] as const;
137
140
  export type TaskStallCode = (typeof TASK_STALL_CODES)[number];
138
141
  export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency" | "no-writes"; peer?: string; dependency?: number; code: TaskStallCode; turns?: number; acceptance?: number; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
139
142
 
@@ -201,6 +204,10 @@ export interface HistoryEntry {
201
204
  sweep?: SweepRecord;
202
205
  /** The PII screen did not clear this entry's note (#198): only the console reads it. */
203
206
  withheld?: true;
207
+ /** Public, head-bound design-spec status; acknowledgement text is screened before this is written. */
208
+ specReport?: SpecTaskReport;
209
+ /** Internal identity for incomplete reports without a full commit SHA; never projected publicly. */
210
+ specRefBinding?: string;
204
211
  /** Host evidence at done, never inferred from the owner's summary. */
205
212
  noWrites?: true;
206
213
  /** Host-recorded expected cost and exact review head, never narrative. */
@@ -277,6 +284,31 @@ function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
277
284
  if (failure && (!id(failure.peer) || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || failure.head !== undefined && (typeof failure.head !== "string" || !failure.head.trim() || failure.head.length > 300 || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head)))) throw new Error("invalid reviewer failure metadata");
278
285
  return { ...(extra.reviewerFamily ? { reviewerFamily: extra.reviewerFamily } : {}), ...(extra.reviewerReason ? { reviewerReason: extra.reviewerReason } : {}), ...(extra.reviewBlocked ? { reviewBlocked: true } : {}), ...(extra.queueReviewHeld ? { queueReviewHeld: true, ...(extra.queueReviewBinding !== undefined ? { queueReviewBinding: extra.queueReviewBinding } : {}) } : {}), ...(failure ? { reviewerFailure: { peer: failure.peer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } } : {}) };
279
286
  }
287
+ function specReportHistory(report: unknown, binding: unknown, event: string): Pick<HistoryEntry, "specReport" | "specRefBinding"> {
288
+ if (!report || typeof report !== "object" || Array.isArray(report) || !["done", "done (checking)"].includes(event)
289
+ || typeof binding !== "string" || !/^[a-f0-9]{64}$/.test(binding)) throw new Error("invalid task spec report metadata");
290
+ const value = report as Record<string, unknown>;
291
+ if (Object.keys(value).some(key => !["head", "complete", "errors", "unchanged", "changed", "truncated", "reason"].includes(key))
292
+ || typeof value.head !== "string" || value.head !== "" && !/^[a-f0-9]{40}$/.test(value.head)
293
+ || value.head === "" && value.complete !== false || typeof value.complete !== "boolean"
294
+ || !Number.isSafeInteger(value.errors) || (value.errors as number) < 0 || (value.errors as number) > 1_000_000
295
+ || value.truncated !== undefined && value.truncated !== true
296
+ || value.reason !== undefined && (typeof value.reason !== "string" || value.reason.length > 500 || /[\p{Cc}\p{Cf}]/u.test(value.reason))
297
+ || !Array.isArray(value.unchanged) || value.unchanged.length > 20 || !Array.isArray(value.changed) || value.changed.length > 20) throw new Error("invalid task spec report metadata");
298
+ const ref = (raw: unknown): SpecRef => {
299
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid task spec report metadata");
300
+ const row = raw as Record<string, unknown>;
301
+ if (Object.keys(row).some(key => !["id", "path", "title", "reason"].includes(key))
302
+ || typeof row.id !== "string" || !row.id.trim() || row.id.length > 160 || /[\p{Cc}\p{Cf}]/u.test(row.id)
303
+ || typeof row.path !== "string" || !row.path.trim() || row.path.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(row.path)
304
+ || typeof row.title !== "string" || !row.title.trim() || row.title.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(row.title)
305
+ || row.reason !== undefined && (typeof row.reason !== "string" || row.reason.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(row.reason))) throw new Error("invalid task spec report metadata");
306
+ return { id: row.id, path: row.path, title: row.title, ...(typeof row.reason === "string" ? { reason: row.reason } : {}) };
307
+ };
308
+ const specReport: SpecTaskReport = { head: value.head, complete: value.complete, errors: value.errors as number,
309
+ unchanged: value.unchanged.map(ref), changed: value.changed.map(ref), ...(value.truncated === true ? { truncated: true } : {}), ...(typeof value.reason === "string" ? { reason: value.reason } : {}) };
310
+ return { specReport, specRefBinding: binding };
311
+ }
280
312
  export interface PlannerBinding { planner: PeerId | null; plannerReason: PlannerReason }
281
313
  function plannerValue(value: unknown): PeerId | null {
282
314
  if (value == null) return null;
@@ -400,11 +432,11 @@ export class Board {
400
432
 
401
433
  /** Keep the generation and business write atomic without changing Bun's reported row count. */
402
434
  private taskWrite<T extends { changes: number }>(write: () => T): T {
403
- return this.db.transaction(() => {
435
+ return controlWriteSpan("board-write", () => this.db.transaction(() => {
404
436
  const result = { ...write() };
405
437
  if (result.changes) this.db.run("UPDATE board_status_revision SET revision=revision+1 WHERE id=1");
406
438
  return result;
407
- })();
439
+ })());
408
440
  }
409
441
 
410
442
  statusRevision(): number {
@@ -1362,6 +1394,26 @@ export class Board {
1362
1394
  statusTasks(): Pick<Task, "id" | "state" | "owner" | "reviewer">[] {
1363
1395
  return this.db.query("SELECT id, state, owner, reviewer FROM tasks WHERE state NOT IN ('approved','cancelled') ORDER BY id").all() as Pick<Task, "id" | "state" | "owner" | "reviewer">[];
1364
1396
  }
1397
+ /** Fresh privacy classification inputs only; no task history, title, detail or plan decoding. */
1398
+ peerTaskSignals(peer: string): Pick<Task, "state" | "signals">[] {
1399
+ const rows = this.db.query("SELECT state, signals FROM tasks WHERE owner=? OR reviewer=?").all(peer, peer) as { state: unknown; signals: unknown }[];
1400
+ return rows.map(row => {
1401
+ const signals: unknown = JSON.parse(String(row.signals));
1402
+ if (typeof row.state !== "string" || !Array.isArray(signals) || signals.some(signal => typeof signal !== "string")) throw new Error("invalid task privacy projection");
1403
+ return { state: row.state as TaskState, signals: signals as string[] };
1404
+ });
1405
+ }
1406
+ /** Fresh privacy facts for a bounded explicit reference set; no task history or text decoding. */
1407
+ taskPrivacySignals(ids: readonly number[]): Pick<Task, "id" | "signals">[] {
1408
+ if (!Array.isArray(ids) || ids.length > 64 || ids.some(id => !Number.isSafeInteger(id) || id < 1) || new Set(ids).size !== ids.length) throw new Error("invalid task privacy reference set");
1409
+ if (!ids.length) return [];
1410
+ const rows = this.db.query(`SELECT id, signals FROM tasks WHERE id IN (${ids.map(() => "?").join(",")})`).all(...ids) as { id: unknown; signals: unknown }[];
1411
+ return rows.map(row => {
1412
+ const signals: unknown = JSON.parse(String(row.signals));
1413
+ if (!Number.isSafeInteger(row.id) || !Array.isArray(signals) || signals.some(signal => typeof signal !== "string")) throw new Error("invalid task privacy projection");
1414
+ return { id: row.id as number, signals: signals as string[] };
1415
+ });
1416
+ }
1365
1417
  statusReservations(): string[] {
1366
1418
  return (this.db.query("SELECT DISTINCT reserved FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND reserved IS NOT NULL ORDER BY reserved").all() as { reserved: string }[]).map(row => row.reserved);
1367
1419
  }
@@ -1454,7 +1506,7 @@ export class Board {
1454
1506
 
1455
1507
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
1456
1508
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
1457
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewedHead" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
1509
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewedHead" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | "specReport" | "specRefBinding" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
1458
1510
  const task = this.get(id);
1459
1511
  if (!task) throw new Error(`no task #${id}`);
1460
1512
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -1480,6 +1532,8 @@ export class Board {
1480
1532
  const plannerHistory = Object.hasOwn(patch, "planner") || extra.plannerReason !== undefined ? plannerMetadata(planner, extra.plannerReason) : {};
1481
1533
  const authorHistory = Object.hasOwn(extra, "planAuthor") ? { planAuthor: plannerValue(extra.planAuthor) } : {};
1482
1534
  if (extra.selfReview !== undefined && extra.selfReview !== true) throw new Error("invalid self-review metadata");
1535
+ const specMetadata = extra.specReport !== undefined || extra.specRefBinding !== undefined
1536
+ ? specReportHistory(extra.specReport, extra.specRefBinding, event) : {};
1483
1537
  if (extra.conductorManual !== undefined && (extra.conductorManual !== true || by === "user" || by === "hub" || extra.reason !== "manual"
1484
1538
  || !["assigned", "reassigned", "reserved", "unassigned", "reviewer changed"].includes(event))) throw new Error("invalid conductor manual metadata");
1485
1539
  if (extra.invocation && (by !== HUB || event !== "invocation")) throw new Error("invocation audit is host-only");
@@ -1533,7 +1587,7 @@ export class Board {
1533
1587
  && priorHead !== undefined && /^[a-f0-9]{40}$/.test(priorHead);
1534
1588
  const previousHistory = backfill ? task.history.map((entry, index) => index === priorApproval && !entry.reviewedHead
1535
1589
  ? { ...entry, reviewedHead: entry.reviewFindings?.head ?? priorHead } : entry) : task.history;
1536
- const history = [...previousHistory, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
1590
+ const history = [...previousHistory, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...specMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
1537
1591
  this.taskWrite(() => {
1538
1592
  const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
1539
1593
  .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, id);
package/src/hub/bus.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { remoteEnvelope, appendNote, DIGEST, frame, HUB, isInvocationKind, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
1
+ import { remoteEnvelope, appendNote, DIGEST, frame, HUB, USER, isInvocationKind, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
2
2
  import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
3
3
  import type { DoingKind } from "./peer-doing.ts";
4
4
  import type { QueuePressure } from "./queue-backpressure.ts";
5
- import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
5
+ import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState, type JournalResolution } from "./delivery-journal.ts";
6
6
  import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
7
7
  import { MAX_DEFERRED_HUB, RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
8
8
  import { parseRemotePeerId } from "../rooms/application.ts";
@@ -617,6 +617,31 @@ export class Bus {
617
617
  this.resolveDelivery(id, row.revision, "retry", "recipient requested one uncertain redelivery", `peer:${peer}`);
618
618
  }
619
619
 
620
+ /** Settle only this peer's own uncertain receipt; this never replays its originals or changes task state. */
621
+ resolveOwnDelivery(peer: string, id: string, revision: number, action: "completed" | "discard", reason: string): void {
622
+ const refuse = (message: string): never => { throw new PreEffectToolRefusal(message); };
623
+ // One message per cause class, each naming its next step. An unknown id, another peer's row and a row that is not needs_review share one message so a refusal never confirms another peer's row.
624
+ if (peer === USER || peer === HUB || remoteId(peer)) return refuse("hub_delivery_resolve settles a local agent's own held delivery; the person settles any other row with ahub queue resolve <delivery-id>");
625
+ if (this.closed || this.recoveryHeld || this.storageError || !this.journal) return refuse("the hub is not accepting delivery changes now (recovery, shutdown or delivery storage); run ahub status, then call hub_delivery_resolve again after it recovers");
626
+ if (this.paused.has(peer) || this.opts.deliveryHeld?.(peer) === true) return refuse("your deliveries are paused or held, so this row cannot be settled now; run ahub status to see which, and call hub_delivery_resolve again once it is released");
627
+ if (!Number.isSafeInteger(revision) || revision < 0 || (action !== "completed" && action !== "discard") || typeof reason !== "string" || !reason.trim() || reason.trim().length > 1000) return refuse("hub_delivery_resolve needs the revision hub_inbox showed (a whole number), action completed or discard, and a reason of 1 to 1000 characters");
628
+ const row = this.queueShow(id), actor = `peer:${peer}`, cleanReason = reason.trim();
629
+ const notYours = "no needs_review delivery with this id is held for you; call hub_inbox to list your held deliveries and their revisions";
630
+ if (!row || row.peer !== peer) return refuse(notYours);
631
+ // An uncertain invocation may still be running: a peer claiming it completed would make the caller wait for a result that may never come. Only the person settles it.
632
+ if (row.originals.some(env => isInvocationKind(env))) return refuse(`delivery ${id} carries an invocation whose outcome is uncertain; only the person settles it (ahub queue show ${id}, then ahub queue resolve ${id})`);
633
+ const previous = this.journal.resolution(id);
634
+ if (previous) {
635
+ if (previous.actor !== actor || previous.action !== action || previous.reason !== cleanReason) return refuse(`delivery ${id} was already settled with a different action or reason; the person can inspect it with ahub queue show ${id}`);
636
+ if (revision !== previous.observedRevision && revision !== row.revision) return refuse("held delivery revision changed; read hub_inbox again for the current revision");
637
+ this.resolveDelivery(id, revision, action, cleanReason, actor);
638
+ return;
639
+ }
640
+ if (row.state !== "needs_review") return refuse(notYours);
641
+ if (row.revision !== revision) return refuse("held delivery revision changed; read hub_inbox again for the current revision");
642
+ this.resolveDelivery(id, revision, action, cleanReason, actor);
643
+ }
644
+
620
645
  private retryOut(peer: string, originals: Envelope[], out: Envelope[]): Envelope[] {
621
646
  if (remoteId(peer)) return out;
622
647
  if (!out.length || !this.journal || !this.journal.retryWarning(peer, originals)) return out;
@@ -631,6 +656,11 @@ export class Bus {
631
656
  });
632
657
  }
633
658
 
659
+ /** #1096: the operator action that settled this delivery, when one did (`queue show` reads it to say what is left to do). */
660
+ deliveryResolution(id: string): JournalResolution | undefined { return this.journal && !this.storageError ? this.journal.resolution(id)?.action : undefined; }
661
+ /** #1096: ids of the deliveries an operator action already settled, in one read; empty without a journal. `queue list` hides them unless asked for all. */
662
+ settledIds(): Set<string> { return this.journal && !this.storageError ? this.journal.resolvedIds() : new Set(); }
663
+
634
664
  resolveDelivery(id: string, revision: number, action: "completed" | "retry" | "discard", reason: string, actor: string = "user"): void {
635
665
  if (!this.journal || this.storageError) throw new Error("delivery journal unavailable");
636
666
  this.trackingQueuePressure();
@@ -638,7 +668,13 @@ export class Bus {
638
668
  if (!record) throw new Error("unknown delivery");
639
669
  const previous = this.journal.resolution(id);
640
670
  if (previous) {
641
- this.journal.resolve(id, revision, action, reason, revision, actor); // verifies identical request; no second enqueue
671
+ // #1096: a row that already carries a resolution is settled. The same request again is idempotent (the journal verifies it); any other request is not a
672
+ // stale read, and `retry` in particular leaves its parent in `failed` for good, because the redelivery is a new row. Say so instead of "stale revision".
673
+ try { this.journal.resolve(id, revision, action, reason, revision, actor); }
674
+ catch (error) {
675
+ if (/stale delivery revision or conflicting operator resolution/.test(error instanceof Error ? error.message : "")) throw new Error(`delivery already resolved by ${previous.action}`);
676
+ throw error;
677
+ }
642
678
  return;
643
679
  }
644
680
  if (action === "retry" && remoteId(record.peer)) {
@@ -648,12 +684,14 @@ export class Bus {
648
684
  if (!Number.isSafeInteger(deadline) || deadline! <= Date.now()) throw new PreEffectToolRefusal("remote original deadline unavailable or expired; discard it or send a new message explicitly");
649
685
  this.checkRemotePublication(record.originals[0]!);
650
686
  }
651
- const recipientConsent = actor === `peer:${record.peer}` && action === "retry" && record.state === "needs_review";
687
+ const recipientConsent = actor === `peer:${record.peer}` && record.state === "needs_review" && (action === "retry" || action === "completed" || action === "discard");
652
688
  // #713: a `queued` delivery was never handed to the peer, so nothing is in flight and a busy peer (a pull-only TUI is busy most of the time) does not block
653
689
  // discarding it. `dispatching` and `accepted` stay refused, and every other resolution keeps the busy fence.
654
690
  if (["dispatching", "accepted"].includes(record.state)) throw new Error("active delivery cannot be resolved");
655
691
  const queuedDiscard = record.state === "queued" && action === "discard";
656
- if (this.peers.get(record.peer)?.state === "busy" && !recipientConsent && !queuedDiscard) throw new Error("peer busy: its delivery cannot be resolved while it works");
692
+ // #1096: a `failed` row was refused before it ran and discarding it touches no queue, so a busy peer has nothing in flight to protect either.
693
+ const failedDiscard = record.state === "failed" && action === "discard";
694
+ if (this.peers.get(record.peer)?.state === "busy" && !recipientConsent && !queuedDiscard && !failedDiscard) throw new Error("peer busy: its delivery cannot be resolved while it works");
657
695
  if (record.revision !== revision) throw new Error("stale delivery revision");
658
696
  if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
659
697
  if (action === "retry" && !remoteId(record.peer) && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
@@ -1435,10 +1473,10 @@ export class Bus {
1435
1473
  * terminal row. The completed row keeps the envelopes (`ahub queue show <id>`) to resend by hand; take/confirm in two
1436
1474
  * steps if loss is ever observed.
1437
1475
  */
1438
- pull(id: PeerId): Envelope[] | undefined {
1476
+ pull(id: PeerId, options: { allowNeedsReviewHold?: boolean } = {}): Envelope[] | undefined {
1439
1477
  if (remoteId(id)) return undefined; // A local inbox read is not signed remote queue admission.
1440
1478
  if (this.storageError) throw new Error("delivery journal unavailable");
1441
- if (this.held(id)) return undefined;
1479
+ if (this.held(id) && !(options.allowNeedsReviewHold === true && this.pullMayReadBehindNeedsReview(id))) return undefined;
1442
1480
  const queue = this.queues.get(id) ?? [];
1443
1481
  this.dropIrrelevant(id, queue);
1444
1482
  const preface = this.prefaces.get(id);
@@ -1472,6 +1510,15 @@ export class Bus {
1472
1510
  return out;
1473
1511
  }
1474
1512
 
1513
+ /** Explicit inbox reads may pass only a needs_review hold; every other recovery fence still blocks. */
1514
+ private pullMayReadBehindNeedsReview(id: PeerId): boolean {
1515
+ if (!this.journal || this.closed || this.storageError || !this.isRostered(id) || this.recoveryHeld || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true) return false;
1516
+ if (this.pendingOutcomes.some(row => row.peer === id)) return false;
1517
+ const unresolved = this.journal.unresolved(id);
1518
+ return unresolved.some(row => row.state === "needs_review") &&
1519
+ unresolved.every(row => row.state === "needs_review" || row.state === "queued");
1520
+ }
1521
+
1475
1522
  private async drain(id: PeerId): Promise<void> {
1476
1523
  if (this.storageError || this.draining.has(id) || !this.peers.has(id)) return;
1477
1524
  if (remoteId(id)) this.expireRemoteQueues();
@@ -7,6 +7,8 @@ import { withoutOrcaEnv } from "./orca-env.ts";
7
7
  * lifecycle calls could bypass the machine recovery lock.
8
8
  */
9
9
  export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
10
+ // Keep the caller's environment for person-owned native launch paths; peer and helper
11
+ // children that must not inherit Orca use peerChildEnv or withoutOrcaEnv explicitly.
10
12
  const env = { ...source };
11
13
  delete env.AGENTHUB_RECOVERY_OPERATION;
12
14
  delete env.AGENTHUB_RECOVERY_CONDUCTOR;
@@ -20,15 +22,15 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
20
22
  * typed or started there has the console's identity, whatever started the hub.
21
23
  */
22
24
  export function personEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
23
- // The Orca terminal the hub itself was started in is not this new terminal: a launch there must not record it as its own, so every ORCA_ variable goes (#613).
24
25
  const env = withoutOrcaEnv(childEnv(source));
25
26
  for (const name of ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "AGENTHUB_CHANNEL", "AGENTHUB_PEER_ID", "AGENTHUB_UNATTENDED"]) delete env[name];
26
27
  return env;
27
28
  }
28
29
 
29
30
  /** Every native child owns its marker; caller vendor markers must not leak across peers (#193). */
30
- export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
31
- const env = childEnv(source);
31
+ export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.env, options: { preserveOrca?: true } = {}): NodeJS.ProcessEnv {
32
+ const inherited = childEnv(source);
33
+ const env = options.preserveOrca ? inherited : withoutOrcaEnv(inherited);
32
34
  delete env.CLAUDECODE;
33
35
  delete env.CLAUDE_CODE_SESSION_ID;
34
36
  delete env.CODEX_THREAD_ID;
@@ -60,7 +62,7 @@ export function parseProcessTable(text: string): ProcRow[] {
60
62
 
61
63
  const PS = ["ps", "-axo", "pid=,ppid=,pgid=,stat=,lstart=,command="];
62
64
  // `lstart` is local time: one zone for every reader, or a reader in another zone sees every identity as changed.
63
- const PS_OPTIONS = { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, detached: true } as const;
65
+ const PS_OPTIONS = { env: { ...withoutOrcaEnv(childEnv(process.env)), LC_ALL: "C", TZ: "UTC" }, detached: true } as const;
64
66
  /** The rows of one read, or undefined when they do not show the reader: a parse that found nothing is not a table. */
65
67
  const own = (text: string) => { const rows = parseProcessTable(text); return rows.some((row) => row.pid === process.pid) ? rows : undefined; };
66
68
 
@@ -27,6 +27,7 @@ import { peerRosterConfig, staleInstanceKey } from "./roster.ts";
27
27
  import { CHARACTER_IDS } from "../pet/characters.ts";
28
28
  import { autonomyOwnership, checkSettingValue, settingDef } from "./settings.ts";
29
29
  import { petPalette } from "../pet/config.ts";
30
+ import { parseSpecsConfig } from "./specs-config.ts";
30
31
 
31
32
  export type ApplyClass = "live" | "next use" | "restart";
32
33
  export interface ApplyRule { applies: ApplyClass; reason?: string }
@@ -202,6 +203,7 @@ const schema: Record<string, Check> = {
202
203
  pii: block({ paths: strings }), data: block({ readonly: strings }),
203
204
  text_projection: v => { projectionConfig(v); },
204
205
  corpus: v => { corpusConfig(v); },
206
+ specs: (v, k) => { block({ enabled: bool, review: words(["off", "report", "require"]), roots: strings, stamps_file: text })(v, k); parseSpecsConfig(v); },
205
207
  conductor: block({ feed: words(["own", "all", "off"]), approval_wait_s: number(0, 2_147_483_647 / 1000) }),
206
208
  budget: block({ gate: number(0, 1), stale_min: timerMinutes, poll_min: timerMinutes, checkpoint_timeout_s: timerSeconds, kimi_tokens_5h: number(0), wait_max_min: number(0) }),
207
209
  context: block({ gate: number(0, 1), stale_min: timerMinutes, max_assignment_used: number(0, 1) }),
@@ -271,7 +273,7 @@ const restart: Record<string, string> = {
271
273
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
272
274
  text_projection: "Converter and cache ownership are bound at startup", corpus: "The corpus index is bound to its roots at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
273
275
  };
274
- const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet"]);
276
+ const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet", "specs"]);
275
277
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
276
278
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
277
279
  export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
@@ -279,6 +281,7 @@ export function configApply(key: string, scopes: readonly ConfigIdentityScope[]
279
281
  if (!(Object.hasOwn(schema, top) || top === "permission" || top === "routing")) return fail(key, "unknown configuration key");
280
282
  if (top !== "routing" && top !== "permission" && key.includes(".")) {
281
283
  const special: Record<string, readonly string[]> = {
284
+ specs: ["enabled", "review", "roots", "stamps_file"],
282
285
  task_sweep: ["enabled", "auto_reassign", "no_writes_turns", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
283
286
  checks: ["timeout_s", "runner", "remote", ...CLASSES], terminal: ["open"], start: ["agents"],
284
287
  };
@@ -132,7 +132,7 @@ function topLevel(lines: readonly string[]): { end: number; spans: Map<string, [
132
132
  * Edits `mode` and `required_checks` line by line. Every line the edit does not touch keeps its own ending, so a one-key change never
133
133
  * rewrites a CRLF checkout; a line the edit writes or adds takes the ending of the first line break the file has (LF for a new file).
134
134
  */
135
- function editSdlcText(text: string | null, set: ConfigEditSet[]): string | null {
135
+ export function editSdlcText(text: string | null, set: ConfigEditSet[]): string | null {
136
136
  const raw = text === null ? [] : text.split("\n");
137
137
  const trailing = raw.length > 0 && raw.at(-1) === "";
138
138
  if (trailing) raw.pop();
@@ -39,6 +39,7 @@ const isObject = (value: unknown): value is Record<string, unknown> => !!value &
39
39
  export function configTier(file: ConfigEditFile, path: readonly string[], value?: unknown, held?: unknown): ConfigTier {
40
40
  if (!path.length || path.some(part => typeof part !== "string" || !part || part.length > 64 || UNSAFE.has(part) || /[\s\x00-\x1f]/.test(part))) return refuse("invalid configuration path");
41
41
  const key = path.join(".");
42
+ if (file === "config.local.json" && path[0] === "specs" && path.length === 2 && ["enabled", "review", "roots", "stamps_file"].includes(path[1]!)) return { tier: 2 };
42
43
  if (file === "sdlc.toml") return path.length === 1 && (SDLC_KEYS as readonly string[]).includes(path[0]!) ? { tier: 2 } : refuse(`sdlc.${key} is not editable here: only mode and required_checks are; edit .agenthub/sdlc.toml in your editor`);
43
44
  if (registryKey(key)) return { refused: `${key} is already editable with ahub settings set ${key}; nothing was written` };
44
45
  if (path[0] === "research" && path[1] === "runners") {
@@ -1,8 +1,16 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { projectContext } from "./project.ts";
4
+ import { newToolRequestId, TOOL_WRITE_FEATURE, toolWriteOperation, toolWriteUnknownReply, toolWriteRefusedReply } from "./tool-request.ts";
4
5
  import { externalAdapterMetadata, externalInstanceCatalog, type ExternalInstanceCatalog, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
5
6
  import { negotiateProtocol, protocolWindow, type ProtocolWindow } from "./protocol-window.ts";
7
+ import { ControlWriteTelemetry, controlWriteMetadata, controlWriteSpan } from "./control-write-trace.ts";
8
+
9
+ // The client has no authoritative task privacy view. Keep kind/timing only, never its args or task id.
10
+ const clientWriteTelemetry = new ControlWriteTelemetry({ emit: line => {
11
+ if (process.env.AHUB_CONTROL_WRITE_TIMING === "1") console.error(`[agent-hub] ${line}`);
12
+ } });
13
+ const publicClientTiming = () => true;
6
14
 
7
15
  export function stateDirFor(cwd: string): string {
8
16
  return projectContext(cwd).stateDir;
@@ -12,7 +20,8 @@ export function stateDirFor(cwd: string): string {
12
20
  export const PROTOCOL = 17;
13
21
  /** The descriptor's legacy protocol field remains this floor for previous published clients. */
14
22
  export const PROTOCOL_MIN = 16;
15
- export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "research_runners", "task_handoff", "session_identity", "schema_guard"] as const;
23
+ // Literal metadata is read without executing a recovery target; consistency is pinned in tests.
24
+ export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "research_runners", "task_handoff", "session_identity", "schema_guard", "design_specs", "queue_discard_states", "tool_write_idempotency"] as const;
16
25
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
17
26
  export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, 16, PROTOCOL] as const;
18
27
 
@@ -219,22 +228,52 @@ export class ControlClient {
219
228
 
220
229
  /** `timeoutMs`: for calls a person waits on. The hub has its own budget; this one only makes sure the CLI never hangs. */
221
230
  request(msg: Record<string, unknown>, timeoutMs = 30_000, signal?: AbortSignal): Promise<any> {
231
+ if (process.env.AHUB_CONTROL_WRITE_TIMING !== "1") return this.requestBody(msg, timeoutMs, signal);
232
+ const metadata = controlWriteMetadata(msg);
233
+ if (!metadata) return this.requestBody(msg, timeoutMs, signal);
234
+ return clientWriteTelemetry.run({ kind: metadata.kind }, publicClientTiming, () => controlWriteSpan("client-wait", () =>
235
+ this.requestBody(msg, timeoutMs, signal)));
236
+ }
237
+
238
+ private requestBody(msg: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<any> {
222
239
  if (this.ws.readyState !== WebSocket.OPEN || this.closing && msg.t !== "request_cancel") return Promise.resolve({ ok: false, error: "hub connection is not open" });
223
240
  const invocation = msg.t === "task" && msg.op === "hub_invoke";
241
+ const operation = toolWriteOperation(msg), write = operation !== undefined;
242
+ const keyedWrite = write && operation !== "hub_delivery_done";
243
+ if (keyedWrite && msg.args && typeof msg.args === "object" && !Array.isArray(msg.args) && Object.hasOwn(msg.args, "request_id")) {
244
+ const { request_id: key, ...args } = msg.args as Record<string, unknown>;
245
+ if (typeof key !== "string" || !key || key.length > 256 || /[\p{Cc}\p{Cf}]/u.test(key)
246
+ || msg.requestId !== undefined && msg.requestId !== key) {
247
+ return Promise.resolve(toolWriteRefusedReply(msg, undefined, "request_id must be a non-empty echoed request key and agree with requestId"));
248
+ }
249
+ msg = { ...msg, args, requestId: key };
250
+ }
251
+ if (keyedWrite && msg.requestId !== undefined && !this.features.includes(TOOL_WRITE_FEATURE)) {
252
+ return Promise.resolve(toolWriteRefusedReply(msg, typeof msg.requestId === "string" ? msg.requestId : undefined,
253
+ "this daemon does not advertise replay protection; nothing was resubmitted"));
254
+ }
255
+ let wire = msg;
256
+ if (keyedWrite && this.features.includes(TOOL_WRITE_FEATURE) && msg.requestId === undefined) {
257
+ try { wire = { ...msg, requestId: newToolRequestId(this.instanceId ?? "") }; }
258
+ catch { return Promise.resolve({ ok: false, error: "write request identity is unavailable; inspect ahub status before retrying" }); }
259
+ }
260
+ const requestId = typeof wire.requestId === "string" ? wire.requestId : undefined;
261
+ const uncertain = (cause: string) => toolWriteUnknownReply(wire, requestId,
262
+ this.features.includes(TOOL_WRITE_FEATURE) ? cause : `${cause}; this daemon does not advertise replay protection`);
224
263
  timeoutMs = controlRequestTimeout(msg, timeoutMs);
225
264
  if (signal?.aborted) return Promise.resolve({ ok: false, error: "hub request cancelled before sending" });
226
265
  const rid = this.nextRid++;
227
266
  return new Promise((resolve) => {
228
267
  let timer: ReturnType<typeof setTimeout> | undefined;
229
268
  const done = (reply: any) => {
230
- this.pending.delete(rid); this.invocations.delete(rid); clearTimeout(timer); signal?.removeEventListener("abort", aborted); resolve(reply);
269
+ this.pending.delete(rid); this.invocations.delete(rid); clearTimeout(timer); signal?.removeEventListener("abort", aborted); resolve(write && reply?.error === "hub connection closed" ? uncertain(reply.error) : reply);
231
270
  };
232
271
  const abandon = (error: string) => {
233
272
  if (!this.pending.has(rid)) return;
234
273
  if (invocation && this.ws.readyState === WebSocket.OPEN) {
235
274
  try { this.ws.send(JSON.stringify({ t: "request_cancel", request: rid })); } catch { /* Socket close also withdraws admission. */ }
236
275
  }
237
- done({ ok: false, error: invocation ? `${error}; invocation admission unconfirmed, inspect before retrying` : error });
276
+ done(write ? uncertain(error) : { ok: false, error: invocation ? `${error}; invocation admission unconfirmed, inspect before retrying` : error });
238
277
  };
239
278
  const aborted = () => abandon("hub request cancelled");
240
279
  this.pending.set(rid, done);
@@ -242,7 +281,7 @@ export class ControlClient {
242
281
  signal?.addEventListener("abort", aborted, { once: true });
243
282
  if (signal?.aborted) { aborted(); return; }
244
283
  timer = timeoutMs ? setTimeout(() => abandon(`no answer from the hub within ${Math.round(timeoutMs / 1000)} s`), timeoutMs) : undefined;
245
- try { this.ws.send(JSON.stringify({ ...msg, rid })); }
284
+ try { controlWriteSpan("client-send", () => this.ws.send(JSON.stringify({ ...wire, rid }))); }
246
285
  catch { abandon("hub request could not be sent"); }
247
286
  });
248
287
  }