@spexcode/spec-cli 0.6.5

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 (201) hide show
  1. package/README.md +95 -0
  2. package/bin/spex.mjs +76 -0
  3. package/dist/attach.d.ts +2 -0
  4. package/dist/attach.js +48 -0
  5. package/dist/claude-headless.d.ts +33 -0
  6. package/dist/claude-headless.js +307 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +1859 -0
  9. package/dist/client.d.ts +111 -0
  10. package/dist/client.js +476 -0
  11. package/dist/cockpit.d.ts +7 -0
  12. package/dist/cockpit.js +26 -0
  13. package/dist/codex-headless.d.ts +1 -0
  14. package/dist/codex-headless.js +7 -0
  15. package/dist/codex-runtime-generations.d.ts +53 -0
  16. package/dist/codex-runtime-generations.js +686 -0
  17. package/dist/commit-surgery.d.ts +1 -0
  18. package/dist/commit-surgery.js +90 -0
  19. package/dist/contract-filter.d.ts +14 -0
  20. package/dist/contract-filter.js +219 -0
  21. package/dist/dashboard-assets.d.ts +6 -0
  22. package/dist/dashboard-assets.js +50 -0
  23. package/dist/delivery-queue.d.ts +23 -0
  24. package/dist/delivery-queue.js +179 -0
  25. package/dist/doctor.d.ts +9 -0
  26. package/dist/doctor.js +547 -0
  27. package/dist/eval-host.d.ts +1 -0
  28. package/dist/eval-host.js +19 -0
  29. package/dist/execution-trace.d.ts +26 -0
  30. package/dist/execution-trace.js +475 -0
  31. package/dist/file-write.d.ts +2 -0
  32. package/dist/file-write.js +26 -0
  33. package/dist/flat.d.ts +89 -0
  34. package/dist/flat.js +1076 -0
  35. package/dist/gateway-auth.d.ts +56 -0
  36. package/dist/gateway-auth.js +168 -0
  37. package/dist/gateway-hub.d.ts +28 -0
  38. package/dist/gateway-hub.js +361 -0
  39. package/dist/gateway.d.ts +37 -0
  40. package/dist/gateway.js +503 -0
  41. package/dist/graphCache.d.ts +27 -0
  42. package/dist/graphCache.js +716 -0
  43. package/dist/graphSnapshot.d.ts +14 -0
  44. package/dist/graphSnapshot.js +35 -0
  45. package/dist/graphStream.d.ts +85 -0
  46. package/dist/graphStream.js +1195 -0
  47. package/dist/guidance-catalog.d.ts +64 -0
  48. package/dist/guidance-catalog.js +166 -0
  49. package/dist/guide.d.ts +8 -0
  50. package/dist/guide.js +684 -0
  51. package/dist/harness-select.d.ts +18 -0
  52. package/dist/harness-select.js +62 -0
  53. package/dist/harness.d.ts +325 -0
  54. package/dist/harness.js +3026 -0
  55. package/dist/headless-controller.d.ts +9 -0
  56. package/dist/headless-controller.js +47 -0
  57. package/dist/help.d.ts +10 -0
  58. package/dist/help.js +574 -0
  59. package/dist/hook-prompts.d.ts +18 -0
  60. package/dist/hook-prompts.js +122 -0
  61. package/dist/hooks.d.ts +1 -0
  62. package/dist/hooks.js +27 -0
  63. package/dist/host-resources.d.ts +97 -0
  64. package/dist/host-resources.js +795 -0
  65. package/dist/host.d.ts +88 -0
  66. package/dist/host.js +663 -0
  67. package/dist/index.d.ts +1 -0
  68. package/dist/index.js +899 -0
  69. package/dist/init.d.ts +1 -0
  70. package/dist/init.js +284 -0
  71. package/dist/issues-cli.d.ts +6 -0
  72. package/dist/issues-cli.js +334 -0
  73. package/dist/issues.d.ts +69 -0
  74. package/dist/issues.js +189 -0
  75. package/dist/lint.d.ts +34 -0
  76. package/dist/lint.js +476 -0
  77. package/dist/listen.d.ts +7 -0
  78. package/dist/listen.js +24 -0
  79. package/dist/localIssues.d.ts +65 -0
  80. package/dist/localIssues.js +582 -0
  81. package/dist/login-page.d.ts +5 -0
  82. package/dist/login-page.js +86 -0
  83. package/dist/loop-in.d.ts +23 -0
  84. package/dist/loop-in.js +68 -0
  85. package/dist/machine-peer.d.ts +69 -0
  86. package/dist/machine-peer.js +603 -0
  87. package/dist/materialize.d.ts +18 -0
  88. package/dist/materialize.js +468 -0
  89. package/dist/mentions.d.ts +50 -0
  90. package/dist/mentions.js +117 -0
  91. package/dist/opencode-headless.d.ts +4 -0
  92. package/dist/opencode-headless.js +208 -0
  93. package/dist/opencode.d.ts +3 -0
  94. package/dist/opencode.js +142 -0
  95. package/dist/pi-harness.d.ts +3 -0
  96. package/dist/pi-harness.js +129 -0
  97. package/dist/pi-headless.d.ts +25 -0
  98. package/dist/pi-headless.js +196 -0
  99. package/dist/plugin-harness.d.ts +21 -0
  100. package/dist/plugin-harness.js +146 -0
  101. package/dist/pty-bridge.d.ts +10 -0
  102. package/dist/pty-bridge.js +404 -0
  103. package/dist/pty-helper.d.mts +1 -0
  104. package/dist/pty-helper.mjs +101 -0
  105. package/dist/pty-native-helper.d.mts +2 -0
  106. package/dist/pty-native-helper.mjs +23 -0
  107. package/dist/public-graph.d.ts +46 -0
  108. package/dist/public-graph.js +56 -0
  109. package/dist/ranker.d.ts +15 -0
  110. package/dist/ranker.js +188 -0
  111. package/dist/reaper.d.ts +6 -0
  112. package/dist/reaper.js +81 -0
  113. package/dist/reviews.d.ts +189 -0
  114. package/dist/reviews.js +350 -0
  115. package/dist/runtime-guard.d.ts +6 -0
  116. package/dist/runtime-guard.js +37 -0
  117. package/dist/runtime-ownership.d.ts +21 -0
  118. package/dist/runtime-ownership.js +84 -0
  119. package/dist/search.bench.d.mts +1 -0
  120. package/dist/search.bench.mjs +75 -0
  121. package/dist/search.d.ts +20 -0
  122. package/dist/search.js +61 -0
  123. package/dist/session-cursors.d.ts +14 -0
  124. package/dist/session-cursors.js +82 -0
  125. package/dist/session-declarations.d.ts +13 -0
  126. package/dist/session-declarations.js +126 -0
  127. package/dist/session-execution.d.ts +10 -0
  128. package/dist/session-execution.js +70 -0
  129. package/dist/session-files.d.ts +28 -0
  130. package/dist/session-files.js +130 -0
  131. package/dist/session-follow.d.ts +31 -0
  132. package/dist/session-follow.js +179 -0
  133. package/dist/session-reparent.d.ts +10 -0
  134. package/dist/session-reparent.js +33 -0
  135. package/dist/session-timeline.d.ts +51 -0
  136. package/dist/session-timeline.js +233 -0
  137. package/dist/session-web.d.ts +24 -0
  138. package/dist/session-web.js +141 -0
  139. package/dist/sessions.d.ts +412 -0
  140. package/dist/sessions.js +4239 -0
  141. package/dist/sh.d.ts +1 -0
  142. package/dist/sh.js +3 -0
  143. package/dist/shim-runtime.d.ts +1 -0
  144. package/dist/shim-runtime.js +161 -0
  145. package/dist/slash-commands.d.ts +9 -0
  146. package/dist/slash-commands.js +307 -0
  147. package/dist/source-files.d.ts +9 -0
  148. package/dist/source-files.js +113 -0
  149. package/dist/supervise.d.ts +1 -0
  150. package/dist/supervise.js +308 -0
  151. package/dist/tree.d.ts +25 -0
  152. package/dist/tree.js +98 -0
  153. package/dist/tsx-bin.d.ts +3 -0
  154. package/dist/tsx-bin.js +41 -0
  155. package/dist/uninstall.d.ts +3 -0
  156. package/dist/uninstall.js +177 -0
  157. package/dist/uploads.d.ts +36 -0
  158. package/dist/uploads.js +243 -0
  159. package/dist/worktree-sources.d.ts +1 -0
  160. package/dist/worktree-sources.js +47 -0
  161. package/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  162. package/hooks/compat/mark-active-sed-v0.fixture +46 -0
  163. package/hooks/dispatch.sh +105 -0
  164. package/hooks/harness.sh +272 -0
  165. package/package.json +47 -0
  166. package/templates/hooks/commit-msg +14 -0
  167. package/templates/hooks/post-checkout +25 -0
  168. package/templates/hooks/post-merge +34 -0
  169. package/templates/hooks/pre-commit +120 -0
  170. package/templates/hooks/prepare-commit-msg +35 -0
  171. package/templates/hooks/reference-transaction +105 -0
  172. package/templates/spec/project/.plugins/commands/extract/spec.md +66 -0
  173. package/templates/spec/project/.plugins/commands/regroup/spec.md +25 -0
  174. package/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  175. package/templates/spec/project/.plugins/commands/spec.md +30 -0
  176. package/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  177. package/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  178. package/templates/spec/project/.plugins/core/comment-altitude/spec.md +9 -0
  179. package/templates/spec/project/.plugins/core/idle/idle.sh +15 -0
  180. package/templates/spec/project/.plugins/core/idle/spec.md +13 -0
  181. package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +59 -0
  182. package/templates/spec/project/.plugins/core/mark-active/spec.md +22 -0
  183. package/templates/spec/project/.plugins/core/session-fail/fail.sh +12 -0
  184. package/templates/spec/project/.plugins/core/session-fail/spec.md +15 -0
  185. package/templates/spec/project/.plugins/core/spec-first/spec-first.sh +43 -0
  186. package/templates/spec/project/.plugins/core/spec-first/spec.md +17 -0
  187. package/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +66 -0
  188. package/templates/spec/project/.plugins/core/spec-of-file/spec.md +15 -0
  189. package/templates/spec/project/.plugins/core/spec.md +22 -0
  190. package/templates/spec/project/.plugins/core/stop-gate/spec.md +23 -0
  191. package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +149 -0
  192. package/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +15 -0
  193. package/templates/spec/project/.plugins/prompts/forge-link/spec.md +10 -0
  194. package/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +13 -0
  195. package/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +12 -0
  196. package/templates/spec/project/.plugins/prompts/spec.md +24 -0
  197. package/templates/spec/project/.plugins/skills/distill/digest.mjs +136 -0
  198. package/templates/spec/project/.plugins/skills/distill/spec.md +67 -0
  199. package/templates/spec/project/.plugins/skills/spec.md +19 -0
  200. package/templates/spec/project/.plugins/spec.md +23 -0
  201. package/templates/spec/project/spec.md +20 -0
