@staix/agent-hub 0.12.22 → 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 (183) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +22 -9
  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 +18 -6
  11. package/docs/agent-notes/benchmarks.md +3 -0
  12. package/docs/agent-notes/budget.md +4 -0
  13. package/docs/agent-notes/bus.md +3 -1
  14. package/docs/agent-notes/daemon.md +14 -1
  15. package/docs/agent-notes/local-worker.md +6 -3
  16. package/docs/agent-notes/tasks.md +6 -1
  17. package/docs/agent-notes/tests.md +4 -1
  18. package/docs/bench.md +28 -3
  19. package/docs/cooperbench.md +4 -0
  20. package/docs/data-policy.md +36 -0
  21. package/docs/events.md +10 -3
  22. package/docs/gsd-collaboration-analysis.md +48 -0
  23. package/docs/memory.md +231 -0
  24. package/docs/operations.md +873 -30
  25. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  26. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  27. package/docs/profiles.md +57 -0
  28. package/docs/quickstart.md +9 -4
  29. package/docs/research.md +31 -1
  30. package/docs/roles.md +85 -0
  31. package/docs/sdlc.md +170 -0
  32. package/docs/security.md +124 -8
  33. package/docs/smoke.md +354 -5
  34. package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
  35. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  36. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  37. package/docs/text-projection.md +19 -0
  38. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  39. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  40. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  41. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  42. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  43. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  44. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  45. package/docs/verification/2026-10-13-board-phone.md +31 -0
  46. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  47. package/docs/verified.json +34 -16
  48. package/package.json +2 -1
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  51. package/plugins/agent-hub/server.js +1108 -261
  52. package/scripts/check-docs.mjs +129 -0
  53. package/src/adapters/acp-quota.ts +13 -0
  54. package/src/adapters/acp.ts +201 -20
  55. package/src/adapters/claude-channel.ts +48 -11
  56. package/src/adapters/codex-appserver.ts +54 -13
  57. package/src/adapters/jcode.ts +110 -0
  58. package/src/adapters/local-worker.ts +10 -7
  59. package/src/adapters/pi.ts +94 -20
  60. package/src/cli/attention.ts +60 -0
  61. package/src/cli/bench.ts +26 -6
  62. package/src/cli/board-read.ts +12 -0
  63. package/src/cli/console-state.ts +131 -39
  64. package/src/cli/console.ts +73 -17
  65. package/src/cli/facts-hook.ts +12 -4
  66. package/src/cli/help.ts +52 -21
  67. package/src/cli/identity.ts +19 -8
  68. package/src/cli/init.ts +48 -10
  69. package/src/cli/jcode-mcp.ts +58 -0
  70. package/src/cli/kimi-mcp.ts +53 -0
  71. package/src/cli/launch.ts +16 -1
  72. package/src/cli/main.ts +641 -193
  73. package/src/cli/memory.ts +193 -0
  74. package/src/cli/output.ts +404 -0
  75. package/src/cli/pet-config.ts +1 -0
  76. package/src/cli/pet.ts +83 -0
  77. package/src/cli/preview.ts +40 -6
  78. package/src/cli/reset.ts +3 -0
  79. package/src/cli/status-lines.ts +1 -29
  80. package/src/cli/tail-render.ts +1 -0
  81. package/src/cli/terminal-recovery.ts +27 -1
  82. package/src/cli/upgrade-runtime.ts +56 -10
  83. package/src/cli/upgrade.ts +23 -5
  84. package/src/cli/watch.ts +31 -0
  85. package/src/hub/activity.ts +53 -0
  86. package/src/hub/ask.ts +10 -5
  87. package/src/hub/bench.ts +10 -3
  88. package/src/hub/board.ts +224 -16
  89. package/src/hub/budget.ts +39 -7
  90. package/src/hub/bus.ts +58 -20
  91. package/src/hub/child-process.ts +11 -0
  92. package/src/hub/citations.ts +7 -0
  93. package/src/hub/conductor.ts +34 -17
  94. package/src/hub/config-trust.ts +20 -7
  95. package/src/hub/conflicts.ts +2 -0
  96. package/src/hub/context-window.ts +8 -1
  97. package/src/hub/crash.ts +11 -1
  98. package/src/hub/daemon.ts +1218 -113
  99. package/src/hub/db-timeout.ts +3 -0
  100. package/src/hub/delivery-journal.ts +7 -1
  101. package/src/hub/envelope.ts +1 -1
  102. package/src/hub/events.ts +14 -2
  103. package/src/hub/execution-budget.ts +2 -0
  104. package/src/hub/facts.ts +28 -8
  105. package/src/hub/git.ts +38 -0
  106. package/src/hub/github-import.ts +28 -0
  107. package/src/hub/hub-tools.ts +49 -10
  108. package/src/hub/inference.ts +1 -1
  109. package/src/hub/lifecycle-schema.ts +17 -0
  110. package/src/hub/lifecycle.ts +10 -4
  111. package/src/hub/listen.ts +15 -0
  112. package/src/hub/manager.ts +26 -6
  113. package/src/hub/path-policy.ts +96 -0
  114. package/src/hub/peer-catalog.ts +28 -0
  115. package/src/hub/peers.ts +5 -3
  116. package/src/hub/permission-mode.ts +33 -5
  117. package/src/hub/planning.ts +70 -0
  118. package/src/hub/profiles.ts +74 -0
  119. package/src/hub/project.ts +2 -1
  120. package/src/hub/projected-diff.ts +73 -0
  121. package/src/hub/projection-exec.ts +24 -0
  122. package/src/hub/recovery-error.ts +31 -0
  123. package/src/hub/recovery-store.ts +6 -5
  124. package/src/hub/registry.ts +2 -1
  125. package/src/hub/research.ts +144 -21
  126. package/src/hub/roles.ts +123 -0
  127. package/src/hub/roster.ts +23 -0
  128. package/src/hub/routing.ts +132 -18
  129. package/src/hub/sdlc-queue.ts +112 -0
  130. package/src/hub/sdlc-release.ts +125 -0
  131. package/src/hub/sdlc.ts +250 -0
  132. package/src/hub/settings.ts +411 -0
  133. package/src/hub/shipped-roles.ts +25 -0
  134. package/src/hub/snapshots.ts +92 -16
  135. package/src/hub/start-mode.ts +50 -0
  136. package/src/hub/supervision.ts +1 -0
  137. package/src/hub/task-sweep.ts +1 -1
  138. package/src/hub/tasks.ts +1014 -111
  139. package/src/hub/terminal-open.ts +64 -0
  140. package/src/hub/text-projection.ts +126 -0
  141. package/src/hub/tooling.ts +159 -0
  142. package/src/hub/ui.ts +40 -14
  143. package/src/hub/workflow.ts +71 -0
  144. package/src/local/deny.ts +29 -7
  145. package/src/local/sandbox.ts +50 -10
  146. package/src/local/tools.ts +11 -6
  147. package/src/memory/brief.ts +28 -14
  148. package/src/memory/import.ts +55 -0
  149. package/src/memory/recall.ts +86 -2
  150. package/src/memory/store.ts +750 -0
  151. package/src/memory/sync.ts +145 -0
  152. package/src/models/mlx.ts +2 -1
  153. package/src/pet/NOTICE.md +10 -0
  154. package/src/pet/characters.ts +1438 -0
  155. package/src/pet/config.ts +31 -0
  156. package/src/pet/frame.ts +17 -0
  157. package/src/pet/pixels.ts +28 -0
  158. package/src/pi/tool-receipts.ts +2 -1
  159. package/src/role-text.d.ts +1 -0
  160. package/src/ui/index.html +983 -40
  161. package/src/ui/needs.ts +58 -0
  162. package/src/ui/office-model.ts +440 -0
  163. package/src/ui/peer-palette.ts +16 -0
  164. package/src/ui/task-board.ts +49 -0
  165. package/src/ui/task-progress.ts +5 -1
  166. package/templates/AGENTS.block.md +3 -2
  167. package/templates/config.json +5 -1
  168. package/templates/profiles/dev.json +22 -0
  169. package/templates/profiles/general.json +26 -0
  170. package/templates/profiles/research.json +57 -0
  171. package/templates/profiles/writing.json +28 -0
  172. package/templates/roles/NOTICE.md +26 -0
  173. package/templates/roles/dev/backend-engineer.md +26 -0
  174. package/templates/roles/dev/code-reviewer.md +26 -0
  175. package/templates/roles/dev/frontend-engineer.md +26 -0
  176. package/templates/roles/dev/security-reviewer.md +26 -0
  177. package/templates/roles/dev/software-architect.md +26 -0
  178. package/templates/roles/dev/test-engineer.md +26 -0
  179. package/templates/roles/research/analyst.md +26 -0
  180. package/templates/roles/research/fact-checker.md +26 -0
  181. package/templates/roles/research/research-lead.md +26 -0
  182. package/templates/roles/research/researcher.md +26 -0
  183. package/templates/roles/research/statistician.md +26 -0
