@staix/agent-hub 0.12.43 → 0.12.45

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 (180) hide show
  1. package/CHANGELOG.md +38 -3
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +228 -0
  5. package/docs/agent-notes/README.md +14 -0
  6. package/docs/agent-notes/adapters.md +61 -42
  7. package/docs/agent-notes/budget.md +2 -2
  8. package/docs/agent-notes/daemon.md +16 -10
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +13 -6
  11. package/docs/agent-notes/tests.md +9 -7
  12. package/docs/operations.md +163 -12
  13. package/docs/quickstart.md +10 -2
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +11 -6
  16. package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  19. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  20. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  21. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  22. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  23. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  24. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  25. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  27. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  28. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  29. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  30. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  35. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  36. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  39. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  44. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  55. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  56. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  57. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  60. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  61. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  62. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  68. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  71. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  72. package/docs/verified.json +38 -20
  73. package/package.json +5 -1
  74. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  75. package/plugins/agent-hub/server.js +10287 -9560
  76. package/src/adapters/acp-owned-group.ts +124 -0
  77. package/src/adapters/acp.ts +92 -25
  78. package/src/adapters/adapter-api.ts +2 -0
  79. package/src/adapters/agy/definition.ts +4 -4
  80. package/src/adapters/agy/entry.ts +4 -1
  81. package/src/adapters/channel-peer.ts +143 -0
  82. package/src/adapters/claude-channel.ts +21 -12
  83. package/src/adapters/codex-appserver.ts +17 -2
  84. package/src/adapters/configured-acp.ts +124 -0
  85. package/src/adapters/conformance/acp.ts +114 -0
  86. package/src/adapters/conformance/codex.ts +191 -0
  87. package/src/adapters/conformance/enrollment.ts +43 -0
  88. package/src/adapters/conformance/factories.ts +17 -0
  89. package/src/adapters/conformance/fake-paths.ts +12 -0
  90. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  91. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  92. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  93. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  94. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  95. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  96. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  97. package/src/adapters/conformance/index.ts +11 -0
  98. package/src/adapters/conformance/kit.ts +50 -0
  99. package/src/adapters/conformance/local-pi.ts +100 -0
  100. package/src/adapters/conformance/owned-acp.ts +44 -0
  101. package/src/adapters/definitions/builtin-metadata.ts +60 -39
  102. package/src/adapters/definitions/native-channel-state.ts +41 -0
  103. package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
  104. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  105. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  106. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  107. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  108. package/src/adapters/definitions/qwen.ts +7 -9
  109. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  110. package/src/adapters/external-metadata.ts +48 -0
  111. package/src/adapters/external-module-scan.ts +126 -0
  112. package/src/adapters/external-modules.ts +240 -0
  113. package/src/adapters/jcode.ts +4 -0
  114. package/src/adapters/local-worker.ts +4 -0
  115. package/src/adapters/pi.ts +6 -0
  116. package/src/adapters/public.ts +7 -0
  117. package/src/adapters/registry-types.ts +28 -16
  118. package/src/adapters/registry.ts +115 -17
  119. package/src/adapters/reviewer-print.ts +4 -0
  120. package/src/cli/agy-mcp.ts +3 -2
  121. package/src/cli/configured-acp-entry.ts +58 -0
  122. package/src/cli/console-state.ts +9 -4
  123. package/src/cli/facts-hook.ts +2 -1
  124. package/src/cli/help.ts +27 -16
  125. package/src/cli/identity.ts +8 -7
  126. package/src/cli/launch.ts +5 -4
  127. package/src/cli/main.ts +101 -32
  128. package/src/cli/memory.ts +13 -10
  129. package/src/cli/output.ts +1 -0
  130. package/src/cli/preview.ts +9 -0
  131. package/src/cli/recovery-guidance.ts +1 -1
  132. package/src/cli/terminal-recovery.ts +16 -13
  133. package/src/cli/upgrade-labels.ts +1 -1
  134. package/src/cli/upgrade-presentation.ts +1 -1
  135. package/src/cli/upgrade-restore-state.ts +6 -4
  136. package/src/cli/upgrade-runtime.ts +25 -7
  137. package/src/cli/upgrade.ts +3 -2
  138. package/src/hub/board.ts +37 -8
  139. package/src/hub/budget.ts +9 -2
  140. package/src/hub/bus.ts +6 -2
  141. package/src/hub/claude-state.ts +2 -37
  142. package/src/hub/conductor.ts +8 -6
  143. package/src/hub/config-apply.ts +37 -17
  144. package/src/hub/config-reload.ts +42 -11
  145. package/src/hub/config-trust.ts +1 -1
  146. package/src/hub/context-window.ts +6 -0
  147. package/src/hub/control-client.ts +4 -1
  148. package/src/hub/crash.ts +8 -6
  149. package/src/hub/daemon.ts +412 -328
  150. package/src/hub/facts.ts +76 -13
  151. package/src/hub/hub-tools.ts +57 -5
  152. package/src/hub/instance-config.ts +33 -12
  153. package/src/hub/instance-execution.ts +23 -11
  154. package/src/hub/instances.ts +47 -22
  155. package/src/hub/lifecycle-schema.ts +5 -3
  156. package/src/hub/memory-profile.ts +16 -3
  157. package/src/hub/no-writes.ts +50 -0
  158. package/src/hub/peers.ts +9 -0
  159. package/src/hub/permission-mode.ts +9 -7
  160. package/src/hub/plan-conformance.ts +83 -0
  161. package/src/hub/review-findings.ts +104 -0
  162. package/src/hub/roster.ts +35 -6
  163. package/src/hub/routing.ts +21 -20
  164. package/src/hub/sdlc-queue.ts +19 -4
  165. package/src/hub/sdlc-release.ts +16 -1
  166. package/src/hub/sdlc.ts +27 -12
  167. package/src/hub/settings.ts +54 -16
  168. package/src/hub/snapshots.ts +32 -6
  169. package/src/hub/start-mode.ts +10 -8
  170. package/src/hub/task-sweep.ts +8 -2
  171. package/src/hub/tasks.ts +358 -60
  172. package/src/hub/ui.ts +2 -2
  173. package/src/memory/capture.ts +2 -1
  174. package/src/ui/index.html +563 -77
  175. package/src/ui/office-model.ts +3 -1
  176. package/src/ui/peer-palette.ts +44 -0
  177. package/templates/AGENTS.block.md +1 -1
  178. package/templates/profiles/general.json +1 -1
  179. package/templates/profiles/research.json +1 -1
  180. package/templates/profiles/writing.json +1 -1
