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,14 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from 'node:fs'
2
2
  import { join, relative, dirname } from 'node:path'
3
- import { repoRoot, headSha, driftIndex, stagedFiles, git } from '../../spec-cli/src/git.js'
3
+ import { repoRoot, headSha, driftIndex, stagedFiles, git, commitReachable } from '../../spec-cli/src/git.js'
4
4
  import { loadSpecs } from '../../spec-cli/src/specs.js'
5
5
  import { loadConfig } from '../../spec-cli/src/lint.js'
6
6
  import { trackedSourceFiles } from '../../spec-cli/src/source-files.js'
7
7
  import { mainBranch, envSessionId, readRawRecord } from '../../spec-cli/src/layout.js'
8
- import { evalNodes, validateScenarios, resolveEvalNode, scenarioHash, EVAL_FILE, type EvalNode, type ScenarioTestReference } from './scenarios.js'
8
+ import { evalNodes, evalNodesAt, validateScenarios, resolveEvalNode, scenarioCodeAxis, scenarioHash, scenarioProjection, writeScenarioMeasurementMetadata, EVAL_FILE, type EvalNode, type ScenarioTestReference } from './scenarios.js'
9
9
  import { readReadings, readSidecar, appendReading, appendRetraction, latestPerScenario, evidenceOf, isJsonBlob, type Reading, type Verdict, type Evidence, type EvidenceKind, type Retraction } from './sidecar.js'
10
- import { staleAxes, contentProbeFor } from './freshness.js'
10
+ import { staleAxes, contentProbeFor, anchorProbeFor, anchorProblems } from './freshness.js'
11
+ import { parseRelation, relationClaimsPath } from '../../spec-cli/src/anchors.js'
11
12
  import { scenarioIndex } from './scenariofresh.js'
12
13
  import { loadEvalRemarkTracks, trackKey } from '../../spec-cli/src/issues.js'
13
14
  import { stripRefSigil } from '../../spec-cli/src/mentions.js'