@@ -0,0 +1,3 @@
1
+ // hub.db is shared by the board, journal, budgets, turns and Pi receipts.
2
+ // Bound each SQLite contention wait; callers must not multiply it with retries.
3
+ export const HUB_DB_BUSY_TIMEOUT_MS = 3000;
@@ -4,6 +4,7 @@ import { Database } from "bun:sqlite";
4
4
  import type { BusSnapshot } from "./bus.ts";
5
5
  import type { Envelope } from "./envelope.ts";
6
6
  import { processLiveness } from "../pi/process-signature.ts";
7
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
7
8
 
8
9
  export type JournalDeliveryState =
9
10
  | "queued"
@@ -239,10 +240,10 @@ export class DeliveryJournal {
239
240
  this.operationId = options.operationId;
240
241
  mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
241
242
  this.db = new Database(options.file, { create: true });
243
+ this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
242
244
  this.harden();
243
245
  this.db.run("PRAGMA journal_mode = WAL");
244
246
  this.db.run("PRAGMA synchronous = FULL");
245
- this.db.run("PRAGMA busy_timeout = 3000");
246
247
  this.createSchema();
247
248
  const meta = this.readMeta();
248
249
  if (!meta)
@@ -657,6 +658,11 @@ export class DeliveryJournal {
657
658
  ) as Record<string, unknown>[];
658
659
  return rows.map(parseDelivery);
659
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
+ }
660
666
  resolve(
661
667
  id: string,
662
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 };
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
 
