@staix/agent-hub 0.12.23 → 0.12.24

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 (158) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +8 -3
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +8 -1
  11. package/docs/agent-notes/bus.md +3 -1
  12. package/docs/agent-notes/daemon.md +7 -1
  13. package/docs/agent-notes/local-worker.md +4 -2
  14. package/docs/agent-notes/tasks.md +4 -0
  15. package/docs/agent-notes/tests.md +2 -1
  16. package/docs/bench.md +28 -3
  17. package/docs/cooperbench.md +4 -0
  18. package/docs/data-policy.md +36 -0
  19. package/docs/events.md +3 -0
  20. package/docs/gsd-collaboration-analysis.md +48 -0
  21. package/docs/memory.md +231 -0
  22. package/docs/operations.md +651 -14
  23. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  24. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  25. package/docs/profiles.md +57 -0
  26. package/docs/quickstart.md +1 -1
  27. package/docs/research.md +31 -1
  28. package/docs/roles.md +85 -0
  29. package/docs/sdlc.md +170 -0
  30. package/docs/security.md +111 -4
  31. package/docs/smoke.md +282 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +601 -7
  33. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  34. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  35. package/docs/text-projection.md +19 -0
  36. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  37. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  38. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  39. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  40. package/docs/verification/2026-10-13-board-phone.md +31 -0
  41. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  42. package/docs/verified.json +22 -13
  43. package/package.json +2 -1
  44. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  45. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  46. package/plugins/agent-hub/server.js +1175 -339
  47. package/scripts/check-docs.mjs +129 -0
  48. package/src/adapters/acp.ts +112 -13
  49. package/src/adapters/claude-channel.ts +48 -11
  50. package/src/adapters/codex-appserver.ts +11 -0
  51. package/src/adapters/jcode.ts +110 -0
  52. package/src/adapters/local-worker.ts +10 -7
  53. package/src/adapters/pi.ts +94 -20
  54. package/src/cli/attention.ts +60 -0
  55. package/src/cli/bench.ts +24 -5
  56. package/src/cli/board-read.ts +12 -0
  57. package/src/cli/console-state.ts +103 -17
  58. package/src/cli/console.ts +54 -8
  59. package/src/cli/facts-hook.ts +12 -4
  60. package/src/cli/help.ts +35 -9
  61. package/src/cli/identity.ts +18 -7
  62. package/src/cli/init.ts +48 -10
  63. package/src/cli/jcode-mcp.ts +58 -0
  64. package/src/cli/kimi-mcp.ts +53 -0
  65. package/src/cli/launch.ts +16 -1
  66. package/src/cli/main.ts +392 -57
  67. package/src/cli/memory.ts +193 -0
  68. package/src/cli/output.ts +45 -4
  69. package/src/cli/pet-config.ts +1 -0
  70. package/src/cli/pet.ts +83 -0
  71. package/src/cli/preview.ts +38 -5
  72. package/src/cli/status-lines.ts +1 -1
  73. package/src/cli/tail-render.ts +1 -0
  74. package/src/cli/upgrade-runtime.ts +32 -3
  75. package/src/cli/watch.ts +31 -0
  76. package/src/hub/activity.ts +53 -0
  77. package/src/hub/ask.ts +10 -5
  78. package/src/hub/bench.ts +10 -3
  79. package/src/hub/board.ts +222 -16
  80. package/src/hub/bus.ts +48 -9
  81. package/src/hub/citations.ts +7 -0
  82. package/src/hub/conductor.ts +12 -7
  83. package/src/hub/config-trust.ts +4 -2
  84. package/src/hub/crash.ts +9 -0
  85. package/src/hub/daemon.ts +910 -120
  86. package/src/hub/delivery-journal.ts +5 -0
  87. package/src/hub/envelope.ts +1 -1
  88. package/src/hub/events.ts +12 -2
  89. package/src/hub/facts.ts +26 -7
  90. package/src/hub/git.ts +8 -2
  91. package/src/hub/github-import.ts +28 -0
  92. package/src/hub/hub-tools.ts +49 -10
  93. package/src/hub/inference.ts +1 -1
  94. package/src/hub/lifecycle-schema.ts +17 -0
  95. package/src/hub/listen.ts +15 -0
  96. package/src/hub/manager.ts +12 -5
  97. package/src/hub/path-policy.ts +96 -0
  98. package/src/hub/peer-catalog.ts +28 -0
  99. package/src/hub/permission-mode.ts +19 -5
  100. package/src/hub/planning.ts +70 -0
  101. package/src/hub/profiles.ts +74 -0
  102. package/src/hub/projected-diff.ts +73 -0
  103. package/src/hub/projection-exec.ts +24 -0
  104. package/src/hub/research.ts +144 -21
  105. package/src/hub/roles.ts +123 -0
  106. package/src/hub/roster.ts +23 -0
  107. package/src/hub/routing.ts +18 -5
  108. package/src/hub/sdlc-queue.ts +112 -0
  109. package/src/hub/sdlc-release.ts +125 -0
  110. package/src/hub/sdlc.ts +250 -0
  111. package/src/hub/settings.ts +34 -15
  112. package/src/hub/shipped-roles.ts +25 -0
  113. package/src/hub/snapshots.ts +89 -15
  114. package/src/hub/start-mode.ts +7 -5
  115. package/src/hub/supervision.ts +1 -0
  116. package/src/hub/task-sweep.ts +1 -1
  117. package/src/hub/tasks.ts +892 -87
  118. package/src/hub/text-projection.ts +126 -0
  119. package/src/hub/tooling.ts +159 -0
  120. package/src/hub/ui.ts +16 -2
  121. package/src/hub/workflow.ts +71 -0
  122. package/src/local/sandbox.ts +6 -4
  123. package/src/local/tools.ts +11 -6
  124. package/src/memory/brief.ts +28 -14
  125. package/src/memory/import.ts +55 -0
  126. package/src/memory/recall.ts +84 -0
  127. package/src/memory/store.ts +750 -0
  128. package/src/memory/sync.ts +145 -0
  129. package/src/pet/NOTICE.md +10 -0
  130. package/src/pet/characters.ts +1438 -0
  131. package/src/pet/config.ts +31 -0
  132. package/src/pet/frame.ts +17 -0
  133. package/src/pet/pixels.ts +28 -0
  134. package/src/role-text.d.ts +1 -0
  135. package/src/ui/index.html +842 -46
  136. package/src/ui/needs.ts +58 -0
  137. package/src/ui/office-model.ts +440 -0
  138. package/src/ui/peer-palette.ts +16 -0
  139. package/src/ui/task-board.ts +49 -0
  140. package/src/ui/task-progress.ts +5 -1
  141. package/templates/AGENTS.block.md +3 -2
  142. package/templates/config.json +5 -1
  143. package/templates/profiles/dev.json +22 -0
  144. package/templates/profiles/general.json +26 -0
  145. package/templates/profiles/research.json +57 -0
  146. package/templates/profiles/writing.json +28 -0
  147. package/templates/roles/NOTICE.md +26 -0
  148. package/templates/roles/dev/backend-engineer.md +26 -0
  149. package/templates/roles/dev/code-reviewer.md +26 -0
  150. package/templates/roles/dev/frontend-engineer.md +26 -0
  151. package/templates/roles/dev/security-reviewer.md +26 -0
  152. package/templates/roles/dev/software-architect.md +26 -0
  153. package/templates/roles/dev/test-engineer.md +26 -0
  154. package/templates/roles/research/analyst.md +26 -0
  155. package/templates/roles/research/fact-checker.md +26 -0
  156. package/templates/roles/research/research-lead.md +26 -0
  157. package/templates/roles/research/researcher.md +26 -0
  158. package/templates/roles/research/statistician.md +26 -0