@@ -60,16 +61,31 @@ function currentNodeId(root: string): string | null {
60
61
  const UI_FILE = /\.(jsx|tsx|vue|svelte|css)$/
61
62
  export const isUiPath = (p: string) => UI_FILE.test(p) || p.includes('spec-dashboard/')
62
63
 
63
- // merge-base anchored so the main branch advancing isn't counted as this branch's change; a git error yields ∅ (--changed scan stays silent)
64
- function changedSinceBase(root: string): Set<string> {
65
- const out = new Set<string>()
66
- const add = (s: string) => { for (const l of s.split('\n')) { const t = l.trim(); if (t) out.add(t) } }
64
+ type ChangedScope = { base: string; paths: Set<string>; config: string }
65
+
66
+ // Merge-base anchored so main advancing is not counted as this branch's change. Scope construction is
67
+ // indivisible: a missing base/diff is no scope at all, never an empty set that can masquerade as clean.
68
+ function changedScope(root: string): ChangedScope {
69
+ const branch = mainBranch()
70
+ const paths = new Set<string>()
71
+ const add = (path: string | undefined) => { if (path) paths.add(path) }
67
72
  try {
68
- const base = git(['-C', root, 'merge-base', mainBranch(), 'HEAD']).trim()
69
- if (base) add(git(['-C', root, '-c', 'core.quotePath=false', 'diff', '--name-only', base]))
70
- add(git(['-C', root, '-c', 'core.quotePath=false', 'ls-files', '--others', '--exclude-standard']))
71
- } catch { /* no base → empty set */ }
72
- return out
73
+ const base = git(['-C', root, 'merge-base', branch, 'HEAD']).trim()
74
+ if (!base) throw new Error('git merge-base returned no commit')
75
+ const fields = git(['-C', root, '-c', 'core.quotePath=false', 'diff', '--name-status', '-z', '-M', base])
76
+ .split('\0').filter(Boolean)
77
+ for (let i = 0; i < fields.length;) {
78
+ const status = fields[i++]
79
+ if (/^[RC]/.test(status)) { add(fields[i++]); add(fields[i++]) }
80
+ else add(fields[i++])
81
+ }
82
+ for (const path of git(['-C', root, '-c', 'core.quotePath=false', 'ls-files', '--others', '--exclude-standard']).split('\n')) add(path.trim())
83
+ const config = existsSync(join(root, 'spexcode.json')) ? join(root, 'spexcode.json') : 'defaults'
84
+ return { base, paths, config }
85
+ } catch (error) {
86
+ const detail = error instanceof Error ? error.message.trim() : String(error)
87
+ throw new Error(`cannot establish changed scope against '${branch}': ${detail}`)
88
+ }
73
89
  }
74
90
 
75
91
  // A node owns the files under its spec dir except those under a descendant node. Its other selection axis
@@ -81,27 +97,35 @@ export function nodeChanged(dirRel: string, codeFiles: readonly string[], change
81
97
  const inDescendant = descendants.some((d) => c === d || c.startsWith(d + '/'))
82
98
  if (inNodeDir && !inDescendant) return true
83
99
  }
84
- return codeFiles.some((cf) => {
85
- if (changed.has(cf)) return true
86
- const dir = cf.replace(/\/+$/, '') + '/'
87
- const re = cf.includes('*') ? new RegExp('^' + cf.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$') : null
88
- for (const c of changed) { if (c.startsWith(dir)) return true; if (re && re.test(c)) return true }
89
- return false
90
- })
100
+ return codeFiles.some((claim) => [...changed].some((file) => relationClaimsPath(claim, file)))
91
101
  }
92
102
 
93
103
  async function scan(args: string[] = []): Promise<number> {
94
104
  const root = repoRoot()
95
105
  const cfg = loadConfig(root)
106
+ const changedOnly = has(args, 'changed')
107
+ let changed: Set<string> | null = null
108
+ if (changedOnly) {
109
+ try {
110
+ const scope = changedScope(root)
111
+ changed = scope.paths
112
+ console.error(`spex eval lint --changed scope: base=${scope.base} paths=${scope.paths.size} config=${scope.config}`)
113
+ } catch (error) {
114
+ console.error(`spex eval lint --changed: ${error instanceof Error ? error.message : String(error)}`)
115
+ return 2
116
+ }
117
+ }
96
118
  // eval-coverage fires on ANY governed source file per the same classifier as spec coverage, not just
97
119
  // frontend — so backend/CLI/non-web source is held to the loss discipline too, not exempted.
98
120
  const sourceFiles = new Set(trackedSourceFiles(root, cfg.governedRoots, cfg))
99
- const changedOnly = has(args, 'changed')
100
- const changed = changedOnly ? changedSinceBase(root) : null
101
121
  const idx = await driftIndex(root)
102
122
  // the off-history content fallback ([[eval-core]]): a rebased/folded-away anchor with byte-identical
103
123
  // governed content reads fresh instead of false-positive stale. Lazy — in-history readings never probe.
104
124
  const probe = contentProbeFor(root)
125
+ // the code axis's spatial narrowing ([[eval-core]]): an anchored `code:` entry stales a reading only when a
126
+ // window commit touched one of its named units. Same lazy shape as the content probe — a bare entry never
127
+ // reaches it, so an unanchored corpus pays nothing.
128
+ const anchors = anchorProbeFor(root, idx)
105
129
  const scidx = await scenarioIndex(root, evalNodes(root).map((n) => n.evalPath))
106
130
  const specs = await loadSpecs()
107
131
  // the non-git REMARK freshness axis ([[remark-teeth]]): the trunk remark track, read ONCE — the CLI is the
@@ -133,16 +157,26 @@ async function scan(args: string[] = []): Promise<number> {
133
157
  // a scenario's own `code` narrows its freshness CODE axis to a subset; a path that does not exist
134
158
  // would make that axis silently immortal (changedSince finds no commits for it), so flag it LOUD as a
135
159
  // malformed declaration — the same loud-fail spirit as a bad node `code:`.
160
+ // ONE resolution of the axis: base paths for every path consumer (existence, changed-scan selection,
161
+ // drift display), folded selectors for the narrowing. A raw `path#symbol` matches no real file, so
162
+ // reading it as a path would report a ghost and drop the scenario out of --changed selection.
163
+ const axis = scenarioCodeAxis(sc.code, s.code)
136
164
  if (nodeSelected) {
137
- for (const [field, paths] of [['code', sc.code], ['related', sc.related]] as const) {
138
- const ghosts = (paths ?? []).filter((p) => !existsSync(join(root, p)))
165
+ for (const [field, paths] of [['code', axis.paths], ['related', parseRelation(sc.related ?? [], 'related').entries.map((e) => e.path)]] as const) {
166
+ const ghosts = paths.filter((p) => !existsSync(join(root, p)))
139
167
  if (ghosts.length) {
140
168
  malformed++
141
169
  findings.push(` • eval-schema: '${s.id}' scenario '${sc.name}' \`${field}\` path(s) not found: ${ghosts.join(', ')} — fix ${y.evalPath}`)
142
170
  }
143
171
  }
172
+ // an anchor is a claim a named unit exists — dead/ambiguous/unextractable is LOUD, and until it is
173
+ // repaired the probe issues no verdict, so the reading stays conservatively stale.
174
+ for (const p of [...axis.problems, ...anchorProblems(root, axis.entries)]) {
175
+ malformed++
176
+ findings.push(` • eval-schema: '${s.id}' scenario '${sc.name}' ${p} — fix ${y.evalPath}`)
177
+ }
144
178
  }
145
- const codeFiles = sc.code?.length ? sc.code : s.code // scenario's own subset, else the node's list
179
+ const codeFiles = axis.paths // scenario's own subset, else the node's list
146
180
  const driftSelected = !changed || nodeChanged(dirRel, codeFiles, changed, nodeDirs)
147
181
  // carry the scenario's tags on the finding line — its SURFACE (e.g. frontend-e2e = browser-measured)
148
182
  // is what routes a drift/missing gap to the right measuring hand, so the proactive nudge and a human
@@ -158,7 +192,9 @@ async function scan(args: string[] = []): Promise<number> {
158
192
  }
159
193
  if (!driftSelected) continue
160
194
  const remSignals = (remarkTracks.get(trackKey(s.id, sc.name))?.remarks ?? []).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt }))
161
- const axes = staleAxes(r, codeFiles, y.evalPath, idx, scidx, remSignals, probe, sc)
195
+ if (!commitReachable(idx, r.codeSha)) await probe.prime?.(r.codeSha, codeFiles, y.evalPath)
196
+ await anchors.prime?.(r.codeSha, axis.entries)
197
+ const axes = staleAxes(r, sc.code?.length ? sc.code : s.code, y.evalPath, idx, scidx, remSignals, probe, sc, anchors)
162
198
  if (axes.length) {
163
199
  staleScores++
164
200
  // a remark-stale scenario is unlocked by a second-party resolve, then a fresh reading; the git axes
@@ -196,7 +232,11 @@ async function scan(args: string[] = []): Promise<number> {
196
232
  if (!changedOnly) {
197
233
  const maxOwners = cfg.maxOwners
198
234
  const govCount = new Map<string, number>()
199
- for (const n of yByDir.values()) for (const sc of n.scenarios) for (const f of sc.code ?? []) govCount.set(f, (govCount.get(f) ?? 0) + 1)
235
+ // scoped entries stay OUT of the bound ([[code-anchor]]): a scenario claiming named units does NOT claim
236
+ // the file, so narrowing an axis can never manufacture an eval-owners smell — the count stays a fact
237
+ // about how many scenarios measure the WHOLE file.
238
+ for (const n of yByDir.values()) for (const sc of n.scenarios)
239
+ for (const e of scenarioCodeAxis(sc.code).entries) if (!e.selectors.length) govCount.set(e.path, (govCount.get(e.path) ?? 0) + 1)
200
240
  const over = [...govCount].filter(([, c]) => c > maxOwners).sort((a, b) => b[1] - a[1])
201
241
  if (over.length) {
202
242
  overOwned = over.length
@@ -621,6 +661,52 @@ export function scenarioListRows(nodes: EvalNode[], unmeasuredOnly = false): Sce
621
661
  return rows
622
662
  }
623
663
 
664
+ function scenarioProjectionProvenance(root: string): { head: string; treeSha: string } {
665
+ // These are fixed-tree provenance fields, deliberately outside semanticIndexHash/fullIndexHash. A mode or
666
+ // type change therefore remains visible to a tree consumer without changing the content-derived scenario
667
+ // identities. The command runs inside a Git project, so failure is loud instead of manufacturing provenance.
668
+ const head = git(['-C', root, 'rev-parse', 'HEAD']).trim()
669
+ if (!head) throw new Error('cannot resolve one fixed HEAD provenance')
670
+ const treeSha = git(['-C', root, 'rev-parse', `${head}^{tree}`]).trim()
671
+ if (!treeSha) throw new Error(`cannot resolve tree provenance for fixed HEAD ${head}`)
672
+ return { head, treeSha }
673
+ }
674
+
675
+ function scenarioWrite(args: string[]): number {
676
+ let rawMutation: string | undefined
677
+ for (let i = 0; i < args.length; i++) {
678
+ const arg = args[i]
679
+ if (arg !== '--mutation') {
680
+ console.error(`spex eval scenario write: unknown argument '${arg}' — accepts only --mutation <json>; eval.md bytes come from stdin`)
681
+ return 2
682
+ }
683
+ if (rawMutation !== undefined) {
684
+ console.error('spex eval scenario write: --mutation must be supplied exactly once')
685
+ return 2
686
+ }
687
+ const value = args[++i]
688
+ if (value === undefined) {
689
+ console.error('spex eval scenario write: --mutation needs one JSON value')
690
+ return 2
691
+ }
692
+ rawMutation = value
693
+ }
694
+ if (rawMutation === undefined) {
695
+ console.error('spex eval scenario write: usage: spex eval scenario write --mutation <json> < eval.md')
696
+ return 2
697
+ }
698
+
699
+ try {
700
+ const mutation = JSON.parse(rawMutation)
701
+ const source = readFileSync(0, 'utf8')
702
+ process.stdout.write(writeScenarioMeasurementMetadata(source, mutation))
703
+ return 0
704
+ } catch (e: any) {
705
+ console.error(`spex eval scenario write: ${e?.message ?? e}`)
706
+ return 1
707
+ }
708
+ }
709
+
624
710
  async function scenarioLs(args: string[]): Promise<number> {
625
711
  for (const a of args) {
626
712
  if (!a.startsWith('--')) continue
@@ -632,6 +718,32 @@ async function scenarioLs(args: string[]): Promise<number> {
632
718
  }
633
719
  const root = repoRoot()
634
720
  const sel = positional(args)
721
+ const unmeasuredOnly = has(args, 'unmeasured')
722
+ if (has(args, 'json')) {
723
+ if (unmeasuredOnly) {
724
+ console.error('spex eval scenario ls --json: --unmeasured needs evals; JSON is the complete declaration projection and reads no eval sidecar')
725
+ return 2
726
+ }
727
+ try {
728
+ const before = scenarioProjectionProvenance(root)
729
+ // Read declarations from the exact commit named by `before`, never from a dirty/staged working tree.
730
+ const fixedNodes = evalNodesAt(root, before.head)
731
+ const selected = sel
732
+ ? (() => {
733
+ const ref = sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
734
+ if (!ref) throw new Error('no current node (no session/node-branch here) — name a node')
735
+ const res = resolveEvalNode(fixedNodes, ref)
736
+ if (!res.ok) throw new Error(res.error)
737
+ return [res.node]
738
+ })()
739
+ : fixedNodes
740
+ console.log(JSON.stringify(scenarioProjection(selected, before), null, 2))
741
+ return 0
742
+ } catch (e: any) {
743
+ console.error(`spex eval scenario ls --json: ${e?.message ?? e}`)
744
+ return 1
745
+ }
746
+ }
635
747
  let nodes = evalNodes(root)
636
748
  if (sel) {
637
749
  const ref = sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
@@ -640,9 +752,7 @@ async function scenarioLs(args: string[]): Promise<number> {
640
752
  if (!res.ok) { console.error(`spex eval scenario ls: ${res.error}`); return 1 }
641
753
  nodes = [res.node]
642
754
  }
643
- const unmeasuredOnly = has(args, 'unmeasured')
644
755
  const rows = scenarioListRows(nodes, unmeasuredOnly)
645
- if (has(args, 'json')) { console.log(JSON.stringify(rows, null, 2)); return 0 }
646
756
  if (!rows.length) {
647
757
  console.log(`spex eval scenario ls: ${unmeasuredOnly ? 'no unmeasured scenarios' : 'no scenarios declared'}${sel ? ` on '${nodes[0]?.id ?? sel}'` : ''}`)
648
758
  return 0
@@ -659,8 +769,9 @@ async function scenarioLs(args: string[]): Promise<number> {
659
769
  }
660
770
 
661
771
  // the `spex eval` drawer's node-scoped verbs ([[cli-surface]]): add (file a measurement) · ls (a node's
662
- // reading timeline) · scenario ls (the declared contracts, --unmeasured = blind spots) · lint (the
663
- // measurement-layer lint — advisory, always exit 0) · ok (the human sign-off) · retract · clean.
772
+ // reading timeline) · scenario ls/write (the declared contracts and their canonical metadata writer) · lint (the
773
+ // measurement-layer lint — findings are advisory; an unreadable --changed scope fails) · ok (the human
774
+ // sign-off) · retract · clean.
664
775
  // The session-scoped read (`spex eval ls --session <SEL>`) is intercepted in cli.ts before this runs;
665
776
  // `check-staged` is hook plumbing, exported separately for `spex internal check-staged`.
666
777
  export async function runEval(args: string[]): Promise<number> {
@@ -679,10 +790,11 @@ export async function runEval(args: string[]): Promise<number> {
679
790
  }
680
791
  if (sub === 'scenario') {
681
792
  if (args[1] === 'ls') return scenarioLs(args.slice(2))
682
- console.error('spex eval scenario: ls [<node>|.] [--unmeasured] [--json] list declared scenarios (the measurement contracts)')
793
+ if (args[1] === 'write') return scenarioWrite(args.slice(2))
794
+ console.error('spex eval scenario: ls [<node>|.] [--unmeasured] [--json] | write --mutation <json> < eval.md')
683
795
  return 2
684
796
  }
685
- console.error('spex eval: add [.|<node>] [--scenario <name>] (--pass|--fail) [--note <text>] [--image <path> …repeatable] [--result <path|->] [--video <path>] [--timeline <json>] | ls [.|<node>] [--json] | ls --session <SEL> [--export] | scenario ls [<node>|.] [--unmeasured] [--json] | matrix <launcher> [--node <id>] [--rows k1,k2] | lint [--changed] | ok <node> [--scenario <name>] | retract [.|<node>] [--scenario <name>] [--last | --ts <iso>] [--note <why>] | clean [--keep-latest|--all]')
797
+ console.error('spex eval: add [.|<node>] [--scenario <name>] (--pass|--fail) [--note <text>] [--image <path> …repeatable] [--result <path|->] [--video <path>] [--timeline <json>] | ls [.|<node>] [--json] | ls --session <SEL> [--export] | scenario ls [<node>|.] [--unmeasured] [--json] | scenario write --mutation <json> < eval.md | matrix <launcher> [--node <id>] [--rows k1,k2] | lint [--changed] | ok <node> [--scenario <name>] | retract [.|<node>] [--scenario <name>] [--last | --ts <iso>] [--note <why>] | clean [--keep-latest|--all]')
686
798
  return 2
687
799
  }
688
800
 
@@ -1,10 +1,10 @@
1
1
  import { relative, dirname } from 'node:path'
2
- import { repoRoot, driftIndex, historyIndex, type DriftIndex, type HistoryIndex } from '../../spec-cli/src/git.js'
2
+ import { repoRoot, driftIndex, historyIndex, commitReachable, type DriftIndex, type HistoryIndex } from '../../spec-cli/src/git.js'
3
3
  import { loadSpecs } from '../../spec-cli/src/specs.js'
4
4
  import { loadEvalRemarkTracks, trackKey, type RemarkTrack, type Issue, type Reply } from '../../spec-cli/src/issues.js'
5
- import { evalNodes, type EvalNode, type ScenarioTestReference } from './scenarios.js'
5
+ import { evalNodes, scenarioCodeAxis, type EvalNode, type ScenarioTestReference } from './scenarios.js'
6
6
  import { readSidecar, applyRetractions, evidenceOf, isJsonBlob, humanOkFor, type Verdict, type EvidenceKind, type Retraction } from './sidecar.js'
7
- import { staleAxes, codeDrift, contentProbeFor, type StaleAxis } from './freshness.js'
7
+ import { staleAxes, codeDrift, contentProbeFor, anchorProbeFor, type StaleAxis } from './freshness.js'
8
8
  import { scenarioIndex, type ScenarioIndex } from './scenariofresh.js'
9
9
  import { hasBlob, getBlob, MISS_BLOB } from './cache.js'
10
10
 
@@ -171,17 +171,24 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
171
171
  // along as the undo trace (newest-first, like the readings), the human-ok events as the sign-off overlay.
172
172
  const { readings: rawReadings, retractions, oks } = readSidecar(ynode.sidecarPath)
173
173
  // the off-history content fallback ([[eval-core]]): fed to both git axes so a rebased/folded-away
174
- // anchor with byte-identical governed content reads fresh. Lazy — an in-history reading never probes.
174
+ // anchor with byte-identical governed content reads fresh. An in-history reading never probes.
175
175
  const probe = contentProbeFor(root)
176
- const readings: EvalEntry[] = applyRetractions(rawReadings, retractions).map((r) => {
176
+ const anchors = anchorProbeFor(root, idx)
177
+ const readings: EvalEntry[] = []
178
+ for (const r of applyRetractions(rawReadings, retractions)) {
177
179
  // a scenario's own `code` is its freshness code axis when it declares one; else the whole node's list.
178
180
  const sc = byName.get(r.scenario)
179
181
  // the teeth feed the WHOLE scenario track against THIS reading — an unresolved (or not-yet-out-run)
180
182
  // remark makes it remark-stale (T1). Display attachment (which reading each remark pins to) is a separate
181
183
  // read-time overlay below; freshness never depends on that pin.
182
184
  const cf = sc?.code?.length ? sc.code : codeFiles
185
+ // an entry may be anchored (`path#symbol`); every PATH consumer below reads base paths, the narrowing
186
+ // reads the folded selectors ([[eval-core]]).
187
+ const axis = scenarioCodeAxis(sc?.code, codeFiles)
188
+ if (!commitReachable(idx, r.codeSha)) await probe.prime?.(r.codeSha, axis.paths, ynode.evalPath)
189
+ await anchors.prime?.(r.codeSha, axis.entries)
183
190
  const axes = staleAxes(r, cf, ynode.evalPath, idx, scidx,
184
- remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })), probe, sc)
191
+ remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })), probe, sc, anchors)
185
192
  // when the code axis is stale, explain it: which of THIS reading's governed files moved, by how many commits.
186
193
  const drift = axes.includes('code') ? codeDrift(idx, r.codeSha, cf, probe) : []
187
194
  // the reading's evidence list, each entry resolved to its live blob state; the primary (video-first, else
@@ -191,7 +198,7 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
191
198
  // the sign-off join ([[human-ok]]): the ok binds by exact (scenario, ts), so only the very reading the
192
199
  // human blessed carries it — a newer or retract-revealed reading reads unblessed.
193
200
  const okRow = humanOkFor(oks, r.scenario, r.ts)
194
- return {
201
+ readings.push({
195
202
  scenario: r.scenario,
196
203
  expected: byName.get(r.scenario)?.expected ?? '',
197
204
  codeSha: r.codeSha,
@@ -209,8 +216,8 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
209
216
  blobState: primary ? primary.state : 'none',
210
217
  ...(threadFor(r.scenario) ? { thread: threadFor(r.scenario) } : {}),
211
218
  ...(okRow ? { humanOk: { by: okRow.by, ts: okRow.ts } } : {}),
212
- }
213
- })
219
+ })
220
+ }
214
221
  readings.reverse() // newest-first
215
222
  // R2 display overlay ([[remark-teeth]]): pin each remark to the reading it JUDGED (targetCodeSha match),
216
223
  // else the scenario's latest reading (first in newest-first order) — a dangling target never HIDES the