spexcode 0.5.1 → 0.5.3

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 (155) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +25 -1
  4. package/spec-cli/hooks/dispatch.sh +24 -1
  5. package/spec-cli/src/anchors.ts +192 -58
  6. package/spec-cli/src/attach.ts +11 -8
  7. package/spec-cli/src/claude-headless.ts +116 -66
  8. package/spec-cli/src/cli.ts +221 -72
  9. package/spec-cli/src/client.ts +143 -5
  10. package/spec-cli/src/gateway-hub.ts +2 -6
  11. package/spec-cli/src/gateway.ts +96 -11
  12. package/spec-cli/src/git.ts +1395 -96
  13. package/spec-cli/src/graph.ts +21 -17
  14. package/spec-cli/src/graphCache.ts +405 -61
  15. package/spec-cli/src/graphStream.ts +581 -182
  16. package/spec-cli/src/guide.ts +13 -3
  17. package/spec-cli/src/harness.ts +965 -70
  18. package/spec-cli/src/headless-controller.ts +63 -0
  19. package/spec-cli/src/help.ts +120 -64
  20. package/spec-cli/src/host-resources.ts +706 -0
  21. package/spec-cli/src/host.ts +1 -0
  22. package/spec-cli/src/index.ts +95 -21
  23. package/spec-cli/src/init.ts +47 -8
  24. package/spec-cli/src/issues.ts +36 -3
  25. package/spec-cli/src/layout.ts +193 -37
  26. package/spec-cli/src/lint.ts +152 -27
  27. package/spec-cli/src/localIssues.ts +5 -1
  28. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  29. package/spec-cli/src/materialize.ts +6 -7
  30. package/spec-cli/src/opencode-headless.ts +117 -7
  31. package/spec-cli/src/opencode.ts +8 -4
  32. package/spec-cli/src/pi-headless.ts +18 -60
  33. package/spec-cli/src/process-identity.ts +49 -0
  34. package/spec-cli/src/project-store.ts +17 -0
  35. package/spec-cli/src/pty-bridge.ts +37 -7
  36. package/spec-cli/src/pty-helper.mjs +16 -6
  37. package/spec-cli/src/pty-native-helper.mjs +22 -0
  38. package/spec-cli/src/reviews.ts +17 -13
  39. package/spec-cli/src/runtime-ownership.ts +99 -0
  40. package/spec-cli/src/session-maintenance.ts +750 -0
  41. package/spec-cli/src/session-timeline.ts +29 -23
  42. package/spec-cli/src/sessions.ts +1262 -200
  43. package/spec-cli/src/sh.ts +3 -0
  44. package/spec-cli/src/source-files.ts +27 -2
  45. package/spec-cli/src/specs.ts +58 -25
  46. package/spec-cli/src/supervise.ts +31 -11
  47. package/spec-cli/src/uninstall.ts +7 -6
  48. package/spec-cli/templates/hooks/commit-msg +14 -0
  49. package/spec-cli/templates/hooks/pre-commit +26 -6
  50. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  51. package/spec-cli/templates/hooks/reference-transaction +105 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  54. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  55. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  56. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  57. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  58. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  59. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  60. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  61. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  116. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  117. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  118. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  119. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  120. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  121. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  122. package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
  123. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  124. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  125. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  126. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  127. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  128. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  129. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  130. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  131. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  132. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  133. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  134. package/spec-dashboard/dist/index.html +5 -4
  135. package/spec-dashboard/src/reviewFilters.js +46 -8
  136. package/spec-dashboard/src/session.js +64 -9
  137. package/spec-eval/src/cli.ts +144 -32
  138. package/spec-eval/src/evaltab.ts +16 -9
  139. package/spec-eval/src/freshness.ts +336 -54
  140. package/spec-eval/src/scenariofresh.ts +51 -1
  141. package/spec-eval/src/scenarios.ts +324 -25
  142. package/spec-eval/src/sessioneval.ts +969 -173
  143. package/spec-cli/src/message-stream.ts +0 -147
  144. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
  145. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
  146. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
  147. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
  148. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
  149. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
  150. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
  151. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
  152. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
  153. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
  154. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
  155. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
@@ -4,7 +4,7 @@ import { listSessions } from './sessions.js'
4
4
  import { repoRoot, driftIndex, historyIndex } from './git.js'
5
5
  import { residentForgeState } from '../../spec-forge/src/resident.js'
6
6
  import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
7
- import { mergedIssues } from './issues.js'
7
+ import { boardThreads } from './issues.js'
8
8
  import { evalContext, evalTimeline } from '../../spec-eval/src/evaltab.js'
9
9
  import { evalNodesAsync } from '../../spec-eval/src/scenarios.js'
10
10
  import { resolveProjectIdentity } from './project-identity.js'
@@ -42,6 +42,14 @@ export function nodeEvalSummary(scenarios: { name: string }[], readings: any[])
42
42
  return summary