package/src/hub/facts.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { repoGitArgs, repoGitEnv, type Repo } from "./snapshots.ts";
2
2
  import { isHubWritePath } from "./permission-mode.ts";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync, type Stats } from "node:fs";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { sanitize, type PeerId } from "./envelope.ts";
7
7
  import { isDenied } from "../local/deny.ts";
@@ -24,6 +24,22 @@ import { realPath } from "./project.ts";
24
24
  const MAX_BYTES = 256 * 1024;
25
25
  /** Larger files that are not read are hashed for the HEAD comparison while all of them in one comparison total this much or less; beyond it, none is compared. */
26
26
  const HASHED_BYTES = 64 * 1024 * 1024;
27
+ /**
28
+ * A large file whose last change (ctime) is this recent may still be rewritten with the same stat: Linux before 6.13
29
+ * stamps files from a coarse clock, so a same-size rewrite within one tick keeps size, mtime, ctime and inode (#428).
30
+ * Such a file is named by its bytes, as git treats a racily clean entry: at most RACY_HASH_MAX_BYTES each, hashed once
31
+ * per version in a public entry point, RACY_BURST_BYTES in all per store (Facts instance) per event-loop turn (the hooks
32
+ * run on the daemon's loop). The HEAD comparison keeps its own bound (HASHED_BYTES) and is not limited by these.
33
+ * ponytail: ceilings, each falling back to the stat-only stamp (no claim either way): a racy file over the per-file cap,
34
+ * or past the turn's budget, misses a same-size rewrite within one timestamp tick; a racy file first seen past the
35
+ * budget (kept stat-only) reads as changed once when a later turn hashes it with the same bytes; a file written continuously never settles and is hashed at every look, within that budget; FAT's 2 s mtime
36
+ * is the widest covered; a settled file made racy with the same bytes (chmod, `cp -p`, touch) reads as changed once; a
37
+ * version first seen racy costs an open and a stat at every later look (largeBlob's cache answers). A per-file history
38
+ * of racy versions if any of these matters.
39
+ */
40
+ const RACY_MS = 2000;
41
+ const RACY_HASH_MAX_BYTES = 4 * 1024 * 1024;
42
+ const RACY_BURST_BYTES = 16 * 1024 * 1024;
27
43
  /** Changed lines shown in one fact, all files together; the rest is counted and the files named. */
28
44
  export const MAX_LINES = 60;
29
45
  /** What Claude Code's Read returns by default: the first 2000 lines, each cut at 2000 characters. */