@@ -658,6 +658,11 @@ export class DeliveryJournal {
658
658
  ) as Record<string, unknown>[];
659
659
  return rows.map(parseDelivery);
660
660
  }
661
+ unresolved(peer: string): JournalDelivery[] {
662
+ this.ensureOpen();
663
+ const rows = this.db.query("SELECT * FROM deliveries WHERE project_id = ? AND peer = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY created_at, id").all(this.projectId, peer) as Record<string, unknown>[];
664
+ return rows.map(parseDelivery);
665
+ }
661
666
  resolve(
662
667
  id: string,
663
668
  revision: number,
@@ -14,7 +14,7 @@ export interface Envelope {
14
14
  kind: Kind;
15
15
  priority: Priority;
16
16
  body: string; // agent conclusions only, never tool noise
17
- refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; supervision?: boolean; supervisionKey?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
17
+ refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
18
18
  /** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
19
19
  private?: boolean;
20
20
  ts: number;
package/src/hub/events.ts CHANGED
@@ -3,6 +3,8 @@ import type { TaskAttribution } from "./attribution.ts";
3
3
  import type { SwitchTrace } from "../models/route/stage.ts";
4
4
  import type { ScreenRecord } from "./inference.ts";
5
5
 
6
+ export interface RoleIdentity { id: string; layer: "shipped" | "project"; hash: string }
7
+
6
8
  /** Bumped whenever a field changes meaning or goes away; new fields and new types do not bump it. */
7
9
  export const EVENTS_SCHEMA = 1;
8
10
 
@@ -13,6 +15,7 @@ export const EVENTS_SCHEMA = 1;
13
15
  export type HubEvent =
14
16
  | { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
15
17
  /** A settings change (#269): the registry key, the value in force before and after (closed words, ids or booleans, never free text) and where it came from. */
18
+ | { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
16
19
  | { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean }
17
20
  | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
18
21
  | { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
@@ -27,11 +30,17 @@ export type HubEvent =
27
30
  | { type: "progress"; peer: string; task: number; severity: number; spinning: number; exploring: number; production: number }
28
31
  | { type: "stuck"; peer: string; task: number; category: "repetition" | "false_progress" | "drift" | "desperation" | "capability_gap"; streak: number; latched: boolean }
29
32
  | { type: "capability"; peer: string; state: "verified" | "lost"; via?: string }
33
+ /** A hub run began: every peer is detached until its next state event (#248: attachments never span a crash). */
34
+ | { type: "hub_start"; instance: string }
35
+ /** #248: the add-ons a peer works with, names and versions from a closed list, at each attach. */
36
+ | { type: "tooling"; peer: string; addons: import("./tooling.ts").Addon[] }
37
+ /** #258: a task brief handed to its owner: how many hub-memory items (and fails among them) and claude-mem items, never their text. */
38
+ | { type: "memory_brief"; peer: string; task: number; native: number; fails: number; claudeMem: number }
30
39
  | { type: "split"; task: number; where?: "routing" | "cohort"; verdict: "split" | "single" | "unknown"; single?: string; splitS?: number; singleS?: number; reason?: string; trace?: string[] }
31
40
  | { type: "state"; peer: string; state: string }
32
41
  | { type: "turn_start"; peer: string; turn: string }
33
42
  | { type: "native_thread"; peer: string; thread: string; fresh: boolean }
34
- | ({ type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number } & Partial<TaskAttribution>)
43
+ | ({ type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number; preparedRoles?: RoleIdentity[] | null } & Partial<TaskAttribution>)
35
44
  | { type: "native_turn_end"; peer: string; id?: string }
36
45
  | { type: "conduct"; peer: string; action: string; task?: number; target?: string }
37
46
  | { type: "agent_cli"; peer: string; command: string; refused: boolean }
@@ -42,7 +51,8 @@ export type HubEvent =
42
51
  | { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
43
52
  | ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
44
53
  | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
45
- | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean; reason?: string }
54
+ | { type: "planning"; by: string; task?: number; release?: number; fields: string[]; revision: number }
55
+ | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; profile?: string; profileClass?: string; role?: string; roleIdentity?: { id: string; layer: "shipped" | "project"; hash: string }; pii: boolean; reason?: string; release?: number | null; priority?: number | null; plannedStart?: number | null; due?: number | null }
46
56
  | { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
47
57
  /** A PII screen verdict (issue #198): what was screened and the closed label, source and category, never the text. */
48
58
  | ({ type: "pii_screen" } & ScreenRecord)
package/src/hub/facts.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { repoGitArgs, repoGitEnv, type Repo } from "./snapshots.ts";
2
+ import { isHubWritePath } from "./permission-mode.ts";
1
3
  import { createHash, randomUUID } from "node:crypto";
2
4
  import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
3
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -54,6 +56,8 @@ export interface FactScope {
54
56
  export interface FactsOptions {
55
57
  /** The project root, a real absolute path. */
56
58
  root: string;
59
+ /** An explicitly discovered shadow repository; ordinary git retains its existing cwd semantics. */
60
+ repo?: Repo;
57
61
  /** A private directory for the two sides of a diff. */
58
62
  tmp: string;
59
63
  /** Names this hub run's offers, so an acknowledgement for another run's offer matches nothing. */
@@ -65,9 +69,14 @@ export interface FactsOptions {
65
69
  nameable: (s: string) => boolean;
66
70
  /** `local.deny` entries: with the one denylist (`src/local/deny.ts`), paths facts never read or show. */
67
71
  deny?: string[];
72
+ /** Configured private paths are never read or shown as automatic facts. */
73
+ privatePath?: (path: string) => boolean;
74
+ /** Bounded machine-local projection; absent retains raw/binary behavior. */
75
+ projection?: (file: string) => import("./text-projection.ts").ProjectedText | undefined;
68
76
  }
69
77
 
70
78
  interface Version {
79
+ projected?: true;
71
80
  hash: string;
72
81
  text?: string;
73
82
  /** Git's blob id of the same bytes: a file equal to HEAD's is no change, whatever its stat data says. */
@@ -171,11 +180,15 @@ export class Facts {
171
180
  this.root = root;
172
181
  }
173
182
 
183
+ private gitArgs(args: string[]): string[] { return hubGitArgv(this.o.repo?.shadow ? repoGitArgs(this.o.repo, args) : args); }
184
+ private gitEnv(): NodeJS.ProcessEnv { return hubGitEnv({ ...(this.o.repo?.shadow ? repoGitEnv(this.o.repo) : process.env), GIT_OPTIONAL_LOCKS: "0" }); }
185
+
174
186
  /**
175
187
  * A project-relative path, or undefined for one outside the project. Resolved as a real path (symlinks and `..` are
176
188
  * followed first), so neither a task's refs nor a tool's input can point the hub at a file outside the project.
177
189
  */
178
190
  rel(path: string): string | undefined {
191
+ if (this.o.privatePath?.(path)) return undefined;
179
192
  const abs = isAbsolute(path) ? path : resolve(this.root, path);
180
193
  let real = abs;
181
194
  try {
@@ -188,7 +201,11 @@ export class Facts {
188
201
  }
189
202
  }
190
203
  const r = relative(this.root, real);
191
- if (!r || r.startsWith("..") || isAbsolute(r)) return undefined;
204
+ if (!r || r.startsWith("..") || isAbsolute(r) || this.o.repo?.shadow && isHubWritePath(r)) return undefined;
205
+ if (this.o.repo?.shadow) {
206
+ const ignored = Bun.spawnSync(this.gitArgs(["check-ignore", "--no-index", "--quiet", "--", r]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: this.gitEnv(), timeout: 3000 });
207
+ if (ignored.exitCode !== 1) return undefined; // ignored or unavailable policy is not readable fact evidence
208
+ }
192
209
  // Never git's own files, and never what the denylist keeps from every agent.
193
210
  return /(^|\/)\.git(\/|$)/i.test(r) || isDenied(r, this.o.deny ?? []) ? undefined : r;
194
211
  }
@@ -202,6 +219,8 @@ export class Facts {
202
219
  */
203
220
  private load(file: string): Omit<Version, "seq"> {
204
221
  if (this.rel(join(this.root, file)) !== file) return { hash: "missing" };
222
+ const projected = this.o.projection?.(join(this.root, file));
223
+ if (projected) return { hash: projected.rawHash, blob: projected.rawBlob, text: projected.text, projected: true };
205
224
  let fd: number | undefined;
206
225
  try {
207
226
  fd = openSync(join(this.root, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
@@ -276,7 +295,7 @@ export class Facts {
276
295
  // One path per line: a newline in a name would shift the answers, and git drops a final carriage return (another file).
277
296
  files = files.filter((f) => !/[\r\n]/.test(f));
278
297
  if (!files.length) return out;
279
- const r = Bun.spawnSync(hubGitArgv(["cat-file", "--batch-check=%(objectname) %(objecttype)"]), { cwd: this.root, stdin: new TextEncoder().encode(files.map((f) => `HEAD:./${f}`).join("\n") + "\n"), stdout: "pipe", stderr: "pipe", env: hubGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0" }) });
298
+ const r = Bun.spawnSync(this.gitArgs(["cat-file", "--batch-check=%(objectname) %(objecttype)"]), { cwd: this.root, stdin: new TextEncoder().encode(files.map((f) => `HEAD:./${f}`).join("\n") + "\n"), stdout: "pipe", stderr: "pipe", env: this.gitEnv() });
280
299
  if (r.exitCode !== 0) return out;
281
300
  r.stdout.toString().split("\n").forEach((line, i) => {
282
301
  const [oid, type] = line.split(" ");
@@ -415,7 +434,7 @@ export class Facts {
415
434
  if (!files) {
416
435
  // Plumbing only, with optional locks off: an agent's own git commands must never meet a lock the hub holds.
417
436
  const git = (...args: string[]) => {
418
- const r = Bun.spawnSync(hubGitArgv([...args, "--", p]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: hubGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0" }) });
437
+ const r = Bun.spawnSync(this.gitArgs([...args, "--", p]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: this.gitEnv() });
419
438
  return r.exitCode === 0 ? r.stdout.toString().split("\0").filter(Boolean) : undefined;
420
439
  };
421
440
  // Against HEAD, so a staged change counts (a staged move by both names); a repository without a commit yet has
@@ -497,7 +516,7 @@ export class Facts {
497
516
  this.touch(peer, file, pre.version);
498
517
  if (["Edit", "MultiEdit", "Write"].includes(tool)) {
499
518
  this.wrote(peer, file);
500
- const expected = this.latest.get(file)?.hash === pre.version.hash ? applyEdit(pre.version.text, tool, input) : undefined;
519
+ const expected = !pre.version.projected && this.latest.get(file)?.hash === pre.version.hash ? applyEdit(pre.version.text, tool, input) : undefined;
501
520
  const version = this.observe(file, expected === undefined ? undefined : { peer, expected: sha1(expected) });
502
521
  const last = this.transitions.get(file)?.at(-1);
503
522
  if (last?.seq === version.seq && last.by === peer && this.view(peer).get(file)?.hash === pre.version.hash) this.view(peer).set(file, version);
@@ -506,7 +525,7 @@ export class Facts {
506
525
  if (tool === "Read") {
507
526
  const version = this.observe(file);
508
527
  // Only a whole read of an unchanged file says what the peer saw: no offset or limit, and nothing Read cuts.
509
- if (version.hash === pre.version.hash && input.offset === undefined && input.limit === undefined && readWhole(pre.version.text)) this.view(peer).set(file, version);
528
+ if (!pre.version.projected && version.hash === pre.version.hash && input.offset === undefined && input.limit === undefined && readWhole(pre.version.text)) this.view(peer).set(file, version);
510
529
  return this.observeAll();
511
530
  }
512
531
  }
@@ -534,7 +553,7 @@ export class Facts {
534
553
  this.touch(peer, file, was);
535
554
  this.wrote(peer, file);
536
555
  const now = this.load(file);
537
- const matches = was ? codexEffect(was.text, now, change, (a, b) => this.diff(a, b)) : false;
556
+ const matches = was && !was.projected && !now.projected ? codexEffect(was.text, now, change, (a, b) => this.diff(a, b)) : false;
538
557
  const version = this.observe(file, matches ? { peer, expected: now.hash } : undefined);
539
558
  const last = this.transitions.get(file)?.at(-1);
540
559
  if (was && last?.seq === version.seq && last.by === peer && this.view(peer).get(file)?.hash === was.hash) this.view(peer).set(file, version);
@@ -694,7 +713,7 @@ export class Facts {
694
713
  tree(paths: string[], windows: { peer: PeerId; since: number; until?: number }[] = []): string {
695
714
  this.boundary();
696
715
  const h = createHash("sha1");
697
- const head = Bun.spawnSync(hubGitArgv(["rev-parse", "-q", "--verify", "HEAD"]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: hubGitEnv() });
716
+ const head = Bun.spawnSync(this.gitArgs(["rev-parse", "-q", "--verify", "HEAD"]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: this.gitEnv() });
698
717
  h.update(head.exitCode === 0 ? head.stdout.toString().trim() : "no-head").update("\0");
699
718
  // The files the members wrote while at work in the cohort count too: a symbol-only overlap names no path, and a
700
719
  // tool's write outside the named paths still moves the work the integration checked. What they only read never
package/src/hub/git.ts CHANGED
@@ -18,9 +18,15 @@ import { spawnSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncRetur
18
18
  * command. GIT_CONFIG_NOSYSTEM/global are left alone: those files are the user's own.
19
19
  */
20
20
  const SAFE_CONFIG = ["core.fsmonitor=false", "core.hooksPath=/dev/null", "diff.external=", "core.pager=cat", "credential.helper=", "core.askpass="];
21
+ /** Hub memory sync (#258) pushes to the person's own remote: their global credential helper stays, the rest does not. */
22
+ const CREDENTIAL_KEYS = new Set(["credential.helper=", "core.askpass="]);
21
23
 
22
- /** `["git", ...safe options, ...args]`: use instead of a literal "git" argv anywhere under src/. */
23
- export const hubGitArgv = (args: string[]): string[] => ["git", "--no-pager", ...SAFE_CONFIG.flatMap((c) => ["-c", c]), ...args];
24
+ /**
25
+ * `["git", ...safe options, ...args]`: use instead of a literal "git" argv anywhere under src/. `credentials` keeps the
26
+ * person's credential helper, for a repository the hub created itself (the memory store), never a project's.
27
+ */
28
+ export const hubGitArgv = (args: string[], opts: { credentials?: boolean } = {}): string[] =>
29
+ ["git", "--no-pager", ...SAFE_CONFIG.filter((c) => !opts.credentials || !CREDENTIAL_KEYS.has(c)).flatMap((c) => ["-c", c]), ...args];
24
30
 
25
31
  /** The caller's env plus the no-prompt switch; existing keys (childEnv, GIT_OPTIONAL_LOCKS=0) pass through. */
26
32
  export const hubGitEnv = (env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => ({ ...env, GIT_TERMINAL_PROMPT: "0" });
@@ -0,0 +1,28 @@
1
+ import { sdlcGh, type SdlcGh } from "./sdlc.ts";
2
+ export interface GitHubSource { provider: "github"; repo: string; issue: number; url: string; }
3
+ export interface GitHubImportIssue { source: GitHubSource; title: string; detail: string; }
4
+ export interface GitHubImportSelection { repo?: string; label: string; milestone?: string; }
5
+ const REPO=/^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9_.-]{1,100}$/;
6
+ const selectionText=(value:unknown):value is string=>typeof value==="string"&&value.trim()===value&&value.length>0&&value.length<=100&&!value.startsWith("-")&&!/\p{Cc}/u.test(value);
7
+ /** Read-only issue intake; the existing reader bounds process time, stdout and diagnostics. */
8
+ export async function readGitHubImport(input: GitHubImportSelection, run: SdlcGh=sdlcGh): Promise<{ issues: GitHubImportIssue[]; partial: boolean; }> {
9
+ if(!selectionText(input.label)||input.label.includes(",")||input.label.includes('"')||input.milestone!==undefined&&!selectionText(input.milestone)||input.repo!==undefined&&!REPO.test(input.repo))throw new Error("invalid GitHub import selection");
10
+ const {label,milestone}=input;
11
+ const deadline=performance.now()+10_000;
12
+ const read=async(args:string[])=>{const left=deadline-performance.now();if(left<=0)throw new Error("GitHub import read deadline expired");const result=await run(args,Math.min(left,10_000),undefined,2*1024*1024);if(performance.now()>deadline)throw new Error("GitHub import read deadline expired");if(result.code!==0||new TextEncoder().encode(result.stdout).length>2*1024*1024)throw new Error("GitHub import read failed or exceeded its output bound");return JSON.parse(result.stdout) as unknown;};
13
+ let repo=input.repo;
14
+ if(repo===undefined){const raw=await read(["repo","view","--json","nameWithOwner"]);if(!raw||typeof raw!=="object"||typeof (raw as Record<string,unknown>).nameWithOwner!=="string")throw new Error("GitHub repository identity unavailable");repo=(raw as {nameWithOwner:string}).nameWithOwner;}
15
+ if(!REPO.test(repo))throw new Error("invalid GitHub repository identity");
16
+ // ponytail: one bounded page of32 issues; add paginated reads under the shared deadline for larger intake.
17
+ const args=["issue","list","--repo",repo,"--state","open","--label",label,...(milestone===undefined?[]:["--milestone",milestone]),"--limit","33","--json","number,title,body,url,state"];
18
+ const raw=await read(args);if(!Array.isArray(raw)||raw.length>33)throw new Error("invalid GitHub issue page");
19
+ const seen=new Set<number>();
20
+ const issues=raw.map(row=>{
21
+ if(!row||typeof row!=="object"||!Number.isSafeInteger(row.number)||row.number<=0||seen.has(row.number)||row.state!=="OPEN"||typeof row.title!=="string"||!row.title.trim()||row.title.length>300||typeof row.body!=="string"||row.body.length>8000||typeof row.url!=="string")throw new Error("invalid GitHub issue data");
22
+ seen.add(row.number);
23
+ const expected=`https://github.com/${repo}/issues/${row.number}`;
24
+ if(row.url.toLowerCase()!==expected.toLowerCase())throw new Error("GitHub issue identity mismatch");
25
+ return {source:{provider:"github" as const,repo:repo!.toLowerCase(),issue:row.number,url:expected},title:row.title,detail:row.body};
26
+ });
27
+ return {issues:issues.slice(0,32),partial:issues.length>32};
28
+ }
@@ -1,3 +1,7 @@
1
+ import { SDLC_STAGES } from "./sdlc.ts";
2
+ import { projectProfile, profileData, PROFILE_CLASS_NAMES, type ProfileName, type ProfileSelection } from "./profiles.ts";
3
+ import { catalogSection, type CatalogRole } from "./roles.ts";
4
+ import { SUPPORTED_PEERS, defaultPeerRoles } from "./peer-catalog.ts";
1
5
  // The hub's task tools, defined once: the MCP server (Claude plugin, Kimi, Codex) and the local worker both read this.
2
6
  import { NOTE_KINDS } from "./envelope.ts";
3
7
 
@@ -22,6 +26,9 @@ const plan = {
22
26
  additionalProperties: false,
23
27
  } as const;
24
28
 
29
+ // Accept may target a task from an older project profile; Tasks normalizes by the stored task profile.
30
+ const acceptPlan = { ...plan, properties: { ...plan.properties, sections: list("document heading anchors, such as report.md#methods"), sources: list("citation ids, DOI or source URL ids"), claims: list("short claim ids to verify") } } as const;
31
+
25
32
  export interface HubTool {
26
33
  name: string;
27
34
  description: string;
@@ -35,14 +42,22 @@ const tool = (name: string, description: string, properties: Record<string, unkn
35
42
  });
36
43
 
37
44
  export const TASK_TOOLS: HubTool[] = [
38
- tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" } }, ["title"]),
39
- tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan }, ["id"]),
45
+ tool("hub_backlog_list", "Read public backlog cards with planning revisions. Configured planners only; PII remains a stub.", {}),
46
+ tool("hub_backlog_plan", "Plan an unpinned backlog item outside frozen releases with the revision you read and a bounded reason. A person's rank is retained. Actual dates cannot be set.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", maxLength: 1000 }, fields: { type: "object", properties: { rank: { type: ["integer", "null"], minimum: 0 }, priority: { type: ["integer", "null"], minimum: 0, maximum: 3 }, release: { type: ["integer", "null"], minimum: 1 }, plannedStart: { type: ["integer", "null"], minimum: 1 }, due: { type: ["integer", "null"], minimum: 1 }, deps: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, reserved: { type: ["string", "null"], minLength: 1, maxLength: 32 } }, additionalProperties: false } }, ["id", "revision", "reason", "fields"]),
47
+ tool("hub_backlog_pull", "Reserve a WIP slot and pull ready unpinned backlog work into To Do at its current revision. Preserves person order/reservations and frozen scope; pending unowned work can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "reason"]),
48
+ tool("hub_backlog_split", "Split unstarted backlog work into two to eight staged parts. The original card retains integration/acceptance and depends on the children. Use after for existing prerequisite ids and afterParts for earlier zero-based part indexes. Latest unused unchanged children can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 }, parts: { type: "array", minItems: 2, maxItems: 8, items: { type: "object", properties: { title: { type: "string", minLength: 1, maxLength: 300 }, detail: { type: "string", maxLength: 8000 }, class: str, refs, plan, after: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, afterParts: { type: "array", maxItems: 8, uniqueItems: true, items: { type: "integer", minimum: 0 } } }, required: ["title"], additionalProperties: false } } }, ["id", "revision", "reason", "parts"]),
49
+ tool("hub_backlog_undo", "Undo your latest planning change at its current revision. Refuses intervening changes and another actor's edit.", { id, revision: { type: "integer", minimum: 1 } }, ["id", "revision"]),
50
+ tool("hub_release_list", "Read public release scope and planned/actual dates for phased planning. Configured planners only.", {}),
51
+
52
+ tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, role: { type: "string", description: "catalog role id for this task class; guidance only, no routing or authority change" }, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, backlog: { type: "boolean", description: "stage unoffered work; owner is only a reservation, even when naming yourself" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" }, goal: { type: "string", description: "id of this project's long-term task (ahub goal list) the work is part of" } }, ["title"]),
53
+ tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan: acceptPlan }, ["id"]),
40
54
  tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
41
55
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. 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"]),
56
+ 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"]),
42
57
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
43
- 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. Two changes_requested in a row move the task to another peer.", { id, 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" } }, ["id", "verdict"]),
58
+ 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. Two changes_requested in a row 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" } }, ["id", "verdict"]),
44
59
  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"]),
45
- tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id }, ["text"]),
60
+ tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's memory and claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter. A note on an open task is short-term until the task is approved, when its decisions, contracts and fails become long-term; horizon long keeps it now, scope shared lets the person's other projects find it.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id, horizon: { type: "string", enum: ["short", "long"] }, scope: { type: "string", enum: ["project", "shared"] } }, ["text"]),
46
61
  ];
47
62
 
48
63
  export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
@@ -51,9 +66,9 @@ export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
51
66
  export const CONDUCTOR_TOOLS: HubTool[] = [
52
67
  tool("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
53
68
  tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
54
- tool("hub_task_assign", "Move a task to another peer, as the conductor, or redirect a task you proposed while it is proposed and nobody accepted it. A task that still waits gets the peer as its reserved owner. When you have an explicit capabilities list, the conductor needs assign capability for any assign, the proposer only to hand the task to another peer.", { id, peer: str }, ["id", "peer"]),
69
+ tool("hub_task_assign", "Name an owner (default role) or reviewer. Only the conductor may name a reviewer through this tool. A proposer may redirect an unaccepted proposed task's owner; a task that still waits reserves that owner. Pull-only peers receive work when named or when they claim it. With an explicit capabilities list, the conductor needs assign capability for either role, the proposer only to give ownership to another peer.", { id, peer: str, role: { type: "string", enum: ["owner", "reviewer"] } }, ["id", "peer"]),
55
70
  tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
56
- tool("hub_peer_start", "Start local, kimi, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum: ["local", "kimi", "pi", "claude", "codex"] } }, ["peer"]),
71
+ tool("hub_peer_start", "Start local, kimi, qwen, jcode, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum: ["local", "kimi", "pi", ...SUPPORTED_PEERS.filter(peer => !["local", "kimi", "pi"].includes(peer))] } }, ["peer"]),
57
72
  tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
58
73
  tool("hub_peer_release", "Release only the conductor hold you placed. Never lifts a person's hold or a budget pause.", { peer: str }, ["peer"]),
59
74
  ];
@@ -68,10 +83,34 @@ export const ROLE_TEXT: Record<string, string> = {
68
83
  reviewer: "reviewer: when asked to review, read the change itself, then hub_review with approved or changes_requested and a note that says what to fix.",
69
84
  };
70
85
 
71
- export const DEFAULT_ROLES: Record<string, string[]> = { claude: ["planner", "reviewer"], codex: ["implementer"], kimi: ["implementer", "verifier"], local: ["implementer", "verifier"], pi: ["implementer", "verifier"] };
86
+ export const DEFAULT_ROLES: Record<string, string[]> = defaultPeerRoles(true);
72
87
 
73
- export function roleContract(peer: string, roles: Record<string, string[]> = DEFAULT_ROLES): string {
74
- const mine = (roles[peer] ?? []).map((r) => ROLE_TEXT[r]).filter(Boolean);
88
+ export function roleContract(peer: string, roles: Record<string, string[]> = DEFAULT_ROLES, catalog: CatalogRole[] = [], profile: ProfileSelection = "dev"): string {
89
+ const data = profileData(profile);
90
+ const mine = (roles[peer] ?? []).map((r) => data.role_text[r] ?? ROLE_TEXT[r]).filter(Boolean);
75
91
  if (!mine.length) return "";
76
- return [`Your roles in this project ("${peer}"):`, ...mine.map((t) => `- ${t}`), "The task board (hub_task_list) is the shared record of who does what."].join("\n");
92
+ return [`Your roles in this project ("${peer}"):`, ...mine.map((t) => `- ${t}`), ...(data !== projectProfile("dev") ? [`Project profile ${data.name}: classes ${Object.entries(data.classes).map(([name, base]) => `${name}=${base}`).join(", ")}. Profile guidance grants no authority.`] : []), ...(catalog.length ? [catalogSection(catalog)] : []), "The task board (hub_task_list) is the shared record of who does what."].join("\n");
93
+ }
94
+
95
+ /** Profile schemas are data over the existing tools; dev retains the exact existing definitions. */
96
+ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: readonly string[]): HubTool[] {
97
+ const customStages = stages && (stages.length !== SDLC_STAGES.length || stages.some((s, i) => s !== SDLC_STAGES[i]));
98
+ const customProfile = !(profile === "dev" || profileData(profile) === projectProfile("dev"));
99
+ if (!customStages && !customProfile) return TASK_TOOLS;
100
+ const data = profileData(profile);
101
+ return TASK_TOOLS.map(tool => {
102
+ const properties = { ...tool.inputSchema.properties };
103
+ if (customStages && tool.name === "hub_sdlc_record") {
104
+ const receipt = properties.receipt as { type: string; properties: Record<string, unknown>; additionalProperties: boolean };
105
+ properties.receipt = { ...receipt, properties: { ...receipt.properties, stage: { type: "string", enum: [...(stages ?? SDLC_STAGES)] } } };
106
+ }
107
+ 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])])] };
108
+ 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")])) };
109
+ if (customProfile && tool.name === "hub_backlog_split") {
110
+ const parts = properties.parts as { items: { properties: Record<string, unknown> } };
111
+ 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")])) };
112
+ properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
113
+ }
114
+ 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 } };
115
+ });
77
116
  }
@@ -168,7 +168,7 @@ export interface ScreenRecord {
168
168
  task?: number;
169
169
  item: ScreenItem;
170
170
  label: "pii" | "clear";
171
- source: "regex" | "screen" | "unknown";
171
+ source: "regex" | "screen" | "unknown" | "inherited";
172
172
  category?: PiiCategory;
173
173
  miss?: ScreenMiss;
174
174
  ms?: number;
@@ -0,0 +1,17 @@
1
+ import { ControlClient } from "./control-client.ts";
2
+ import { SDLC_STAGES } from "./sdlc.ts";
3
+ /** The daemon owns the active definition. Unavailable reads never invent a project-file stage set. */
4
+ export async function activeLifecycleStages(stateDir: string, peer: string, projectRoot: string, active?: ControlClient): Promise<string[] | undefined> {
5
+ let client: ControlClient | undefined;
6
+ try {
7
+ client = active ?? await ControlClient.connect(stateDir, { role: "tools", peer, projectRoot }, 1000);
8
+ const reply = await client.request({ t: "status" }, 1000);
9
+ if (reply.t !== "status" || !reply.status || typeof reply.status !== "object" || Array.isArray(reply.status)) return undefined;
10
+ // A live older daemon has the fixed seven-stage contract and no optional metadata field.
11
+ if (!Object.hasOwn(reply.status, "lifecycleStages")) return [...SDLC_STAGES];
12
+ const stages: unknown = reply.status.lifecycleStages;
13
+ 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;
14
+ return [...stages] as string[];
15
+ } catch { return undefined; }
16
+ finally { if (client !== active) client?.close(); }
17
+ }
@@ -0,0 +1,15 @@
1
+ /** Keep rejected owned listeners bound until a replacement cannot reuse their ports. */
2
+ export function listenWithoutPortCollision<T extends { port?: number; stop(closeActiveConnections?: boolean): void }>(listen: () => T, reservedPorts: readonly number[]): T {
3
+ const reserved = new Set(reservedPorts.filter(port => Number.isSafeInteger(port) && port > 0));
4
+ const temporary: T[] = [];
5
+ try {
6
+ for (let attempt = 0; attempt <= reserved.size; attempt++) {
7
+ const candidate = listen();
8
+ if (!reserved.has(candidate.port ?? -1)) return candidate;
9
+ temporary.push(candidate);
10
+ }
11
+ throw new Error("automatic control listener could not avoid requested peer ports");
12
+ } finally {
13
+ for (const listener of temporary) listener.stop(true);
14
+ }
15
+ }
@@ -1,3 +1,5 @@
1
+ import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS } from "./planning.ts";
2
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
1
3
  import { Database } from "bun:sqlite";
2
4
  import { randomBytes, randomUUID } from "node:crypto";
3
5
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
@@ -153,18 +155,23 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
153
155
  return { ok: true };
154
156
  }
155
157
  // The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
156
- if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "setting", "setting_undo", "setting_preview", "start_peer"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
158
+ if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
157
159
  client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
158
- const nominalMs = input.action === "start_peer" ? MAX_COMMAND_MS + 5_000 : 10_000;
160
+ const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
161
+ let nominalMs = 10_000;
162
+ if (input.action === "start_peer") nominalMs = MAX_COMMAND_MS + 5_000;
163
+ else if (input.action === "backlog_split") nominalMs = BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000;
164
+ else if (deliveryRead) nominalMs = 30_000;
159
165
  const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
160
166
  const result = await client.request(input.action === "snapshot"
161
- ? { t: "ui_snapshot", after: input.after ?? 0 }
167
+ ? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}) }
162
168
  : { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
163
169
  // ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
164
- if (input.action === "start_peer" && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
165
- const text = "Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.";
170
+ if (["start_peer", "backlog_split"].includes(String(input.action)) && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
171
+ const text = input.action === "backlog_split" ? "Split result is unconfirmed. Reload the task before retrying; the hub may still commit its children." : "Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.";
166
172
  return { ok: false, unconfirmed: true, text, error: text };
167
173
  }
174
+ if (deliveryRead) { const { rid: _transportId, ...report } = result; return report; }
168
175
  return result;
169
176
  } catch (error) { return { ok: false, error: safeError(error) }; }
170
177
  finally { client?.close(); }
@@ -0,0 +1,96 @@
1
+ import { lstatSync, opendirSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { realPath } from "./project.ts";
4
+ /** Bounded project-relative exact paths/subtrees or *, ? and whole-segment ** globs. */
5
+ export function policyPaths(raw: unknown, field: string): string[] {
6
+ if (raw === undefined) return [];
7
+ if (!Array.isArray(raw) || raw.length > 64 || raw.some(p => typeof p !== "string" || !p || p.length > 200 || isAbsolute(p) || /[\[\]{}!]/.test(p) || /[\x00-\x1f\x7f]/.test(p) || p.split(/[\\/]/).includes("..") ||
8
+ (/[?*]/.test(p) && (p.includes("\\") || (p.match(/[?*]/g)?.length ?? 0) > 16 || p.split("/").some(s => s.includes("**") && s !== "**"))))) throw new Error(`${field} takes up to 64 bounded project-relative paths or *, ? and whole-segment ** globs`);
9
+ return [...new Set(raw.map(p => p.replace(/\/+/g, "/").replace(/\/$/, "") || "."))];
10
+ }
11
+ function existing(path: string): boolean { try { lstatSync(path); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; } }
12
+ /** Resolve the deepest existing ancestor, retaining dangling links as failures. */
13
+ export function policyRealPath(path: string): string {
14
+ let ancestor = path;
15
+ while (!existing(ancestor)) { const parent = dirname(ancestor); if (parent === ancestor) throw new Error("path cannot be resolved"); ancestor = parent; }
16
+ return join(realPath(ancestor), relative(ancestor, path));
17
+ }
18
+ const beneath = (path: string, root: string) => { path = path.toLowerCase(); root = root.toLowerCase(); return path === root || path.startsWith(root.endsWith(sep) ? root : root + sep); };
19
+ export function policyTargets(cwd: string, paths: string[]): string[] {
20
+ const root = realPath(cwd);
21
+ return [...new Set(paths.flatMap(path => { const lexical = resolve(root, path); return [lexical, policyRealPath(lexical)]; }))];
22
+ }
23
+ /** Linear-space wildcard DP, avoiding regex backtracking on untrusted paths. */
24
+ function segmentMatches(pattern: string, name: string): boolean {
25
+ const chars = [...name.toLowerCase()];
26
+ let previous = new Array<boolean>(chars.length + 1).fill(false); previous[0] = true;
27
+ for (const char of pattern.toLowerCase()) {
28
+ const next = new Array<boolean>(chars.length + 1).fill(false);
29
+ next[0] = char === "*" && previous[0]!;
30
+ for (let i = 1; i <= chars.length; i++) next[i] = char === "*" ? previous[i]! || next[i - 1]! : previous[i - 1]! && (char === "?" || char === chars[i - 1]);
31
+ previous = next;
32
+ }
33
+ return previous[chars.length]!;
34
+ }
35
+ function closure(pattern: string[], states: Set<number>): Set<number> {
36
+ const out = new Set(states);
37
+ for (const i of out) if (pattern[i] === "**") out.add(i + 1);
38
+ return out;
39
+ }
40
+ function advance(pattern: string[], states: Set<number>, name: string): Set<number> {
41
+ const next = new Set<number>();
42
+ for (const i of closure(pattern, states)) {
43
+ if (pattern[i] === "**") next.add(i);
44
+ else if (pattern[i] !== undefined && segmentMatches(pattern[i]!, name)) next.add(i + 1);
45
+ }
46
+ return closure(pattern, next);
47
+ }
48
+ function patternScope(pattern: string[], states: Set<number>, path: string, includeParents: boolean): boolean {
49
+ if (isAbsolute(path) || path === ".." || path.startsWith("../")) return false;
50
+ states = closure(pattern, states);
51
+ if (states.has(pattern.length)) return true;
52
+ for (const name of path.split("/").filter(p => p && p !== ".")) {
53
+ states = advance(pattern, states, name);
54
+ if (states.has(pattern.length)) return true; // a matched directory protects its entire subtree
55
+ if (!states.size) return false;
56
+ }
57
+ return includeParents && states.size > 0; // this scope can contain a future protected descendant
58
+ }
59
+ /** Resolve wildcard directory aliases without trusting a cached expansion. Bound all filesystem discovery. */
60
+ function globScope(root: string, lexical: string, actual: string, path: string, includeParents: boolean, budget: { entries: number }): boolean {
61
+ const pattern = path.split("/").filter(p => p && p !== ".");
62
+ const start = closure(pattern, new Set([0]));
63
+ if (patternScope(pattern, start, relative(root, lexical), includeParents) || patternScope(pattern, start, relative(root, actual), includeParents)) return true;
64
+ const visit = (dir: string, states: Set<number>, depth: number, ancestors: Set<string>): boolean => {
65
+ const real = policyRealPath(dir);
66
+ if (states.has(pattern.length)) return beneath(lexical, real) || beneath(actual, real) || includeParents && (beneath(real, lexical) || beneath(real, actual));
67
+ if (patternScope(pattern, states, relative(real, actual), includeParents)) return true;
68
+ if (!existing(dir)) return false;
69
+ if (!beneath(real, root)) throw new Error("policy glob discovery outside project");
70
+ const info = statSync(dir);
71
+ if (!info.isDirectory()) return false;
72
+ if (depth > 32 || ancestors.has(real) || budget.entries > 4096) throw new Error("policy glob discovery unavailable");
73
+ const chain = new Set(ancestors); chain.add(real);
74
+ const handle = opendirSync(dir);
75
+ try {
76
+ for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
77
+ if (++budget.entries > 4096) throw new Error("policy glob discovery unavailable");
78
+ const next = advance(pattern, states, entry.name);
79
+ if (next.size && visit(join(dir, entry.name), next, depth + 1, chain)) return true;
80
+ }
81
+ } finally { handle.closeSync(); }
82
+ return false;
83
+ };
84
+ return visit(root, start, 0, new Set());
85
+ }
86
+ /** Compare declared spelling, real targets and wildcard aliases. Unknown discovery conservatively protects. */
87
+ export function matchesPolicy(cwd: string, path: string, paths: string[], includeParents = false): boolean {
88
+ if (!paths.length) return false;
89
+ try {
90
+ if (path.length > 4096) return true;
91
+ const root = realPath(cwd), lexical = resolve(root, path), actual = policyRealPath(lexical);
92
+ includeParents &&= !existing(lexical) || statSync(lexical).isDirectory();
93
+ const budget = { entries: 0 };
94
+ return paths.some(entry => /[?*]/.test(entry) ? globScope(root, lexical, actual, entry, includeParents, budget) : policyTargets(root, [entry]).some(target => beneath(lexical, target) || beneath(actual, target) || (includeParents && (beneath(target, lexical) || beneath(target, actual)))));
95
+ } catch { return true; }
96
+ }
@@ -0,0 +1,28 @@
1
+ /** Native peers the hub knows; membership checks share this list. */
2
+ export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
3
+ /** Optional production peers are added explicitly; legacy defaults remain stable. */
4
+ export const OPTIONAL_PEERS = ["qwen", "jcode"] as const;
5
+ export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
6
+ export type KnownPeer = typeof SUPPORTED_PEERS[number];
7
+
8
+ export const isKnownPeer = (value: unknown): value is KnownPeer =>
9
+ typeof value === "string" && (KNOWN_PEERS as readonly string[]).includes(value);
10
+
11
+ export const isSupportedPeer = (value: unknown): value is KnownPeer =>
12
+ typeof value === "string" && (SUPPORTED_PEERS as readonly string[]).includes(value);
13
+
14
+ // Policy metadata, not a membership list. Preserve DEFAULT_ROLES' existing key order.
15
+ const PEER_ROLES: Record<KnownPeer, readonly string[]> = {
16
+ claude: ["planner", "reviewer"],
17
+ codex: ["implementer"],
18
+ kimi: ["implementer", "verifier"],
19
+ local: ["implementer", "verifier"],
20
+ pi: ["implementer", "verifier"],
21
+ qwen: ["implementer", "verifier"],
22
+ jcode: ["implementer", "verifier"],
23
+ };
24
+
25
+ /** Each caller owns its role arrays; changing a project's defaults cannot change another's. */
26
+ export function defaultPeerRoles(includeOptional = false): Record<string, string[]> {
27
+ return Object.fromEntries(Object.entries(PEER_ROLES).filter(([peer]) => includeOptional || !(OPTIONAL_PEERS as readonly string[]).includes(peer)).map(([peer, roles]) => [peer, [...roles]]));
28
+ }