spexcode 0.5.2 → 0.5.4

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 (151) 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 +13 -52
  8. package/spec-cli/src/cli.ts +209 -71
  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 +15 -4
  17. package/spec-cli/src/harness.ts +1133 -71
  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 +698 -0
  21. package/spec-cli/src/index.ts +95 -21
  22. package/spec-cli/src/init.ts +73 -15
  23. package/spec-cli/src/issues.ts +36 -3
  24. package/spec-cli/src/layout.ts +189 -41
  25. package/spec-cli/src/lint.ts +152 -27
  26. package/spec-cli/src/localIssues.ts +5 -1
  27. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  28. package/spec-cli/src/materialize.ts +6 -7
  29. package/spec-cli/src/opencode-headless.ts +4 -8
  30. package/spec-cli/src/pi-headless.ts +17 -60
  31. package/spec-cli/src/process-identity.ts +174 -0
  32. package/spec-cli/src/project-store.ts +17 -0
  33. package/spec-cli/src/pty-bridge.ts +6 -3
  34. package/spec-cli/src/reviews.ts +17 -13
  35. package/spec-cli/src/runtime-ownership.ts +88 -0
  36. package/spec-cli/src/session-maintenance.ts +750 -0
  37. package/spec-cli/src/session-timeline.ts +6 -6
  38. package/spec-cli/src/sessions.ts +1251 -208
  39. package/spec-cli/src/sh.ts +3 -0
  40. package/spec-cli/src/source-files.ts +27 -2
  41. package/spec-cli/src/specs.ts +58 -25
  42. package/spec-cli/src/supervise.ts +31 -11
  43. package/spec-cli/src/uninstall.ts +7 -6
  44. package/spec-cli/templates/hooks/commit-msg +14 -0
  45. package/spec-cli/templates/hooks/pre-commit +29 -15
  46. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  47. package/spec-cli/templates/hooks/reference-transaction +105 -0
  48. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  49. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  50. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  51. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  53. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  54. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  56. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  57. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  58. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  59. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  60. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  61. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  116. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  117. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  118. package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
  119. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  120. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  121. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  122. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  123. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  124. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  125. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  126. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  127. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  128. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  129. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  130. package/spec-dashboard/dist/index.html +5 -4
  131. package/spec-dashboard/src/reviewFilters.js +46 -8
  132. package/spec-dashboard/src/session.js +64 -9
  133. package/spec-eval/src/cli.ts +144 -32
  134. package/spec-eval/src/evaltab.ts +16 -9
  135. package/spec-eval/src/freshness.ts +336 -54
  136. package/spec-eval/src/scenariofresh.ts +51 -1
  137. package/spec-eval/src/scenarios.ts +324 -25
  138. package/spec-eval/src/sessioneval.ts +969 -173
  139. package/spec-cli/src/message-stream.ts +0 -147
  140. package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
  141. package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
  142. package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
  143. package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
  144. package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
  145. package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
  146. package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
  147. package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
  148. package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
  149. package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
  150. package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
  151. package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
@@ -1,13 +1,15 @@
1
1
  import { readFileSync, existsSync, readdirSync } from 'node:fs'
2
2
  import { join, dirname } from 'node:path'
3
- import { homedir } from 'node:os'
4
3
  import { git, repoRoot, gitA, headSha, worktreeSpecSig, worktreeSpecDelta, type NodeOp } from './git.js'
5
4
  import { guardWorktree } from './resilience.js'
6
5
  import { HARNESSES, type HarnessId } from './harness.js'
6
+ import { encodeProject, projectRuntimeRoot, spexcodeHome } from './project-store.js'
7
7
 