@@ -145,6 +161,10 @@ export class Facts {
145
161
  private readonly seenDropped = new Map<PeerId, Set<string>>();
146
162
  /** Git blob ids of large files, by path, with the stat they were taken at. */
147
163
  private readonly largeBlobs = new Map<string, { stamp: string; blob: string }>();
164
+ /** Blob ids of racy large files hashed in the current boundary, by path and stat (#428). */
165
+ private racyMemo = new Map<string, string>();
166
+ /** Bytes of racy large files left to hash in this event-loop turn. */
167
+ private racyLeft?: { bytes: number };
148
168
  /** Directories each peer acknowledged being told were cut: once per session, not at every boundary. */
149
169
  private readonly cutTold = new Map<PeerId, Set<string>>();
150
170
  /**
@@ -217,7 +237,7 @@ export class Facts {
217
237
  * ponytail: a directory swapped between the containment check and the open is followed once; an fd-relative walk
218
238
  * (openat per component) closes that if it matters.
219
239
  */
220
- private load(file: string): Omit<Version, "seq"> {
240
+ private load(file: string, statOnly = false): Omit<Version, "seq"> {
221
241
  if (this.rel(join(this.root, file)) !== file) return { hash: "missing" };
222
242
  const projected = this.o.projection?.(join(this.root, file));
223
243
  if (projected) return { hash: projected.rawHash, blob: projected.rawBlob, text: projected.text, projected: true };
@@ -226,7 +246,10 @@ export class Facts {
226
246
  fd = openSync(join(this.root, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
227
247
  const st = fstatSync(fd);
228
248
  if (!st.isFile()) return { hash: "missing" }; // a directory, a device or a fifo: never read
229
- if (st.size > MAX_BYTES) return { hash: `large:${st.size}:${st.mtimeMs}` };
249
+ if (st.size > MAX_BYTES) {
250
+ const blob = statOnly ? undefined : this.racyBlob(file, fd, st);
251
+ return blob ? { hash: `large:${st.size}:${st.mtimeMs}:${blob}`, blob } : { hash: `large:${st.size}:${st.mtimeMs}` };
252
+ }
230
253
  const buf = Buffer.alloc(st.size);
231
254
  let got = 0;
232
255
  while (got < buf.length) {
@@ -317,13 +340,17 @@ export class Facts {
317
340
  const seen = files.filter((f) => head.has(f)).map((f) => [f, version(f)] as const);
318
341
  const large = seen.reduce((n, [, v]) => n + (v?.hash.startsWith("large:") ? Number(v.hash.split(":")[1]) : 0), 0);
319
342
  return new Set(seen.filter(([f, v]) => {
320
- const blob = v?.blob ?? (v?.hash.startsWith("large:") && large <= HASHED_BYTES ? this.largeBlob(f, v.hash) : undefined);
343
+ const blob = !v?.hash.startsWith("large:") ? v?.blob : large <= HASHED_BYTES ? v.blob ?? this.largeBlob(f, v.hash, true) : undefined;
321
344
  return blob !== undefined && blob === head.get(f);
322
345
  }).map(([f]) => f));
323
346
  }
324
347
 
325
348
  /** Git's blob id of a file too large to load, streamed; undefined for anything that is not a small enough regular file. */
326
- private largeBlob(file: string, observed: string): string | undefined {
349
+ /**
350
+ * `head`: for the HEAD comparison, which bounds its own hashing (HASHED_BYTES): a racy file is streamed whatever the
351
+ * racy cap and the turn's budget, uncached. Otherwise a racy file goes through racyBlob's limits.
352
+ */
353
+ private largeBlob(file: string, observed: string, head = false): string | undefined {
327
354
  if (this.rel(join(this.root, file)) !== file) return undefined;
328
355
  let fd: number | undefined;
329
356
  try {
@@ -333,13 +360,10 @@ export class Facts {
333
360
  // A file that stays stat-dirty but equal to HEAD is looked at again at every boundary: hashed once per version.
334
361
  const stamp = `${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`, known = this.largeBlobs.get(file);
335
362
  if (known?.stamp === stamp) return known.blob;
336
- const h = createHash("sha1").update(`blob ${st.size}\0`);
337
- const buf = Buffer.alloc(1024 * 1024);
338
- let got = 0;
339
- for (let n = 0; got < st.size && (n = readSync(fd, buf, 0, buf.length, got)) > 0; got += n) h.update(buf.subarray(0, n));
340
- if (got !== st.size) return undefined; // changed while read: no claim
341
- const blob = h.digest("hex");
342
- this.largeBlobs.set(file, { stamp, blob });
363
+ // A racy stamp may name other bytes later: never cached.
364
+ if (Date.now() - st.ctimeMs < RACY_MS) return head ? this.racyMemo.get(racyKey(file, st)) ?? streamBlob(fd, st.size) : this.racyBlob(file, fd, st);
365
+ const blob = streamBlob(fd, st.size);
366
+ if (blob) this.largeBlobs.set(file, { stamp, blob });
343
367
  return blob;
344
368
  } catch {
345
369
  return undefined;
@@ -348,6 +372,22 @@ export class Facts {
348
372
  }
349
373
  }
350
374
 
375
+ /** A racy large file's blob id (#428), or undefined when it is settled, over the per-file cap or past the turn's budget. */
376
+ private racyBlob(file: string, fd: number, st: Stats): string | undefined {
377
+ if (Date.now() - st.ctimeMs >= RACY_MS || st.size > RACY_HASH_MAX_BYTES) return undefined;
378
+ const key = racyKey(file, st), known = this.racyMemo.get(key);
379
+ if (known) return known;
380
+ if (!this.racyLeft) {
381
+ const turn = this.racyLeft = { bytes: RACY_BURST_BYTES };
382
+ setImmediate(() => { if (this.racyLeft === turn) this.racyLeft = undefined; });
383
+ }
384
+ if (st.size > this.racyLeft.bytes) return undefined;
385
+ this.racyLeft.bytes -= st.size;
386
+ const blob = streamBlob(fd, st.size);
387
+ if (blob) this.racyMemo.set(key, blob);
388
+ return blob;
389
+ }
390
+
351
391
  /** `file` is covered again: an offer still unread that named it as dropped must not spend the notice of a later drop. */
352
392
  private coveredAgain(peer: PeerId, notice: "untracked" | "unfollowed", file: string): void {
353
393
  for (const o of this.offers.get(peer) ?? []) if (o[notice]?.includes(file)) o[notice] = o[notice]!.filter((f) => f !== file);
@@ -357,6 +397,7 @@ export class Facts {
357
397
  private boundary(): void {
358
398
  this.expanded = new Map();
359
399
  this.expandedCut = new Set();
400
+ this.racyMemo = new Map(); // not per turn: a rewrite between two entry points of one turn is another version
360
401
  }
361
402
 
362
403
  /** `peer` worked on `file`; `seen` is the file as it was when it did, the baseline if it has no view of it yet. */
@@ -463,6 +504,12 @@ export class Facts {
463
504
  const now = this.load(file);
464
505
  const was = this.latest.get(file);
465
506
  if (was && was.hash === now.hash) return was;
507
+ // Seen while racy (named by its bytes), now with the same stat but no blob (settled, or past the turn's budget): the
508
+ // same version if the bytes are, or when they cannot be hashed now (no claim).
509
+ if (was?.blob && was.hash === `${now.hash}:${was.blob}`) {
510
+ const blob = this.largeBlob(file, now.hash);
511
+ if (blob === undefined || blob === was.blob) return was;
512
+ }
466
513
  const version = { ...now, seq: ++this.seq };
467
514
  this.latest.set(file, version);
468
515
  if (was) {
@@ -720,7 +767,9 @@ export class Facts {
720
767
  // does, nor what they wrote before or after (a member settling keeps its files in the set).
721
768
  const written = windows.flatMap((w) => [...(this.writes.get(w.peer) ?? [])].filter(([, times]) => times.some((t) => t >= w.since && (w.until === undefined || t <= w.until))).map(([file]) => file));
722
769
  const files = [...new Set([...this.expand(paths.flatMap((p) => { const r = this.rel(p); return r ? [r] : []; }), true), ...written])].sort();
723
- for (const file of files) h.update(file).update("\0").update(this.load(file).hash).update("\0");
770
+ // ponytail: a large file counts by its stat, racy or not, so the target does not move when it settles; a same-size
771
+ // rewrite within one timestamp tick does not move it either (observe() sees that one).
772
+ for (const file of files) h.update(file).update("\0").update(this.load(file, true).hash).update("\0");
724
773
  return h.digest("hex");
725
774
  }
726
775
 
@@ -871,3 +920,17 @@ export function codexEffect(before: string | undefined, now: { hash: string; tex
871
920
  const observed = changedLines(diff(before, now.text));
872
921
  return reported.length > 0 && reported.length === observed.length && reported.every((l, i) => l === observed[i]);
873
922
  }
923
+
924
+ /** Git's blob id of the `size` bytes `fd` reads, streamed; undefined when fewer were read (changed while read). */
925
+ function streamBlob(fd: number, size: number): string | undefined {
926
+ const h = createHash("sha1").update(`blob ${size}\0`);
927
+ const buf = Buffer.alloc(1024 * 1024);
928
+ let got = 0;
929
+ for (let n = 0; got < size && (n = readSync(fd, buf, 0, buf.length, got)) > 0; got += n) h.update(buf.subarray(0, n));
930
+ return got === size ? h.digest("hex") : undefined;
931
+ }
932
+
933
+ /** A racy large file's version within one boundary: its path and every stat field. */
934
+ function racyKey(file: string, st: Stats): string {
935
+ return `${file}\0${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`;
936
+ }
@@ -17,10 +17,26 @@ const refs = {
17
17
  additionalProperties: false,
18
18
  } as const;
19
19
  const list = (description: string) => ({ type: "array", items: str, description }) as const;
20
+ const reviewFindings = {
21
+ type: "array", maxItems: 64,
22
+ description: "Head-bound findings; open Important findings carry across heads until explicitly fixed, false_positive or person-accepted; omission never settles them. Nits stay on their head. accepted is person-only and requires rationale. Nit count is capped by sdlc.max_nits (default 5).",
23
+ items: { type: "object", properties: {
24
+ id: { type: "string", minLength: 1, maxLength: 96, pattern: "^[A-Za-z0-9._:-]+$" },
25
+ pass: { type: "string", enum: ["bugs", "security", "compliance"] },
26
+ severity: { type: "string", enum: ["important", "nit"] },
27
+ path: { type: "string", minLength: 1, maxLength: 512 },
28
+ disposition: { type: "string", enum: ["open", "fixed", "false_positive", "accepted"] },
29
+ rationale: { type: "string", minLength: 1, maxLength: 2048 },
30
+ }, required: ["id", "pass", "severity", "disposition"], additionalProperties: false },
31
+ } as const;
20
32
  const costPlanProperties = {
21
33
  review: { type: "object", properties: { scope: { type: "string", enum: ["none", "delta", "full"] }, reviewers: { type: "integer", minimum: 0, maximum: 8 } }, required: ["scope", "reviewers"], additionalProperties: false },
22
34
  ci: { type: "string", enum: ["focused", "full", "reuse"] },
23
35
  } as const;
36
+ const evidencePlanProperties = {
37
+ proof: { type: "array", maxItems: 32, items: { type: "string", maxLength: 512 }, description: "test paths or check names planned to prove the change" },
38
+ risks: { type: "array", maxItems: 32, items: { type: "string", maxLength: 512 }, description: "short risks to check during implementation and review" },
39
+ } as const;
24
40
  const plan = {
25
41
  type: "object",
26
42
  description: "what you will change, before you start. Owners of open tasks on the same files or symbols see it, and get a notice when your task is done.",
@@ -75,7 +91,7 @@ export const TASK_TOOLS: HubTool[] = [
75
91
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
76
92
  tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
77
93
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
78
- tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" }, coverage: { type: "object", description: "the review brief's reviewable paths you read (reviewed) and any you did not (skipped, each with a reason); in enforce an approval must account for every one", properties: { reviewed: { type: "array", items: str }, skipped: { type: "array", items: { type: "object", properties: { path: str, reason: str }, required: ["path", "reason"] } } } } }, ["id", "verdict"]),
94
+ tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, findings: reviewFindings, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" }, coverage: { type: "object", description: "the review brief's reviewable paths you read (reviewed) and any you did not (skipped, each with a reason); in enforce an approval must account for every one", properties: { reviewed: { type: "array", items: str }, skipped: { type: "array", items: { type: "object", properties: { path: str, reason: str }, required: ["path", "reason"] } } } } }, ["id", "verdict"]),
79
95
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
80
96
  tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
81
97
  tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
@@ -105,6 +121,42 @@ export const CONDUCTOR_TOOLS: HubTool[] = [
105
121
  ];
106
122
  export const CONDUCTOR_TOOL_NAMES = new Set(CONDUCTOR_TOOLS.map((t) => t.name));
107
123
 
124
+ /** Authenticated status metadata only; malformed values never add a tool target. */
125
+ function projectPeerIds(value: unknown): string[] | undefined {
126
+ if (!Array.isArray(value) || value.length > 256 || value.some(id => typeof id !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(id) || ["user", "hub", "digest"].includes(id)) || new Set(value).size !== value.length) return undefined;
127
+ return [...value] as string[];
128
+ }
129
+
130
+ /** A project-local schema projection; released builtin arrays and all other tools keep their identity. */
131
+ export function projectConductorTools(startPeers?: unknown): HubTool[] {
132
+ const peers = startPeers === undefined ? undefined : projectPeerIds(startPeers);
133
+ if (peers === undefined) return CONDUCTOR_TOOLS;
134
+ return CONDUCTOR_TOOLS.map(tool => tool.name !== "hub_peer_start" ? tool : {
135
+ ...tool, inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties,
136
+ peer: { ...(tool.inputSchema.properties.peer as Record<string, unknown>), enum: [...new Set([...CONDUCTOR_START_PEERS, ...peers])] },
137
+ } },
138
+ });
139
+ }
140
+
141
+ export interface ProjectToolContext { startPeers?: string[]; readOnly?: boolean }
142
+ /** Ordinary projects retain their exact prior publication context; configured metadata is body-free and bounded. */
143
+ export function configuredToolContext(status: unknown, peer: string): ProjectToolContext {
144
+ if (!status || typeof status !== "object" || Array.isArray(status)) return {};
145
+ const raw = status as Record<string, unknown>, configured = projectPeerIds(raw.configuredAdapters);
146
+ if (!configured?.length) return {};
147
+ const startPeers = projectPeerIds(raw.startablePeers), readOnlyPeers = projectPeerIds(raw.readOnlyPeers);
148
+ // With an incomplete configured-project reading, publish only R2 reads until authenticated status is complete.
149
+ if (!startPeers || !readOnlyPeers) return { readOnly: true };
150
+ return { startPeers, readOnly: readOnlyPeers.includes(peer) };
151
+ }
152
+
153
+ const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_status"]);
154
+ /** Publication restricts exposure only; server-side authority remains mandatory for every call. */
155
+ export function projectToolList<T extends { name: string }>(tools: T[], readOnly = false): T[] {
156
+ return readOnly ? tools.filter(tool => READ_ONLY_PROJECT_TOOLS.includes(tool.name)) : tools;
157
+ }
158
+
159
+
108
160
  /** Role contracts, by role name. Shown to each peer for the roles `.agenthub/config.json` gives it. */
109
161
  export const ROLE_TEXT: Record<string, string> = {
110
162
  conductor: "conductor: plan and split work into tasks with owners, watch the team with hub_status, move stalled work, and ensure every task is reviewed (review it yourself only if you also hold reviewer). Report results and open decisions to the person. Do not implement tasks you handed out. Never ask a peer to answer an approval; ask the person for human-only actions. You may release only holds you placed, never human holds or budget pauses.",
@@ -144,19 +196,19 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
144
196
  properties.receipt = { ...receipt, properties: fields };
145
197
  }
146
198
  if (customProfile && tool.name === "hub_task_propose") properties.class = { type: "string", enum: [...new Set([...Object.keys(data.classes), ...PROFILE_CLASS_NAMES.filter(v => projectProfile("dev").classes[v])])] };
147
- if (customProfile && properties.plan && tool.name !== "hub_task_accept") properties.plan = { ...plan, properties: Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) };
199
+ if (customProfile && properties.plan && tool.name !== "hub_task_accept") properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
148
200
  if (customProfile && tool.name === "hub_backlog_split") {
149
201
  const parts = properties.parts as { items: { properties: Record<string, unknown> } };
150
- const selectedPlan = { ...plan, properties: Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) };
202
+ const selectedPlan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
151
203
  properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
152
204
  }
153
205
  if (criteria && properties.plan) {
154
206
  const current = properties.plan as { properties: Record<string, unknown> };
155
- properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties } };
207
+ properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties, ...evidencePlanProperties } };
156
208
  }
157
209
  if (criteria && tool.name === "hub_backlog_split") {
158
210
  const parts = properties.parts as { items: { properties: { plan: { properties: Record<string, unknown> } } } };
159
- properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: { ...parts.items.properties.plan, properties: { ...parts.items.properties.plan.properties, ...costPlanProperties } } } } };
211
+ properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: { ...parts.items.properties.plan, properties: { ...parts.items.properties.plan.properties, ...costPlanProperties, ...evidencePlanProperties } } } } };
160
212
  }
161
213
  return { ...tool, ...(customProfile && tool.name === "hub_task_propose" ? { description: tool.description + ` Selected profile ${data.name}: ${Object.keys(data.classes).join(", ")}. Routing uses their existing base classes.` } : {}), inputSchema: { ...tool.inputSchema, properties } };
162
214
  });
@@ -1,6 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { defaultPeerRoles } from "../adapters/registry.ts";
3
+ import { createProjectAdapterCatalog, defaultPeerRoles, definitionOf, type ProjectAdapterCatalog, type AdapterDefinition } from "../adapters/registry.ts";
4
+ import { readAdapterModuleDeclarations, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
5
+ import { externalMetadataDefinitions } from "../adapters/external-metadata.ts";
6
+ import { readControl, stateDirFor } from "./control-client.ts";
4
7
  import { conductorPeer } from "./conductor.ts";
5
8
  import { configRefusal } from "./config-trust.ts";
6
9
  import { validateInstances, type InstancesConfig } from "./instances.ts";
@@ -17,24 +20,42 @@ export function readInstanceConfigLayers(cwd: string): InstanceConfigLayer[] {
17
20
  }
18
21
 
19
22
  /** Declaration authority is file provenance, never a suffix or a claim made by a native child. */
20
- export function readInstanceConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): { instances?: InstancesConfig; ignored: string[] } {
21
- const ignored: string[] = [];
22
- let instances: InstancesConfig | undefined;
23
+ export interface InstanceRoleConfig {
24
+ adapter_modules?: readonly AdapterModuleDeclaration[];
25
+ adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
26
+ instances?: InstancesConfig;
27
+ catalog: ProjectAdapterCatalog;
28
+ ignored: string[];
29
+ }
30
+ export function readInstanceConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd), externalDefinitions?: readonly AdapterDefinition[]): InstanceRoleConfig {
31
+ const moduleConfig = readAdapterModuleDeclarations(cwd, layers);
32
+ const ignored: string[] = [...moduleConfig.ignored];
33
+ let adapters: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>> | undefined, rawInstances: unknown;
23
34
  for (const { name, raw } of layers) {
24
- const checked = validateInstances(raw.instances);
25
- if (!Object.keys(checked).length) continue;
26
- if (name !== "config.local.json") { ignored.push(`instances in .agenthub/${name} ignored: instances are machine-local config.local.json declarations`); continue; }
27
- const refusal = configRefusal(cwd, name);
28
- if (refusal) ignored.push(`instances in .agenthub/${name} ignored: ${refusal}`);
29
- else instances = checked;
35
+ const keys = ["adapters", "instances"].filter(key => raw[key] !== undefined);
36
+ if (!keys.length) continue;
37
+ const refusal = name !== "config.local.json" ? "declarations are machine-local config.local.json values" : configRefusal(cwd, name);
38
+ if (refusal) { ignored.push(`${keys.join(", ")} in .agenthub/${name} ignored: ${refusal}`); continue; }
39
+ if (raw.adapters !== undefined) adapters = raw.adapters as Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
40
+ if (raw.instances !== undefined) rawInstances = raw.instances;
41
+ }
42
+ let external = externalDefinitions;
43
+ if (external === undefined) {
44
+ // Metadata discovery is optional: retained recovery receipts may name a project that no longer exists.
45
+ let metadata;
46
+ try { metadata = readControl(stateDirFor(cwd))?.externalAdapters; } catch { /* no running catalog available */ }
47
+ external = externalMetadataDefinitions(metadata);
30
48
  }
31
- return { ...(instances ? { instances } : {}), ignored };
49
+ const catalog = createProjectAdapterCatalog(adapters, external);
50
+ const instances = validateInstances(rawInstances, catalog);
51
+ return { ...(moduleConfig.modules.length ? { adapter_modules: moduleConfig.modules } : {}), ...(adapters !== undefined ? { adapters } : {}), ...(Object.keys(instances).length ? { instances } : {}), catalog, ignored };
32
52
  }