43
43
  }
44
44
 
45
+ // @@@ a shelved row carries no delta ([[archive]]) - the board has TWO producers (a full buildBoard and the
46
+ // sessions-only spliceSessions), and shelving is a session-scoped write, so it takes the SPLICE path — which
47
+ // reuses the previous board's ops by path. Skipping the delta only in layout's row builder would therefore be
48
+ // invisible exactly when it fires: the splice would carry the stale ops forward forever. The rule belongs to
49
+ // the row, not to one producer, so both decorate through this one function.
50
+ const rowOps = (s: { path: string; archived?: boolean }, opsByPath: Record<string, any[]>): any[] =>
51
+ (s.archived ? [] : opsByPath[s.path] || [])
52
+
45
53
  export async function buildBoard() {
46
54
  // all three sources are warm-cheap and independent, so the board inherits their speed for free: loadSpecs
47
55
  // REUSES the HEAD-keyed spec-history cache (the git-derived node data — see specs.ts/git.ts), resolveLayout
@@ -113,20 +121,16 @@ export async function buildBoard() {
113
121
  // Reconcile Issues once. Full rows stay in the server-only review snapshot; graph nodes get counts and
114
122
  // open identity only, enough for tile/stat/tree glances without reconstructing the list.
115
123
  const isOpen = (i: { status: string }) => i.status === 'open'
116
- const merged = mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, nodes.map((n) => n.id))
117
- // ONE board-level freshness stamp over EVERY issue thread (noded or nodeless, both stores):
118
- // open-count : thread-count : reply-count : latest-activity. Every thread write open, reply, remark,
119
- // resolve, retract, close moves at least one component, so a store write ALWAYS moves board bytes:
120
- // [[graph-delta]] suppresses no-change broadcasts, and without a moving byte an external write would
121
- // stay invisible to viewers until the fallback poll ([[remark-substrate]] write-visibility). The per-node
122
- // fold below stays [[graph-lean]]-slim (no reply payloads); this stamp is the freshness carrier.
123
- const issuesStamp = [
124
- merged.filter(isOpen).length,
125
- merged.length,
126
- merged.reduce((n, i) => n + i.replies.length, 0),
127
- merged.flatMap((i) => [i.created, ...i.replies.flatMap((r) => [r.at, r.resolvedAt ?? ''])]).reduce((a, b) => (b > a ? b : a), ''),
128
- ].join(':')
129
- const issuesByNode: Record<string, ReturnType<typeof mergedIssues>> = {}
124
+ // ONE store walk yielding both halves ([[issues]] boardThreads): the ISSUE surfaces get the split
125
+ // population, the freshness carrier gets the whole store.
126
+ const { issues: merged, stamp: issuesStamp } = boardThreads({ host: resolveForgeHost(), state: residentForgeState() }, nodes.map((n) => n.id))
127
+ // `issuesStamp` above is that ONE board-level freshness stamp, over EVERY thread noded or nodeless,
128
+ // both stores, BOTH remark hosts. It is folded from the whole store and NOT from the split `merged`: a
129
+ // scenario-hosted remark lands on an eval track the issue read splits out ([[eval-issue-split]]), so a
130
+ // carrier folded over the issue half alone left an open READING blind to every remark on it — the write
131
+ // moved no board byte, [[graph-delta]] correctly suppressed the no-change broadcast, and the push never
132
+ // fired at all. The per-node fold below stays [[graph-lean]]-slim (no reply payloads).
133
+ const issuesByNode: Record<string, ReturnType<typeof boardThreads>['issues']> = {}
130
134
  for (const issue of merged)
131
135
  for (const nid of issue.nodes) (issuesByNode[nid] ??= []).push(issue)
132
136
  for (const n of nodes) {
@@ -161,7 +165,7 @@ export async function buildBoard() {
161
165
  const sess = sessions.map((s) => ({
162
166
  ...s,
163
167
  source: s.path,
164
- ops: opsByPath[s.path] || [],
168
+ ops: rowOps(s, opsByPath),
165
169
  evalSummary: evalProjections.get(s.id),
166
170
  }))
167
171
 
@@ -189,7 +193,7 @@ export async function spliceSessions(prev: Awaited<ReturnType<typeof buildBoard>
189
193
  const sess = sessions.map((s) => ({
190
194
  ...s,
191
195
  source: s.path,
192
- ops: opsByPath[s.path] || [],
196
+ ops: rowOps(s, opsByPath),
193
197
  evalSummary: evalProjections.get(s.id),
194
198
  }))
195
199
  return { ...prev, sessions: sess }
@@ -1,4 +1,14 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
3
+ import { isAbsolute, join, resolve } from 'node:path'
1
4
  import { buildBoard, spliceSessions } from './graph.js'
5
+ import { headSha, repoRoot, withGitAbortSignal } from './git.js'
6
+ import { listSessionIds, mainBranch, mainCheckout, readPublicRecordEntry, sessionArtifactPath, sessionRecordPath } from './layout.js'
7
+ import { boardThreads } from './issues.js'
8
+ import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
9
+ import { residentForgeState } from '../../spec-forge/src/resident.js'
10
+ import { resolveProjectIdentity } from './project-identity.js'
11
+ import { sessionEvalProjection } from '../../spec-eval/src/sessioneval.js'
2
12
 
3
13
  // @@@ graph-cache — single-flight + cache for the hot /api/graph build ([[graph-lean]]). Assembling the
4
14
  // board is expensive (two full-history git-log walks cold, a full `.spec` fs walk every build), so the
@@ -7,11 +17,197 @@ import { buildBoard, spliceSessions } from './graph.js'
7
17
  // starved the event loop — one real user could wedge the backend. Here ONE build is shared by all
8
18
  // concurrent callers (a promise memo — this IS the max-concurrent-builds cap: at most one runs) and its
9
19
  // result is cached until a REAL change invalidates it. The cache is invalidated by the SAME freshness
10
- // signals [[graph-stream]] already watches (session-store writes, git-ref moves, the cold tick), via
11
- // invalidateBoard(). So a poll storm costs ONE build, a quiet stretch costs ZERO, and the SSE rebuild and
12
- // the route share the very same in-flight build.
20
+ // signals [[graph-stream]] already watches (session-store writes, git-ref/worktree moves); the cold patrol
21
+ // enters this cache's input-revision validation flight instead of manufacturing a change. So a poll storm
22
+ // costs ONE build, a quiet stretch costs ZERO, and the SSE rebuild and the route share the same operation.
13
23
 
14
24
  export type Board = Awaited<ReturnType<typeof buildBoard>>
25
+ export type BoardConsistency = 'fresh' | 'stale-ok'
26
+ export type BoardRead = { board: Board; freshness: 'fresh' | 'stale'; refreshing: boolean; error?: string }
27
+ export type BoardJsonRead = BoardRead & { json: string }
28
+
29
+ type BoardInputRevision = {
30
+ full: string
31
+ sessions: string
32
+ projections: string
33
+ combined: string
34
+ fullParts: Record<string, string>
35
+ projectionIds: string[]
36
+ }
37
+ const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1'
38
+
39
+ function textOrNull(path: string): string | null {
40
+ try { return readFileSync(path, 'utf8') }
41
+ catch (error) {
42
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
43
+ throw error
44
+ }
45
+ }
46
+
47
+ function gitDirOf(root: string): string {
48
+ const dotgit = join(root, '.git')
49
+ try { if (statSync(dotgit).isDirectory()) return dotgit }
50
+ catch (error) {
51
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
52
+ throw error
53
+ }
54
+ const dotgitText = textOrNull(dotgit)
55
+ if (dotgitText === null) return ''
56
+ const match = dotgitText.match(/^gitdir:\s*(.+)$/m)
57
+ if (!match) throw new Error(`unparseable gitdir pointer at ${dotgit}`)
58
+ const dir = match[1].trim()
59
+ return isAbsolute(dir) ? dir : resolve(root, dir)
60
+ }
61
+
62
+ function commonDirOf(root: string): string {
63
+ const gitDir = gitDirOf(root)
64
+ if (!gitDir) return ''
65
+ const common = textOrNull(join(gitDir, 'commondir'))
66
+ if (common === null) return gitDir
67
+ const dir = common.trim()
68
+ if (!dir) throw new Error(`empty commondir pointer at ${join(gitDir, 'commondir')}`)
69
+ return isAbsolute(dir) ? dir : resolve(gitDir, dir)
70
+ }
71
+
72
+ function refSha(root: string, ref: string): string {
73
+ if (/^[0-9a-f]{40}$/.test(ref)) return ref
74
+ const common = commonDirOf(root)
75
+ if (!common) return '<missing>'
76
+ const names = ref.startsWith('refs/') ? [ref] : [`refs/heads/${ref}`, `refs/remotes/${ref}`]
77
+ for (const name of names) {
78
+ const loose = textOrNull(join(common, name))
79
+ if (loose !== null) return loose.trim() || '<missing>'
80
+ }
81
+ const packed = textOrNull(join(common, 'packed-refs'))
82
+ if (packed !== null) {
83
+ for (const line of packed.split('\n')) {
84
+ if (!line || line[0] === '#' || line[0] === '^') continue
85
+ const space = line.indexOf(' ')
86
+ if (space > 0 && names.includes(line.slice(space + 1).trim())) return line.slice(0, space).trim()
87
+ }
88
+ }
89
+ return '<missing>'
90
+ }
91
+
92
+ function headRevision(root: string): string {
93
+ if (!gitDirOf(root)) return '<missing>'
94
+ return headSha(root)
95
+ }
96
+
97
+ // Patrol validation needs a stronger contract than the overlay cache's best-effort mtime key: ctime catches
98
+ // a same-size edit whose mtime was restored, and every non-ENOENT read failure is loud instead of collapsing an
99
+ // unreadable subtree into a stable "unchanged" revision. A path that vanishes during the sample is represented
100
+ // by its absence and is rechecked by the producer fence.
101
+ function strictSpecTreeRevision(wtPath: string): string {
102
+ const root = join(wtPath, '.spec')
103
+ try { statSync(root) }
104
+ catch (error) {
105
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
106
+ throw error
107
+ }
108
+ const parts: string[] = []
109
+ const stack = [root]
110
+ while (stack.length) {
111
+ const dir = stack.pop()!
112
+ let entries
113
+ try { entries = readdirSync(dir, { withFileTypes: true }) }
114
+ catch (error) {
115
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
116
+ continue
117
+ }
118
+ for (const entry of entries) {
119
+ const path = join(dir, entry.name)
120
+ if (entry.isDirectory()) { stack.push(path); continue }
121
+ try {
122
+ const stat = statSync(path)
123
+ parts.push(`${path}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`)
124
+ } catch (error) {
125
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
126
+ }
127
+ }
128
+ }
129
+ return parts.sort().join('\n')
130
+ }
131
+
132
+ function worktreeRevision(root: string): unknown {
133
+ return {
134
+ root,
135
+ head: headRevision(root),
136
+ spec: strictSpecTreeRevision(root),
137
+ }
138
+ }
139
+
140
+ function digest(value: unknown): string {
141
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex')
142
+ }
143
+
144
+ // The patrol's cheap authority check. These are exactly the mutable inputs a board assembly reads, folded
145
+ // without doing the assembly: HEAD/config + `.spec`, governed records and active governed worktree state,
146
+ // the issue-store carrier, and the already-resident session-eval projections. It intentionally does not call
147
+ // listSessions or sessionEvalProjections: verification must neither poll tmux again nor mint/schedule eval work.
148
+ // The hot/warm liveness signatures and eval generations remain graph-stream's canonical event-owned axes.
149
+ function boardInputRevision(board: Board | null): BoardInputRevision {
150
+ const root = repoRoot()
151
+ const ids = listSessionIds().sort()
152
+ // listSessions projects both the structured record and the separately-stored originating prompt into each
153
+ // board row. Fold both exact artifacts so a missed store event cannot leave a stale label/prompt forever.
154
+ const sessionInputs = ids.map((id) => [
155
+ id,
156
+ textOrNull(sessionRecordPath(id)),
157
+ textOrNull(sessionArtifactPath(id, 'prompt')),
158
+ ] as const)
159
+ const records = ids.map(readPublicRecordEntry).flatMap((entry) => entry.kind === 'ok' ? [entry.raw] : [])
160
+ const governed = records.filter((record) => record.governed).sort((a, b) => a.session_id.localeCompare(b.session_id))
161
+ const activeRoots = [...new Set(governed.filter((record) => !record.archived).map((record) => record.worktree_path))].sort()
162
+ const main = mainCheckout()
163
+ const base = mainBranch()
164
+ const mainTip = refSha(main, base)
165
+
166
+ const nodeIds = (board?.nodes ?? []).map((node) => node.id)
167
+ const issuesStamp = boardThreads({ host: resolveForgeHost(), state: residentForgeState() }, nodeIds).stamp
168
+ const fullInputs = {
169
+ root: worktreeRevision(root),
170
+ config: [
171
+ [join(root, 'spexcode.json'), textOrNull(join(root, 'spexcode.json'))],
172
+ [join(root, 'spexcode.local.json'), textOrNull(join(root, 'spexcode.local.json'))],
173
+ [join(main, 'spexcode.json'), textOrNull(join(main, 'spexcode.json'))],
174
+ [join(main, 'spexcode.local.json'), textOrNull(join(main, 'spexcode.local.json'))],
175
+ ],
176
+ main: { root: main, branch: base, tip: mainTip },
177
+ worktrees: activeRoots.map((worktree) => worktreeRevision(worktree)),
178
+ issuesStamp,
179
+ identity: resolveProjectIdentity(root, root),
180
+ }
181
+ const fullParts = Object.fromEntries(Object.entries(fullInputs).map(([key, value]) => [key, digest(value)]))
182
+ const full = digest(fullParts)
183
+ const sessions = digest(sessionInputs)
184
+
185
+ // Every board row carries an evalSummary, including non-governed sessions. Governed filtering owns only
186
+ // worktree observation; projection identity follows the whole store enumeration so a blind observer cannot
187
+ // strand a non-governed row on an older resident phase/value.
188
+ const projectionIds = ids
189
+ const projections = digest(projectionIds.map((id) => [id, sessionEvalProjection(id)]))
190
+ return { full, sessions, projections, combined: digest([full, sessions, projections]), fullParts, projectionIds }
191
+ }
192
+
193
+ // Bind an input sample to what the completed board actually carries. The sample supplies the graph/session
194
+ // revision observed immediately after the producer; issue and projection values come from the returned board
195
+ // itself. Re-reading all inputs here would create a certification race: a blind change after the producer's
196
+ // after-sample could otherwise be mistaken for an input the already-built board contains.
197
+ function revisionCarriedByBoard(sample: BoardInputRevision, board: Board): BoardInputRevision {
198
+ const fullParts = { ...sample.fullParts, issuesStamp: digest(board.issuesStamp) }
199
+ const full = digest(fullParts)
200
+ const boardProjections = new Map(board.sessions.map((session) => [session.id, session.evalSummary ?? null]))
201
+ const projections = digest(sample.projectionIds.map((id) => [id, boardProjections.get(id) ?? null]))
202
+ return {
203
+ full,
204
+ sessions: sample.sessions,
205
+ projections,
206
+ combined: digest([full, sample.sessions, projections]),
207
+ fullParts,
208
+ projectionIds: sample.projectionIds,
209
+ }
210
+ }
15
211
 
16
212
  // a build slower than this is LOGGED, never silently tolerated — the fail-loud regression alarm. Sized
17
213
  // above a warm build (~sub-second once the fs walks yield) but below the cold two-walk first build, so a
@@ -29,88 +225,236 @@ const BUDGET_MS = Number(process.env.SPEXCODE_BOARD_BUDGET_MS || 1500)
29
225
  // the common cause die sooner. Generous: well above the slowest legitimate cold build, so it only ever
30
226
  // fires on a genuine wedge.
31
227
  const BUILD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_BUILD_TIMEOUT_MS || 120000)
228
+ const RETRY_BACKOFF_MS = Number(process.env.SPEXCODE_BOARD_RETRY_BACKOFF_MS || 1000)
229
+ const BACKGROUND_START_DELAY_MS = Number(process.env.SPEXCODE_BOARD_BACKGROUND_START_DELAY_MS || 300)
32
230
 
33
231
  // the cache's staleness has a DOMAIN, not just a bit: a 'sessions' change (a lifecycle write, a
34
232
  // liveness/activity poll flip) touches only the session rows, so the next read can SPLICE fresh sessions
35
233
  // onto the still-valid node/meta units instead of re-walking git+`.spec`; a 'full' change (a ref move, a
36
- // worktree `.spec` edit, the cold-tick patrol) can reshape anything, so the next read does the whole
37
- // buildBoard(). 'none' = clean.
234
+ // worktree `.spec` edit, or a changed graph-domain patrol revision) can reshape anything, so the next read
235
+ // does the whole buildBoard(). 'none' = clean.
38
236
  type Scope = 'sessions' | 'full'
39
237
  let cached: Board | null = null // last completed build; served while `dirty === 'none'`
40
238
  let cachedJson: string | null = null // JSON.stringify(cached), serialized ONCE per build (see getBoardJson)
239
+ let cachedRevision: BoardInputRevision | null = null // input revision represented by `cached`
41
240
  let dirty: Scope | 'none' = 'full' // no cached board yet → the first read builds fully
42
- let inflight: Promise<Board> | null = null
43
- let gen = 0 // bumped on every invalidation — detects a change that landed MID-build
241
+ type Flight = { wait: Promise<Board>; settle: Promise<Board> }
242
+ let inflight: Flight | null = null
243
+ let gen = 0 // bumped on invalidation so patrol validation cannot certify across an event
244
+ let retryAt = 0
245
+ let lastFailure: Error | null = null
44
246
 
45
247
  // mark the cache stale at a SCOPE. Called by every board-stream freshness source (see
46
248
  // boardStream.fireChanged), so a real change forces the next getBoard() to rebuild while a quiet poll storm
47
249
  // keeps hitting the cache. The scope only ESCALATES within a dirty window: none→sessions→full, and a
48
250
  // 'sessions' signal arriving while 'full' is already pending stays 'full' (a full rebuild subsumes a
49
- // sessions splice). cachedJson is dropped either way a splice replaces the board object, so its old
50
- // serialization is stale regardless of scope.
51
- export function invalidateBoard(scope: Scope = 'full'): void {
52
- gen++
251
+ // sessions splice). The last-good JSON stays intact while dirty so stale readers can return it without
252
+ // paying serialization again; a successful replacement clears it.
253
+ function mergeDirty(scope: Scope): void {
53
254
  if (scope === 'full' || dirty === 'full') dirty = 'full'
54
255
  else dirty = 'sessions'
55
- cachedJson = null
56
- }
57
-
58
- // the coalesced board read the route and the SSE rebuild both go through. A concurrent caller during a
59
- // build shares the in-flight promise; a caller after a completed build gets the cached value until the
60
- // next invalidation. A 'sessions'-scoped dirty with a cached board takes the SPLICE path (spliceSessions —
61
- // fresh session rows onto the cached node/meta units) under the SAME single-flight promise + watchdog +
62
- // generation rules; anything else (dirty 'full', or no cache to splice onto) does a full buildBoard(). A
63
- // change that lands WHILE a build runs (gen moved) leaves the cache dirty so the NEXT read rebuilds — a
64
- // 'full' invalidation landing mid-splice leaves it dirty 'full' for the next read. The just-finished build
65
- // still returns to its waiters (freshest available when they asked), never cached as current. Mirrors
66
- // [[graph-stream]]'s building/dirty loop.
67
- export function getBoard(): Promise<Board> {
256
+ }
257
+
258
+ export function invalidateBoard(scope: Scope = 'full'): void {
259
+ gen++
260
+ mergeDirty(scope)
261
+ retryAt = 0
262
+ lastFailure = null
263
+ }
264
+
265
+ // One flight owns BOTH patrol validation and a producer. A patrol first folds the cheap input revision; an
266
+ // exact match resolves to `cached` without calling either producer. A mismatch derives the changed domain and
267
+ // falls through to the same build path an explicit watcher invalidation takes. An invalidation during validation
268
+ // also falls through under this flight, so validation can never race a second producer into existence.
269
+ type FlightMode = 'dirty' | 'patrol'
270
+ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
68
271
  if (inflight) return inflight
69
- if (dirty === 'none' && cached) return Promise.resolve(cached)
70
- const startGen = gen
71
- const sessionsOnly = dirty === 'sessions' && cached !== null
72
- const prev = cached
73
- const p = (async () => {
74
- const t0 = Date.now()
75
- let watchdog: ReturnType<typeof setTimeout> | undefined
272
+ if (Date.now() < retryAt) return null
273
+ const flightStartGen = gen
274
+ const controller = new AbortController()
275
+ let watchdog: ReturnType<typeof setTimeout> | undefined
276
+ let timedOut = false
277
+ let built = false
278
+ let buildStartedAt = 0
279
+ let buildScope: Scope = 'full'
280
+ let buildFullStable = true
281
+ let buildSessionsStable = true
282
+ let completedRevision: BoardInputRevision | null = null
283
+ // Do not invoke the producer inline. buildBoard() has an asynchronous signature but performs a sizeable
284
+ // synchronous setup before its first await (Promise.all evaluates its arguments immediately). A stale HTTP
285
+ // caller must be able to return its last-good bytes before that setup runs; first-cold/fresh callers still
286
+ // await the same deferred promise below.
287
+ let resolveBuild!: (board: Board) => void
288
+ let rejectBuild!: (error: unknown) => void
289
+ const build = new Promise<Board>((resolve, reject) => {
290
+ resolveBuild = resolve
291
+ rejectBuild = reject
292
+ })
293
+ // Give a stale HTTP response a turn to flush before the producer's synchronous setup occupies the event
294
+ // loop. Fresh callers simply absorb this small scheduling window while waiting on the same flight.
295
+ setTimeout(() => {
296
+ if (controller.signal.aborted) {
297
+ rejectBuild(Object.assign(new Error('graph build aborted before start'), { name: 'AbortError' }))
298
+ return
299
+ }
76
300
  try {
77
- const board = await Promise.race([
78
- sessionsOnly ? spliceSessions(prev!) : buildBoard(),
79
- // the race consumes the loser's eventual settlement, so an abandoned build that fails later
80
- // can't surface as an unhandled rejection; unref'd so a pending watchdog never holds a one-shot
81
- // CLI process open.
82
- new Promise<never>((_, reject) => {
83
- watchdog = setTimeout(() => {
84
- console.warn(`spec-cli: /api/graph build did not settle within ${BUILD_TIMEOUT_MS}ms — wedged build abandoned so the next read can retry`)
85
- reject(new Error(`graph build did not settle within ${BUILD_TIMEOUT_MS}ms`))
86
- }, BUILD_TIMEOUT_MS)
87
- watchdog.unref?.()
88
- }),
89
- ])
301
+ Promise.resolve(withGitAbortSignal(controller.signal, async () => {
302
+ if (mode === 'patrol' && dirty === 'none' && cached && cachedRevision) {
303
+ const anchorRevision = cachedRevision
304
+ const observed = await boardInputRevision(cached)
305
+ if (controller.signal.aborted)
306
+ throw Object.assign(new Error('graph patrol aborted'), { name: 'AbortError' })
307
+ if (gen === flightStartGen && dirty === 'none' && observed.combined === anchorRevision.combined)
308
+ return cached
309
+ // A changed revision with no watcher event is the patrol doing its repair job. If an explicit event
310
+ // arrived meanwhile it already set the correct (possibly sessions-only) scope, which we must not
311
+ // overwrite. An otherwise-clean mismatch derives its scope from the moved input domain.
312
+ if (gen === flightStartGen && dirty === 'none') {
313
+ gen++
314
+ if (DEBUG) {
315
+ const moved = Object.keys(observed.fullParts)
316
+ .filter((key) => observed.fullParts[key] !== anchorRevision.fullParts[key])
317
+ console.warn(`spec-cli: graph patrol revision moved — scope=${observed.full === anchorRevision.full ? 'sessions' : 'full'} inputs=[${moved.join(', ')}]`)
318
+ }
319
+ dirty = observed.full === anchorRevision.full ? 'sessions' : 'full'
320
+ }
321
+ }
322
+
323
+ const prev = cached
324
+ const before = await boardInputRevision(prev)
325
+ if (controller.signal.aborted)
326
+ throw Object.assign(new Error('graph build aborted before producer start'), { name: 'AbortError' })
327
+ // A session signal cannot vouch for the graph domain. Before splicing, compare the current full inputs
328
+ // to the revision the cached node/meta units actually carry; a missed graph watcher promotes this same
329
+ // flight to full instead of certifying old nodes under a new revision.
330
+ if (dirty === 'sessions' && prev && cachedRevision && before.full !== cachedRevision.full) {
331
+ if (DEBUG) console.warn('spec-cli: session refresh found moved graph inputs — promoting to full')
332
+ dirty = 'full'
333
+ }
334
+ const sessionsOnly = dirty === 'sessions' && prev !== null
335
+ buildScope = sessionsOnly ? 'sessions' : 'full'
336
+ // Consume the scope this producer is satisfying. Invalidation after this point starts a NEW dirty
337
+ // window with its own domain: a session completion during a long full build owes one splice, not
338
+ // another full build. The occupied `inflight` slot keeps fresh/stale readers joined while dirty is clean.
339
+ dirty = 'none'
340
+ built = true
341
+ buildStartedAt = Date.now()
342
+ const board = await (sessionsOnly ? spliceSessions(prev!) : buildBoard())
343
+ const after = await boardInputRevision(prev)
344
+ const carried = revisionCarriedByBoard(after, board)
345
+ const movedFull = Object.keys(after.fullParts)
346
+ .filter((key) => after.fullParts[key] !== before.fullParts[key])
347
+ // The first build can initialize the resident forge carrier that it then returns. That movement is
348
+ // stable only when the finished board carries the after-sample's exact issue stamp. Every other cold
349
+ // movement remains dirty just like a warm one; cold start is not a blanket race exemption.
350
+ const coldIssueInitialization = prev === null
351
+ && movedFull.every((key) => key === 'issuesStamp')
352
+ && after.fullParts.issuesStamp === carried.fullParts.issuesStamp
353
+ buildFullStable = before.full === after.full || coldIssueInitialization
354
+ buildSessionsStable = before.sessions === after.sessions
355
+ if (DEBUG && (!buildFullStable || !buildSessionsStable)) {
356
+ console.warn(`spec-cli: graph inputs moved during ${buildScope} producer — next=${buildFullStable ? 'sessions' : 'full'} inputs=[${movedFull.join(', ')}] sessions=${buildSessionsStable ? 'stable' : 'moved'}`)
357
+ }
358
+ completedRevision = carried
359
+ return board
360
+ }))
361
+ .then(resolveBuild, rejectBuild)
362
+ } catch (error) {
363
+ rejectBuild(error)
364
+ }
365
+ }, mode === 'patrol' ? 0 : BACKGROUND_START_DELAY_MS).unref?.()
366
+ const timeoutError = () => new Error(`graph build did not settle within ${BUILD_TIMEOUT_MS}ms`)
367
+
368
+ // `settle` owns the real builder. The watchdog only rejects `wait`; the slot remains occupied until this
369
+ // promise settles, so a next read can never overlap an abandoned git/fs build.
370
+ let settle!: Promise<Board>
371
+ settle = build.then((board) => {
372
+ if (timedOut) throw timeoutError()
373
+ if (built) {
90
374
  cached = board
91
- cachedJson = null // invalidate the memoized serialization; re-serialized lazily on first read
92
- // clean ONLY if no invalidation landed during the build; otherwise leave `dirty` at whatever scope
93
- // those in-build invalidations escalated it to (a mid-splice 'full' stays 'full' for the next read).
94
- if (gen === startGen) dirty = 'none'
95
- return board
96
- } finally {
97
- clearTimeout(watchdog)
98
- const ms = Date.now() - t0
99
- if (ms > BUDGET_MS) console.warn(`spec-cli: /api/graph build took ${ms}ms (budget ${BUDGET_MS}ms) — hot path is slow`)
100
- inflight = null
375
+ cachedJson = null
376
+ cachedRevision = completedRevision
377
+ if (!buildFullStable) mergeDirty('full')
378
+ else if (!buildSessionsStable) mergeDirty('sessions')
101
379
  }
102
- })()
103
- inflight = p
104
- return p
380
+ retryAt = 0
381
+ lastFailure = null
382
+ return board
383
+ }).catch((error) => {
384
+ const failure = error instanceof Error ? error : new Error(String(error))
385
+ if (built) mergeDirty(buildScope)
386
+ lastFailure = failure
387
+ retryAt = Date.now() + RETRY_BACKOFF_MS
388
+ console.warn(`spec-cli: /api/graph build failed — ${failure.message}`)
389
+ throw failure
390
+ }).finally(() => {
391
+ clearTimeout(watchdog)
392
+ if (inflight?.settle === settle) inflight = null
393
+ const ms = built ? Date.now() - buildStartedAt : 0
394
+ if (built && ms > BUDGET_MS)
395
+ console.warn(`spec-cli: /api/graph build took ${ms}ms (budget ${BUDGET_MS}ms) — ${buildScope} path is slow`)
396
+ })
397
+
398
+ const wait = new Promise<Board>((resolve, reject) => {
399
+ watchdog = setTimeout(() => {
400
+ timedOut = true
401
+ console.warn(`spec-cli: /api/graph build did not settle within ${BUILD_TIMEOUT_MS}ms — aborting the single-flight build`)
402
+ controller.abort()
403
+ reject(timeoutError())
404
+ }, BUILD_TIMEOUT_MS)
405
+ watchdog.unref?.()
406
+ settle.then((board) => {
407
+ if (!timedOut) resolve(board)
408
+ }, (error) => {
409
+ if (!timedOut) reject(error)
410
+ })
411
+ })
412
+ const flight = { wait, settle }
413
+ inflight = flight
414
+ // Background stale readers intentionally do not await these promises. Observe both rejection paths so a
415
+ // failed build is loud without becoming an unhandled rejection.
416
+ void wait.catch(() => {})
417
+ void settle.catch(() => {})
418
+ return flight
419
+ }
420
+
421
+ export function getBoard(): Promise<Board> {
422
+ // A clean-looking cache can be under patrol validation. Fresh readers join that flight before taking the
423
+ // cache fast path, otherwise one can return stale bytes while the flight is discovering a missed change.
424
+ if (inflight) return inflight.wait
425
+ if (dirty === 'none' && cached) return Promise.resolve(cached)
426
+ const flight = startBuild('dirty')
427
+ if (flight) return flight.wait
428
+ return Promise.reject(lastFailure ?? new Error('graph build retry is temporarily backing off'))
429
+ }
430
+
431
+ // The delta-gated cold tick calls this instead of invalidating. Equal inputs resolve to the cached object;
432
+ // changed inputs repair through the same flight and full producer as a watcher-owned invalidation.
433
+ export function patrolBoard(): Promise<Board> {
434
+ if (!cached) return getBoard()
435
+ const flight = startBuild('patrol')
436
+ if (flight) return flight.wait
437
+ return Promise.reject(lastFailure ?? new Error('graph patrol retry is temporarily backing off'))
438
+ }
439
+
440
+ export async function readBoard(consistency: BoardConsistency = 'fresh'): Promise<BoardRead> {
441
+ if (consistency === 'stale-ok' && cached) {
442
+ const stale = dirty !== 'none' || inflight !== null
443
+ const flight = stale ? startBuild('dirty') : null
444
+ return { board: cached, freshness: stale ? 'stale' : 'fresh', refreshing: !!flight, ...(lastFailure ? { error: lastFailure.message } : {}) }
445
+ }
446
+ const board = await getBoard()
447
+ return { board, freshness: 'fresh', refreshing: false }
105
448
  }
106
449
 
107
450
  // the SERIALIZED board for the /api/graph route — JSON.stringify runs ONCE per build, not once per poll,
108
451
  // so a poll storm of cache hits costs zero serialization CPU (only the etag hash for the 304 path). The SSE
109
452
  // path still takes the object (getBoard) because it decomposes it into delta units ([[graph-delta]]).
110
- export async function getBoardJson(): Promise<string> {
111
- const board = await getBoard()
112
- if (board === cached && cachedJson !== null) return cachedJson
453
+ export async function getBoardJson(consistency: BoardConsistency = 'fresh'): Promise<BoardJsonRead> {
454
+ const result = await readBoard(consistency)
455
+ const board = result.board
456
+ if (board === cached && cachedJson !== null) return { ...result, json: cachedJson }
113
457
  const json = JSON.stringify(board)
114
458
  if (board === cached) cachedJson = json // memoize only the CURRENT build's serialization
115
- return json
459
+ return { ...result, json }
116
460
  }