@@ -12,6 +14,9 @@ export const EVENTS_SCHEMA = 1;
12
14
  */
13
15
  export type HubEvent =
14
16
  | { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
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 }[] }
19
+ | { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean }
15
20
  | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
16
21
  | { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
17
22
  | { type: "stale"; id: string; from: string; peer: string; task?: string }
@@ -25,11 +30,17 @@ export type HubEvent =
25
30
  | { type: "progress"; peer: string; task: number; severity: number; spinning: number; exploring: number; production: number }
26
31
  | { type: "stuck"; peer: string; task: number; category: "repetition" | "false_progress" | "drift" | "desperation" | "capability_gap"; streak: number; latched: boolean }
27
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 }
28
39
  | { type: "split"; task: number; where?: "routing" | "cohort"; verdict: "split" | "single" | "unknown"; single?: string; splitS?: number; singleS?: number; reason?: string; trace?: string[] }
29
40
  | { type: "state"; peer: string; state: string }
30
41
  | { type: "turn_start"; peer: string; turn: string }
31
42
  | { type: "native_thread"; peer: string; thread: string; fresh: boolean }
32
- | ({ 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>)
33
44
  | { type: "native_turn_end"; peer: string; id?: string }
34
45
  | { type: "conduct"; peer: string; action: string; task?: number; target?: string }
35
46
  | { type: "agent_cli"; peer: string; command: string; refused: boolean }
@@ -40,7 +51,8 @@ export type HubEvent =
40
51
  | { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
41
52
  | ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
42
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>)
43
- | { 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 }
44
56
  | { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
45
57
  /** A PII screen verdict (issue #198): what was screened and the closed label, source and category, never the text. */
46
58
  | ({ type: "pii_screen" } & ScreenRecord)
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
2
3
 
3
4
  export type ExecutionUnit = "model_calls" | "tool_calls" | "elapsed_ms" | "tokens";
4
5
  export type ExecutionBudgetLimits = Partial<Record<ExecutionUnit, number>>;
@@ -42,6 +43,7 @@ export class ExecutionBudget {
42
43
  private readonly db: Database;
43
44
  constructor(path: string) {
44
45
  this.db = new Database(path, { create: true });
46
+ this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
45
47
  this.db.run("PRAGMA journal_mode = WAL");
46
48
  this.db.run(`CREATE TABLE IF NOT EXISTS execution_budgets (
47
49
  id TEXT PRIMARY KEY, kind TEXT NOT NULL, task_id INTEGER, peers TEXT NOT NULL,
package/src/hub/facts.ts CHANGED
@@ -1,8 +1,11 @@
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";
4
6
  import { sanitize, type PeerId } from "./envelope.ts";
5
7
  import { isDenied } from "../local/deny.ts";
8
+ import { hubGitArgv, hubGitEnv } from "./git.ts";
6
9
  import { realPath } from "./project.ts";
7
10
 
8
11
  /**
@@ -53,6 +56,8 @@ export interface FactScope {
53
56
  export interface FactsOptions {
54
57
  /** The project root, a real absolute path. */
55
58
  root: string;
59
+ /** An explicitly discovered shadow repository; ordinary git retains its existing cwd semantics. */
60
+ repo?: Repo;
56
61
  /** A private directory for the two sides of a diff. */
57
62
  tmp: string;
58
63
  /** Names this hub run's offers, so an acknowledgement for another run's offer matches nothing. */
@@ -64,9 +69,14 @@ export interface FactsOptions {
64
69
  nameable: (s: string) => boolean;
65
70
  /** `local.deny` entries: with the one denylist (`src/local/deny.ts`), paths facts never read or show. */
66
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;
67
76
  }
68
77
 
69
78
  interface Version {
79
+ projected?: true;
70
80
  hash: string;
71
81
  text?: string;
72
82
  /** Git's blob id of the same bytes: a file equal to HEAD's is no change, whatever its stat data says. */
@@ -170,11 +180,15 @@ export class Facts {
170
180
  this.root = root;
171
181
  }
172
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
+
173
186
  /**
174
187
  * A project-relative path, or undefined for one outside the project. Resolved as a real path (symlinks and `..` are
175
188
  * followed first), so neither a task's refs nor a tool's input can point the hub at a file outside the project.
176
189
  */
177
190
  rel(path: string): string | undefined {
191
+ if (this.o.privatePath?.(path)) return undefined;
178
192
  const abs = isAbsolute(path) ? path : resolve(this.root, path);
179
193
  let real = abs;
180
194
  try {
@@ -187,7 +201,11 @@ export class Facts {
187
201
  }
188
202
  }
189
203
  const r = relative(this.root, real);
190
- 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
+ }
191
209
  // Never git's own files, and never what the denylist keeps from every agent.
192
210
  return /(^|\/)\.git(\/|$)/i.test(r) || isDenied(r, this.o.deny ?? []) ? undefined : r;
193
211
  }
@@ -201,6 +219,8 @@ export class Facts {
201
219
  */
202
220
  private load(file: string): Omit<Version, "seq"> {
203
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 };
204
224
  let fd: number | undefined;
205
225
  try {
206
226
  fd = openSync(join(this.root, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
@@ -275,7 +295,7 @@ export class Facts {
275
295
  // One path per line: a newline in a name would shift the answers, and git drops a final carriage return (another file).
276
296
  files = files.filter((f) => !/[\r\n]/.test(f));
277
297
  if (!files.length) return out;
278
- const r = Bun.spawnSync(["git", "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: { ...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() });
279
299
  if (r.exitCode !== 0) return out;
280
300
  r.stdout.toString().split("\n").forEach((line, i) => {
281
301
  const [oid, type] = line.split(" ");
@@ -414,7 +434,7 @@ export class Facts {
414
434
  if (!files) {
415
435
  // Plumbing only, with optional locks off: an agent's own git commands must never meet a lock the hub holds.
416
436
  const git = (...args: string[]) => {
417
- const r = Bun.spawnSync(["git", ...args, "--", p], { cwd: this.root, stdout: "pipe", stderr: "pipe", env: { ...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() });
418
438
  return r.exitCode === 0 ? r.stdout.toString().split("\0").filter(Boolean) : undefined;
419
439
  };
420
440
  // Against HEAD, so a staged change counts (a staged move by both names); a repository without a commit yet has
@@ -496,7 +516,7 @@ export class Facts {
496
516
  this.touch(peer, file, pre.version);
497
517
  if (["Edit", "MultiEdit", "Write"].includes(tool)) {
498
518
  this.wrote(peer, file);
499
- 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;
500
520
  const version = this.observe(file, expected === undefined ? undefined : { peer, expected: sha1(expected) });
501
521
  const last = this.transitions.get(file)?.at(-1);
502
522
  if (last?.seq === version.seq && last.by === peer && this.view(peer).get(file)?.hash === pre.version.hash) this.view(peer).set(file, version);
@@ -505,7 +525,7 @@ export class Facts {
505
525
  if (tool === "Read") {
506
526
  const version = this.observe(file);
507
527
  // Only a whole read of an unchanged file says what the peer saw: no offset or limit, and nothing Read cuts.
508
- 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);
509
529
  return this.observeAll();
510
530
  }
511
531
  }
@@ -533,7 +553,7 @@ export class Facts {
533
553
  this.touch(peer, file, was);
534
554
  this.wrote(peer, file);
535
555
  const now = this.load(file);
536
- 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;
537
557
  const version = this.observe(file, matches ? { peer, expected: now.hash } : undefined);
538
558
  const last = this.transitions.get(file)?.at(-1);
539
559
  if (was && last?.seq === version.seq && last.by === peer && this.view(peer).get(file)?.hash === was.hash) this.view(peer).set(file, version);
@@ -693,7 +713,7 @@ export class Facts {
693
713
  tree(paths: string[], windows: { peer: PeerId; since: number; until?: number }[] = []): string {
694
714
  this.boundary();
695
715
  const h = createHash("sha1");
696
- const head = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", "HEAD"], { cwd: this.root, stdout: "pipe", stderr: "pipe" });
716
+ const head = Bun.spawnSync(this.gitArgs(["rev-parse", "-q", "--verify", "HEAD"]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: this.gitEnv() });
697
717
  h.update(head.exitCode === 0 ? head.stdout.toString().trim() : "no-head").update("\0");
698
718
  // The files the members wrote while at work in the cohort count too: a symbol-only overlap names no path, and a
699
719
  // tool's write outside the named paths still moves the work the integration checked. What they only read never
@@ -780,7 +800,7 @@ export class Facts {
780
800
  try {
781
801
  writeFileSync(a, before, { mode: 0o600 });
782
802
  writeFileSync(b, after, { mode: 0o600 });
783
- const r = Bun.spawnSync(["git", "-c", "core.quotepath=off", "diff", "--no-index", "--no-color", "--no-ext-diff", "--unified=2", "--", a, b], { stdout: "pipe", stderr: "pipe" });
803
+ const r = Bun.spawnSync(hubGitArgv(["-c", "core.quotepath=off", "diff", "--no-index", "--no-color", "--no-ext-diff", "--no-textconv", "--unified=2", "--", a, b]), { stdout: "pipe", stderr: "pipe", env: hubGitEnv() });
784
804
  const lines = r.stdout.toString().split("\n");
785
805
  const start = lines.findIndex((l) => l.startsWith("@@"));
786
806
  if (start === -1) return [];
package/src/hub/git.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { spawnSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from "node:child_process";
2
+
3
+ /**
4
+ * The one helper for every git call the hub makes outside the sandbox (#281): the repository's own configuration
5
+ * must not name a program the hub then runs with its own rights (defense in depth behind the seatbelt profile,
6
+ * which refuses a sandboxed command the writes that would plant one). Every call gets:
7
+ * - core.fsmonitor=false: the file-system monitor is an arbitrary command read from the repo config.
8
+ * - core.hooksPath=/dev/null: hooks come from a non-directory, so every hook lookup fails closed.
9
+ * - diff.external= (empty): no external diff driver.
10
+ * - core.pager=cat and --no-pager: no pager, whatever the config says (stdout is a pipe everywhere anyway).
11
+ * - credential.helper= (empty resets the list) and core.askpass= (empty fails closed), with GIT_TERMINAL_PROMPT=0:
12
+ * no hub command authenticates, so any prompt is a bug and must fail instead of running a configured helper.
13
+ * git has no global switch for filter drivers or textconv: both are per-driver config keys. Of the hub's commands
14
+ * only the snapshot's `add`/`restore` would run a filter driver, and only a content diff would run textconv — the
15
+ * hub's one content diff is `diff --no-index`, which DOES read the repository's config (verified: a planted
16
+ * diff.external runs for it), so that call passes `--no-ext-diff` and `--no-textconv` on top of the config here,
17
+ * and the driver command of a filter lives in the `.git/config` the seatbelt profile refuses to a sandboxed
18
+ * command. GIT_CONFIG_NOSYSTEM/global are left alone: those files are the user's own.
19
+ */
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="]);
23
+
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];
30
+
31
+ /** The caller's env plus the no-prompt switch; existing keys (childEnv, GIT_OPTIONAL_LOCKS=0) pass through. */
32
+ export const hubGitEnv = (env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => ({ ...env, GIT_TERMINAL_PROMPT: "0" });
33
+
34
+ /** spawnSync form of the same call: the env option is merged with hubGitEnv, everything else passes through. */
35
+ export function hubGitSync(args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string> {
36
+ const argv = hubGitArgv(args);
37
+ return spawnSync(argv[0]!, argv.slice(1), { ...options, env: hubGitEnv(options.env ?? process.env) });
38
+ }
@@ -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 or headless pi. Claude, Codex and Pi TUI requests return a command for the person to run, without launching a terminal.", { peer: { type: "string", enum: ["local", "kimi", "pi", "claude", "codex"] }, mode: { type: "string", enum: ["headless", "tui"] } }, ["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
+ }
@@ -1,3 +1,4 @@
1
+ import { isDatabaseBusyReply, RecoveryDatabaseBusy } from "./recovery-error.ts";
1
2
  import { spawn } from "node:child_process";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -5,19 +6,21 @@ import { join } from "node:path";
5
6
  import { createServer, type Server } from "node:net";
6
7
  import { ControlClient, PROTOCOL, readControl } from "./control-client.ts";
7
8
  import { startDaemon } from "./daemon.ts";
9
+ import { terminalOpener } from "./terminal-open.ts";
8
10
  import { Registry, type Project } from "./registry.ts";
9
11
  import { CODEX_APP, CODEX_PROXY, CONTROL, SWITCHYARD } from "./ports.ts";
10
12
  import { assertLifecycleAvailable, recoveryLock } from "./recovery-store.ts";
11
13
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
12
14
 
13
15
  export type ProjectInspection = {
16
+ failure?: { code: "database_busy" };
14
17
  state: "running" | "stopped" | "stopping" | "unavailable" | "incompatible" | "missing" | "starting";
15
18
  status?: any;
16
19
  error?: string;
17
20
  };
18
21
 
19
22
  /** A port or PID alone is never evidence that the selected project's hub is alive. */
20
- export async function inspectProject(project: Project): Promise<ProjectInspection> {
23
+ export async function inspectProject(project: Project, timeoutMs = 3000): Promise<ProjectInspection> {
21
24
  if (!existsSync(project.root)) return { state: "missing", error: "project directory is missing" };
22
25
  const control = readControl(project.stateDir);
23
26
  if (control) {
@@ -32,8 +35,9 @@ export async function inspectProject(project: Project): Promise<ProjectInspectio
32
35
  try {
33
36
  // Older manifests omit identity. Authenticate first, then require the full current contract.
34
37
  client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root,
35
- ...(control.projectId ? { projectId: project.id } : {}), ...(control.instanceId ? { instanceId: control.instanceId } : {}) });
36
- const response = await client.request({ t: "status" }, 3000);
38
+ ...(control.projectId ? { projectId: project.id } : {}), ...(control.instanceId ? { instanceId: control.instanceId } : {}) }, timeoutMs);
39
+ const response = await client.request({ t: "status" }, timeoutMs);
40
+ if (isDatabaseBusyReply(response)) return { state: "unavailable", error: new RecoveryDatabaseBusy().message, failure: { code: "database_busy" } };
37
41
  if (!response.status || response.status.projectId !== project.id || !response.status.instanceId || response.status.cwd !== project.root) {
38
42
  return { state: "unavailable", error: "authenticated hub identity does not match registration" };
39
43
  }
@@ -112,7 +116,9 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
112
116
  try {
113
117
  daemon = await startDaemon({ cwd: project.root, stateDir: project.stateDir, projectId: project.id, instanceId,
114
118
  controlPort: base + CONTROL, codexAppPort: base + CODEX_APP, codexProxyPort: base + CODEX_PROXY,
115
- switchyardPort: base + SWITCHYARD, unattended, onShutdownStart });
119
+ switchyardPort: base + SWITCHYARD, unattended, onShutdownStart,
120
+ // #269: the real terminal providers belong to the running product; a daemon built without them opens nothing.
121
+ terminal: (template) => terminalOpener({ template, cwd: project.root, stateDir: project.stateDir }) });
116
122
  break;
117
123
  } catch (error) {
118
124
  if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
@@ -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";
@@ -9,15 +11,19 @@ import { hubHome } from "./project.ts";
9
11
  import { startDashboard } from "./ui.ts";
10
12
  import { projectChain } from "../memory/recall.ts";
11
13
  import type { ProjectInspection } from "./lifecycle.ts";
14
+ import { MAX_COMMAND_MS } from "../cli/terminal-recovery.ts";
12
15
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
13
16
 
17
+ const MANAGER_OWNER_DB_BUSY_TIMEOUT_MS = 5000;
14
18
  export type ProjectRecord = Project;
15
19
  export type Lifecycle = {
16
20
  inspectProject(project: Project): Promise<ProjectInspection>;
17
21
  startProject(project: Project, options?: { unattended?: boolean; env?: NodeJS.ProcessEnv }): Promise<any>;
18
22
  stopProject(project: Project, expectedInstance?: string): Promise<void>;
19
23
  };
20
- export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number };
24
+ export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number;
25
+ /** Test clock seam: maps nominal forwarding deadlines, never comes from dashboard input. */
26
+ forwardTimeoutMs?: (action: string, nominalMs: number) => number };
21
27
  type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
22
28
  type Manifest = { port: number; protocol: number; instanceId: string; pid: number; pidSignature?: string };
23
29
  const active = new Map<string, { handle: ManagerHandle; issue(): string }>();
@@ -45,7 +51,7 @@ async function bounded<T>(promise: Promise<T>, ms: number): Promise<T> {
45
51
  function claimOwner(path: string, instanceId: string, signature: string | undefined): Database {
46
52
  const db = new Database(path, { create: true });
47
53
  try {
48
- db.run("PRAGMA busy_timeout = 5000");
54
+ db.run(`PRAGMA busy_timeout = ${MANAGER_OWNER_DB_BUSY_TIMEOUT_MS}`);
49
55
  db.run("CREATE TABLE IF NOT EXISTS owner (slot INTEGER PRIMARY KEY CHECK (slot = 1), instance_id TEXT NOT NULL, pid INTEGER NOT NULL, signature TEXT)");
50
56
  if (!(db.query("PRAGMA table_info(owner)").all() as { name: string }[]).some((c) => c.name === "signature")) {
51
57
  try { db.run("ALTER TABLE owner ADD COLUMN signature TEXT"); }
@@ -148,11 +154,25 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
148
154
  await bounded(lifecycle.stopProject(project, input.instanceId), 20_000);
149
155
  return { ok: true };
150
156
  }
151
- if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
157
+ // The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
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" };
152
159
  client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
153
- return await client.request(input.action === "snapshot"
154
- ? { t: "ui_snapshot", after: input.after ?? 0 }
155
- : { t: "ui_action", action: input, instanceId: input.instanceId }, 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;
165
+ const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
166
+ const result = await client.request(input.action === "snapshot"
167
+ ? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}) }
168
+ : { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
169
+ // ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
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.";
172
+ return { ok: false, unconfirmed: true, text, error: text };
173
+ }
174
+ if (deliveryRead) { const { rid: _transportId, ...report } = result; return report; }
175
+ return result;
156
176
  } catch (error) { return { ok: false, error: safeError(error) }; }
157
177
  finally { client?.close(); }
158
178
  };