@@ -0,0 +1,716 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import { rebasePublishedSessions } from '@spexcode/spec-core';
5
+ import { buildBoard, spliceSessions } from './graphSnapshot.js';
6
+ import { headSha, repoRoot, withGitAbortSignal } from '@spexcode/spec-core';
7
+ import { listSessionIds, mainBranch, mainCheckout, readPublicRecordEntry, sessionArtifactPath, sessionRecordPath } from '@spexcode/spec-core';
8
+ import { boardThreads } from './issues.js';
9
+ import { resolveForgeHost } from '@spexcode/spec-forge/drivers';
10
+ import { residentForgeState } from '@spexcode/spec-forge/resident';
11
+ import { resolveProjectIdentity } from '@spexcode/spec-core';
12
+ import { readReviewSnapshot } from '@spexcode/spec-core';
13
+ import { sessionEvalProjection } from '@spexcode/spec-eval/sessioneval';
14
+ const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1';
15
+ function textOrNull(path) {
16
+ try {
17
+ return readFileSync(path, 'utf8');
18
+ }
19
+ catch (error) {
20
+ if (error.code === 'ENOENT')
21
+ return null;
22
+ throw error;
23
+ }
24
+ }
25
+ function gitDirOf(root) {
26
+ const dotgit = join(root, '.git');
27
+ try {
28
+ if (statSync(dotgit).isDirectory())
29
+ return dotgit;
30
+ }
31
+ catch (error) {
32
+ if (error.code === 'ENOENT')
33
+ return '';
34
+ throw error;
35
+ }
36
+ const dotgitText = textOrNull(dotgit);
37
+ if (dotgitText === null)
38
+ return '';
39
+ const match = dotgitText.match(/^gitdir:\s*(.+)$/m);
40
+ if (!match)
41
+ throw new Error(`unparseable gitdir pointer at ${dotgit}`);
42
+ const dir = match[1].trim();
43
+ return isAbsolute(dir) ? dir : resolve(root, dir);
44
+ }
45
+ function commonDirOf(root) {
46
+ const gitDir = gitDirOf(root);
47
+ if (!gitDir)
48
+ return '';
49
+ const common = textOrNull(join(gitDir, 'commondir'));
50
+ if (common === null)
51
+ return gitDir;
52
+ const dir = common.trim();
53
+ if (!dir)
54
+ throw new Error(`empty commondir pointer at ${join(gitDir, 'commondir')}`);
55
+ return isAbsolute(dir) ? dir : resolve(gitDir, dir);
56
+ }
57
+ function refSha(root, ref) {
58
+ if (/^[0-9a-f]{40}$/.test(ref))
59
+ return ref;
60
+ const common = commonDirOf(root);
61
+ if (!common)
62
+ return '<missing>';
63
+ const names = ref.startsWith('refs/') ? [ref] : [`refs/heads/${ref}`, `refs/remotes/${ref}`];
64
+ for (const name of names) {
65
+ const loose = textOrNull(join(common, name));
66
+ if (loose !== null)
67
+ return loose.trim() || '<missing>';
68
+ }
69
+ const packed = textOrNull(join(common, 'packed-refs'));
70
+ if (packed !== null) {
71
+ for (const line of packed.split('\n')) {
72
+ if (!line || line[0] === '#' || line[0] === '^')
73
+ continue;
74
+ const space = line.indexOf(' ');
75
+ if (space > 0 && names.includes(line.slice(space + 1).trim()))
76
+ return line.slice(0, space).trim();
77
+ }
78
+ }
79
+ return '<missing>';
80
+ }
81
+ function headRevision(root) {
82
+ if (!gitDirOf(root))
83
+ return '<missing>';
84
+ return headSha(root);
85
+ }
86
+ // Patrol validation needs a stronger contract than the overlay cache's best-effort mtime key: ctime catches
87
+ // a same-size edit whose mtime was restored, and every non-ENOENT read failure is loud instead of collapsing an
88
+ // unreadable subtree into a stable "unchanged" revision. A path that vanishes during the sample is represented
89
+ // by its absence and is rechecked by the producer fence.
90
+ function strictSpecTreeRevision(wtPath) {
91
+ const root = join(wtPath, '.spec');
92
+ try {
93
+ statSync(root);
94
+ }
95
+ catch (error) {
96
+ if (error.code === 'ENOENT')
97
+ return '';
98
+ throw error;
99
+ }
100
+ const parts = [];
101
+ const stack = [root];
102
+ while (stack.length) {
103
+ const dir = stack.pop();
104
+ let entries;
105
+ try {
106
+ entries = readdirSync(dir, { withFileTypes: true });
107
+ }
108
+ catch (error) {
109
+ if (error.code !== 'ENOENT')
110
+ throw error;
111
+ continue;
112
+ }
113
+ for (const entry of entries) {
114
+ const path = join(dir, entry.name);
115
+ if (entry.isDirectory()) {
116
+ stack.push(path);
117
+ continue;
118
+ }
119
+ try {
120
+ const stat = statSync(path);
121
+ parts.push(`${path}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`);
122
+ }
123
+ catch (error) {
124
+ if (error.code !== 'ENOENT')
125
+ throw error;
126
+ }
127
+ }
128
+ }
129
+ return parts.sort().join('\n');
130
+ }
131
+ function worktreeRevision(root) {
132
+ return {
133
+ root,
134
+ head: headRevision(root),
135
+ spec: strictSpecTreeRevision(root),
136
+ };
137
+ }
138
+ function digest(value) {
139
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
140
+ }
141
+ // The patrol's cheap authority check. These are exactly the mutable inputs a board assembly reads, folded
142
+ // without doing the assembly: HEAD/config + `.spec`, governed records and active governed worktree state,
143
+ // the issue-store carrier, and the already-resident session-eval projections. It intentionally does not call
144
+ // listSessions or sessionEvalProjections: verification must neither poll tmux again nor mint/schedule eval work.
145
+ // The hot/warm liveness signatures and eval generations remain graph-stream's canonical event-owned axes.
146
+ function sessionInputRevision() {
147
+ const ids = listSessionIds().sort();
148
+ // listSessions projects the structured record plus a one-line preview of the separately-stored originating
149
+ // prompt into each board row. Fold both exact artifacts so a missed store event cannot leave a stale
150
+ // label/preview forever — the prompt file is folded because the preview is derived from it.
151
+ const sessionInputs = ids.map((id) => [
152
+ id,
153
+ textOrNull(sessionRecordPath(id)),
154
+ textOrNull(sessionArtifactPath(id, 'prompt')),
155
+ ]);
156
+ const projections = digest(ids.map((id) => [id, sessionEvalProjection(id)]));
157
+ const activeRoots = [...new Set(ids.flatMap((id) => {
158
+ const entry = readPublicRecordEntry(id);
159
+ return entry.kind === 'ok' && entry.raw.governed && !entry.raw.archived ? [entry.raw.worktree_path] : [];
160
+ }))].sort();
161
+ return { sessions: digest(sessionInputs), projections, projectionIds: ids, activeRoots };
162
+ }
163
+ function boardInputRevision(board) {
164
+ const root = repoRoot();
165
+ const session = sessionInputRevision();
166
+ // Durable active records are the current root set; a cached ordinary row may be stale and must not replace
167
+ // them. The one projection-only addition is explicit: listSessions republishes an archived-runtime hazard
168
+ // into the working set, and that marked row keeps its root monitored until cold state is repaired.
169
+ const activeRoots = [...new Set([
170
+ ...session.activeRoots,
171
+ ...(board?.sessions.flatMap((row) => row.archiveHazard ? [row.path] : []) ?? []),
172
+ ])].sort();
173
+ const main = mainCheckout();
174
+ const base = mainBranch();
175
+ const mainTip = refSha(main, base);
176
+ const nodeIds = (board?.nodes ?? []).map((node) => node.id);
177
+ const issuesStamp = boardThreads({ host: resolveForgeHost(), state: residentForgeState() }, nodeIds).stamp;
178
+ const worktreeParts = Object.fromEntries(activeRoots.map((worktree) => [worktree, digest(worktreeRevision(worktree))]));
179
+ const fullInputs = {
180
+ root: worktreeRevision(root),
181
+ config: [
182
+ [join(root, 'spexcode.json'), textOrNull(join(root, 'spexcode.json'))],
183
+ [join(root, 'spexcode.local.json'), textOrNull(join(root, 'spexcode.local.json'))],
184
+ [join(main, 'spexcode.json'), textOrNull(join(main, 'spexcode.json'))],
185
+ [join(main, 'spexcode.local.json'), textOrNull(join(main, 'spexcode.local.json'))],
186
+ ],
187
+ main: { root: main, branch: base, tip: mainTip },
188
+ worktrees: worktreeParts,
189
+ issuesStamp,
190
+ identity: resolveProjectIdentity(root, root),
191
+ };
192
+ const fullParts = Object.fromEntries(Object.entries(fullInputs).map(([key, value]) => [key, digest(value)]));
193
+ const full = digest(fullParts);
194
+ return {
195
+ full,
196
+ ...session,
197
+ combined: digest([full, session.sessions, session.projections]),
198
+ fullParts,
199
+ worktreeParts,
200
+ };
201
+ }
202
+ // Bind an input sample to what the completed board actually carries. The sample supplies the graph/session
203
+ // revision observed immediately after the producer; issue and projection values come from the returned board
204
+ // itself. Re-reading all inputs here would create a certification race: a blind change after the producer's
205
+ // after-sample could otherwise be mistaken for an input the already-built board contains.
206
+ function revisionCarriedByBoard(sample, board) {
207
+ const fullParts = { ...sample.fullParts, issuesStamp: digest(board.issuesStamp) };
208
+ const full = digest(fullParts);
209
+ const boardProjections = new Map(board.sessions.map((session) => [session.id, session.evalSummary ?? null]));
210
+ const projections = digest(sample.projectionIds.map((id) => [id, boardProjections.get(id) ?? null]));
211
+ return {
212
+ full,
213
+ sessions: sample.sessions,
214
+ projections,
215
+ combined: digest([full, sample.sessions, projections]),
216
+ fullParts,
217
+ worktreeParts: sample.worktreeParts,
218
+ projectionIds: sample.projectionIds,
219
+ };
220
+ }
221
+ // A sessions splice reuses its base topology. It may sample fresh record/projection inputs, but it must never
222
+ // certify that those old nodes carry a full revision sampled after the base was built.
223
+ function revisionCarriedBySessionSplice(base, sample, board, stable) {
224
+ const boardProjections = new Map(board.sessions.map((session) => [session.id, session.evalSummary ?? null]));
225
+ const projections = stable
226
+ ? digest(sample.projectionIds.map((id) => [id, boardProjections.get(id) ?? null]))
227
+ : sample.projections;
228
+ return {
229
+ full: base.full,
230
+ sessions: sample.sessions,
231
+ projections,
232
+ combined: digest([base.full, sample.sessions, projections]),
233
+ fullParts: base.fullParts,
234
+ worktreeParts: base.worktreeParts,
235
+ projectionIds: sample.projectionIds,
236
+ };
237
+ }
238
+ // The full producer's topology may be newer than the last published session projection. When completion
239
+ // re-bases that already-visible projection onto the new topology, keep the full carrier from the producer and
240
+ // only the sessions/projections carrier from the published rows. Do not sample current inputs here: that would
241
+ // certify a write that neither producer actually carried.
242
+ function revisionCarriedByPublishedSessionRebase(full, published) {
243
+ return {
244
+ full: full.full,
245
+ sessions: published.sessions,
246
+ projections: published.projections,
247
+ combined: digest([full.full, published.sessions, published.projections]),
248
+ fullParts: full.fullParts,
249
+ worktreeParts: full.worktreeParts,
250
+ projectionIds: published.projectionIds,
251
+ };
252
+ }
253
+ function carrySubtractiveWorktrees(base, activeRoots) {
254
+ const active = new Set(activeRoots);
255
+ const known = Object.keys(base.worktreeParts);
256
+ if (activeRoots.some((root) => !(root in base.worktreeParts)))
257
+ return { revision: base, added: true };
258
+ if (known.every((root) => active.has(root)))
259
+ return { revision: base, added: false };
260
+ const worktreeParts = Object.fromEntries(Object.entries(base.worktreeParts).filter(([root]) => active.has(root)));
261
+ const fullParts = { ...base.fullParts, worktrees: digest(worktreeParts) };
262
+ const full = digest(fullParts);
263
+ return {
264
+ revision: {
265
+ ...base,
266
+ full,
267
+ fullParts,
268
+ worktreeParts,
269
+ combined: digest([full, base.sessions, base.projections]),
270
+ },
271
+ added: false,
272
+ };
273
+ }
274
+ function isPureSubtractiveWorktreeMove(base, sample) {
275
+ const moved = Object.keys(sample.fullParts).filter((key) => sample.fullParts[key] !== base.fullParts[key]);
276
+ if (moved.length !== 1 || moved[0] !== 'worktrees')
277
+ return false;
278
+ const current = Object.keys(sample.worktreeParts);
279
+ const previous = Object.keys(base.worktreeParts);
280
+ return current.length < previous.length
281
+ && current.every((root) => sample.worktreeParts[root] === base.worktreeParts[root]);
282
+ }
283
+ // a build slower than this is LOGGED, never silently tolerated — the fail-loud regression alarm. Sized
284
+ // above a warm build (~sub-second once the fs walks yield) but below the cold two-walk first build, so a
285
+ // genuinely-degraded hot path shouts while an ordinary cold start stays quiet-ish.
286
+ const BUDGET_MS = Number(process.env.SPEXCODE_BOARD_BUDGET_MS || 1500);
287
+ // a build that NEVER settles is a different animal from a slow one: `inflight` clears only in the finally
288
+ // below, so a never-settling buildBoard() would pin the single-flight forever — every later read (even of a
289
+ // perfectly good cached board) short-circuits into the pinned promise before `valid` is consulted,
290
+ // invalidation can't help, no log ever fires, and only a restart cures it (the live wedge: hung git
291
+ // children → /api/graph 503 forever, silently). So the build races a generous watchdog that REJECTS loudly;
292
+ // the rejection flows through the SAME finally → inflight clears → the next read retries fresh. Sitting at
293
+ // the single-flight boundary, this one wall bounds every never-settle cause — including ones with no child
294
+ // process at all (fs/promises under libuv threadpool starvation); git.ts's per-child timeouts merely make
295
+ // the common cause die sooner. Generous: well above the slowest legitimate cold build, so it only ever
296
+ // fires on a genuine wedge.
297
+ const BUILD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_BUILD_TIMEOUT_MS || 120000);
298
+ const RETRY_BACKOFF_MS = Number(process.env.SPEXCODE_BOARD_RETRY_BACKOFF_MS || 1000);
299
+ const BACKGROUND_START_DELAY_MS = Number(process.env.SPEXCODE_BOARD_BACKGROUND_START_DELAY_MS || 300);
300
+ let cached = null; // last completed build; served while `dirty === 'none'`
301
+ let cachedJson = null; // JSON.stringify(cached), serialized ONCE per build (see getBoardJson)
302
+ let cachedRevision = null; // input revision represented by `cached`
303
+ let dirty = 'full'; // no cached board yet → the first read builds fully
304
+ let inflight = null;
305
+ let sessionFlight = null;
306
+ let sessionOwed = false;
307
+ let sessionGeneration = 0;
308
+ let sessionProjectionPublication = 0;
309
+ let topologyGeneration = 0;
310
+ let gen = 0; // bumped on invalidation so patrol validation cannot certify across an event
311
+ let retryAt = 0;
312
+ let lastFailure = null;
313
+ // mark the cache stale at a SCOPE. Called by every board-stream freshness source (see
314
+ // boardStream.fireChanged), so a real change forces the next getBoard() to rebuild while a quiet poll storm
315
+ // keeps hitting the cache. `dirty` is the structural producer's scope; `sessionOwed` is intentionally not
316
+ // folded into it, because full+sessions owes both a full convergence and a cheap first projection.
317
+ function mergeDirty(scope) {
318
+ if (scope === 'full' || dirty === 'full')
319
+ dirty = 'full';
320
+ else
321
+ dirty = 'sessions';
322
+ }
323
+ export function invalidateBoard(scope = 'full') {
324
+ gen++;
325
+ if (scope === 'sessions') {
326
+ sessionOwed = true;
327
+ sessionGeneration++;
328
+ }
329
+ mergeDirty(scope);
330
+ retryAt = 0;
331
+ lastFailure = null;
332
+ }
333
+ function traceCacheCommit(scope, startedAt) {
334
+ if (!DEBUG)
335
+ return;
336
+ const at = Date.now();
337
+ console.warn(`spec-cli: graph cache ${JSON.stringify({ at, stage: 'cache-commit', scope, buildMs: at - startedAt })}`);
338
+ }
339
+ function startSessionSplice() {
340
+ if (!cached || !cachedRevision || !sessionOwed)
341
+ return null;
342
+ if (sessionFlight)
343
+ return sessionFlight;
344
+ sessionOwed = false;
345
+ if (dirty === 'sessions')
346
+ dirty = 'none';
347
+ let timedOut = false;
348
+ const startedAt = Date.now();
349
+ let watchdog;
350
+ const timeoutError = () => new Error(`graph session splice did not settle within ${BUILD_TIMEOUT_MS}ms`);
351
+ const producer = Promise.resolve().then(async () => {
352
+ // A full completion may replace the topology while listSessions is in flight. Rebase before publishing;
353
+ // the splice is a projection over whatever last-good structure is current, never a whole-board write race.
354
+ while (true) {
355
+ const base = cached, revision = cachedRevision, generation = topologyGeneration;
356
+ if (!base || !revision)
357
+ throw new Error('graph session splice lost its cached topology');
358
+ const before = sessionInputRevision();
359
+ const board = await spliceSessions(base);
360
+ const after = sessionInputRevision();
361
+ if (base !== cached || revision !== cachedRevision || generation !== topologyGeneration)
362
+ continue;
363
+ const stable = before.sessions === after.sessions && before.projections === after.projections &&
364
+ JSON.stringify(before.activeRoots) === JSON.stringify(after.activeRoots);
365
+ const projectedRoots = [...new Set(board.sessions.map((row) => row.path))].sort();
366
+ const rootTransition = carrySubtractiveWorktrees(revision, projectedRoots);
367
+ const carried = stable ? rootTransition.revision : revision;
368
+ cached = board;
369
+ cachedJson = null;
370
+ cachedRevision = revisionCarriedBySessionSplice(carried, before, board, stable);
371
+ sessionProjectionPublication++;
372
+ if (rootTransition.added)
373
+ mergeDirty('full');
374
+ if (!stable) {
375
+ sessionOwed = true;
376
+ sessionGeneration++;
377
+ mergeDirty('sessions');
378
+ }
379
+ return board;
380
+ }
381
+ })
382
+ .then((board) => {
383
+ if (timedOut)
384
+ throw timeoutError();
385
+ traceCacheCommit('sessions', startedAt);
386
+ return board;
387
+ })
388
+ .catch((error) => {
389
+ sessionOwed = true;
390
+ mergeDirty('sessions');
391
+ throw error;
392
+ });
393
+ let settle;
394
+ settle = producer.finally(() => {
395
+ clearTimeout(watchdog);
396
+ if (sessionFlight?.settle === settle)
397
+ sessionFlight = null;
398
+ });
399
+ const wait = new Promise((resolve, reject) => {
400
+ watchdog = setTimeout(() => {
401
+ timedOut = true;
402
+ console.warn(`spec-cli: graph session splice did not settle within ${BUILD_TIMEOUT_MS}ms`);
403
+ reject(timeoutError());
404
+ }, BUILD_TIMEOUT_MS);
405
+ watchdog.unref?.();
406
+ settle.then((board) => { if (!timedOut)
407
+ resolve(board); }, (error) => { if (!timedOut)
408
+ reject(error); });
409
+ });
410
+ const flight = { wait, settle };
411
+ sessionFlight = flight;
412
+ void wait.catch(() => { });
413
+ void settle.catch(() => { });
414
+ return flight;
415
+ }
416
+ function startBuild(mode = 'dirty') {
417
+ if (inflight)
418
+ return inflight;
419
+ if (Date.now() < retryAt)
420
+ return null;
421
+ const controller = new AbortController();
422
+ let watchdog;
423
+ let timedOut = false;
424
+ let built = false;
425
+ let buildStartedAt = 0;
426
+ let buildScope = 'full';
427
+ let buildFullStable = true;
428
+ let buildSessionsStable = true;
429
+ let buildSessionGeneration = sessionGeneration;
430
+ let buildSessionProjectionPublication = sessionProjectionPublication;
431
+ let buildStartedWithSessionOwed = false;
432
+ let buildAddedSessionRoot = false;
433
+ let completedRevision = null;
434
+ // Do not invoke the producer inline. buildBoard() has an asynchronous signature but performs a sizeable
435
+ // synchronous setup before its first await (Promise.all evaluates its arguments immediately). A stale HTTP
436
+ // caller must be able to return its last-good bytes before that setup runs; first-cold/fresh callers still
437
+ // await the same deferred promise below.
438
+ let resolveBuild;
439
+ let rejectBuild;
440
+ const build = new Promise((resolve, reject) => {
441
+ resolveBuild = resolve;
442
+ rejectBuild = reject;
443
+ });
444
+ // Give a stale HTTP response a turn to flush before the producer's synchronous setup occupies the event
445
+ // loop. Fresh callers simply absorb this small scheduling window while waiting on the same flight.
446
+ setTimeout(() => {
447
+ if (controller.signal.aborted) {
448
+ rejectBuild(Object.assign(new Error('graph build aborted before start'), { name: 'AbortError' }));
449
+ return;
450
+ }
451
+ try {
452
+ Promise.resolve(withGitAbortSignal(controller.signal, async () => {
453
+ const prev = cached;
454
+ const anchor = cachedRevision;
455
+ const sampledGen = gen;
456
+ const before = await boardInputRevision(prev);
457
+ if (controller.signal.aborted)
458
+ throw Object.assign(new Error('graph build aborted before producer start'), { name: 'AbortError' });
459
+ // ONE rule for every refresh: the domain a producer runs is DERIVED from the inputs that actually
460
+ // moved, never assigned by whoever signalled. A watcher names the leaf it saw, not what the board
461
+ // reads — a generated harness artifact rewritten inside a live worktree, or a linked worktree no
462
+ // governed record names, moves no board input at all, and used to buy a whole structural assembly
463
+ // anyway. The revision sampled here is the complete full-domain contract, so an unmoved full
464
+ // obligation is DISCHARGED without assembly. The session projection is deliberately NOT
465
+ // dischargeable: liveness lives on graph-stream's poller axis, outside this revision, so a claimed
466
+ // sessions obligation always survives and takes its splice. A sample taken after the write is what
467
+ // the watcher event was about; an invalidation arriving after the sample keeps its own dirty window.
468
+ if (prev && anchor && gen === sampledGen) {
469
+ const fullMoved = before.full !== anchor.full;
470
+ const subtractiveWorktreeMove = fullMoved && isPureSubtractiveWorktreeMove(anchor, before);
471
+ const structuralMoved = fullMoved && !subtractiveWorktreeMove;
472
+ const projectionMoved = before.sessions !== anchor.sessions || before.projections !== anchor.projections;
473
+ // A projection move nobody signalled is this validation doing the patrol's repair job.
474
+ if ((projectionMoved || subtractiveWorktreeMove) && !sessionOwed && dirty !== 'sessions') {
475
+ gen++;
476
+ sessionOwed = true;
477
+ sessionGeneration++;
478
+ }
479
+ const owed = structuralMoved ? 'full' : (sessionOwed || dirty === 'sessions') ? 'sessions' : 'none';
480
+ if (DEBUG && owed !== 'none') {
481
+ const moved = Object.keys(before.fullParts).filter((key) => before.fullParts[key] !== anchor.fullParts[key]);
482
+ console.warn(`spec-cli: graph refresh revision moved — signalled=${dirty} scope=${owed} inputs=[${moved.join(', ')}]`);
483
+ }
484
+ else if (DEBUG && dirty !== 'none') {
485
+ console.warn(`spec-cli: graph refresh discharged a ${dirty} signal — no graph input moved`);
486
+ }
487
+ if (structuralMoved)
488
+ dirty = 'full';
489
+ else if (sessionOwed || dirty === 'sessions')
490
+ dirty = 'sessions';
491
+ // Discharging CONSUMES the claim. Leaving it standing would make every later read re-enter this
492
+ // validation, hold the board permanently stale/refreshing, and never converge.
493
+ else {
494
+ dirty = 'none';
495
+ return prev;
496
+ }
497
+ }
498
+ const sessionsOnly = dirty === 'sessions' && prev !== null;
499
+ buildScope = sessionsOnly ? 'sessions' : 'full';
500
+ // Consume the scope this producer is satisfying. Invalidation after this point starts a NEW dirty
501
+ // window with its own domain: a session completion during a long full build owes one splice, not
502
+ // another full build. The occupied `inflight` slot keeps fresh/stale readers joined while dirty is clean.
503
+ dirty = 'none';
504
+ if (sessionsOnly)
505
+ sessionOwed = false;
506
+ built = true;
507
+ buildSessionGeneration = sessionGeneration;
508
+ buildSessionProjectionPublication = sessionProjectionPublication;
509
+ buildStartedWithSessionOwed = sessionOwed;
510
+ buildStartedAt = Date.now();
511
+ const board = await (sessionsOnly ? spliceSessions(prev) : buildBoard());
512
+ const after = await boardInputRevision(prev);
513
+ const movedFull = Object.keys(after.fullParts)
514
+ .filter((key) => after.fullParts[key] !== before.fullParts[key]);
515
+ // The first build can initialize the resident forge carrier that it then returns. That movement is
516
+ // stable only when the finished board carries the after-sample's exact issue stamp. Every other cold
517
+ // movement remains dirty just like a warm one; cold start is not a blanket race exemption.
518
+ const coldIssueInitialization = prev === null
519
+ && movedFull.every((key) => key === 'issuesStamp')
520
+ && after.fullParts.issuesStamp === digest(board.issuesStamp);
521
+ buildFullStable = before.full === after.full || coldIssueInitialization;
522
+ buildSessionsStable = before.sessions === after.sessions;
523
+ if (DEBUG && (!buildFullStable || !buildSessionsStable)) {
524
+ console.warn(`spec-cli: graph inputs moved during ${buildScope} producer — next=${buildFullStable ? 'sessions' : 'full'} inputs=[${movedFull.join(', ')}] sessions=${buildSessionsStable ? 'stable' : 'moved'}`);
525
+ }
526
+ // The anchor must name the sample this board is KNOWN to have read, because validation now discharges
527
+ // an unmoved obligation for every refresh, not only the patrol's. An input that moved DURING the
528
+ // producer leaves the board built from the pre-move value while the after-sample already names the
529
+ // post-move one; anchoring on `after` would let the next validation discharge the re-owed dirty scope
530
+ // against a revision this board never carried, and the cache would converge on nothing. So a moved
531
+ // half anchors on `before` — the conservative direction, which costs at most one extra rebuild and can
532
+ // never certify unread bytes.
533
+ if (sessionsOnly && anchor) {
534
+ const projectedRoots = [...new Set(board.sessions.map((row) => row.path))].sort();
535
+ const rootTransition = carrySubtractiveWorktrees(anchor, projectedRoots);
536
+ buildAddedSessionRoot = rootTransition.added;
537
+ completedRevision = revisionCarriedBySessionSplice(rootTransition.revision, before, board, buildFullStable && buildSessionsStable && !rootTransition.added);
538
+ }
539
+ else {
540
+ completedRevision = revisionCarriedByBoard(buildFullStable && buildSessionsStable ? after : before, board);
541
+ }
542
+ return board;
543
+ }))
544
+ .then(resolveBuild, rejectBuild);
545
+ }
546
+ catch (error) {
547
+ rejectBuild(error);
548
+ }
549
+ }, mode === 'patrol' ? 0 : BACKGROUND_START_DELAY_MS).unref?.();
550
+ const timeoutError = () => new Error(`graph build did not settle within ${BUILD_TIMEOUT_MS}ms`);
551
+ // `settle` owns the real builder. The watchdog only rejects `wait`; the slot remains occupied until this
552
+ // promise settles, so a next read can never overlap an abandoned git/fs build.
553
+ let settle;
554
+ settle = build.then(async (board) => {
555
+ if (timedOut)
556
+ throw timeoutError();
557
+ if (built) {
558
+ // A full build's session snapshot may predate a session projection that clients already saw. Rebase those
559
+ // published rows in memory on the new topology. This completion path must stay bounded: a later/unpublished
560
+ // session generation remains owed to the independent cheap splice rather than making full wait for quiet.
561
+ const publishedDuringBuild = sessionProjectionPublication !== buildSessionProjectionPublication;
562
+ if (buildScope === 'full' && publishedDuringBuild && cached && cachedRevision) {
563
+ board = rebasePublishedSessions(board, cached);
564
+ completedRevision = revisionCarriedByPublishedSessionRebase(completedRevision, cachedRevision);
565
+ }
566
+ const sessionStillOwed = sessionOwed || (!publishedDuringBuild && (buildStartedWithSessionOwed || !buildSessionsStable || buildSessionGeneration !== sessionGeneration));
567
+ if (sessionStillOwed) {
568
+ sessionOwed = true;
569
+ mergeDirty('sessions');
570
+ }
571
+ cached = board;
572
+ cachedJson = null;
573
+ cachedRevision = completedRevision;
574
+ if (buildScope === 'full')
575
+ topologyGeneration++;
576
+ else
577
+ sessionProjectionPublication++;
578
+ if (!buildFullStable || buildAddedSessionRoot)
579
+ mergeDirty('full');
580
+ traceCacheCommit(buildScope, buildStartedAt);
581
+ }
582
+ retryAt = 0;
583
+ lastFailure = null;
584
+ return board;
585
+ }).catch((error) => {
586
+ const failure = error instanceof Error ? error : new Error(String(error));
587
+ if (built)
588
+ mergeDirty(buildScope);
589
+ lastFailure = failure;
590
+ retryAt = Date.now() + RETRY_BACKOFF_MS;
591
+ console.warn(`spec-cli: /api/graph build failed — ${failure.message}`);
592
+ throw failure;
593
+ }).finally(() => {
594
+ clearTimeout(watchdog);
595
+ if (inflight?.settle === settle)
596
+ inflight = null;
597
+ const ms = built ? Date.now() - buildStartedAt : 0;
598
+ if (built && ms > BUDGET_MS)
599
+ console.warn(`spec-cli: /api/graph build took ${ms}ms (budget ${BUDGET_MS}ms) — ${buildScope} path is slow`);
600
+ });
601
+ const wait = new Promise((resolve, reject) => {
602
+ watchdog = setTimeout(() => {
603
+ timedOut = true;
604
+ console.warn(`spec-cli: /api/graph build did not settle within ${BUILD_TIMEOUT_MS}ms — aborting the single-flight build`);
605
+ controller.abort();
606
+ reject(timeoutError());
607
+ }, BUILD_TIMEOUT_MS);
608
+ watchdog.unref?.();
609
+ settle.then((board) => {
610
+ if (!timedOut)
611
+ resolve(board);
612
+ }, (error) => {
613
+ if (!timedOut)
614
+ reject(error);
615
+ });
616
+ });
617
+ const flight = { wait, settle };
618
+ inflight = flight;
619
+ // Background stale readers intentionally do not await these promises. Observe both rejection paths so a
620
+ // failed build is loud without becoming an unhandled rejection.
621
+ void wait.catch(() => { });
622
+ void settle.catch(() => { });
623
+ return flight;
624
+ }
625
+ export function getBoard() {
626
+ // A clean-looking cache can be under patrol validation. Fresh readers join that flight before taking the
627
+ // cache fast path, otherwise one can return stale bytes while the flight is discovering a missed change.
628
+ if (inflight)
629
+ return inflight.wait;
630
+ if (dirty === 'full') {
631
+ const flight = startBuild('dirty');
632
+ if (flight)
633
+ return flight.wait;
634
+ }
635
+ if (sessionFlight)
636
+ return sessionFlight.wait;
637
+ if (dirty === 'none' && cached)
638
+ return Promise.resolve(cached);
639
+ const flight = startBuild('dirty');
640
+ if (flight)
641
+ return flight.wait;
642
+ return Promise.reject(lastFailure ?? new Error('graph build retry is temporarily backing off'));
643
+ }
644
+ export async function waitForPublishedRevision(required, publication) {
645
+ while (publication.revision() < required) {
646
+ publication.invalidate();
647
+ await publication.wait();
648
+ }
649
+ }
650
+ // A board flight can have captured a resident forge slice before its reconcile publishes a newer revision.
651
+ // That older flight may settle, but cannot discharge this request: its invalidation stays owed and the fence
652
+ // consumes the next publication instead of letting a fresh /api/issues read return the old review snapshot.
653
+ export async function getBoardForForgeRevision(required) {
654
+ await waitForPublishedRevision(required, {
655
+ revision: () => readReviewSnapshot().forgeRevision,
656
+ invalidate: () => invalidateBoard('full'),
657
+ wait: async () => {
658
+ const current = inflight;
659
+ if (current)
660
+ await current.settle;
661
+ else
662
+ await getBoard();
663
+ },
664
+ });
665
+ return cached ?? getBoard();
666
+ }
667
+ // Delta delivery may choose the sessions obligation while a route-owned full is still in flight. Starting
668
+ // the full first preserves structural convergence; returning the splice first keeps the lifecycle surface live.
669
+ export function getBoardForSessionRefresh() {
670
+ if (cached && sessionOwed) {
671
+ if (!inflight && dirty === 'full')
672
+ startBuild('dirty');
673
+ const flight = startSessionSplice();
674
+ if (flight)
675
+ return flight.wait;
676
+ }
677
+ return getBoard();
678
+ }
679
+ // The delta-gated cold tick calls this instead of invalidating. Equal inputs resolve to the cached object;
680
+ // changed inputs repair through the same flight and full producer as a watcher-owned invalidation.
681
+ export function patrolBoard() {
682
+ if (!cached)
683
+ return getBoard();
684
+ const flight = startBuild('patrol');
685
+ if (flight)
686
+ return flight.wait;
687
+ return Promise.reject(lastFailure ?? new Error('graph patrol retry is temporarily backing off'));
688
+ }
689
+ export async function readBoard(consistency = 'fresh') {
690
+ if (consistency === 'stale-ok' && cached) {
691
+ const stale = dirty !== 'none' || inflight !== null || sessionFlight !== null || sessionOwed;
692
+ // A held session splice already owns this refresh. Returning stale bytes must not manufacture a full
693
+ // producer beside it; a real full obligation still starts its one structural producer independently.
694
+ const flight = inflight
695
+ ?? (dirty === 'full' ? startBuild('dirty') : null)
696
+ ?? sessionFlight
697
+ ?? (sessionOwed ? startSessionSplice() : null)
698
+ ?? (dirty === 'sessions' ? startBuild('dirty') : null);
699
+ return { board: cached, freshness: stale ? 'stale' : 'fresh', refreshing: !!flight, ...(lastFailure ? { error: lastFailure.message } : {}) };
700
+ }
701
+ const board = await getBoard();
702
+ return { board, freshness: 'fresh', refreshing: false };
703
+ }
704
+ // the SERIALIZED board for the /api/graph route — JSON.stringify runs ONCE per build, not once per poll,
705
+ // so a poll storm of cache hits costs zero serialization CPU (only the etag hash for the 304 path). The SSE
706
+ // path still takes the object (getBoard) because it decomposes it into delta units ([[graph-delta]]).
707
+ export async function getBoardJson(consistency = 'fresh') {
708
+ const result = await readBoard(consistency);
709
+ const board = result.board;
710
+ if (board === cached && cachedJson !== null)
711
+ return { ...result, json: cachedJson };
712
+ const json = JSON.stringify(board);
713
+ if (board === cached)
714
+ cachedJson = json; // memoize only the CURRENT build's serialization
715
+ return { ...result, json };
716
+ }