@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
@@ -0,0 +1,250 @@
1
+ import { phaseReceipt, type PhaseReceipt, type PhaseView } from "./workflow.ts";
2
+ import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { childEnv, stopOwnedProcess } from "./child-process.ts";
6
+
7
+ export const SDLC_STAGES = ["spec", "plan", "implement", "review", "verify", "merge", "release"] as const;
8
+ export type SdlcBaseStage = typeof SDLC_STAGES[number];
9
+ export type SdlcStage = string;
10
+ export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "phase";
11
+ export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
12
+ export type SdlcMode = "off" | "advise" | "enforce";
13
+ export interface SdlcConfig { workflow?: "gsd-lite"; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; }
14
+ export interface SdlcReceipt {
15
+ stage: SdlcStage;
16
+ phase?: PhaseReceipt;
17
+ repo?: string;
18
+ issue?: number;
19
+ pr?: number;
20
+ head?: string;
21
+ reviewedHead?: string;
22
+ localHead?: string;
23
+ localResult?: "passed" | "failed" | "unknown";
24
+ observed?: number;
25
+ }
26
+ export interface PrEvidence {
27
+ head: string;
28
+ state: "OPEN" | "MERGED" | "CLOSED";
29
+ checks: { name: string; state: "passed" | "failed" | "pending" | "unknown" }[];
30
+ review: "approved" | "changes_requested" | "unknown";
31
+ unresolved: number | null;
32
+ pendingReviews: number | null;
33
+ paths: string[];
34
+ pathsComplete?: boolean;
35
+ draft?: boolean;
36
+ base?: string;
37
+ baseHead?: string;
38
+ mergeCommit?: string;
39
+ }
40
+ const sha = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
41
+ const repo = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
42
+ const id = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
43
+
44
+ const STAGE_ID = /^[a-z][a-z0-9_-]{0,47}$/;
45
+ const STAGE_GATES: StageGate[] = ["issue", "pr", "local", "review", "checks", "phase"];
46
+ const baseActor = (base: SdlcBaseStage) => base === "review" || base === "verify" ? "reviewer" : base === "merge" || base === "release" ? "person" : "owner/planner";
47
+ export function stageDefinition(config: SdlcConfig, stage: string): StageDefinition | undefined {
48
+ if (!config.stages.includes(stage)) return undefined;
49
+ if (config.definitions && Object.hasOwn(config.definitions, stage)) return config.definitions[stage];
50
+ if (!(SDLC_STAGES as readonly string[]).includes(stage)) return undefined;
51
+ const base = stage as SdlcBaseStage;
52
+ return { base, actor: baseActor(base), entry_gates: [], exit_gates: [], after: [] };
53
+ }
54
+ export function lifecycleStage(config: SdlcConfig, base: SdlcBaseStage): string {
55
+ return config.stages.find(stage => stageDefinition(config, stage)?.base === base) ?? base;
56
+ }
57
+ function definitions(raw: unknown, stages: unknown): { stages: string[]; definitions: Record<string, StageDefinition> } {
58
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).length > 32) throw new Error("sdlc.definitions: expected at most 32 stage definitions");
59
+ if (!Array.isArray(stages) || !stages.length || stages.length > 32 || stages.some(s => typeof s !== "string" || !STAGE_ID.test(s)) || new Set(stages).size !== stages.length) throw new Error("sdlc.stages: expected 1..32 distinct bounded stage ids");
60
+ const result: Record<string, StageDefinition> = Object.create(null);
61
+ for (const [id, value] of Object.entries(raw)) {
62
+ if (!STAGE_ID.test(id) || !stages.includes(id) || !value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc.definitions: invalid or unlisted stage");
63
+ const def = value as Record<string, unknown>;
64
+ if (Object.keys(def).some(k => !["base", "actor", "entry_gates", "exit_gates", "after"].includes(k)) || !(SDLC_STAGES as readonly unknown[]).includes(def.base)) throw new Error("sdlc.definitions: unknown field or base");
65
+ const base = def.base as SdlcBaseStage;
66
+ if ((SDLC_STAGES as readonly string[]).includes(id) && id !== base) throw new Error("sdlc.definitions: built-in stage base cannot change");
67
+ const actor = def.actor ?? baseActor(base);
68
+ if (typeof actor !== "string" || actor !== baseActor(base) && !["owner", "reviewer", "person", "planner"].includes(actor)) throw new Error("sdlc.definitions.actor: unknown actor");
69
+ const gates = (key: string): StageGate[] => {
70
+ const list = def[key] ?? [];
71
+ if (!Array.isArray(list) || list.length > 6 || list.some(g => !STAGE_GATES.includes(g)) || new Set(list).size !== list.length) throw new Error(`sdlc.definitions.${key}: unknown or duplicate gate`);
72
+ return [...list] as StageGate[];
73
+ };
74
+ const after = def.after ?? [];
75
+ if (!Array.isArray(after) || after.length > 8 || after.some(a => typeof a !== "string" || !stages.includes(a) || stages.indexOf(a) >= stages.indexOf(id)) || new Set(after).size !== after.length) throw new Error("sdlc.definitions.after: dependencies must be distinct earlier stages (no missing, self, forward or cyclic edge)");
76
+ result[id] = { base, actor: actor as string, entry_gates: gates("entry_gates"), exit_gates: gates("exit_gates"), after: [...after] as string[] };
77
+ }
78
+ const config = { stages, definitions: result } as SdlcConfig;
79
+ if (stages.some(s => !stageDefinition(config, s)) || !["review", "merge"].every(base => stages.some(s => stageDefinition(config, s)?.base === base))) throw new Error("sdlc.stages: define every custom id and include review and merge base destinations");
80
+ return { stages: [...stages], definitions: result };
81
+ }
82
+
83
+ export function sdlcConfig(value: unknown): SdlcConfig {
84
+ if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
85
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
86
+ const raw = value as Record<string, unknown>;
87
+ for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
88
+ const mode = raw.mode ?? "off";
89
+ if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
90
+ const workflow = raw.workflow ?? "none";
91
+ if (workflow !== "none" && workflow !== "gsd-lite") throw new Error("sdlc.workflow: expected none or gsd-lite");
92
+ const checks = raw.required_checks ?? [];
93
+ if (!Array.isArray(checks) || checks.length > 32 || checks.some(c => typeof c !== "string" || !c.trim() || c.length > 120 || /[\r\n\x00-\x1f]/.test(c)) || new Set(checks).size !== checks.length) throw new Error("sdlc.required_checks: expected at most 32 unique nonempty names");
94
+ if (raw.definitions !== undefined) {
95
+ const custom = definitions(raw.definitions, raw.stages);
96
+ if (workflow !== "gsd-lite" && Object.values(custom.definitions).some(d => [...d.entry_gates, ...d.exit_gates].includes("phase"))) throw new Error("sdlc.definitions: phase gates require workflow = gsd-lite");
97
+ return { mode, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}) };
98
+ }
99
+ const stages = raw.stages ?? [...SDLC_STAGES];
100
+ if (!Array.isArray(stages) || stages.length !== SDLC_STAGES.length || stages.some((stage, i) => stage !== SDLC_STAGES[i])) throw new Error("sdlc.stages: use spec, plan, implement, review, verify, merge, release in that order; custom stages are not supported yet");
101
+ return { mode, requiredChecks: [...checks] as string[], stages: [...stages] as SdlcStage[], ...(workflow === "gsd-lite" ? { workflow } : {}) };
102
+ }
103
+
104
+ export function loadSdlc(cwd: string): SdlcConfig {
105
+ try {
106
+ const fd = openSync(join(cwd, ".agenthub", "sdlc.toml"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
107
+ try {
108
+ const before = fstatSync(fd), cap = 64 * 1024;
109
+ if (!before.isFile() || before.nlink !== 1 || before.size > cap) throw new Error("sdlc.toml must be a regular single-link file within 64 KiB");
110
+ const bytes = Buffer.alloc(cap + 1); let length = 0, n = 0;
111
+ while ((n = readSync(fd, bytes, length, bytes.length - length, null)) > 0) { length += n; if (length > cap) throw new Error("sdlc.toml exceeds 64 KiB"); }
112
+ const after = fstatSync(fd);
113
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || length !== after.size) throw new Error("sdlc.toml changed during read");
114
+ return sdlcConfig(Bun.TOML.parse(bytes.subarray(0, length).toString("utf8")));
115
+ } finally { closeSync(fd); }
116
+ }
117
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return sdlcConfig(undefined); throw error; }
118
+ }
119
+
120
+ /** Identities and claimed local receipts only. External facts are read, never accepted as input. */
121
+ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined = undefined, config: SdlcConfig = sdlcConfig(undefined)): SdlcReceipt {
122
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc receipt: expected an object");
123
+ const raw = value as Record<string, unknown>;
124
+ for (const key of Object.keys(raw)) if (!["stage", "repo", "issue", "pr", "reviewedHead", "localHead", "localResult", "phase"].includes(key)) throw new Error(`sdlc receipt.${key}: unsupported or externally observed field`);
125
+ previous ??= { stage: config.stages[0] ?? "spec" };
126
+ const next = { ...previous };
127
+ if (raw.phase !== undefined) next.phase = phaseReceipt(raw.phase);
128
+ if (raw.stage !== undefined) {
129
+ if (typeof raw.stage !== "string" || !stageDefinition(config, raw.stage)) throw new Error("sdlc receipt.stage: unknown stage");
130
+ next.stage = raw.stage as SdlcStage;
131
+ }
132
+ if (raw.repo !== undefined) { if (!repo(raw.repo)) throw new Error("sdlc receipt.repo: expected owner/repository"); next.repo = raw.repo; }
133
+ for (const key of ["issue", "pr"] as const) if (raw[key] !== undefined) { if (!id(raw[key])) throw new Error(`sdlc receipt.${key}: expected a positive integer`); next[key] = raw[key]; }
134
+ for (const key of ["reviewedHead", "localHead"] as const) if (raw[key] !== undefined) { if (!sha(raw[key])) throw new Error(`sdlc receipt.${key}: expected a full lowercase SHA`); next[key] = raw[key]; }
135
+ if (raw.localResult !== undefined) {
136
+ if (!["passed", "failed", "unknown"].includes(String(raw.localResult))) throw new Error("sdlc receipt.localResult: expected passed, failed or unknown");
137
+ next.localResult = raw.localResult as SdlcReceipt["localResult"];
138
+ }
139
+ // A new artifact cannot inherit another PR's review or local gate.
140
+ if (next.repo !== previous.repo || next.pr !== previous.pr) {
141
+ delete next.head; delete next.observed;
142
+ if (raw.phase === undefined) delete next.phase;
143
+ if (raw.reviewedHead === undefined) delete next.reviewedHead;
144
+ if (raw.localHead === undefined) delete next.localHead;
145
+ if (raw.localResult === undefined) delete next.localResult;
146
+ }
147
+ return next;
148
+ }
149
+
150
+ export function parsePrEvidence(value: unknown): PrEvidence {
151
+ const p = value as Record<string, any>;
152
+ if (!p || !sha(p.headRefOid) || !["OPEN", "CLOSED", "MERGED"].includes(p.state) || !Array.isArray(p.statusCheckRollup) || !Array.isArray(p.files) || p.files.length > 1000) throw new Error("PR readback: invalid or incomplete structured evidence");
153
+ const checks = p.statusCheckRollup.map((c: Record<string, unknown>) => {
154
+ const name = c.name ?? c.context;
155
+ if (typeof name !== "string" || !name || name.length > 120) throw new Error("PR readback: invalid check identity");
156
+ const native = c.conclusion ?? c.state;
157
+ const state = native === "SUCCESS" ? "passed" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"].includes(String(native)) ? "failed" : ["PENDING", "QUEUED", "IN_PROGRESS"].includes(String(c.status ?? native)) ? "pending" : "unknown";
158
+ return { name, state } as PrEvidence["checks"][number];
159
+ });
160
+ const paths = p.files.map((f: Record<string, unknown>) => { if (typeof f.path !== "string" || !f.path || f.path.length > 1000) throw new Error("PR readback: invalid file identity"); return f.path; });
161
+ return { head: p.headRefOid, state: p.state, checks, paths, pathsComplete: Number.isSafeInteger(p.changedFiles) && p.changedFiles >= 0 && p.changedFiles === paths.length, ...(typeof p.isDraft === "boolean" ? { draft: p.isDraft } : {}), ...(typeof p.baseRefName === "string" && p.baseRefName && p.baseRefName.length <= 200 && !/[\x00-\x1f\x7f]/.test(p.baseRefName) ? { base: p.baseRefName } : {}), ...(sha(p.baseRefOid) ? { baseHead: p.baseRefOid } : {}),
162
+ review: p.reviewDecision === "APPROVED" ? "approved" : p.reviewDecision === "CHANGES_REQUESTED" ? "changes_requested" : "unknown",
163
+ pendingReviews: Array.isArray(p.reviewRequests) && p.reviewRequests.length <= 100 && p.reviewRequests.every((r: unknown) => !!r && typeof r === "object") ? p.reviewRequests.length : null,
164
+ unresolved: Number.isSafeInteger(p.unresolvedThreads) && p.unresolvedThreads >= 0 ? p.unresolvedThreads : null,
165
+ ...(sha(p.mergeCommit?.oid) ? { mergeCommit: p.mergeCommit.oid } : {}) };
166
+ }
167
+
168
+ export interface SdlcStatus { workflow?: "gsd-lite"; phase?: PhaseView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
169
+ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, phase?: PhaseView): SdlcStatus {
170
+ const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
171
+ const definition = stageDefinition(config, stage);
172
+ if (!definition) blockers.push("lifecycle stage definition unknown");
173
+ if (!receipt?.repo || !receipt.issue) blockers.push("issue identity missing");
174
+ if (!receipt?.pr) blockers.push("PR identity missing");
175
+ if (!evidence) blockers.push("current PR evidence unknown");
176
+ else {
177
+ if (evidence.state !== "OPEN") blockers.push("PR is not open");
178
+ if (receipt?.reviewedHead !== evidence.head) blockers.push("current head has not been reviewed");
179
+ if (receipt?.localHead !== evidence.head || receipt.localResult !== "passed") blockers.push("local gate on current head missing or failed");
180
+ if (!config.requiredChecks.length) blockers.push("required check set unknown");
181
+ for (const name of config.requiredChecks) {
182
+ const matches = evidence.checks.filter(c => c.name === name);
183
+ if (!matches.length || matches.some(c => c.state !== "passed")) blockers.push(`required check ${name}: not successful on current head`);
184
+ }
185
+ if (evidence.review !== "approved") blockers.push("GitHub review approval missing or changes requested");
186
+ if (evidence.pendingReviews !== 0) blockers.push("review requests pending or unknown");
187
+ if (evidence.unresolved !== 0) blockers.push("review threads unresolved or unknown");
188
+ }
189
+ if (config.workflow === "gsd-lite") {
190
+ blockers.unshift(...(phase?.blockers ?? ["phase board evidence unknown"]));
191
+ if (!evidence || receipt?.phase?.head !== evidence.head) blockers.push("phase checklist head missing or stale");
192
+ }
193
+ const custom = config.definitions && definition ? stageGates(definition, blockers, receipt, evidence, phase) : undefined;
194
+ if (custom) for (const blocker of [...custom.entry, ...custom.exit]) if (!blockers.includes(blocker)) blockers.push(blocker);
195
+ return { ...(config.workflow ? { workflow: config.workflow, ...(phase ? { phase } : {}) } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
196
+ actor: !definition ? "person" : phase?.blockers.length ? "reviewer/person" : config.definitions && definition ? definition.actor : stage === "review" || stage === "verify" ? "reviewer" : stage === "merge" || stage === "release" ? "person" : "owner/planner",
197
+ next: blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
198
+ }
199
+
200
+ function stageGates(def: StageDefinition, blockers: string[], receipt: SdlcReceipt | undefined, evidence: PrEvidence | undefined, phase: PhaseView | undefined): { entry: string[]; exit: string[] } {
201
+ const select = (gates: StageGate[]) => [...new Set(gates.flatMap(gate => {
202
+ if (gate === "phase") return [...(phase?.blockers ?? ["phase board evidence unknown"]), ...(!evidence || receipt?.phase?.head !== evidence.head ? ["phase checklist head missing or stale"] : [])];
203
+ const matching = (text: string) => gate === "issue" ? text === "issue identity missing" : gate === "pr" ? ["PR identity missing", "current PR evidence unknown", "PR is not open"].includes(text) : gate === "local" ? ["current PR evidence unknown", "local gate on current head missing or failed"].includes(text) : gate === "checks" ? text === "current PR evidence unknown" || text === "required check set unknown" || text.startsWith("required check ") : ["current PR evidence unknown", "current head has not been reviewed", "GitHub review approval missing or changes requested", "review requests pending or unknown", "review threads unresolved or unknown"].includes(text);
204
+ return blockers.filter(matching);
205
+ }))];
206
+ return { entry: select(def.entry_gates), exit: select(def.exit_gates) };
207
+ }
208
+
209
+ export function touchingPrs(prs: { pr: number; paths: string[] }[]): { before: number; after: number; paths: string[] }[] {
210
+ const conflicts: ReturnType<typeof touchingPrs> = [];
211
+ for (let i = 0; i < prs.length; i++) for (const right of prs.slice(i + 1)) {
212
+ const left = prs[i]!, paths = [...new Set(left.paths.filter(path => right.paths.includes(path)))].sort();
213
+ if (paths.length) conflicts.push({ before: left.pr, after: right.pr, paths });
214
+ }
215
+ return conflicts;
216
+ }
217
+
218
+ export type SdlcGh = (args: string[], timeoutMs?: number, cwd?: string, maxOutputBytes?: number) => Promise<{ code: number; stdout: string }>;
219
+ /** Read-only, fixed argv. Output and time are bounded; diagnostics never expose credentials or remote text. */
220
+ export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 256 * 1024) => new Promise((resolve) => {
221
+ const outputLimit = Number.isSafeInteger(maxOutputBytes) ? Math.min(2 * 1024 * 1024, Math.max(1, maxOutputBytes)) : 256 * 1024;
222
+ const child = spawn("gh", args, { ...(cwd ? { cwd } : {}), env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
223
+ let stdout = "", failed = false;
224
+ const stop = () => { failed = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
225
+ const timer = setTimeout(stop, Math.max(1, Math.min(10_000, timeoutMs)));
226
+ child.stdout?.setEncoding("utf8");
227
+ child.stdout?.on("data", chunk => { if (failed) return; stdout += chunk.toString(); if (Buffer.byteLength(stdout) > outputLimit) { stdout = ""; stop(); } });
228
+ child.once("error", () => { failed = true; clearTimeout(timer); resolve({ code: 1, stdout: "" }); });
229
+ child.once("close", code => { clearTimeout(timer); resolve({ code: failed ? 1 : code ?? 1, stdout: failed ? "" : stdout }); });
230
+ });
231
+
232
+ export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Promise<PrEvidence | undefined> {
233
+ if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
234
+ try {
235
+ const view = await run(["pr", "view", String(receipt.pr), "--repo", receipt.repo, "--json", "headRefOid,state,statusCheckRollup,reviewDecision,reviewRequests,files,changedFiles,mergeCommit,isDraft,baseRefName,baseRefOid"]);
236
+ if (view.code !== 0) return undefined;
237
+ const [owner, name] = receipt.repo.split("/");
238
+ const threads = await run(["api", "graphql", "-f", "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid reviewThreads(first:100){nodes{isResolved} pageInfo{hasNextPage}}}}}", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${receipt.pr}`]);
239
+ const p = JSON.parse(view.stdout);
240
+ delete p.unresolvedThreads; // only the complete thread query can establish this fact
241
+ if (threads.code === 0) {
242
+ const current = JSON.parse(threads.stdout)?.data?.repository?.pullRequest;
243
+ const nodes = current?.reviewThreads?.nodes;
244
+ // Paginated or malformed thread data is unknown, never a clean review.
245
+ if (current?.headRefOid === p.headRefOid && current?.reviewThreads?.pageInfo?.hasNextPage === false && Array.isArray(nodes) && nodes.every(n => typeof n?.isResolved === "boolean")) p.unresolvedThreads = nodes.filter(n => !n.isResolved).length;
246
+ else if (current?.headRefOid && current.headRefOid !== p.headRefOid) return undefined;
247
+ }
248
+ return parsePrEvidence(p);
249
+ } catch { return undefined; }
250
+ }
@@ -1,3 +1,4 @@
1
+ import { SUPPORTED_PEERS } from "./peer-catalog.ts";
1
2
  import { createHash } from "node:crypto";
2
3
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
@@ -13,14 +14,15 @@ import { OVERLAY_FILE, overlayToml, parseOverlay, parseRouting, routingText, typ
13
14
  * written by either. Writes go only to the machine-local files (`.agenthub/config.local.json`,
14
15
  * `.agenthub/routing.local.toml`), never to a tracked one.
15
16
  */
16
- export const SETTING_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
17
+ export const SETTING_PEERS = SUPPORTED_PEERS;
17
18
  /** `null` removes the machine-local value, so the shared file's or the default applies again. */
18
- export type SettingValue = string | boolean | string[] | null;
19
+ export type SettingValue = string | boolean | number | string[] | null;
19
20
  export interface SettingDef {
20
21
  key: string;
21
- group: "Permissions" | "Start" | "Routing" | "Switches";
22
+ group: "Permissions" | "Start" | "Routing" | "Switches" | "Roster" | "Roles";
22
23
  label: string;
23
- type: "enum" | "boolean" | "peers";
24
+ type: "enum" | "boolean" | "peers" | "roles" | "integer";
25
+ min?: number; max?: number;
24
26
  /** The closed values of an enum; a route setting takes the project's own route ids instead. */
25
27
  values?: readonly string[];
26
28
  /** `runtime` lives in the running hub only; the others are the machine-local file that holds the value. */
@@ -29,7 +31,7 @@ export interface SettingDef {
29
31
  applies: "live" | "peer start" | "hub start";
30
32
  /** `raises`: only a settings session or a person's terminal may set it, except to `floor`. */
31
33
  risk: "safe" | "raises";
32
- floor?: string | boolean;
34
+ floor?: string | boolean | number;
33
35
  /** Where the value sits inside its file (or, for `runtime`, the peer). */
34
36
  path: readonly string[];
35
37
  }
@@ -43,8 +45,12 @@ const perClass = (name: TaskClass): SettingDef[] => [
43
45
  { key: `routing.classes.${name}.pi_backend`, group: "Routing", label: `${name}: Pi backend`, type: "enum", values: ["dgx", "mlx"], store: "routing", applies: "live", risk: "raises", path: ["classes", name, "pi_backend"] },
44
46
  ];
45
47
  export const SETTINGS: readonly SettingDef[] = [
46
- ...SETTING_PEERS.map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
47
- ...SETTING_PEERS.map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at hub start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "hub start", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
48
+ ...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "hub start", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
49
+ ...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "peer start", risk: "safe", path: ["catalog_roles", peer] })),
50
+ { key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
51
+ ...SETTING_PEERS.map((peer): SettingDef => ({ key: `roster.${peer}`, group: "Roster", label: `${peer}: participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] })),
52
+ ...SETTING_PEERS.filter(peer => peer !== "jcode").map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
53
+ ...SETTING_PEERS.filter(peer => peer !== "jcode").map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at hub start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "hub start", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
48
54
  ...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "peer start", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
49
55
  { key: "pi.auto_start", group: "Start", label: "Start Pi with the hub", type: "boolean", store: "config", applies: "hub start", risk: "raises", floor: false, path: ["pi", "auto_start"] },
50
56
  { key: "routing.stay_switch", group: "Routing", label: "Stay or switch (hub/auto and stage routes)", type: "enum", values: ["off", "shadow", "enforce"], store: "routing", applies: "live", risk: "raises", path: ["stay_switch"] },
@@ -59,7 +65,7 @@ export const SETTINGS: readonly SettingDef[] = [
59
65
  * Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
60
66
  * text go. A key equal to an entry, or below it, is refused by name.
61
67
  */
62
- export const NEVER_EDITABLE = ["kimi_cmd", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal"] as const;
68
+ export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal"] as const;
63
69
 
64
70
  /** The definition of a key, or why there is none. */
65
71
  export function settingDef(key: unknown): SettingDef | string {
@@ -76,10 +82,19 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
76
82
  if (def.store === "runtime") throw new Error(`${def.key} has no stored value to remove`);
77
83
  return null;
78
84
  }
85
+ if (def.type === "integer") {
86
+ if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < (def.min ?? 0) || raw > (def.max ?? Number.MAX_SAFE_INTEGER)) throw new Error(`${def.key} takes an integer from ${def.min ?? 0} to ${def.max ?? Number.MAX_SAFE_INTEGER}`);
87
+ return raw;
88
+ }
79
89
  if (def.type === "boolean") {
80
90
  if (typeof raw !== "boolean") throw new Error(`${def.key} takes true or false`);
81
91
  return raw;
82
92
  }
93
+ if (def.type === "roles") {
94
+ const allowed = def.values ?? routes;
95
+ if (!Array.isArray(raw) || raw.length > 2 || new Set(raw).size !== raw.length || raw.some(id => typeof id !== "string" || !allowed.includes(id))) throw new Error(`${def.key} takes at most two distinct compatible catalog ids; allowed: ${allowed.join(", ") || "(none)"}`);
96
+ return [...raw] as string[];
97
+ }
83
98
  if (def.type === "peers") {
84
99
  if (!Array.isArray(raw) || raw.length > PEERS_MAX || raw.some((p) => typeof p !== "string" || !PEER.test(p)) || new Set(raw).size !== raw.length) throw new Error(`${def.key} takes up to ${PEERS_MAX} distinct peer ids`);
85
100
  return [...(raw as string[])];
@@ -92,8 +107,9 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
92
107
  /** A terminal's text for a value: `inherit` removes the machine-local value, a peers setting takes a comma list. */
93
108
  export function parseSettingText(def: SettingDef, text: string): unknown {
94
109
  if (text === "inherit") return null;
110
+ if (def.type === "integer") return /^(0|[1-9]\d*)$/.test(text) ? Number(text) : text;
95
111
  if (def.type === "boolean") return text === "true" ? true : text === "false" ? false : text;
96
- if (def.type === "peers") return text.split(",").map((p) => p.trim()).filter(Boolean);
112
+ if (def.type === "peers" || def.type === "roles") return text.split(",").map((p) => p.trim()).filter(Boolean);
97
113
  return text;
98
114
  }
99
115
  export const settingText = (value: unknown): string => value === null || value === undefined ? "(not set)" : Array.isArray(value) ? value.join(",") || "(none)" : String(value);
@@ -101,7 +117,8 @@ export const settingText = (value: unknown): string => value === null || value =
101
117
  /** `ordinary`: a dashboard session from `ahub ui`. `settings`: one from `ahub ui --settings`. `terminal`: `ahub settings`. */
102
118
  export type SettingAuthority = "ordinary" | "settings" | "terminal";
103
119
  /** Why this authority may not set this value, or undefined. Removing a stored value can raise, so it counts as one. */
104
- export function settingRefusal(def: SettingDef, value: SettingValue, authority: SettingAuthority): string | undefined {
120
+ export function settingRefusal(def: SettingDef | string, value: SettingValue, authority: SettingAuthority): string | undefined {
121
+ if (typeof def === "string") return def || "invalid setting definition";
105
122
  if (def.risk === "safe" || authority !== "ordinary") return undefined;
106
123
  if (value !== null && value === def.floor) return undefined;
107
124
  return `${def.key} can widen what agents do without asking: open a settings session with ahub ui --settings, or use ahub settings in a terminal`;
@@ -334,10 +351,10 @@ export function ignoredLine(ignored: readonly string[] | undefined, def: Setting
334
351
  });
335
352
  }
336
353
  export interface SettingRow {
337
- key: string; group: SettingDef["group"]; label: string; type: SettingDef["type"]; values?: readonly string[];
354
+ key: string; group: SettingDef["group"]; label: string; type: SettingDef["type"]; values?: readonly string[]; min?: number; max?: number;
338
355
  /** The file a write goes to, or "this hub run". */
339
356
  file: string;
340
- applies: SettingDef["applies"]; risk: SettingDef["risk"]; floor?: string | boolean;
357
+ applies: SettingDef["applies"]; risk: SettingDef["risk"]; floor?: string | boolean | number;
341
358
  /** What is in force now; null when nothing sets it. */
342
359
  value: SettingValue;
343
360
  /** Which file the value in force comes from. */
@@ -364,15 +381,16 @@ export function settingRows(input: {
364
381
  routingProblem?: string;
365
382
  /** The loader's own lines about machine-local fields it ignored (`HubConfig.ignored`). */
366
383
  ignored?: string[];
384
+ roles?: (peer: string) => { value: SettingValue; pending?: SettingValue; values: string[]; note?: string };
367
385
  }): SettingRow[] {
368
386
  const raw = (name: string): unknown => { try { return JSON.parse(readFileSync(join(input.cwd, ".agenthub", name), "utf8")); } catch { return undefined; } };
369
387
  const local = raw(CONFIG_LOCAL), shared = raw("config.json");
370
388
  const projectRouting = existsSync(join(input.cwd, ".agenthub", "routing.toml"));
371
389
  const routes = [...Object.keys(input.routing.routes), ...Object.keys(input.routing.hub_routes ?? {})];
372
- const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" ? value : Array.isArray(value) ? value.map(String) : null;
390
+ const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
373
391
  return SETTINGS.map((def): SettingRow => {
374
- const base = { key: def.key, group: def.group, label: def.label, type: def.type, applies: def.applies, risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
375
- ...(def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!) } : {}) };
392
+ const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(def.type === "integer" ? { min: def.min, max: def.max } : {}), applies: def.applies, risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
393
+ ...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!) } : {}) };
376
394
  if (def.store === "runtime") {
377
395
  const mode = input.mode(def.path[0]!);
378
396
  return { ...base, values: def.values!, file: "this hub run", value: mode.value ?? null, source: "this hub run", ...(mode.note ? { note: mode.note } : {}) };
@@ -382,6 +400,7 @@ export function settingRows(input: {
382
400
  return { ...base, ...(def.type === "enum" ? { values: def.values ?? routes } : {}), file: OVERLAY_FILE, value, source: input.routing.sources?.[def.key.slice("routing.".length)] ?? (value === null ? "default" : projectRouting ? "routing.toml" : "default"),
383
401
  ...(input.routingProblem ? { note: `the routing files do not load, so the last policy that did stays in force: ${input.routingProblem}` } : {}) };
384
402
  }
403
+ if (def.type === "roles" && input.roles) return { ...base, ...input.roles(def.path[1]!), file: CONFIG_LOCAL, source: valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "profile/default", hint: "Guidance only; grants no tool or permission. At most two roles, applied at the next peer start." };
385
404
  const value = scalar(input.running(def.path));
386
405
  const stored = input.next ? scalar(input.next(def.path)) : value;
387
406
  const source = valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "default";
@@ -0,0 +1,25 @@
1
+ import role0 from "../../templates/roles/dev/software-architect.md" with { type: "text" };
2
+ import role1 from "../../templates/roles/dev/backend-engineer.md" with { type: "text" };
3
+ import role2 from "../../templates/roles/dev/frontend-engineer.md" with { type: "text" };
4
+ import role3 from "../../templates/roles/dev/test-engineer.md" with { type: "text" };
5
+ import role4 from "../../templates/roles/dev/code-reviewer.md" with { type: "text" };
6
+ import role5 from "../../templates/roles/dev/security-reviewer.md" with { type: "text" };
7
+ import role6 from "../../templates/roles/research/research-lead.md" with { type: "text" };
8
+ import role7 from "../../templates/roles/research/researcher.md" with { type: "text" };
9
+ import role8 from "../../templates/roles/research/analyst.md" with { type: "text" };
10
+ import role9 from "../../templates/roles/research/statistician.md" with { type: "text" };
11
+ import role10 from "../../templates/roles/research/fact-checker.md" with { type: "text" };
12
+
13
+ export const SHIPPED_ROLES: Readonly<Record<string, string>> = {
14
+ "software-architect": role0,
15
+ "backend-engineer": role1,
16
+ "frontend-engineer": role2,
17
+ "test-engineer": role3,
18
+ "code-reviewer": role4,
19
+ "security-reviewer": role5,
20
+ "research-lead": role6,
21
+ "researcher": role7,
22
+ "analyst": role8,
23
+ "statistician": role9,
24
+ "fact-checker": role10,
25
+ };
@@ -1,10 +1,11 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync, utimesSync } from "node:fs";
2
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { childEnv } from "./child-process.ts";
6
6
  import type { Touch } from "./conflicts.ts";
7
7
  import { hubGitSync } from "./git.ts";
8
+ import { realPath } from "./project.ts";
8
9
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
9
10
 
10
11
  /**
@@ -15,10 +16,10 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
15
16
  * ponytail: the trees are unreferenced, so `git gc` may prune them after gc.pruneExpire (two weeks by default); undo
16
17
  * of an older turn says so. Refs under refs/agenthub/ would keep them, at the cost of showing up in the user's repo.
17
18
  */
18
- const git = (top: string, args: string[], env: Record<string, string> = {}) =>
19
- hubGitSync(["-C", top, ...args], {
19
+ const git = (top: string | Repo, args: string[], env: Record<string, string> = {}) =>
20
+ hubGitSync(repoGitArgs(top, args), {
20
21
  encoding: "utf8",
21
- env: { ...childEnv(process.env), ...env },
22
+ env: { ...repoGitEnv(top), ...env },
22
23
  // These run inside the bus's state change: a hung git (a huge new directory, a stuck filter) must not freeze the hub.
23
24
  timeout: SNAPSHOT_TIMEOUT_MS,
24
25
  });
@@ -29,14 +30,73 @@ export interface Repo {
29
30
  top: string;
30
31
  dir: string;
31
32
  prefix: string;
33
+ shadow?: true;
34
+ }
35
+
36
+ /** Refuse links throughout metadata, including loose-object fan-outs and packed-object files. */
37
+ function shadowStorageSafe(dir: string): boolean {
38
+ let entries = 0;
39
+ const visit = (path: string, depth: number): boolean => {
40
+ if (++entries > 20000 || depth > 32) return false;
41
+ const st = lstatSync(path);
42
+ if (st.isSymbolicLink()) return false;
43
+ if (st.isFile()) return st.nlink === 1;
44
+ if (!st.isDirectory()) return false;
45
+ return readdirSync(path).every(name => visit(join(path, name), depth + 1));
46
+ };
47
+ try {
48
+ if (lstatSync(join(dir, "commondir"), { throwIfNoEntry: false }) || lstatSync(join(dir, "objects/info/alternates"), { throwIfNoEntry: false })) return false;
49
+ return visit(dir, 0);
50
+ } catch { return false; }
51
+ }
52
+ /** Explicit flags keep the shadow work tree at its verified project root. */
53
+ export function repoGitArgs(repo: string | Repo, args: string[]): string[] {
54
+ const top = typeof repo === "string" ? repo : repo.top;
55
+ if (typeof repo !== "string" && repo.shadow) {
56
+ const parent = lstatSync(join(repo.top, ".agenthub"), { throwIfNoEntry: false });
57
+ const ignore = parent?.isDirectory() && !parent.isSymbolicLink() ? lstatSync(join(repo.top, ".agenthub/ignore"), { throwIfNoEntry: false }) : undefined;
58
+ if (!parent?.isDirectory() || parent.isSymbolicLink() || ignore && (!ignore.isFile() || ignore.isSymbolicLink() || ignore.nlink !== 1) || repo.dir !== join(repo.top, ".agenthub/shadow.git") || !shadowStorageSafe(repo.dir)) throw new Error("shadow repository metadata is invalid or linked; operation refused");
59
+ }
60
+ return ["-C", top, ...(typeof repo !== "string" && repo.shadow ? ["--git-dir=" + repo.dir, "--work-tree=" + repo.top, "-c", "core.excludesFile=" + join(repo.top, ".agenthub", "ignore")] : []), ...args];
61
+ }
62
+ export function repoGitEnv(repo: string | Repo, env = process.env): NodeJS.ProcessEnv {
63
+ return { ...childEnv(env), ...(typeof repo !== "string" && repo.shadow ? { GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_ATTR_NOSYSTEM: "1" } : {}) };
32
64
  }
33
65
 
34
66
  /** The repository holding `root`, or undefined when it is not inside a work tree. */
35
67
  export function repoOf(root: string): Repo | undefined {
36
68
  const r = git(root, ["rev-parse", "--show-toplevel", "--absolute-git-dir", "--show-prefix"]);
37
- if (r.status !== 0) return undefined;
38
- const [top, dir, prefix = ""] = r.stdout.replace(/\n$/, "").split("\n");
39
- return top && dir ? { top, dir, prefix } : undefined;
69
+ if (r.status === 0) {
70
+ const [top, dir, prefix = ""] = r.stdout.replace(/\n$/, "").split("\n");
71
+ if (top && dir) return { top, dir, prefix };
72
+ }
73
+ const top = realPath(root), hub = join(top, ".agenthub"), dir = join(hub, "shadow.git");
74
+ const parent = lstatSync(hub, { throwIfNoEntry: false });
75
+ if (!parent?.isDirectory() || parent.isSymbolicLink()) return undefined;
76
+ const own = lstatSync(dir, { throwIfNoEntry: false });
77
+ if (!own?.isDirectory() || own.isSymbolicLink()) return undefined;
78
+ for (const name of ["objects", "refs", "hooks", "info"]) { const st = lstatSync(join(dir, name), { throwIfNoEntry: false }); if (!st?.isDirectory() || st.isSymbolicLink()) return undefined; }
79
+ for (const file of ["config", "HEAD"]) { const st = lstatSync(join(dir, file), { throwIfNoEntry: false }); if (!st?.isFile() || st.isSymbolicLink() || st.nlink !== 1) return undefined; }
80
+ if (existsSync(join(dir, "commondir")) || existsSync(join(dir, "objects/info/alternates"))) return undefined;
81
+ const ignore = lstatSync(join(hub, "ignore"), { throwIfNoEntry: false });
82
+ if (ignore && (!ignore.isFile() || ignore.isSymbolicLink() || ignore.nlink !== 1)) return undefined;
83
+ if (!shadowStorageSafe(dir)) return undefined;
84
+ const repo: Repo = { top, dir: realPath(dir), prefix: "", shadow: true };
85
+ const probe = git(repo, ["rev-parse", "--absolute-git-dir"]);
86
+ return probe.status === 0 && realPath(probe.stdout.trim()) === repo.dir ? repo : undefined;
87
+ }
88
+ /** Opt-in creation only; a real repository is never replaced or modified. */
89
+ export function initShadow(root: string): boolean {
90
+ const top = realPath(root), hub = join(top, ".agenthub"), dir = join(hub, "shadow.git"), existing = repoOf(top);
91
+ if (existing) { if (!existing.shadow) throw new Error("project already has a Git work tree; shadow mode is for non-git folders"); return false; }
92
+ const parent = lstatSync(hub, { throwIfNoEntry: false });
93
+ if (parent && (!parent.isDirectory() || parent.isSymbolicLink())) throw new Error(".agenthub must be a project directory, not a link");
94
+ if (lstatSync(dir, { throwIfNoEntry: false })) throw new Error("existing shadow metadata is invalid; nothing was replaced");
95
+ mkdirSync(hub, { recursive: true });
96
+ const result = hubGitSync(["init", "--bare", "--quiet", dir], { cwd: top, encoding: "utf8", timeout: SNAPSHOT_TIMEOUT_MS, env: { ...childEnv(process.env), GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" } });
97
+ if (result.status !== 0) throw new Error("could not initialize shadow repository");
98
+ writeFileSync(join(dir, "info/exclude"), ".agenthub/shadow.git/\n.agenthub/state/\n.agenthub/archive/\n.agenthub/config.local.json\n.agenthub/routing.local.toml\n", { mode: 0o600 });
99
+ return true;
40
100
  }
41
101
 
42
102
  /**
@@ -62,12 +122,12 @@ export function snapshot(repo: Repo): string | undefined {
62
122
  utimesSync(env.GIT_INDEX_FILE, atime, mtime);
63
123
  }
64
124
  // `ahub reset --all` archives hub.db (task text, PII included) under .agenthub/archive: never copy it either.
65
- if (git(repo.top, ["add", "-A", "--", scope(repo), ":(exclude,glob)**/.agenthub/state/**", ":(exclude,glob)**/.agenthub/archive/**"], env).status !== 0) return undefined;
125
+ if (git(repo, ["add", "-A", "--", scope(repo), ":(exclude,glob)**/.agenthub/state/**", ":(exclude,glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(exclude,glob)**/.agenthub/shadow.git/**"] : [])], env).status !== 0) return undefined;
66
126
  // The copy starts from the user's index: hub state somebody tracked or staged is still in it, and the exclude above
67
127
  // only keeps `add` from touching it. Take it out explicitly; -f because staged state the hub has rewritten since
68
128
  // matches neither HEAD nor the file, which `rm --cached` otherwise refuses. Only the copy changes.
69
- if (git(repo.top, ["rm", "-r", "-f", "--cached", "--quiet", "--ignore-unmatch", "--", ":(glob)**/.agenthub/state/**", ":(glob)**/.agenthub/archive/**"], env).status !== 0) return undefined;
70
- const r = git(repo.top, ["write-tree"], env);
129
+ if (git(repo, ["rm", "-r", "-f", "--cached", "--quiet", "--ignore-unmatch", "--", ":(glob)**/.agenthub/state/**", ":(glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(glob)**/.agenthub/shadow.git/**"] : [])], env).status !== 0) return undefined;
130
+ const r = git(repo, ["write-tree"], env);
71
131
  return r.status === 0 ? r.stdout.trim() : undefined;
72
132
  } finally {
73
133
  rmSync(tmp, { recursive: true, force: true });
@@ -76,14 +136,14 @@ export function snapshot(repo: Repo): string | undefined {
76
136
 
77
137
  /** Project paths (relative to the top level) that differ between two trees. */
78
138
  export function changedPaths(repo: Repo, from: string, to: string): string[] {
79
- const r = git(repo.top, ["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to, "--", scope(repo)]);
139
+ const r = git(repo, ["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to, "--", scope(repo)]);
80
140
  return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : [];
81
141
  }
82
142
 
83
- const inTree = (top: string, tree: string, path: string) => git(top, ["rev-parse", "--verify", "--quiet", `${tree}:${path}`]).status === 0;
143
+ const inTree = (top: string | Repo, tree: string, path: string) => git(top, ["rev-parse", "--verify", "--quiet", `${tree}:${path}`]).status === 0;
84
144
 
85
145
  /** Whether a snapshot's tree is still in the object store (`git gc` prunes unreferenced ones eventually). */
86
- export const hasTree = (top: string, tree: string): boolean => git(top, ["cat-file", "-e", `${tree}^{tree}`]).status === 0;
146
+ export const hasTree = (top: string | Repo, tree: string): boolean => git(top, ["cat-file", "-e", `${tree}^{tree}`]).status === 0;
87
147
 
88
148
  export interface UndoPlan {
89
149
  restore: string[];
@@ -122,10 +182,10 @@ export function planUndo(repo: Repo, turn: { start_tree: string; end_tree: strin
122
182
  export function restore(repo: Repo, startTree: string, paths: string[]): void {
123
183
  // Removals first: after a case-only rename on a case-insensitive disk, `Foo.ts` and `foo.ts` are one file, and
124
184
  // removing the created spelling after restoring the old one would delete it.
125
- const back = paths.filter((p) => inTree(repo.top, startTree, p));
185
+ const back = paths.filter((p) => inTree(repo, startTree, p));
126
186
  for (const path of paths.filter((p) => !back.includes(p))) rmSync(join(repo.top, path), { force: true });
127
187
  for (const path of back) {
128
- if (git(repo.top, ["restore", `--source=${startTree}`, "--worktree", "--", literal(path)]).status !== 0) throw new Error(`could not restore ${path}`);
188
+ if (git(repo, ["restore", `--source=${startTree}`, "--worktree", "--", literal(path)]).status !== 0) throw new Error(`could not restore ${path}`);
129
189
  }
130
190
  }
131
191
 
@@ -156,6 +216,20 @@ export class Turns {
156
216
  // ponytail: rows of finished tasks stay (a few per file per task); prune by task state if hub.db ever grows.
157
217
  this.db.run("CREATE TABLE IF NOT EXISTS touches (task INTEGER NOT NULL, peer TEXT NOT NULL, path TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (task, peer, path))");
158
218
  this.db.run("CREATE INDEX IF NOT EXISTS touches_path ON touches (path)"); // the PreToolUse hook looks up one path
219
+ this.db.run("CREATE TABLE IF NOT EXISTS task_snapshots (task INTEGER NOT NULL, proposed_at INTEGER NOT NULL, start_tree TEXT, PRIMARY KEY (task, proposed_at))");
220
+ }
221
+ /** A proposal's original project snapshot; task ids may be reused after reset. Never repair a missing baseline later. */
222
+ rememberTaskStart(task: number, proposedAt: number, tree: string | undefined): void {
223
+ this.db.query("INSERT OR IGNORE INTO task_snapshots (task, proposed_at, start_tree) VALUES (?, ?, ?)").run(task, proposedAt, tree ?? null);
224
+ }
225
+ taskStart(task: number, proposedAt: number): string | undefined {
226
+ try {
227
+ const row = this.db.query("SELECT start_tree FROM task_snapshots WHERE task = ? AND proposed_at = ?").get(task, proposedAt) as { start_tree: string | null } | null;
228
+ return row?.start_tree ?? undefined;
229
+ } catch (error) {
230
+ if (/no such table/.test((error as Error).message)) return undefined;
231
+ throw error;
232
+ }
159
233
  }
160
234
  begin(id: string, peer: string, startTree: string | undefined): void {
161
235
  this.db.query("INSERT OR REPLACE INTO turns (id, peer, started, start_tree) VALUES (?, ?, ?, ?)").run(id, peer, Date.now(), startTree ?? null);