33
53
 
34
54
  /** Capture ordinary role policy and trusted instance declarations together, without loading daemon configuration. */
35
- export function readInstanceRoleConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): { instances?: InstancesConfig; roles: Record<string, string[]>; ignored: string[] } {
55
+ export function readInstanceRoleConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): InstanceRoleConfig & { roles: Record<string, string[]> } {
36
56
  const declarationConfig = readInstanceConfig(cwd, layers);
37
57
  const roles = defaultPeerRoles(false);
58
+ for (const definition of declarationConfig.catalog.definitions) if (!definitionOf(definition.kind)) roles[definition.kind] = [...definition.roles];
38
59
  for (const name of INSTANCE_CONFIG_FILES) {
39
60
  const raw = layers.find(layer => layer.name === name)?.raw;
40
61
  if (raw?.roles !== undefined) {
@@ -1,23 +1,35 @@
1
1
  import { lstatSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
- import { definitionOf } from "../adapters/registry.ts";
3
+ import type { ProjectAdapterCatalog } from "../adapters/registry.ts";
4
4
  import { hubGitSync } from "./git.ts";
5
5
  import { realPath } from "./project.ts";
6
6
  import { validateInstances, type InstancesConfig } from "./instances.ts";
7
7
 
8
8
  const refuse = (reason: string): never => { throw new Error(`instance execution workspace refused: ${reason}`); };
9
- const GIT_TIMEOUT_MS = 5000, GIT_OUTPUT_BYTES = 1024 * 1024;
10
- function git(cwd: string, args: string[]): string {
11
- const result = hubGitSync(["-C", cwd, ...args], { encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_BYTES });
12
- if (result.error || result.status !== 0) return refuse("Git workspace evidence unavailable");
13
- return result.stdout;
9
+ // #225: this runs synchronously on the daemon's event loop (an extra-instance start, turn_revert), and a resolution makes up to four git
10
+ // calls. One allowance covers all of them, so the loop is held for at most GIT_BUDGET_MS in git however many calls remain; a call that
11
+ // finds the allowance spent refuses without starting git. ponytail: the calls stay synchronous because the CLI and recovery callers are
12
+ // synchronous by contract; an async twin for the two daemon call sites is the upgrade if a typical resolution ever costs more than a few ms
13
+ // (measured about 15 ms for four calls on a warm local repository).
14
+ const GIT_BUDGET_MS = 5000, GIT_OUTPUT_BYTES = 1024 * 1024;
15
+ type Gitter = (cwd: string, args: string[]) => string;
16
+ function gitWithin(budgetMs: number, clock: () => number, run: typeof hubGitSync = hubGitSync): Gitter {
17
+ const deadline = clock() + budgetMs;
18
+ return (cwd, args) => {
19
+ const left = Math.floor(deadline - clock());
20
+ if (left <= 0) return refuse("Git workspace evidence unavailable");
21
+ const result = run(["-C", cwd, ...args], { encoding: "utf8", timeout: left, maxBuffer: GIT_OUTPUT_BYTES });
22
+ if (result.error || result.status !== 0) return refuse("Git workspace evidence unavailable");
23
+ return result.stdout;
24
+ };
14
25
  }
15
- const commonDirectory = (cwd: string): string => realPath(git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).replace(/\n$/, ""));
26
+ const commonDirectory = (git: Gitter, cwd: string): string => realPath(git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).replace(/\n$/, ""));
16
27
 
17
28
  /** Point-in-time read only. No worktree is created, repaired or chosen implicitly. */
18
- export function resolveInstanceExecutionCwd(projectRoot: string, id: string, declarations: InstancesConfig | undefined, roles: readonly string[]): string {
19
- if (definitionOf(id)) return projectRoot;
20
- const declaration = validateInstances(declarations)[id];
29
+ export function resolveInstanceExecutionCwd(projectRoot: string, id: string, declarations: InstancesConfig | undefined, roles: readonly string[], adapterCatalog: ProjectAdapterCatalog, budgetMs = GIT_BUDGET_MS, clock: () => number = () => performance.now(), run: typeof hubGitSync = hubGitSync): string {
30
+ if (adapterCatalog.definitionOf(id)) return projectRoot;
31
+ const git = gitWithin(budgetMs, clock, run);
32
+ const declaration = validateInstances(declarations, adapterCatalog)[id];
21
33
  if (!declaration) return refuse("extra instance is not declared");
22
34
  const implementer = roles.includes("implementer");
23
35
  if (!declaration.worktree) {
@@ -41,7 +53,7 @@ export function resolveInstanceExecutionCwd(projectRoot: string, id: string, dec
41
53
  try { return realPath(path) === cwd; } catch { return false; }
42
54
  });
43
55
  if (matches.length !== 1 || matches[0]!.some(field => field === "prunable" || field.startsWith("prunable "))) return refuse("directory is not an available registered project worktree");
44
- if (commonDirectory(root) !== commonDirectory(cwd)) return refuse("worktree belongs to a different Git repository");
56
+ if (commonDirectory(git, root) !== commonDirectory(git, cwd)) return refuse("worktree belongs to a different Git repository");
45
57
  const top = git(cwd, ["rev-parse", "--show-toplevel"]).replace(/\n$/, "");
46
58
  if (realPath(top) !== cwd) return refuse("directory is not the registered worktree root");
47
59
  if (implementer && !matches[0]!.some(field => field.startsWith("branch refs/heads/") && field.length > "branch refs/heads/".length)) return refuse("an implementer worktree requires an attached branch");
@@ -1,5 +1,5 @@
1
1
  import { types } from "node:util";
2
- import { ADAPTERS, defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm } from "../adapters/registry.ts";
2
+ import { defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm, type ProjectAdapterCatalog } from "../adapters/registry.ts";
3
3
  import { isPermissionMode, type PermissionMode } from "./permission-mode.ts";
4
4
 
5
5
  export interface InstanceConfig {
@@ -9,6 +9,7 @@ export interface InstanceConfig {
9
9
  permission_mode?: PermissionMode;
10
10
  review_mode?: "fresh";
11
11
  worktree?: string;
12
+ readOnly?: boolean;
12
13
  }
13
14
  export type InstancesConfig = Readonly<Record<string, InstanceConfig>>;
14
15
  export interface InstanceCatalogEntry { id: string; kind: string; definition: AdapterDefinition; config?: InstanceConfig }
@@ -40,20 +41,32 @@ function roles(value: unknown, field: string): string[] {
40
41
  return out;
41
42
  }
42
43
 
43
- /** Pure I2 declaration validation. Physical worktree and startup authority checks belong to the bound host. */
44
- export function validateInstances(raw: unknown): InstancesConfig {
44
+ /** Declaration support is explicit policy, independent of the native MCP binding mechanism. */
45
+ export function supportsHeadlessInstances(definition: AdapterDefinition): boolean {
46
+ return definition.instances?.mode === "headless" && !!definition.start.create && definition.start.forms.includes("headless");
47
+ }
48
+
49
+ /** Resolve project declarations only through their captured, explicit catalog. */
50
+ export function instanceDefinitionOf(id: string, declarations: InstancesConfig | undefined, catalog: ProjectAdapterCatalog): AdapterDefinition | undefined {
51
+ return catalog.definitionOf(id, declarations);
52
+ }
53
+
54
+ /** Pure I2 validation; physical worktree and startup authority stay host-bound. */
55
+ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog): InstancesConfig {
56
+ const resolve = catalog.definitionOf;
45
57
  const result: Record<string, InstanceConfig> = Object.create(null);
46
58
  if (raw === undefined) return Object.freeze(result);
47
59
  const declarations = fields(raw, "instances");
48
60
  if (Object.keys(declarations).length > MAX_INSTANCES) return fail("instances", "at most 64 extra instances are supported");
49
61
  for (const [id, descriptor] of Object.entries(declarations)) {
50
- if (!PEER_ID.test(id) || RESERVED.has(id) || definitionOf(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
62
+ if (!PEER_ID.test(id) || RESERVED.has(id) || resolve(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
51
63
  const path = `instances.${id}`, entry = fields(descriptor.value, path);
52
- if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode"].includes(key))) return fail(path, "unsupported field");
64
+ if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode", "readOnly"].includes(key))) return fail(path, "unsupported field");
53
65
  const kind: unknown = entry.kind?.value;
54
66
  if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
55
- const definition = definitionOf(kind), prefix = `${kind}-`;
56
- if (!definition) return fail(`${path}.kind`, "unknown native kind");
67
+ const definition = resolve(kind), prefix = `${kind}-`;
68
+ if (!definition) return fail(`${path}.kind`, "unknown native kind (external adapter instances are unsupported in A4)");
69
+ if (catalog.externalDefinitions.some(item => item.kind === kind)) return fail(`${path}.kind`, "external adapter instances are unsupported in A4");
57
70
  if (!id.startsWith(prefix) || !/^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length))) return fail(path, "id must be <kind>-<name>");
58
71
  const declaredRoles = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
59
72
  const reviewer = (definition.reviewerVariant ?? definition).reviewer;
@@ -63,11 +76,12 @@ export function validateInstances(raw: unknown): InstancesConfig {
63
76
  if (definition.routing?.reviewOnly && !freshReviewer) return fail(path, "a review-only kind requires headless roles exactly [\"reviewer\"]");
64
77
  if (freshReviewer && entry.worktree) return fail(`${path}.worktree`, "a fresh reviewer reads the project checkout: worktree is not supported");
65
78
  if (freshReviewer && entry.permission_mode) return fail(`${path}.permission_mode`, "a fresh reviewer has a fixed read-only policy");
66
- // #450 I3: a channel-plugin kind (Claude) runs only in the person's terminal, so it reviews or plans and never owns writes.
67
- const channel = definition.identity.mcp === "channel-plugin";
79
+ // An external instance runs only in the person's terminal; its definition bounds the declared roles.
80
+ const external = definition.instances?.mode === "external";
81
+ const runtime = freshReviewer ? definition.reviewerVariant ?? definition : definition;
68
82
  const unavailableReviewer = freshReviewer && !!(definition.reviewerVariant ?? definition).reviewer?.unavailable;
69
- if (!channel && !unavailableReviewer && (!definition.start.create || !definition.start.forms.includes("headless") || !["env", "acp-session"].includes(definition.identity.mcp))) return fail(`${path}.kind`, "unsupported; a headless factory with env or ACP-session identity, or a channel-plugin kind, is required, not project-file identity");
70
- if (channel) {
83
+ if (!unavailableReviewer && runtime.instances?.mode !== "external" && !supportsHeadlessInstances(runtime)) return fail(`${path}.kind`, definition.reviewerVariant ? 'unsupported; this kind only declares headless reviewer instances with roles exactly ["reviewer"]' : "unsupported; explicit external instance policy or a headless instance policy with a headless factory is required");
84
+ if (external) {
71
85
  const declared = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
72
86
  // #223: a headless one is the kind's reviewer variant, one fresh hub-run session per review request.
73
87
  if (entry.start_mode && definition.reviewerVariant) {
@@ -75,10 +89,20 @@ export function validateInstances(raw: unknown): InstancesConfig {
75
89
  if (declared.length !== 1 || declared[0] !== "reviewer") return fail(`${path}.roles`, "a headless channel-kind instance is a reviewer: roles must be exactly [\"reviewer\"]");
76
90
  } else {
77
91
  if (entry.start_mode || entry.worktree) return fail(path, "a channel instance starts only from the person's terminal (ahub <kind> --as <id>): start_mode and worktree are not supported; a headless reviewer declares roles [\"reviewer\"] and start_mode \"headless\"");
78
- if (!declared.length || declared.some(role => role !== "reviewer" && role !== "planner")) return fail(`${path}.roles`, "a channel instance must declare its roles: reviewer and/or planner");
92
+ const allowed = definition.instances?.roles ?? definition.roles;
93
+ if (!declared.length || declared.some(role => !allowed.includes(role))) return fail(`${path}.roles`, "a channel instance must declare its roles: reviewer and/or planner");
79
94
  }
95
+ } else {
96
+ const allowed = runtime.instances?.roles;
97
+ if (allowed && (declaredRoles.length ? declaredRoles : runtime.roles).some(role => !allowed.includes(role))) return fail(`${path}.roles`, "roles must fit the definition's instance policy");
98
+ }
99
+ if (entry.readOnly) {
100
+ if (typeof entry.readOnly.value !== "boolean") return fail(`${path}.readOnly`, "must be a boolean");
101
+ if (!runtime.permission.genericAcp?.readOnly) return fail(`${path}.readOnly`, "native adapter has no verified read-only mode");
102
+ if (entry.readOnly.value && entry.permission_mode) return fail(`${path}.permission_mode`, "read-only instances cannot select a permission mode");
80
103
  }
81
104
  const value: InstanceConfig = { kind, ...(entry.review_mode ? { review_mode: "fresh" as const } : {}) };
105
+ if (entry.readOnly) value.readOnly = entry.readOnly.value as boolean;
82
106
  if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
83
107
  if (entry.start_mode) {
84
108
  if (entry.start_mode.value !== "headless") return fail(`${path}.start_mode`, "I2 extra instances require headless mode");
@@ -99,24 +123,25 @@ export function validateInstances(raw: unknown): InstancesConfig {
99
123
  }
100
124
 
101
125
  /** Each call derives a fresh project-local projection, without registration or suffix discovery. */
102
- export function instanceCatalog(declarations?: InstancesConfig): readonly InstanceCatalogEntry[] {
103
- const instances = validateInstances(declarations);
126
+ export function instanceCatalog(declarations: InstancesConfig | undefined, catalog: ProjectAdapterCatalog): readonly InstanceCatalogEntry[] {
127
+ const instances = validateInstances(declarations, catalog);
104
128
  return Object.freeze([
105
- ...ADAPTERS.map(definition => Object.freeze({ id: definition.kind, kind: definition.kind, definition })),
106
- ...Object.entries(instances).map(([id, config]) => Object.freeze({ id, kind: config.kind, definition: definitionOf(id, instances)!, config })),
129
+ ...catalog.definitions.map(definition => Object.freeze({ id: definition.kind, kind: definition.kind, definition })),
130
+ ...Object.entries(instances).map(([id, config]) => Object.freeze({ id, kind: config.kind, definition: catalog.definitionOf(id, instances)!, config })),
107
131
  ]);
108
132
  }
109
- export function instancePeerIds(declarations?: InstancesConfig): string[] { return instanceCatalog(declarations).map(entry => entry.id); }
110
- export function instanceDefaultRoles(declarations?: InstancesConfig, includeOptional = true): Record<string, string[]> {
133
+ export function instancePeerIds(declarations: InstancesConfig | undefined, catalog: ProjectAdapterCatalog): string[] { return instanceCatalog(declarations, catalog).map(entry => entry.id); }
134
+ export function instanceDefaultRoles(declarations: InstancesConfig | undefined, includeOptional: boolean, catalog: ProjectAdapterCatalog): Record<string, string[]> {
111
135
  const result = defaultPeerRoles(includeOptional);
112
- const instances = validateInstances(declarations);
113
- for (const [id, config] of Object.entries(instances)) result[id] = [...(config.roles ?? definitionOf(id, instances)!.roles)];
136
+ for (const definition of catalog.definitions) if (!definitionOf(definition.kind) && (includeOptional || !definition.optional)) result[definition.kind] = [...definition.roles];
137
+ const instances = validateInstances(declarations, catalog);
138
+ for (const [id, config] of Object.entries(instances)) result[id] = [...(config.roles ?? catalog.definitionOf(id, instances)!.roles)];
114
139
  return result;
115
140
  }
116
141
 
117
142
  /** One precedence rule for startup, native launch and recovery; ordinary roles may use the instance id or its kind. */
118
- export function effectiveInstanceRoles(id: string, declarations: InstancesConfig | undefined, policyRoles: Readonly<Record<string, readonly string[]>>): string[] {
119
- const definition = definitionOf(id, declarations);
143
+ export function effectiveInstanceRoles(id: string, declarations: InstancesConfig | undefined, policyRoles: Readonly<Record<string, readonly string[]>>, catalog: ProjectAdapterCatalog): string[] {
144
+ const definition = catalog.definitionOf(id, declarations);
120
145
  if (!definition) throw new Error(`${id}: no declared native instance`);
121
146
  const declaration = declarations && Object.hasOwn(declarations, id) ? declarations[id] : undefined;
122
147
  const ownRoles = (key: string): readonly string[] | undefined => Object.hasOwn(policyRoles, key) ? policyRoles[key] : undefined;
@@ -1,18 +1,20 @@
1
1
  import { ControlClient } from "./control-client.ts";
2
+ import { configuredToolContext, type ProjectToolContext } from "./hub-tools.ts";
2
3
  import { SDLC_STAGES } from "./sdlc.ts";
3
4
  /** The daemon owns the active definition. Unavailable reads never invent a project-file stage set. */
4
5
  /** The active stage ids and whether the lifecycle is enabled (`criteria`, #481 S8); an older daemon never says so. */
5
- export async function activeLifecycle(stateDir: string, peer: string, projectRoot: string, active?: ControlClient): Promise<{ stages: string[]; criteria: boolean } | undefined> {
6
+ export async function activeLifecycle(stateDir: string, peer: string, projectRoot: string, active?: ControlClient): Promise<({ stages: string[]; criteria: boolean } & ProjectToolContext) | undefined> {
6
7
  let client: ControlClient | undefined;
7
8
  try {
8
9
  client = active ?? await ControlClient.connect(stateDir, { role: "tools", peer, projectRoot }, 1000);
9
10
  const reply = await client.request({ t: "status" }, 1000);
10
11
  if (reply.t !== "status" || !reply.status || typeof reply.status !== "object" || Array.isArray(reply.status)) return undefined;
12
+ const projectTools = configuredToolContext(reply.status, peer);
11
13
  // A live older daemon has the fixed seven-stage contract and no optional metadata field.
12
- if (!Object.hasOwn(reply.status, "lifecycleStages")) return { stages: [...SDLC_STAGES], criteria: false };
14
+ if (!Object.hasOwn(reply.status, "lifecycleStages")) return { stages: [...SDLC_STAGES], criteria: false, ...projectTools };
13
15
  const stages: unknown = reply.status.lifecycleStages;
14
16
  if (!Array.isArray(stages) || !stages.length || stages.length > 32 || stages.some(s => typeof s !== "string" || !/^[a-z][a-z0-9_-]{0,47}$/.test(s)) || new Set(stages).size !== stages.length) return undefined;
15
- return { stages: [...stages] as string[], criteria: reply.status.lifecycleCriteria === true };
17
+ return { stages: [...stages] as string[], criteria: reply.status.lifecycleCriteria === true, ...projectTools };
16
18
  } catch { return undefined; }
17
19
  finally { if (client !== active) client?.close(); }
18
20
  }
@@ -117,6 +117,14 @@ export function memoryProjectFor(cwd: string, opts: Pick<StoreOptions, "home"> =
117
117
  return resolveAlias(homeFor(opts), memoryProjectKey(cwd));
118
118
  }
119
119
 
120
+ /**
121
+ * #240: the project key for a caller-injected store. Its alias log is the one in its own bound home (`profile.home`),
122
+ * never the ambient memory home's: a different alias log could hide that store's rows or refuse its writes.
123
+ */
124
+ export function memoryProjectForStore(store: MemoryStore, cwd: string): string {
125
+ return resolveAlias(canonicalHome(store.profile.home), memoryProjectKey(cwd));
126
+ }
127
+
120
128
  /** Inspect selection without constructing a store, index, or candidate sidecar. */
121
129
  export function selectedMemoryProfile(opts: StoreOptions = {}): MemoryProfileReceipt {
122
130
  assertLoadedAgentMem();
@@ -241,11 +249,16 @@ export function openProfileMemory(opts: ProfileMemoryOptions = {}, onUnavailable
241
249
  }
242
250
  }
243
251
 
244
- /** Bind a caller-owned store only when it exactly matches the current person selection. */
245
- export function bindProfileMemory(store: MemoryStore, onUnavailable?: (neutralReason: string) => void): {
252
+ /**
253
+ * Bind a caller-owned store only when it exactly matches the current person selection. `projectKey` is the raw key
254
+ * the caller resolves its project from (#240): a sharded store whose home now aliases that key to another project is
255
+ * refused, and a later remap latches the binding unavailable, so a command never runs as B through A's shard.
256
+ */
257
+ export function bindProfileMemory(store: MemoryStore, onUnavailable?: (neutralReason: string) => void, projectKey?: string): {
246
258
  store: MemoryStore; binding: MemoryProfileBinding;
247
259
  } {
248
- const binding = new MemoryProfileBinding({ project: store.profile.shardKey }, onUnavailable, store.profile.home);
260
+ const sharded = store.profile.shardKey !== undefined;
261
+ const binding = new MemoryProfileBinding({ project: store.profile.shardKey, ...(sharded && projectKey !== undefined ? { projectKey } : {}) }, onUnavailable, store.profile.home);
249
262
  binding.assertCurrent();
250
263
  if (!sameProfile(store.profile, binding.profile)) throw new Error(UNAVAILABLE);
251
264
  return { store, binding };