8
- type Config = {
8
+ export { encodeProject, spexcodeHome } from './project-store.js'
9
+
10
+ export type Config = {
9
11
  main?: string // path to the source-of-truth checkout (default: the `main` worktree)
10
- mainBranch?: string // source-of-truth BRANCH worktrees fork from (default: auto-detected — see mainBranch())
12
+ mainBranch?: string // stable source-of-truth branch stamped by init (default: "main")
11
13
  branchPrefix?: string // how a branch names its node (default: "node/")
12
14
  preset?: string // the SELECTED init preset — which cumulative .plugins tier `spex init` seeds (default 'default'; seed-time only, no launcher gate; read by init.ts; see [[init-preset]])
13
15
  // RETIRED ([[residence]]) — the old three-word footprint vote. Materialized artifacts carry no facts and are never
@@ -38,6 +40,13 @@ type Config = {
38
40
  launchers?: { [name: string]: { harness?: HarnessId; cmd: string } }
39
41
  defaultLauncher?: string // the launcher a create with no explicit --launcher/dropdown pick uses; required for no-choice creates
40
42
  }
43
+ resources?: {
44
+ sessionRssMiB?: number // resident-memory budget for one session owner (default 1024)
45
+ backendRssMiB?: number // resident-memory budget for this project's backend instance (default 2048)
46
+ idleCpuPercent?: number // CPU budget for a non-progressing owner (default 2)
47
+ sampleMs?: number // CPU measurement window for an on-demand report (default 1000)
48
+ reportIntervalMs?: number // supervisor-owned snapshot cadence (default 60000)
49
+ }
41
50
  serve?: {
42
51
  // public-exposure config for `spex serve --public` (resolved gateway-side; see [[public-mode]] / gateway.ts).
43
52
  // The password is NEVER read from here — flag/env only — so this file stays committable.
@@ -58,11 +67,12 @@ type Config = {
58
67
  // `serve`, `harnesses`, `render`, and `preset` are frontend/runtime/policy concerns (read separately via readConfig —
59
68
  // preset by init.ts at seed time, harnesses by [[harness-select]]; see api-endpoint / sessions.ts maxActive /
60
69
  // gateway.ts), NOT layout fields, so they stay out of the convention rather than forcing a default.
61
- type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'forge' | 'private' | 'render'>>
70
+ type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'resources' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'forge' | 'private' | 'render'>>
62
71
 
63
72
  export type Worktree = {
64
73
  path: string; branch: string | null; node: string | null
65
74
  session: string | null; status: string | null; isMain: boolean
75
+ liveness?: 'offline' | 'unknown'
66
76
  ops: NodeOp[] // pending spec-node changes this worktree makes vs main (the board's overlay)
67
77
  }
68
78
  export type Layout = { main: string; convention: Convention; worktrees: Worktree[] }
@@ -104,13 +114,9 @@ export function gitCommonDir(): string {
104
114
  }
105
115
 
106
116
  export function mainBranch(): string {
107
- try {
108
- const override = readConfig(mainCheckout()).mainBranch?.trim()
109
- if (override) return override
110
- const cur = git(['-C', mainCheckout(), 'symbolic-ref', '--short', 'HEAD']).trim()
111
- if (cur) return cur
112
- } catch { /* fall through to the conventional default */ }
113
- return 'main'
117
+ let checkout: string
118
+ try { checkout = mainCheckout() } catch { return 'main' }
119
+ return readConfig(checkout).mainBranch?.trim() || 'main'
114
120
  }
115
121
 
116
122
  // the MAIN checkout (the root working tree) for a project — the SAME answer from main OR any linked worktree
@@ -132,14 +138,8 @@ export function mainCheckout(proj?: string): string {
132
138
  // so the board enumerates ONE directory. This is the single seam that knows where the store sits; sessions.ts
133
139
  // and the shell hooks resolve through the SAME scheme (the hooks reimplement it in bash, so any change here
134
140
  // must be mirrored in .plugins/core/*/). SPEXCODE_HOME overrides the root for test isolation.
135
- export function spexcodeHome(): string {
136
- return process.env.SPEXCODE_HOME || join(homedir(), '.spexcode')
137
- }
138
141
  // encode a project-root path into ONE safe directory segment (Claude's scheme: path separators → '-'). The
139
142
  // SAME transform runs in TS and in the shell hooks, so a board read and a hook write land on the SAME dir.
140
- export function encodeProject(root: string): string {
141
- return root.replace(/[/.]/g, '-')
142
- }
143
143
  // this project's per-PROJECT runtime tier — the sessions/ records AND the per-TREE materialize slots (below) —
144
144
  // living under the SAME global per-project dir, so NOTHING SpexCode materializes stays in the worktree (the
145
145
  // worktree holds only the harness-discovered CLAUDE.md/AGENTS.md + shims, which must sit in-tree).
@@ -149,7 +149,7 @@ export function runtimeRoot(proj?: string): string {
149
149
  const gcd = proj
150
150
  ? git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
151
151
  : gitCommonDir()
152
- return join(spexcodeHome(), 'projects', encodeProject(dirname(gcd)))
152
+ return projectRuntimeRoot(gcd)
153
153
  }
154
154
  // the per-WORKTREE materialize slot — <runtime>/trees/<enc(worktree-toplevel)> — holding the materialize
155
155
  // products that are a pure function of ONE tree's .plugins (hooks-manifest, content-hash, plugin-folders).
@@ -178,9 +178,61 @@ export type RawRecord = {
178
178
  node: string | null; title: string | null; name: string | null; parent?: string | null
179
179
  status: string; proposal: string | null; merges: number; note: string | null
180
180
  sortkey: number | null; createdAt: number; harness?: string; harness_session_id?: string
181
+ stopped?: boolean
182
+ archived?: boolean // the human ARCHIVED this session ([[archive]]) — only a proven cold/offline row; absent → false on old records
183
+ cold_proof?: string // durable exact leaf + adapter cold proof; absent on legacy archives, which remain visible hazards
184
+ adapter_recovery?: string // explicit lifecycle recovery required after a partial adapter mutation; absent on old records
181
185
  launcher?: string // the launcher profile this session was created under ([[launcher-select]]); absent/empty only on old records predating launchers
182
186
  launch_cmd?: string // the RESOLVED base launcher command PINNED at creation, so a resume replays the EXACT launcher (and its config-dir env) that made the conversation, never a since-changed default ([[launcher-select]] resume-launcher-pin); absent → old record, fall back to the launcher name / ambient
187
+ launch_readiness_pending?: '' | RawLaunchReadinessPending
188
+ }
189
+
190
+ export const SESSION_LIFECYCLES = ['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'] as const
191
+ export const SESSION_PROPOSALS = ['merge', 'nothing', 'close'] as const
192
+ export type SessionLifecycle = typeof SESSION_LIFECYCLES[number]
193
+ export type SessionProposal = typeof SESSION_PROPOSALS[number]
194
+ const sessionLifecycles = new Set<string>(SESSION_LIFECYCLES)
195
+ const sessionProposals = new Set<string>(SESSION_PROPOSALS)
196
+ export const isSessionLifecycle = (value: unknown): value is SessionLifecycle =>
197
+ typeof value === 'string' && sessionLifecycles.has(value)
198
+ export const isSessionProposal = (value: unknown): value is SessionProposal =>
199
+ typeof value === 'string' && sessionProposals.has(value)
200
+
201
+ export type RawLaunchReadinessOriginal = {
202
+ status: string
203
+ proposal: string | null
204
+ note: string | null
205
+ stopped: boolean
206
+ archived: boolean
207
+ cold_proof: string | null
208
+ adapter_recovery: string | null
183
209
  }
210
+
211
+ export type RawLaunchReadinessPending = {
212
+ version: 1
213
+ startedAt: number
214
+ original: RawLaunchReadinessOriginal
215
+ }
216
+
217
+ // A launch candidate is durable before it is public. Readers of the authored lifecycle use this one parser
218
+ // so the board and the independent timeline observer cannot disagree about an in-flight resume. Invalid
219
+ // pending bytes throw: a damaged publication fence is unknowable state, never permission to project online.
220
+ export function rawLaunchReadinessOriginal(raw: RawRecord): RawLaunchReadinessOriginal | null {
221
+ const pending = raw.launch_readiness_pending
222
+ if (pending == null || pending === '') return null
223
+ const original = pending && typeof pending === 'object' ? pending.original : null
224
+ if (pending.version !== 1 || !Number.isFinite(pending.startedAt) || !original || typeof original !== 'object'
225
+ || !isSessionLifecycle(original.status)
226
+ || !(original.proposal === null || original.proposal === '' || isSessionProposal(original.proposal))
227
+ || !(typeof original.note === 'string' || original.note === null)
228
+ || typeof original.stopped !== 'boolean' || typeof original.archived !== 'boolean'
229
+ || !(typeof original.cold_proof === 'string' || original.cold_proof === null)
230
+ || !(typeof original.adapter_recovery === 'string' || original.adapter_recovery === null)) {
231
+ throw new Error(`session '${raw.session_id}' has an invalid launch_readiness_pending fence`)
232
+ }
233
+ return original
234
+ }
235
+
184
236
  // the agent's OWN session id from the environment — the only locator now that the record left the worktree.
185
237
  // Three tiers, in order:
186
238
  // (1) a harness's per-thread env var (`sessionEnvVar`) RESOLVED VIA THE ALIAS — when it lands on a governed
@@ -211,11 +263,80 @@ export function envSessionId(): string | null {
211
263
  for (const h of HARNESSES) { const v = process.env[h.sessionEnvVar]; if (v && v.trim()) return v.trim() }
212
264
  return null
213
265
  }
214
- export function readRawRecord(id: string): RawRecord | null {
266
+ // @@@ RecordEntry - a record read has THREE outcomes, and collapsing them is what let a live session read as
267
+ // "no session record". ABSENT (no file) is the legitimate nothing — a self-launched agent that only ever wrote
268
+ // spec-discipline sentinels has a store dir and no record. CORRUPT (present but unparseable, or parseable but
269
+ // not a record) is a FACT about a session that exists, so it must reach the surfaces as itself instead of
270
+ // masquerading as absence: sessions-core refuses every writer on it and the board gives it its own row. Any
271
+ // OTHER read failure (permissions, I/O) still THROWS — a transient fault must not read as either.
272
+ export type RecordEntry =
273
+ | { kind: 'ok'; raw: RawRecord }
274
+ | { kind: 'absent' }
275
+ | { kind: 'corrupt'; path: string; error: string }
276
+
277
+ export type PublicRecordEntry =
278
+ | { kind: 'ok'; raw: RawRecord; liveness: 'offline' | null }
279
+ | { kind: 'absent' }
280
+ | { kind: 'corrupt'; sessionId: string; governed: boolean | null; path: string; error: string; liveness: 'unknown' }
281
+
282
+ export function readRecordEntry(id: string): RecordEntry {
283
+ const path = sessionRecordPath(id)
284
+ let text: string
285
+ try { text = readFileSync(path, 'utf8') }
286
+ catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'absent' }; throw e }
287
+ let raw: unknown
288
+ try { raw = JSON.parse(text) }
289
+ catch (e) { return { kind: 'corrupt', path, error: e instanceof Error ? e.message : String(e) } }
290
+ if (!raw || typeof raw !== 'object' || !(raw as RawRecord).session_id)
291
+ return { kind: 'corrupt', path, error: 'parsed, but carries no session_id — not a session record' }
292
+ return { kind: 'ok', raw: raw as RawRecord }
293
+ }
294
+
295
+ // The ONE public session-record parser. Internal mutation/readiness code uses readRecordEntry's exact raw
296
+ // candidate; every public projection passes through here. A valid pending fence replaces all lifecycle-facing
297
+ // fields with its frozen original and forces offline liveness. Malformed pending bytes remain a present,
298
+ // corrupt/unknown row instead of leaking candidate state or disappearing as absence.
299
+ export function projectPublicRecordEntry(id: string, entry: RecordEntry): PublicRecordEntry {
300
+ if (entry.kind === 'absent') return entry
301
+ if (entry.kind === 'corrupt') return {
302
+ kind: 'corrupt', sessionId: id, governed: null, path: entry.path, error: entry.error, liveness: 'unknown',
303
+ }
215
304
  try {
216
- const raw = JSON.parse(readFileSync(sessionRecordPath(id), 'utf8'))
217
- return raw && typeof raw === 'object' && raw.session_id ? raw as RawRecord : null
218
- } catch { return null }
305
+ const original = rawLaunchReadinessOriginal(entry.raw)
306
+ if (!original) return { kind: 'ok', raw: entry.raw, liveness: null }
307
+ return {
308
+ kind: 'ok',
309
+ raw: {
310
+ ...entry.raw,
311
+ status: original.status,
312
+ proposal: original.proposal || null,
313
+ note: original.note || null,
314
+ stopped: original.stopped,
315
+ archived: original.archived,
316
+ cold_proof: original.cold_proof ?? undefined,
317
+ adapter_recovery: original.adapter_recovery ?? undefined,
318
+ launch_readiness_pending: '',
319
+ },
320
+ liveness: 'offline',
321
+ }
322
+ } catch (error) {
323
+ return {
324
+ kind: 'corrupt',
325
+ sessionId: id,
326
+ governed: typeof entry.raw.governed === 'boolean' ? entry.raw.governed : null,
327
+ path: sessionRecordPath(id),
328
+ error: error instanceof Error ? error.message : String(error),
329
+ liveness: 'unknown',
330
+ }
331
+ }
332
+ }
333
+
334
+ export function readPublicRecordEntry(id: string): PublicRecordEntry {
335
+ return projectPublicRecordEntry(id, readRecordEntry(id))
336
+ }
337
+ export function readRawRecord(id: string): RawRecord | null {
338
+ try { const e = readRecordEntry(id); return e.kind === 'ok' ? e.raw : null }
339
+ catch { return null }
219
340
  }
220
341
  // resolve a possibly-ALIASED session id to its raw record. A codex hook or spawned command can carry the codex
221
342
  // THREAD id — payload session_id / CODEX_THREAD_ID — not the SpexCode record id the store is keyed by. Direct id
@@ -223,13 +344,20 @@ export function readRawRecord(id: string): RawRecord | null {
223
344
  // before any tool turn).
224
345
  // Null when neither resolves. Mirrors the shell `hp_store_dir` alias grep — one resolution rule, both layers.
225
346
  export function readAliasedRawRecord(id: string): RawRecord | null {
226
- const direct = readRawRecord(id)
227
- if (direct) return direct
347
+ const e = readAliasedRecordEntry(id)
348
+ return e.kind === 'ok' ? e.raw : null
349
+ }
350
+ // the same alias resolution, keeping the three-way outcome. A CORRUPT record at the direct id settles the
351
+ // question — we found this session and cannot read it; walking on to the alias would report a corrupt record
352
+ // as absent, the exact collapse this type exists to prevent.
353
+ export function readAliasedRecordEntry(id: string): RecordEntry {
354
+ const direct = readRecordEntry(id)
355
+ if (direct.kind !== 'absent') return direct
228
356
  for (const sid of listSessionIds()) {
229
357
  const r = readRawRecord(sid)
230
- if (r && r.harness_session_id && r.harness_session_id === id) return r
358
+ if (r && r.harness_session_id && r.harness_session_id === id) return { kind: 'ok', raw: r }
231
359
  }
232
- return null
360
+ return { kind: 'absent' }
233
361
  }
234
362
  // every session_id this project has a record for (the board's enumeration source — replaces `git worktree
235
363
  // list`). A MISSING store dir means no session ever launched → []. But any OTHER readdir failure THROWS
@@ -242,24 +370,27 @@ export function listSessionIds(): string[] {
242
370
  return ents.filter((d) => d.isDirectory()).map((d) => d.name)
243
371
  }
244
372
 
245
- // memo the overlay (3 git diffs/worktree) keyed on fork-point merge-base + HEAD + spec sig — keying on the
246
- // merge-base NOT main's HEAD means unrelated merges that don't move the fork point stay cache hits.
373
+ // memo the overlay (4 git diffs/worktree, all .spec-scoped) keyed on fork-point merge-base + HEAD + spec
374
+ // sig + MAIN'S TIP ([[worktree-linker]]): the main-tip component is what lets a merge landing identical
375
+ // content dissolve a worktree's now-moot ops — the recompute it triggers is cheap because every diff is
376
+ // .spec-scoped.
247
377
  const deltaCache = new Map<string, { key: string; ops: NodeOp[] }>()
248
378
  const safeHead = (p: string): string => { try { return headSha(p) } catch { return '' } }
249
379
  const safeMergeBase = async (wtPath: string, mainRef: string): Promise<string> => {
250
380
  try { return (await gitA(['-C', wtPath, 'merge-base', mainRef, 'HEAD'])).trim() } catch { return '' }
251
381
  }
252
382
  let layoutHeadWarned = false
253
- async function cachedDelta(wtPath: string, mainRef: string): Promise<NodeOp[]> {
383
+ async function cachedDelta(wtPath: string, mainRef: string, mainSha: string): Promise<NodeOp[]> {
254
384
  const wtHead = safeHead(wtPath)
255
385
  const base = await safeMergeBase(wtPath, mainRef)
256
- // fail loud, never stale: if the merge-base or HEAD can't be read the key is untrustworthy — bypass the
257
- // cache and recompute (warn once) rather than risk serving a delta keyed on an empty sha across a real change.
258
- if (!base || !wtHead) {
259
- if (!layoutHeadWarned) { layoutHeadWarned = true; console.warn('spec-cli: layout overlay cache bypassed (unreadable merge-base/HEAD), recomputing every read') }
386
+ // fail loud, never stale: if the merge-base, HEAD, or main tip can't be read the key is untrustworthy —
387
+ // bypass the cache and recompute (warn once) rather than risk serving a delta keyed on an empty sha
388
+ // across a real change.
389
+ if (!base || !wtHead || !mainSha) {
390
+ if (!layoutHeadWarned) { layoutHeadWarned = true; console.warn('spec-cli: layout overlay cache bypassed (unreadable merge-base/HEAD/main tip), recomputing every read') }
260
391
  return worktreeSpecDelta(wtPath, mainRef)
261
392
  }
262
- const key = `${base}\0${wtHead}\0${worktreeSpecSig(wtPath)}`
393
+ const key = `${base}\0${wtHead}\0${mainSha}\0${worktreeSpecSig(wtPath)}`
263
394
  const hit = deltaCache.get(wtPath)
264
395
  if (hit && hit.key === key) return hit.ops
265
396
  const ops = await worktreeSpecDelta(wtPath, mainRef, base)
@@ -284,20 +415,37 @@ export async function resolveLayout(): Promise<Layout> {
284
415
  // independent → compute (or cache-hit) in parallel, keyed by worktree path as before. guardWorktree wraps
285
416
  // each: a worktree whose dir was genuinely removed mid-read (a worker self-merged + retired it) is OMITTED;
286
417
  // one that still exists but hit a transient detail failure is kept as a DEGRADED row from the last cached delta.
287
- const records = listSessionIds().map(readRawRecord).filter((r): r is RawRecord => !!r && r.governed)
288
- const rows = await Promise.all(records.map((r) => {
418
+ const publicEntries = listSessionIds().map((id) => readPublicRecordEntry(id))
419
+ .filter((entry) => entry.kind === 'corrupt' ? entry.governed !== false : entry.kind === 'ok' && entry.raw.governed)
420
+ const records = publicEntries.flatMap((entry) => entry.kind === 'ok' ? [entry] : [])
421
+ // main's tip, resolved ONCE per board read — a component of every worktree's overlay cache key
422
+ // ([[worktree-linker]]: landed content must dissolve the ops it made moot).
423
+ const mainSha = await (async () => {
424
+ try { return (await gitA(['-C', main, 'rev-parse', '--verify', `${mainRef}^{commit}`])).trim() } catch { return '' }
425
+ })()
426
+ const rows = await Promise.all(records.map(({ raw: r, liveness }) => {
289
427
  const node = r.node ?? (r.branch && r.branch.startsWith(convention.branchPrefix) ? r.branch.slice(convention.branchPrefix.length) : null)
290
- const base: Worktree = { path: r.worktree_path, branch: r.branch, node, session: r.session_id, status: r.status, isMain: false, ops: [] }
428
+ const base: Worktree = { path: r.worktree_path, branch: r.branch, node, session: r.session_id, status: r.status, isMain: false, ...(liveness ? { liveness } : {}), ops: [] }
429
+ // @@@ archived rows cost nothing - a shelved session ([[archive]]) keeps its row (the record is the
430
+ // existence truth) but skips the per-worktree spec-delta entirely: that git-history probe is the board's
431
+ // dominant per-row cost, and shelving is exactly the human saying "stop spending attention here". So the
432
+ // price of a retained archive is one enumerated record, NOT a git walk per poll.
433
+ if (r.archived) return Promise.resolve(base)
291
434
  return guardWorktree<Worktree>(r.worktree_path,
292
- async (): Promise<Worktree> => ({ ...base, ops: await cachedDelta(r.worktree_path, mainRef) }),
435
+ async (): Promise<Worktree> => ({ ...base, ops: await cachedDelta(r.worktree_path, mainRef, mainSha) }),
293
436
  (): Worktree => ({ ...base, ops: deltaCache.get(r.worktree_path)?.ops ?? [] }))
294
437
  }))
295
- const sessionWorktrees = rows.filter((w): w is Worktree => w !== null)
438
+ const corruptRows: Worktree[] = publicEntries.flatMap((entry) => entry.kind === 'corrupt'
439
+ ? [{ path: '', branch: null, node: null, session: entry.sessionId, status: 'corrupt', liveness: 'unknown', isMain: false, ops: [] }]
440
+ : [])
441
+ const sessionWorktrees = [...rows.filter((w): w is Worktree => w !== null), ...corruptRows]
296
442
  // the main checkout row (isMain) — always present, carries no overlay; it anchors the merged tree the board draws.
297
443
  const mainRow: Worktree = { path: main, branch: base, node: null, session: null, status: null, isMain: true, ops: [] }
298
444
  const worktrees = [mainRow, ...sessionWorktrees]
299
- // drop cache entries for worktrees no longer in the store (closed sessions), so the map stays bounded.
300
- const live = new Set(sessionWorktrees.map((w) => w.path))
445
+ // drop cache entries for worktrees that may no longer hold one closed sessions (gone from the store) AND
446
+ // newly-archived ones (which no longer compute a delta), so archiving SELF-EVICTS its cached ops instead of
447
+ // stranding them in a map nothing prunes.
448
+ const live = new Set(records.filter(({ raw }) => !raw.archived).map(({ raw }) => raw.worktree_path))
301
449
  for (const k of [...deltaCache.keys()]) if (!live.has(k)) deltaCache.delete(k)
302
450
  return { main: convention.main || main || root, convention, worktrees }
303
451
  }
@@ -1,9 +1,9 @@
1
1
  import { readFileSync, existsSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { repoRoot, git, driftIndex, historyIndex, rowsFor } from './git.js'
4
- import { loadSpecs } from './specs.js'
3
+ import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText } from './git.js'
4
+ import { loadSpecs, parseFrontmatter } from './specs.js'
5
5
  import { readJsonConfig } from './layout.js'
6
- import { extractors, extractorFor, extOf, resolveAnchor, windowCommits, anchorHitCommits } from './anchors.js'
6
+ import { extractors, extractorFor, extOf, parseCodeEntry, relationClaimsPath, resolveAnchor, windowEvents, anchorHitCommits } from './anchors.js'
7
7
  import { DEFAULT_TEST_GLOBS, sourcePolicyDescription, trackedSourceFiles } from './source-files.js'
8
8
 
9
9
  export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
@@ -31,10 +31,17 @@ const DEFAULT_CONFIG: LintConfig = {
31
31
  scenarioTags: ['frontend-e2e', 'backend-api', 'cli', 'desktop', 'mobile'],
32
32
  scopedCodeMiss: 'warn',
33
33
  }
34
- export function loadConfig(root: string): LintConfig {
34
+ export function loadConfig(root: string, pendingSource?: string | null): LintConfig {
35
35
  // Absent spexcode.json → tuned defaults; a MALFORMED one throws LOUD (readJsonConfig) rather than
36
36
  // silently reverting the author's budgets to defaults and green-washing the very warnings they tuned.
37
- const c = readJsonConfig(join(root, 'spexcode.json'))?.lint ?? {}
37
+ let parsed: any
38
+ if (pendingSource === undefined) parsed = readJsonConfig(join(root, 'spexcode.json'))
39
+ else if (pendingSource === null) parsed = {}
40
+ else {
41
+ try { parsed = JSON.parse(pendingSource) }
42
+ catch (e: any) { throw new Error(`invalid JSON in candidate spexcode.json: ${e?.message ?? e}`) }
43
+ }
44
+ const c = parsed?.lint ?? {}
38
45
  const merged = { ...DEFAULT_CONFIG, ...c }
39
46
  return normalizeConfig(merged)
40
47
  }
@@ -61,11 +68,92 @@ export function normalizeConfig(cfg: LintConfig): LintConfig {
61
68
  }
62
69
  }
63
70
 
64
- export async function specLint(): Promise<Finding[]> {
65
- const root = repoRoot()
66
- const cfg = loadConfig(root)
67
- const governed = trackedSourceFiles(root, cfg.governedRoots, cfg)
68
- const specs = await loadSpecs()
71
+ export type SpecLintOptions = { tip?: string }
72
+
73
+ function untrackedAdoptionFiles(root: string): string[] {
74
+ if (git(['-C', root, 'rev-parse', '--is-inside-work-tree']).trim() !== 'true') return []
75
+ const status = git([
76
+ '-C', root,
77
+ '-c', 'core.quotePath=false',
78
+ 'status', '--porcelain=v1', '-z', '--untracked-files=all',
79
+ '--', '.spec', 'spexcode.json',
80
+ ])
81
+ return status.split('\0')
82
+ .filter((entry) => entry.startsWith('?? '))
83
+ .map((entry) => entry.slice(3))
84
+ .filter(Boolean)
85
+ }
86
+
87
+ function pendingChangedPaths(root: string, tip: string): string[] {
88
+ try {
89
+ git(['-C', root, 'rev-parse', `${tip}^{commit}`])
90
+ // `-m` compares a merge with every parent. An `ours` merge can leave the result tree
91
+ // identical to its first parent while still making an unacknowledged side-branch commit
92
+ // reachable; first-parent-only paths would filter that debt out of the pending anchor window.
93
+ const fields = git(['-C', root, '-c', 'core.quotePath=false', 'diff-tree', '--no-commit-id', '--name-status', '-z', '-r', '-m', '-M', tip])
94
+ .split('\0').filter(Boolean)
95
+ const changed: string[] = []
96
+ for (let i = 0; i < fields.length;) {
97
+ const status = fields[i++]
98
+ if (/^[RC]/.test(status)) {
99
+ changed.push(fields[i++], fields[i++])
100
+ } else {
101
+ changed.push(fields[i++])
102
+ }
103
+ }
104
+ return [...new Set(changed.filter(Boolean))]
105
+ } catch { return [] }
106
+ }
107
+ // Cheap pending classification for the reference hook. It reads only the candidate tree and claims; it
108
+ // never constructs either history index. A normal lint call deliberately does not use this
109
+ // shortcut so its full findings/oracle contract remains unchanged.
110
+ export async function pendingTouchesGoverned(root: string, tip: string): Promise<boolean> {
111
+ // A merge can introduce reachable side-branch debt without changing the result tree. The first-parent
112
+ // diff is insufficient for a scope proof, so all multi-parent candidates stay on the full lint path.
113
+ const parentCount = git(['-C', root, 'rev-list', '--parents', '-n1', tip]).trim().split(/\s+/).length - 1
114
+ if (parentCount > 1) return true
115
+ const changed = pendingChangedPaths(root, tip)
116
+ if (!changed.length) return true
117
+ // `governedRoots` is source discovery policy, not the set of actual code claims: a spec may deliberately
118
+ // govern a path outside those roots. Read only the candidate spec tree (no history/drift indexes) so the
119
+ // scope proof follows the same code:/related: declarations that lint later enforces.
120
+ const specs = await loadSpecs(root, { tip, history: null, drift: null })
121
+ const claims = specs.flatMap((spec) => [...spec.code, ...spec.related])
122
+ return changed.some((path) => claims.some((claim) => relationClaimsPath(claim, path))
123
+ || path === 'spexcode.json' || path === 'spexcode.local.json'
124
+ || (path.startsWith('.spec/') && !path.startsWith('.spec/.issues/'))
125
+ || path === '.spec')
126
+ }
127
+
128
+ export async function specLint(root = repoRoot(), regs = extractors(root), options: SpecLintOptions = {}): Promise<Finding[]> {
129
+ const tip = options.tip ?? 'HEAD'
130
+ const pending = tip !== 'HEAD'
131
+ const changed = pending ? pendingChangedPaths(root, tip) : []
132
+ const files = pending ? treeFilePaths(root, tip) : null
133
+ const directories = new Set<string>()
134
+ for (const file of files ?? []) {
135
+ const parts = file.split('/')
136
+ for (let i = 1; i < parts.length; i++) directories.add(parts.slice(0, i).join('/'))
137
+ }
138
+ const existsAtTip = (path: string) => files ? files.has(path) || directories.has(path.replace(/\/+$/, '')) : existsSync(join(root, path))
139
+ const isDirectoryAtTip = (path: string) => files
140
+ ? !files.has(path) && directories.has(path.replace(/\/+$/, ''))
141
+ : statSync(join(root, path)).isDirectory()
142
+ const textAtTip = (path: string) => pending ? treeFileText(root, tip, path) : readFileSync(join(root, path), 'utf8')
143
+ const cfg = loadConfig(root, pending ? treeFileText(root, tip, 'spexcode.json') : undefined)
144
+ const untracked = untrackedAdoptionFiles(root)
145
+ if (untracked.length) {
146
+ const shown = untracked.slice(0, 6)
147
+ const suffix = untracked.length > shown.length ? ` (+${untracked.length - shown.length} more)` : ''
148
+ return [{
149
+ level: 'error',
150
+ rule: 'integrity',
151
+ msg: `project source of truth is untracked: ${shown.join(', ')}${suffix} — add it with \`git add .spec spexcode.json\` and commit it; generated harness files such as .codex/, .claude/, and AGENTS.md are machine-local`,
152
+ }]
153
+ }
154
+ const governed = trackedSourceFiles(root, cfg.governedRoots, cfg, tip)
155
+ const [hidx, didx] = await sourceIndexes(root, tip)
156
+ const specs = await loadSpecs(root, { tip, history: hidx, drift: didx })
69
157
  const out: Finding[] = []
70
158
 
71
159
  // integrity + build the file -> owners map. A relation's STRUCTURAL problems (a duplicate entry,
@@ -73,14 +161,16 @@ export async function specLint(): Promise<Finding[]> {
73
161
  // [[code-anchor]]) are integrity errors: malformed edges block like broken ones.
74
162
  const owners = new Map<string, string[]>()
75
163
  const claimed = new Set<string>()
164
+ const governedClaims = new Set<string>()
76
165
  for (const s of specs) {
77
166
  for (const p of s.relationProblems)
78
167
  out.push({ level: 'error', rule: 'integrity', spec: s.id, msg: `'${s.id}' ${p}` })
79
168
  const scopedPaths = new Set(s.codeScoped.map((e) => e.path))
80
169
  for (const f of s.code) {
81
- if (!existsSync(join(root, f)))
170
+ if (!existsAtTip(f))
82
171
  out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing file: ${f}` })
83
172
  claimed.add(f)
173
+ governedClaims.add(f)
84
174
  // a selector-SCOPED entry claims named units, not the whole file, so it stays out of the owners
85
175
  // bound below ([[code-anchor]]) — `spex spec owner` still displays it as a (scoped) governor.
86
176
  if (!scopedPaths.has(f)) owners.set(f, [...(owners.get(f) ?? []), s.id])
@@ -96,11 +186,34 @@ export async function specLint(): Promise<Finding[]> {
96
186
  // `related:` is the coverage net: govern is a sharp ideally-one-file pointer, so most files are reached by
97
187
  // related, not govern (see [[governed-related]]). It carries coverage but never drift, never eval freshness.
98
188
  for (const s of specs) for (const f of s.related) {
99
- if (!existsSync(join(root, f)))
189
+ if (!existsAtTip(f))
100
190
  out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing related file: ${f}` })
101
191
  claimed.add(f)
102
192
  }
103
193
 
194
+ // A candidate may retire a node together with the implementation it owned, or transfer that path to a
195
+ // new node. It may not delete the governor while leaving the governed subject behind: ordinary coverage
196
+ // is advisory, so without this transition check a candidate could erase the very contract that would
197
+ // reject its code change. Read only deleted spec blobs from HEAD; no second history index is needed.
198
+ if (pending) {
199
+ let deleted: string[] = []
200
+ try {
201
+ deleted = git(['-C', root, '-c', 'core.quotePath=false', 'diff', '--diff-filter=D', '--name-only', 'HEAD', tip, '--', '.spec'])
202
+ .split('\n').map((path) => path.trim()).filter((path) => path.endsWith('/spec.md'))
203
+ } catch { /* an unreadable comparison is already surfaced by the candidate history reads */ }
204
+ for (const specPath of deleted) {
205
+ const oldSource = treeFileText(root, 'HEAD', specPath)
206
+ if (oldSource === null) continue
207
+ const value = parseFrontmatter(oldSource).fm.code
208
+ const rows = Array.isArray(value) ? value : value ? [value] : []
209
+ for (const row of rows) {
210
+ const path = parseCodeEntry(row).path
211
+ if (existsAtTip(path) && !governedClaims.has(path))
212
+ out.push({ level: 'error', rule: 'integrity', file: path, msg: `candidate deletes governor '${specPath}' but leaves its governed subject '${path}' without a code: owner — delete the retired implementation too, or transfer it to a real node in this commit` })
213
+ }
214
+ }
215
+ }
216
+
104
217
  // id-format: a node id (its leaf dir basename) passes an EXACT per-character whitelist — an ascii char
105
218
  // must be [a-z0-9-]; a non-ascii char must be a unicode letter/number (judged on NFC, the mint's
106
219
  // canonical form) — and is UNIQUE tree-wide (ERROR). This is THE id vocabulary, defined once (the
@@ -200,23 +313,22 @@ export async function specLint(): Promise<Finding[]> {
200
313
 
201
314
  // code anchors ([[code-anchor]]): a code:/related: entry may pin named units (`path#symbol` — any
202
315
  // number per base file, OR'd). On code:, the anchor is the BLOCKING tier of drift: a window
203
- // commit (spec's last version..HEAD, non-merge, touching the governed file) whose --unified=0 hunks
316
+ // commit (spec's last version..tip, ordinary diff or merge-authored combined diff) whose zero-context hunks
204
317
  // intersect any pinned unit's line range — extracted from the file AS OF that commit, by the
205
318
  // extension's ONE designated extractor — is ONE anchor-drift ERROR naming the hit selectors, unless a
206
319
  // Spec-OK ack covers it. On related:, the SAME engine yields only a soft warn on a hit — a scoped
207
320
  // related miss is silent (never blocks, no ack, no eval freshness). Resolution failures are never
208
- // silent for either relation: a dead or ambiguous selector, a selector on a directory, an unparseable
209
- // working-tree file, an extension with no designated extractor, and a designated extractor that can't
210
- // run here (no host typescript) all ERROR with the repair spelled out.
211
- const regs = extractors(root)
212
- const [didx, hidx] = await Promise.all([driftIndex(root), historyIndex(root)])
321
+ // silent for either relation: a dead or ambiguous selector, a selector on a directory, and an
322
+ // unparseable working-tree file ERROR. An extension with no designated extractor, or a designated
323
+ // extractor that cannot run here, also ERRORS but skips those anchors so the remaining checks continue.
213
324
  const readyWarned = new Set<string>()
214
325
  for (const s of specs) {
215
326
  for (const { relation, entries } of [{ relation: 'code' as const, entries: s.codeScoped }, { relation: 'related' as const, entries: s.relatedScoped }]) {
216
327
  for (const { path, selectors } of entries) {
328
+ if (pending && !changed.some((file) => relationClaimsPath(path, file))) continue
217
329
  const x = extractorFor(regs, extOf(path))
218
330
  if (!x) {
219
- out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' anchors ${path}#${selectors.join(', #')} (${relation}:), but no extractor is designated for '.${extOf(path)}' files — this language has no anchor support yet: add a LangSpec row (anchors.ts) or drop the selector(s)` })
331
+ out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' anchors ${path}#${selectors.join(', #')} (${relation}:), but no extractor is designated for '.${extOf(path)}' files — anchor validation was skipped and remains unverified; add a LangSpec row (anchors.ts) or drop the selector(s)` })
220
332
  continue
221
333
  }
222
334
  const ready = x.ready()
@@ -225,13 +337,17 @@ export async function specLint(): Promise<Finding[]> {
225
337
  if (!readyWarned.has(x.id + ready)) { readyWarned.add(x.id + ready); out.push({ level: 'error', rule: 'integrity', msg: `anchor extractor '${x.id}' cannot run: ${ready}` }) }
226
338
  continue
227
339
  }
228
- if (!existsSync(join(root, path))) continue // the missing FILE already errored above
229
- if (statSync(join(root, path)).isDirectory()) {
340
+ if (!existsAtTip(path)) continue // the missing FILE already errored above
341
+ if (isDirectoryAtTip(path)) {
230
342
  out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' puts a selector on a directory (${relation}: ${path}#${selectors[0]}) — a selector scopes ONE real file` })
231
343
  continue
232
344
  }
233
345
  let units
234
- try { units = x.extract(readFileSync(join(root, path), 'utf8'), path) } catch (e: any) {
346
+ try {
347
+ const source = textAtTip(path)
348
+ if (source === null) throw new Error(`candidate tree has no file '${path}'`)
349
+ units = x.extract(source, path)
350
+ } catch (e: any) {
235
351
  out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `anchor ${path}#${selectors.join(', #')} ('${s.id}') is unverifiable — the current file does not parse: ${e?.message ?? e}` })
236
352
  continue
237
353
  }
@@ -253,17 +369,26 @@ export async function specLint(): Promise<Finding[]> {
253
369
  }
254
370
  if (!live.length) continue
255
371
  const since = rowsFor(hidx, s.path)[0]?.hash || ''
256
- const win = windowCommits(didx, since, path)
372
+ const win = windowEvents(didx, since, path, s.id)
257
373
  if (!win.length) continue
258
- const hits = await anchorHitCommits(root, win, path, live, x)
374
+ const hits = await anchorHitCommits(root, win, live, regs)
259
375
  if (!hits.length) continue
260
376
  const hitSyms = [...new Set(hits.flatMap((h) => h.selectors))]
261
377
  const shas = hits.map((h) => h.commit.slice(0, 8)).join(', ')
262
378
  const unparseable = hits.filter((h) => h.unparseable)
263
379
  const parseNote = unparseable.length ? ` (${unparseable.length} of these could not be parsed at that commit — counted as hits conservatively)` : ''
264
- if (relation === 'code')
265
- out.push({ level: 'error', rule: 'anchor-drift', spec: s.id, file: path, msg: `${path}#${hitSyms.join(', #')} was changed by ${hits.length} commit(s) since spec '${s.id}' v${s.version} [${shas}]${parseNote} — the anchored contract's code moved: update the spec, or 'spex spec ack ${s.id} --reason "…"' if the contract still holds` })
266
- else
380
+ if (relation === 'code') {
381
+ const current = pending && hits.some((hit) => hit.commit === tip)
382
+ const older = pending && hits.some((hit) => hit.commit !== tip)
383
+ const remedy = !pending
384
+ ? `update the spec, or 'spex spec ack ${s.id} --reason "…"' if the contract still holds`
385
+ : current && older
386
+ ? `update the spec in this commit; its own hit can be declared by retrying with 'git commit --trailer "Spec-OK: ${s.id}" …', but the listed older debt must be cleared first (an in-commit declaration never pardons ancestors)`
387
+ : current
388
+ ? `update the spec in this commit, or retry with 'git commit --trailer "Spec-OK: ${s.id}" …'; a later empty ack cannot pre-author this candidate`
389
+ : `update the spec in this commit, or clear this older debt with 'spex spec ack ${s.id} --reason "…"' before retrying the candidate`
390
+ out.push({ level: 'error', rule: 'anchor-drift', spec: s.id, file: path, msg: `${path}#${hitSyms.join(', #')} was changed by ${hits.length} commit(s) since spec '${s.id}' v${s.version} [${shas}]${parseNote} — the anchored contract's code moved: ${remedy}` })
391
+ } else
267
392
  out.push({ level: 'warn', rule: 'related-drift', spec: s.id, file: path, msg: `related ${path}#${hitSyms.join(', #')} ('${s.id}') was changed by ${hits.length} commit(s) since v${s.version} [${shas}]${parseNote} — a scoped dependency shifted, worth a glance (SOFT: never blocks, no ack, no eval staleness)` })
268
393
  }
269
394
  }
@@ -178,11 +178,15 @@ export function loadOne(id: string): Issue {
178
178
  }
179
179
 
180
180
  // a filesystem-safe, readable, collision-free id from the concern (slug + numeric suffix if taken).
181
+ // RESERVED_IDS are the words an issue address gives its own meaning — `new` is the compose PAGE
182
+ // ([[issues-view]]), so an issue owning that id would be unreachable at its own detail address. The slug
183
+ // steps around them with the same numeric suffix a taken id gets: a collision, not a silent shadow.
184
+ const RESERVED_IDS = new Set(['new'])
181
185
  function uniqueId(concern: string): string {
182
186
  const base = concern.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'issue'
183
187
  const dir = localStoreDir()
184
188
  let id = base
185
- for (let n = 2; existsSync(join(dir, `${id}.md`)); n++) id = `${base}-${n}`
189
+ for (let n = 2; RESERVED_IDS.has(id) || existsSync(join(dir, `${id}.md`)); n++) id = `${base}-${n}`
186
190
  return id
187
191
  }
188
192