spexcode 0.4.0 → 0.4.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 (111) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +163 -30
  7. package/spec-cli/src/cli.ts +97 -42
  8. package/spec-cli/src/client.ts +34 -11
  9. package/spec-cli/src/doctor.ts +174 -36
  10. package/spec-cli/src/gateway-auth.ts +194 -0
  11. package/spec-cli/src/gateway-hub.ts +360 -0
  12. package/spec-cli/src/gateway.ts +31 -16
  13. package/spec-cli/src/git.ts +12 -5
  14. package/spec-cli/src/graph.ts +53 -36
  15. package/spec-cli/src/graphStream.ts +265 -34
  16. package/spec-cli/src/guide.ts +157 -76
  17. package/spec-cli/src/harness-select.ts +20 -6
  18. package/spec-cli/src/harness.ts +266 -67
  19. package/spec-cli/src/help.ts +84 -41
  20. package/spec-cli/src/host.ts +610 -0
  21. package/spec-cli/src/identity-presets.d.ts +13 -0
  22. package/spec-cli/src/identity-presets.js +128 -0
  23. package/spec-cli/src/index.ts +163 -86
  24. package/spec-cli/src/init.ts +53 -25
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +115 -169
  27. package/spec-cli/src/login-page.ts +12 -5
  28. package/spec-cli/src/materialize.ts +39 -13
  29. package/spec-cli/src/mentions.ts +16 -11
  30. package/spec-cli/src/opencode.ts +145 -0
  31. package/spec-cli/src/pi-harness.ts +124 -0
  32. package/spec-cli/src/project-identity.ts +73 -0
  33. package/spec-cli/src/pty-bridge.ts +321 -521
  34. package/spec-cli/src/pty-helper.mjs +86 -0
  35. package/spec-cli/src/reaper.ts +16 -8
  36. package/spec-cli/src/reviewSnapshot.ts +23 -0
  37. package/spec-cli/src/reviews.ts +347 -0
  38. package/spec-cli/src/session-timeline.ts +162 -0
  39. package/spec-cli/src/sessions.ts +210 -63
  40. package/spec-cli/src/shim-runtime.ts +169 -0
  41. package/spec-cli/src/slash-commands.ts +76 -0
  42. package/spec-cli/src/source-files.ts +79 -0
  43. package/spec-cli/src/specs.ts +47 -24
  44. package/spec-cli/src/supervise.ts +37 -15
  45. package/spec-cli/src/tree.ts +8 -8
  46. package/spec-cli/src/uninstall.ts +56 -23
  47. package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
  48. package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
  49. package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
  50. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  51. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  53. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  56. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  58. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  59. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  60. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  61. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  62. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  63. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  64. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  65. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
  66. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
  67. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  68. package/spec-cli/templates/spec/project/spec.md +6 -5
  69. package/spec-cli/templates/spexcode.json +4 -2
  70. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
  71. package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
  72. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
  73. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
  74. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
  75. package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
  76. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
  77. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  78. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
  79. package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
  80. package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
  81. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  82. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
  83. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
  84. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  85. package/spec-dashboard/dist/index.html +10 -10
  86. package/spec-dashboard/src/reviewFilters.js +279 -0
  87. package/spec-dashboard/src/reviewQuery.js +174 -0
  88. package/spec-dashboard/src/session.js +167 -0
  89. package/spec-eval/src/cli.ts +80 -45
  90. package/spec-eval/src/evaltab.ts +4 -3
  91. package/spec-eval/src/humanok.ts +1 -1
  92. package/spec-eval/src/matrix.ts +693 -0
  93. package/spec-eval/src/scenarios.ts +116 -4
  94. package/spec-eval/src/sessioneval.ts +726 -66
  95. package/spec-cli/src/migrate-table.ts +0 -398
  96. package/spec-cli/src/migrate.ts +0 -386
  97. package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
  98. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
  99. package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
  100. package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
  108. package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
  109. package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
  110. package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
  111. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
@@ -0,0 +1,174 @@
1
+ // The ONE token-query engine ([[review-query]]) — pure JS, no React/DOM. Both review ListViews, the
2
+ // route layer's legacy replay, and any consumer minting canonical eval/issue addresses import from HERE:
3
+ // the visible query TEXT is the single source of truth, and every tab/menu/autocomplete is only a
4
+ // BUILDER that rewrites tokens in it (GitHub-measured semantics).
5
+
6
+ export const ISSUE_QUERY_DEFAULT = 'is:issue state:open'
7
+ export const EVAL_QUERY_DEFAULT = 'is:eval'
8
+ // the session doors' scoped-list address: the default view, scoped — the text shows exactly that.
9
+ export const scopedEvalQuery = (sessionId) => setToken(EVAL_QUERY_DEFAULT, 'scope', sessionId)
10
+ // the aggregate score/count doors' address ([[eval-score-badge]]): the default view, node-filtered.
11
+ export const nodeEvalQuery = (nodeId) => setToken(EVAL_QUERY_DEFAULT, 'node', nodeId)
12
+
13
+ const KEY_RE = /^([A-Za-z][A-Za-z0-9-]*):(.*)$/s
14
+ const unquote = (v) => (v.length >= 2 && v.startsWith('"') && v.endsWith('"') ? v.slice(1, -1) : v)
15
+ export const quoteValue = (v) => (/\s/.test(String(v)) ? `"${v}"` : String(v))
16
+
17
+ // segment scan preserving EVERY character — whitespace runs and tokens (a `"` swallows spaces until it
18
+ // closes), each with [start,end) offsets — so the aria-hidden highlight overlay mirrors the input
19
+ // glyph-for-glyph and the autocomplete can find the token under the caret.
20
+ export function scanQuery(text) {
21
+ const s = String(text ?? '')
22
+ const out = []
23
+ let i = 0
24
+ while (i < s.length) {
25
+ let j = i
26
+ if (/\s/.test(s[i])) {
27
+ while (j < s.length && /\s/.test(s[j])) j++
28
+ out.push({ ws: true, raw: s.slice(i, j), start: i, end: j })
29
+ } else {
30
+ while (j < s.length && !/\s/.test(s[j])) {
31
+ if (s[j] === '"') {
32
+ j++
33
+ while (j < s.length && s[j] !== '"') j++
34
+ if (j < s.length) j++
35
+ } else j++
36
+ }
37
+ const raw = s.slice(i, j)
38
+ const m = KEY_RE.exec(raw)
39
+ out.push(m
40
+ ? { ws: false, raw, start: i, end: j, key: m[1].toLowerCase(), value: unquote(m[2]) }
41
+ : { ws: false, raw, start: i, end: j, key: null, value: unquote(raw) })
42
+ }
43
+ i = j
44
+ }
45
+ return out
46
+ }
47
+
48
+ export const tokenize = (text) => scanQuery(text).filter((seg) => !seg.ws)
49
+ export const serialize = (tokens) => tokens.map((t) => t.raw).join(' ')
50
+ export const normalizeQuery = (text) => serialize(tokenize(text))
51
+ export const sameQuery = (a, b) => normalizeQuery(a) === normalizeQuery(b)
52
+
53
+ // duplicate qualifiers: the LAST occurrence wins; bare words all apply.
54
+ export const effectiveTokens = (tokens) => {
55
+ const last = new Map()
56
+ for (const t of tokens) if (t.key != null) last.set(t.key, t)
57
+ return tokens.filter((t) => t.key == null || last.get(t.key) === t)
58
+ }
59
+
60
+ export const readToken = (text, key) => {
61
+ const tokens = tokenize(text)
62
+ for (let i = tokens.length - 1; i >= 0; i--) if (tokens[i].key === key) return tokens[i].value
63
+ return ''
64
+ }
65
+
66
+ // token SURGERY: rewrite the key at its first position, drop later duplicates, append when absent,
67
+ // remove the key entirely on an empty value. Every other token — known or not — survives verbatim.
68
+ export function setToken(text, key, value) {
69
+ const next = value == null || String(value) === ''
70
+ ? null
71
+ : { ws: false, raw: `${key}:${quoteValue(value)}`, key, value: String(value) }
72
+ const out = []
73
+ let placed = false
74
+ for (const t of tokenize(text)) {
75
+ if (t.key === key) {
76
+ if (next && !placed) { out.push(next); placed = true }
77
+ continue
78
+ }
79
+ out.push(t)
80
+ }
81
+ if (next && !placed) out.push(next)
82
+ return serialize(out)
83
+ }
84
+
85
+ // MATCHING is deliberately NOT here: this module owns text — scan/serialize/surgery/suggestions and the
86
+ // canonical-address discipline — while the conjunctive field matching (including the unknown-qualifier
87
+ // IMPOSSIBLE state) lives in the ONE [[review-filters]] engine, reached through its tokenFilterState
88
+ // bridge. A second predicate here would be the exact fork the fusion removed.
89
+
90
+ // canonical address discipline: the default view is the BARE page address; any other state is exactly
91
+ // ?q=<raw text>. An emptied submit falls back to the default (→ bare).
92
+ export const queryParam = (text, defaultText) => {
93
+ const trimmed = String(text ?? '').trim()
94
+ if (!trimmed || sameQuery(trimmed, defaultText)) return null
95
+ return { q: trimmed }
96
+ }
97
+
98
+ // A query/filter action resets by omitting page; a PAGINATION action passes a page (including 1) and
99
+ // therefore records GitHub's explicit page=1 history form. The distinction is the action, not equivalence.
100
+ export const reviewRouteQuery = (text, defaultText, page = null) => {
101
+ const query = queryParam(text, defaultText) || {}
102
+ if (page != null) query.page = String(page)
103
+ return Object.keys(query).length ? query : null
104
+ }
105
+
106
+ // LEGACY structured params → token pairs. `kind=all` maps to nothing: the evidence default IS all.
107
+ const LEGACY_PARAMS = {
108
+ state: (v) => ['state', v],
109
+ concluded: (v) => (v === '1' ? ['state', 'closed'] : null),
110
+ ok: (v) => (v === '1' ? ['state', 'reviewed'] : null),
111
+ verdict: (v) => ['verdict', v],
112
+ freshness: (v) => ['freshness', v],
113
+ kind: (v) => (v === 'all' ? null : ['evidence', v]),
114
+ store: (v) => ['store', v],
115
+ author: (v) => ['author', v],
116
+ node: (v) => ['node', v],
117
+ filer: (v) => ['filer', v],
118
+ live: (v) => (v === '1' ? ['session', 'present'] : null),
119
+ session: (v) => ['scope', v],
120
+ }
121
+
122
+ export const hasLegacyParams = (query) =>
123
+ Object.keys(LEGACY_PARAMS).some((k) => query?.[k] != null && query[k] !== '')
124
+
125
+ // the legacy free q was ONE substring search — it must replay as ONE text token. Quote it whenever the
126
+ // tokenizer would read it as anything else: spaces (several words), a colon (q=drift:check would become
127
+ // an unknown qualifier and match zero), or a stray quote (which would swallow neighbours).
128
+ const freeTextToken = (v) => (/[\s:"]/.test(v) ? `"${v.replace(/"/g, '')}"` : v)
129
+
130
+ // a legacy LIST address replays as the FULL visible state: the page's default tokens with each legacy
131
+ // param surgically applied (live=1→session:present, session=<id>→scope:<id>, ok=1→state:reviewed,
132
+ // kind→evidence:), the free-text q appended as ONE bare/phrase token preserving the old
133
+ // single-substring search. Returns null when nothing legacy is present.
134
+ export function legacyQueryText(defaultText, query) {
135
+ if (!hasLegacyParams(query)) return null
136
+ let text = defaultText
137
+ for (const [param, toPair] of Object.entries(LEGACY_PARAMS)) {
138
+ const v = query[param]
139
+ if (v == null || v === '') continue
140
+ const pair = toPair(String(v))
141
+ if (pair) text = setToken(text, pair[0], pair[1])
142
+ }
143
+ const free = String(query.q ?? '').trim()
144
+ if (free) text = `${text} ${freeTextToken(free)}`.trim()
145
+ return text
146
+ }
147
+
148
+ // inline autocomplete at the caret — client-side and BOUNDED. A bare prefix completes qualifier KEYS
149
+ // (insert `key:`, keep typing); a `key:prefix` completes VALUES from the page-supplied candidate list
150
+ // only (data-derived; scope = sessions on the current board), capped at 8. Everything else stays
151
+ // hand-typable and submits verbatim.
152
+ export function suggestAt(text, caret, keys = [], values = {}) {
153
+ const s = String(text ?? '')
154
+ const at = Math.max(0, Math.min(caret ?? s.length, s.length))
155
+ const seg = scanQuery(s).find((g) => !g.ws && g.start < at && at <= g.end)
156
+ if (!seg) return { start: at, end: at, items: [] }
157
+ const typed = s.slice(seg.start, at)
158
+ const m = KEY_RE.exec(typed)
159
+ if (!m) {
160
+ const w = typed.toLowerCase()
161
+ if (!w || w.includes('"')) return { start: seg.start, end: seg.end, items: [] }
162
+ const items = keys.filter((k) => k.startsWith(w)).slice(0, 8)
163
+ .map((k) => ({ type: 'key', key: k, insert: `${k}:` }))
164
+ return { start: seg.start, end: seg.end, items }
165
+ }
166
+ const key = m[1].toLowerCase()
167
+ const prefix = m[2].replace(/^"/, '').replace(/"$/, '').toLowerCase()
168
+ const pool = values[key] || []
169
+ const items = pool
170
+ .filter((c) => String(c.value).toLowerCase().startsWith(prefix) && String(c.value).toLowerCase() !== prefix)
171
+ .slice(0, 8)
172
+ .map((c) => ({ type: 'value', key, value: String(c.value), label: c.label || null, insert: `${key}:${quoteValue(c.value)} ` }))
173
+ return { start: seg.start, end: seg.end, items }
174
+ }
@@ -0,0 +1,167 @@
1
+ // status→colour values are theme tokens (styles.css :root) so the palette stays single-sourced; var() resolves in inline styles.
2
+ export const STATUS_COLOR = {
3
+ working: 'var(--green)', parked: 'var(--green)',
4
+ asking: 'var(--yellow)', review: 'var(--yellow)', done: 'var(--yellow)',
5
+ error: 'var(--red)',
6
+ idle: 'var(--muted)', starting: 'var(--muted)', queued: 'var(--muted)',
7
+ 'close-pending': 'var(--muted)', offline: 'var(--muted)',
8
+ unknown: 'var(--yellow)', // liveness probe FAILED (box overloaded) — death unproven, so warn, never read as dead
9
+ }
10
+
11
+ // compact one-line surfaces (the console's terminal-styled sidebar) render the status as a SINGLE glyph
12
+ // instead of the word — STATUS_COLOR still paints it, and the exact word stays in the title/aria for hover +
13
+ // a11y. One terminal-ish mark per lifecycle; same four-hue traffic-light meaning as the word it replaces.
14
+ export const STATUS_GLYPH = {
15
+ working: '●', parked: '‖',
16
+ asking: '?', review: '◑', done: '✓',
17
+ error: '✕',
18
+ idle: '·', starting: '◌', queued: '⋯', 'close-pending': '⊘', offline: '○', unknown: '⁇',
19
+ }
20
+
21
+ // the three triage zones the session list groups into — "whose turn is it?". `offline` = the process is
22
+ // DEAD/dormant (can't act until relaunched) — checked FIRST, because a session whose process died while
23
+ // asking/review/error keeps that pre-death lifecycle, yet it belongs at the bottom, not under Needs You.
24
+ // `need` = the ball is with the HUMAN (asking / review / done / close-pending / error → answer, review,
25
+ // close, fix); `run` = self-driving, the agent's turn (working / parked / starting / queued / idle — booting
26
+ // counts as running, not dead). Closed sessions aren't on the board at all. Same partition drives every
27
+ // session-list surface.
28
+ const NEED_STATUS = new Set(['asking', 'review', 'done', 'close-pending', 'error'])
29
+ export const sessionZone = (s) => {
30
+ if (s?.liveness === 'offline' || s?.status === 'offline') return 'offline'
31
+ return NEED_STATUS.has(s?.status) ? 'need' : 'run'
32
+ }
33
+ export const ZONE_ORDER = ['need', 'run', 'offline']
34
+ // the ONE liveness join: resolve an id against the board sessions and return the
35
+ // session only while it is ALIVE (listed and not offline) — the same alive/offline judgment the originator
36
+ // chip renders (Thread.jsx). A non-session id ('human', a github
37
+ // login) resolves to null, honestly.
38
+ export const liveSession = (sessions, id) => {
39
+ const s = id ? (sessions || []).find((x) => x.id === id) : null
40
+ return s && sessionZone(s) !== 'offline' ? s : null
41
+ }
42
+ // the ONE source-session PRESENCE join ([[live-session-filter]] — the session:present|missing facet):
43
+ // does the id still resolve to a session on the current board at ALL, any zone? Presence, not liveness —
44
+ // the facet asks "is the source still around", never "is it online".
45
+ export const sessionPresent = (sessions, id) => {
46
+ const s = id ? (sessions || []).find((x) => x.id === id) : null
47
+ return s || null
48
+ }
49
+ // zone-partition the list: needs-you first, self-running next, offline (dormant) at the bottom; and WITHIN
50
+ // each zone the NEWEST session on top (descending effective time) — the fresh, recently-touched work you
51
+ // actually reach for, not the oldest.
52
+ const effOf = (s) => (s?.sortKey != null ? s.sortKey : (s?.created ?? 0))
53
+ export const zoneSort = (sessions) => {
54
+ const rank = { need: 0, run: 1, offline: 2 }
55
+ return [...sessions].sort((a, b) => rank[sessionZone(a)] - rank[sessionZone(b)] || effOf(b) - effOf(a))
56
+ }
57
+
58
+ // the session's display strings are DERIVED SERVER-SIDE ([[session-label]]): the wire carries `label`
59
+ // (stable handle) and `headline` (the live line a human reads), computed once in toSession; the bare parts
60
+ // (rename `name`, prompt-truncation `title`) don't ride the wire at the top level, so a surface CANNOT
61
+ // re-derive its own chain — these two accessors are the only doors, and the legacy chain below each exists
62
+ // solely as the old-backend fallback, confined to THIS file. Reach for s.raw.name / s.raw.title only for an
63
+ // explicitly raw consumer (the rename prefill).
64
+ //
65
+ // `sessionHandle` is the STABLE handle — its ONLY sanctioned uses are the avatar/hover TOOLTIP, mobile's
66
+ // handle-line, and search MATCHING. On a current backend the wire always carries `label`, so this door
67
+ // short-circuits there: what search matches IS the label (a rename name or the prompt truncation);
68
+ // raw id/node/branch fragments are deliberately NOT promised searchable.
69
+ // It is NEVER a visible one-line title: EVERY surface where a human reads "which session is this" (board
70
+ // rows, the map window, Enter tabs, the console strip, the search palette, the @-mention dropdown, and the
71
+ // node-menu overlay list) renders `sessionHeadline`. Naming the stable door `Handle`, not `Name`, is the
72
+ // architectural guard: a dev wanting "the name to show" reaches for the headline by reflex and can no longer
73
+ // grab the handle by mistake — the divergence that kept recurring ([[session-activity]]: one name everywhere).
74
+ export const sessionHandle = (s) =>
75
+ s?.label || s?.name || s?.node || s?.title || s?.branch || s?.id
76
+
77
+ export const sessionHeadline = (s) =>
78
+ s?.headline || s?.name || s?.activity || s?.promptPreview || s?.node || s?.title || s?.branch || s?.id
79
+
80
+ // @@@ session nesting ([[session-nesting]]) — a session launched by `spex new` from INSIDE another carries
81
+ // that spawner's id as `parent`. Fold it into a forest, DERIVED here at read time (never stored on the child):
82
+ // a child nests under its parent ONLY IF that parent is present in this list, so a closed parent's children
83
+ // auto-promote to top-level on the next board read. Returns the top-level `roots` (a real parent or an orphan
84
+ // whose parent is gone) and `childrenOf` (parentId → its direct children), both recursive to any depth.
85
+ export function nestSessions(sessions) {
86
+ const present = new Set(sessions.map((s) => s?.id))
87
+ const childrenOf = new Map()
88
+ const roots = []
89
+ for (const s of sessions) {
90
+ const p = s?.parent && s.parent !== s.id && present.has(s.parent) ? s.parent : null
91
+ if (p) { const arr = childrenOf.get(p) || []; arr.push(s); childrenOf.set(p, arr) }
92
+ else roots.push(s)
93
+ }
94
+ return { roots, childrenOf }
95
+ }
96
+
97
+ // Present ancestors of one session, nearest first. This mirrors nestSessions' rule that a missing parent
98
+ // makes its child a root, and bounds malformed cycles so an external jump can safely reveal the row.
99
+ export function sessionAncestorIds(sessions, id) {
100
+ const byId = new Map(sessions.map((s) => [s?.id, s]))
101
+ const ids = []
102
+ const seen = new Set([id])
103
+ let cur = byId.get(id)
104
+ while (cur?.parent && !seen.has(cur.parent)) {
105
+ const parent = byId.get(cur.parent)
106
+ if (!parent) break
107
+ ids.push(parent.id)
108
+ seen.add(parent.id)
109
+ cur = parent
110
+ }
111
+ return ids
112
+ }
113
+
114
+ // @@@ subtree rollup ([[session-nesting]]) — the disclosure-triangle COLOUR: a PURELY informational summary of
115
+ // the hidden subtree that must NOT touch the parent's own status/glyph/zone/sort. Dark-yellow if ANY descendant
116
+ // needs attention (the needs-you zone, error folded in — the widest signal wins); else green if any descendant
117
+ // is actively running (a STATUS_COLOR-green status: working/parked); else neutral (all idle/offline). Reuses
118
+ // the STATUS_COLOR hues so the triangle speaks the same four-hue language as every other status mark.
119
+ export function subtreeRollup(id, childrenOf) {
120
+ let need = false, run = false, count = 0
121
+ const walk = (pid, seen) => {
122
+ for (const c of childrenOf.get(pid) || []) {
123
+ if (seen.has(c.id)) continue
124
+ seen.add(c.id)
125
+ count++
126
+ if (NEED_STATUS.has(c.status)) need = true
127
+ else if (STATUS_COLOR[c.status] === STATUS_COLOR.working) run = true
128
+ walk(c.id, seen)
129
+ }
130
+ }
131
+ walk(id, new Set([id]))
132
+ return { color: need ? STATUS_COLOR.asking : run ? STATUS_COLOR.working : STATUS_COLOR.idle, count }
133
+ }
134
+
135
+ // @@@ the ordered render list ([[session-nesting]]) both session-list surfaces share. Roots are zone-sorted by
136
+ // their OWN status (no aggregation), each carrying a zone header when the zone changes; a parent's children
137
+ // follow it (zone-sorted among themselves) ONLY when `isExpanded(id)` — collapsed by default, so a fleet reads
138
+ // as one row. Emits {type:'zone',zone} and {type:'row', s, depth, expandable, expanded, rollup, guides}; the
139
+ // visible row order is also what ↑/↓ nav and drag-reorder walk, so a collapsed child is never a hidden nav
140
+ // target. `guides` is the file-tree rail vector, one bool per connector column (length === depth): the LAST
141
+ // entry marks whether THIS row has a following sibling (branch tee vs end elbow), each earlier entry whether
142
+ // the ancestor in that column continues (draw a pass-through vertical line vs blank).
143
+ export function sessionForest(sessions, isExpanded) {
144
+ const { roots, childrenOf } = nestSessions(sessions)
145
+ const items = []
146
+ const emit = (s, depth, seen, guides) => {
147
+ const kids = childrenOf.get(s.id) || []
148
+ const expandable = kids.length > 0
149
+ const expanded = expandable && !!isExpanded(s.id)
150
+ const roll = expandable ? subtreeRollup(s.id, childrenOf) : null
151
+ items.push({ type: 'row', s, depth, expandable, expanded, rollup: roll?.color ?? null, kin: roll?.count ?? 0, guides })
152
+ if (expanded) {
153
+ const vis = zoneSort(kids).filter((c) => !seen.has(c.id))
154
+ vis.forEach((c, i) => { seen.add(c.id); emit(c, depth + 1, seen, [...guides, i < vis.length - 1]) })
155
+ }
156
+ }
157
+ const seen = new Set()
158
+ let prevZone = null
159
+ for (const r of zoneSort(roots)) {
160
+ if (seen.has(r.id)) continue
161
+ seen.add(r.id)
162
+ const z = sessionZone(r)
163
+ if (z !== prevZone) { items.push({ type: 'zone', zone: z }); prevZone = z }
164
+ emit(r, 0, seen, [])
165
+ }
166
+ return items
167
+ }
@@ -2,9 +2,10 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
2
2
  import { join, relative, dirname } from 'node:path'
3
3
  import { repoRoot, headSha, driftIndex, stagedFiles, git } from '../../spec-cli/src/git.js'
4
4
  import { loadSpecs } from '../../spec-cli/src/specs.js'
5
- import { loadConfig, sourceExtRe } from '../../spec-cli/src/lint.js'
5
+ import { loadConfig } from '../../spec-cli/src/lint.js'
6
+ import { trackedSourceFiles } from '../../spec-cli/src/source-files.js'
6
7
  import { mainBranch, envSessionId, readRawRecord } from '../../spec-cli/src/layout.js'
7
- import { evalNodes, validateScenarios, resolveEvalNode, scenarioHash, EVAL_FILE, type EvalNode } from './scenarios.js'
8
+ import { evalNodes, validateScenarios, resolveEvalNode, scenarioHash, EVAL_FILE, type EvalNode, type ScenarioTestReference } from './scenarios.js'
8
9
  import { readReadings, readSidecar, appendReading, appendRetraction, latestPerScenario, evidenceOf, isJsonBlob, type Reading, type Verdict, type Evidence, type EvidenceKind, type Retraction } from './sidecar.js'
9
10
  import { staleAxes, contentProbeFor } from './freshness.js'
10
11
  import { scenarioIndex } from './scenariofresh.js'
@@ -53,9 +54,9 @@ function currentNodeId(root: string): string | null {
53
54
  }
54
55
 
55
56
  // isUiPath answers a FRONTEND-specific question — "does this node need a real BROWSER reading?" — and is
56
- // consumed by the session-eval's `uncoveredFrontend` blindspot, NOT by eval lint's coverage check. Scan keys off
57
- // the general `sourceExtensions` knob instead (see the `eval-coverage` branch below), so a non-web project's
58
- // sources are held to the loss discipline too; this stays web-shaped on purpose.
57
+ // consumed by the session-eval's `uncoveredFrontend` blindspot, NOT by eval lint's coverage check. Scan uses
58
+ // the shared tracked-source classifier instead, so a non-web project's sources are held to the loss discipline
59
+ // too; this stays web-shaped on purpose.
59
60
  const UI_FILE = /\.(jsx|tsx|vue|svelte|css)$/
60
61
  export const isUiPath = (p: string) => UI_FILE.test(p) || p.includes('spec-dashboard/')
61
62
 
@@ -71,10 +72,15 @@ function changedSinceBase(root: string): Set<string> {
71
72
  return out
72
73
  }
73
74
 
74
- // a node is "changed" if the branch touched its node dir (spec.md / eval.md / sidecar all live there) or
75
- // any governed code pathmatched as an exact file, a directory prefix, or a `*` glob.
76
- export function nodeChanged(dirRel: string, codeFiles: string[], changed: Set<string>): boolean {
77
- for (const c of changed) if (c === dirRel || c.startsWith(dirRel + '/')) return true
75
+ // A node owns the files under its spec dir except those under a descendant node. Its other selection axis
76
+ // is the supplied code listnode code for per-node findings, effective scenario code for drift.
77
+ export function nodeChanged(dirRel: string, codeFiles: readonly string[], changed: Set<string>, nodeDirs: Iterable<string> = []): boolean {
78
+ const descendants = [...nodeDirs].filter((d) => d !== dirRel && d.startsWith(dirRel + '/'))
79
+ for (const c of changed) {
80
+ const inNodeDir = c === dirRel || c.startsWith(dirRel + '/')
81
+ const inDescendant = descendants.some((d) => c === d || c.startsWith(d + '/'))
82
+ if (inNodeDir && !inDescendant) return true
83
+ }
78
84
  return codeFiles.some((cf) => {
79
85
  if (changed.has(cf)) return true
80
86
  const dir = cf.replace(/\/+$/, '') + '/'
@@ -87,9 +93,9 @@ export function nodeChanged(dirRel: string, codeFiles: string[], changed: Set<st
87
93
  async function scan(args: string[] = []): Promise<number> {
88
94
  const root = repoRoot()
89
95
  const cfg = loadConfig(root)
90
- // eval-coverage fires on ANY governed source file (per the shared `sourceExtensions` knob), not just
91
- // frontend — so a backend/CLI/non-web project's sources are held to the loss discipline too, not exempted.
92
- const srcRe = sourceExtRe(cfg.sourceExtensions)
96
+ // eval-coverage fires on ANY governed source file per the same classifier as spec coverage, not just
97
+ // frontend — so backend/CLI/non-web source is held to the loss discipline too, not exempted.
98
+ const sourceFiles = new Set(trackedSourceFiles(root, cfg.governedRoots, cfg))
93
99
  const changedOnly = has(args, 'changed')
94
100
  const changed = changedOnly ? changedSinceBase(root) : null
95
101
  const idx = await driftIndex(root)
@@ -106,42 +112,51 @@ async function scan(args: string[] = []): Promise<number> {
106
112
  // lint's drift now fans to every owner; nobody's loss signal is suppressed. An over-owned file is lint's
107
113
  // `owners` concern (split it), not a reason to go silent here.
108
114
  const yByDir = new Map(evalNodes(root).map((n) => [relative(root, n.dir), n]))
115
+ const nodeDirs = specs.map((s) => dirname(s.path))
109
116
  let flaggedNodes = 0, malformed = 0, staleScores = 0, missingScores = 0, uncovered = 0, danglingTracks = 0
110
117
  for (const s of specs) {
111
118
  const dirRel = dirname(s.path)
112
- if (changed && !nodeChanged(dirRel, s.code, changed)) continue
119
+ const nodeSelected = !changed || nodeChanged(dirRel, s.code, changed, nodeDirs)
113
120
  const y = yByDir.get(dirRel)
114
121
  const findings: string[] = []
115
122
  if (y) {
116
123
  // schema first: a malformed eval.md is the loudest gap — report each violation, then still scan its
117
124
  // (leniently-parsed) scenarios for stale/missing so a typo doesn't mask a real freshness gap.
118
- for (const e of validateScenarios(readFileSync(join(y.dir, EVAL_FILE), 'utf8'), cfg.scenarioTags)) {
119
- malformed++
120
- findings.push(` • eval-schema: '${s.id}' ${e} — fix ${y.evalPath}`)
125
+ if (nodeSelected) {
126
+ for (const e of validateScenarios(readFileSync(join(y.dir, EVAL_FILE), 'utf8'), cfg.scenarioTags, root)) {
127
+ malformed++
128
+ findings.push(` • eval-schema: '${s.id}' ${e} — fix ${y.evalPath}`)
129
+ }
121
130
  }
122
131
  const latest = latestPerScenario(readReadings(y.sidecarPath))
123
132
  for (const sc of y.scenarios) {
124
133
  // a scenario's own `code` narrows its freshness CODE axis to a subset; a path that does not exist
125
134
  // would make that axis silently immortal (changedSince finds no commits for it), so flag it LOUD as a
126
135
  // malformed declaration — the same loud-fail spirit as a bad node `code:`.
127
- for (const [field, paths] of [['code', sc.code], ['related', sc.related]] as const) {
128
- const ghosts = (paths ?? []).filter((p) => !existsSync(join(root, p)))
129
- if (ghosts.length) {
130
- malformed++
131
- findings.push(` • eval-schema: '${s.id}' scenario '${sc.name}' \`${field}\` path(s) not found: ${ghosts.join(', ')} — fix ${y.evalPath}`)
136
+ 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)))
139
+ if (ghosts.length) {
140
+ malformed++
141
+ findings.push(` • eval-schema: '${s.id}' scenario '${sc.name}' \`${field}\` path(s) not found: ${ghosts.join(', ')} — fix ${y.evalPath}`)
142
+ }
132
143
  }
133
144
  }
134
145
  const codeFiles = sc.code?.length ? sc.code : s.code // scenario's own subset, else the node's list
146
+ const driftSelected = !changed || nodeChanged(dirRel, codeFiles, changed, nodeDirs)
135
147
  // carry the scenario's tags on the finding line — its SURFACE (e.g. frontend-e2e = browser-measured)
136
148
  // is what routes a drift/missing gap to the right measuring hand, so the proactive nudge and a human
137
149
  // reading `spex eval lint` both see whether this stale score needs a real e2e/browser pass to refresh.
138
150
  const tagStr = sc.tags?.length ? ` [${sc.tags.join(',')}]` : ''
139
151
  const r = latest.get(sc.name)
140
152
  if (!r) {
141
- missingScores++
142
- findings.push(` • eval-missing: '${s.id}' scenario '${sc.name}'${tagStr} has no eval yet — measure with \`spex eval add ${s.id}\``)
153
+ if (nodeSelected) {
154
+ missingScores++
155
+ findings.push(` • eval-missing: '${s.id}' scenario '${sc.name}'${tagStr} has no eval yet — measure with \`spex eval add ${s.id}\``)
156
+ }
143
157
  continue
144
158
  }
159
+ if (!driftSelected) continue
145
160
  const remSignals = (remarkTracks.get(trackKey(s.id, sc.name))?.remarks ?? []).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt }))
146
161
  const axes = staleAxes(r, codeFiles, y.evalPath, idx, scidx, remSignals, probe, sc)
147
162
  if (axes.length) {
@@ -163,12 +178,12 @@ async function scan(args: string[] = []): Promise<number> {
163
178
  // (`spex remark resolve`/`spex remark retract`), and they age nothing (there is no reading to stale).
164
179
  const declared = new Set(y.scenarios.map((sc) => sc.name))
165
180
  const orphans = [...remarkTracks.values()].filter((tr) => tr.node === s.id && tr.remarks.length && !declared.has(tr.scenario) && !latest.has(tr.scenario))
166
- if (orphans.length) {
181
+ if (nodeSelected && orphans.length) {
167
182
  danglingTracks += orphans.length
168
183
  const names = orphans.map((o) => `'${o.scenario}' (${o.threadId}, ${o.remarks.length} remark${o.remarks.length > 1 ? 's' : ''})`).join(', ')
169
184
  findings.push(` • eval-dangling: '${s.id}' has ${orphans.length} orphaned remark track(s) — scenario ${names} renamed/deleted; resolve/retract via \`spex remark resolve <ref>\` / \`spex remark retract <ref>\` or restore the scenario name`)
170
185
  }
171
- } else if (s.code.some((p) => srcRe.test(p))) {
186
+ } else if (nodeSelected && s.code.some((p) => sourceFiles.has(p))) {
172
187
  uncovered++
173
188
  findings.push(` • eval-coverage: '${s.id}' governs source code but has no eval.md — give it a scenario (description + expected) so its loss can be measured`)
174
189
  }
@@ -444,7 +459,7 @@ async function okCmd(args: string[]): Promise<number> {
444
459
  return 2
445
460
  }
446
461
  if (envSessionId()) {
447
- console.error('spex eval ok: refusing under a governed session — human-ok is the HUMAN\'s sign-off, never an agent\'s self-blessing. Ask the human to ok it from the dashboard or their own terminal; an agent\'s judgment on a reading is a remark (`spex remark add`).')
462
+ console.error('spex eval ok: refusing under a governed session — human-ok is the HUMAN\'s sign-off, never an agent\'s self-blessing. Ask the human to ok it from the dashboard or their own terminal; an agent\'s judgment on an eval is a remark (`spex remark add`).')
448
463
  return 1
449
464
  }
450
465
  const sel = positional(args)
@@ -463,10 +478,10 @@ async function okCmd(args: string[]): Promise<number> {
463
478
  const r = fileHumanOk(res.node.id, scenario, 'human')
464
479
  if (!r.ok) { console.error(`spex eval ok: ${r.error}`); return 1 }
465
480
  if (r.already) {
466
- console.log(`spex eval ok: '${res.node.id}' scenario '${scenario}' reading @ ${r.humanOk.okTs} is already human-ok'd (by ${r.humanOk.by}, ${r.humanOk.ts}) — monotonic, nothing appended`)
481
+ console.log(`spex eval ok: '${res.node.id}' scenario '${scenario}' eval @ ${r.humanOk.okTs} is already human-ok'd (by ${r.humanOk.by}, ${r.humanOk.ts}) — monotonic, nothing appended`)
467
482
  return 0
468
483
  }
469
- console.log(` ☑ '${res.node.id}' scenario '${scenario}' reading @ ${r.humanOk.okTs} (${r.humanOk.okSha.slice(0, 7)}) human-ok'd`)
484
+ console.log(` ☑ '${res.node.id}' scenario '${scenario}' eval @ ${r.humanOk.okTs} (${r.humanOk.okSha.slice(0, 7)}) human-ok'd`)
470
485
  console.log(r.landed === 'committed'
471
486
  ? 'spex eval ok: 1 sign-off filed (committed straight to the trunk)'
472
487
  : 'spex eval ok: 1 sign-off filed (an appended event — commit the sidecar so the sign-off lands)')
@@ -512,7 +527,7 @@ function checkStaged(): number {
512
527
  for (const rel of staged.filter((p) => p === EVAL_FILE || p.endsWith('/' + EVAL_FILE))) {
513
528
  const abs = join(root, rel)
514
529
  if (!existsSync(abs)) continue // staged deletion — nothing to validate
515
- const errs = validateScenarios(readFileSync(abs, 'utf8'), tagLibrary)
530
+ const errs = validateScenarios(readFileSync(abs, 'utf8'), tagLibrary, root)
516
531
  if (!errs.length) continue
517
532
  bad = true
518
533
  console.error(`✗ SpexCode eval: ${rel} — invalid scenario schema:`)
@@ -578,6 +593,34 @@ export function formatTimeline(tl: EvalTimeline): string {
578
593
  // (the collection view); a <node>/`.` scopes to one node. --unmeasured keeps only scenarios with NO
579
594
  // effective reading — never measured, or every filing retracted — the blind-spot worklist a measuring
580
595
  // hand picks from. Flag set closed, like every eval verb.
596
+ export type ScenarioListRow = {
597
+ node: string
598
+ scenario: string
599
+ tags?: string[]
600
+ test?: ScenarioTestReference
601
+ measured: boolean
602
+ latest?: { verdict?: Verdict; ts: string }
603
+ }
604
+
605
+ export function scenarioListRows(nodes: EvalNode[], unmeasuredOnly = false): ScenarioListRow[] {
606
+ const rows: ScenarioListRow[] = []
607
+ for (const n of nodes) {
608
+ const latest = latestPerScenario(readReadings(n.sidecarPath))
609
+ for (const sc of n.scenarios) {
610
+ const r = latest.get(sc.name)
611
+ if (unmeasuredOnly && r) continue
612
+ rows.push({
613
+ node: n.id, scenario: sc.name,
614
+ ...(sc.tags?.length ? { tags: sc.tags } : {}),
615
+ ...(sc.test ? { test: sc.test } : {}),
616
+ measured: !!r,
617
+ ...(r ? { latest: { ...(r.verdict ? { verdict: r.verdict } : {}), ts: r.ts } } : {}),
618
+ })
619
+ }
620
+ }
621
+ return rows
622
+ }
623
+
581
624
  async function scenarioLs(args: string[]): Promise<number> {
582
625
  for (const a of args) {
583
626
  if (!a.startsWith('--')) continue
@@ -598,21 +641,7 @@ async function scenarioLs(args: string[]): Promise<number> {
598
641
  nodes = [res.node]
599
642
  }
600
643
  const unmeasuredOnly = has(args, 'unmeasured')
601
- type Row = { node: string; scenario: string; tags?: string[]; measured: boolean; latest?: { verdict?: Verdict; ts: string } }
602
- const rows: Row[] = []
603
- for (const n of nodes) {
604
- const latest = latestPerScenario(readReadings(n.sidecarPath))
605
- for (const sc of n.scenarios) {
606
- const r = latest.get(sc.name)
607
- if (unmeasuredOnly && r) continue
608
- rows.push({
609
- node: n.id, scenario: sc.name,
610
- ...(sc.tags?.length ? { tags: sc.tags } : {}),
611
- measured: !!r,
612
- ...(r ? { latest: { ...(r.verdict ? { verdict: r.verdict } : {}), ts: r.ts } } : {}),
613
- })
614
- }
615
- }
644
+ const rows = scenarioListRows(nodes, unmeasuredOnly)
616
645
  if (has(args, 'json')) { console.log(JSON.stringify(rows, null, 2)); return 0 }
617
646
  if (!rows.length) {
618
647
  console.log(`spex eval scenario ls: ${unmeasuredOnly ? 'no unmeasured scenarios' : 'no scenarios declared'}${sel ? ` on '${nodes[0]?.id ?? sel}'` : ''}`)
@@ -642,12 +671,18 @@ export async function runEval(args: string[]): Promise<number> {
642
671
  if (sub === 'retract') return retractCmd(args.slice(1))
643
672
  if (sub === 'clean') return clean(args.slice(1))
644
673
  if (sub === 'ls') return show(args.slice(1))
674
+ if (sub === 'matrix') {
675
+ // the live-behavior matrix runner ([[live-matrix]]) — lazily imported so the heavy session machinery
676
+ // never loads for the plain filing/reading verbs.
677
+ const { runMatrix } = await import('./matrix.js')
678
+ return runMatrix(args.slice(1))
679
+ }
645
680
  if (sub === 'scenario') {
646
681
  if (args[1] === 'ls') return scenarioLs(args.slice(2))
647
682
  console.error('spex eval scenario: ls [<node>|.] [--unmeasured] [--json] — list declared scenarios (the measurement contracts)')
648
683
  return 2
649
684
  }
650
- 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] | lint [--changed] | ok <node> [--scenario <name>] | retract [.|<node>] [--scenario <name>] [--last | --ts <iso>] [--note <why>] | clean [--keep-latest|--all]')
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]')
651
686
  return 2
652
687
  }
653
688
 
@@ -2,7 +2,7 @@ import { relative, dirname } from 'node:path'
2
2
  import { repoRoot, driftIndex, historyIndex, 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 } from './scenarios.js'
5
+ import { evalNodes, 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
7
  import { staleAxes, codeDrift, contentProbeFor, type StaleAxis } from './freshness.js'
8
8
  import { scenarioIndex, type ScenarioIndex } from './scenariofresh.js'
@@ -91,7 +91,7 @@ function toRemarkView(rm: Reply, threadId: string, dangling: boolean): RemarkVie
91
91
  // at NODE level. `scenario` is the orphaned name (rendered struck-through / gone); `remarks` are all dangling.
92
92
  export type DanglingTrack = { scenario: string; threadId: string; thread: Issue; remarks: RemarkView[] }
93
93
 
94
- export type ScenarioInfo = { name: string; expected: string; tags?: string[]; code?: string[] }
94
+ export type ScenarioInfo = { name: string; expected: string; tags?: string[]; test?: ScenarioTestReference; code?: string[] }
95
95
 
96
96
  // `hasEvalFile` distinguishes a node that declares no scenarios (no eval.md) from one that declares some but
97
97
  // has no readings yet — the tab says different things for each. `scenarios` is the declared set; `readings`
@@ -164,7 +164,8 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
164
164
  const threadFor = (scenario: string): Issue | undefined => tracks.get(trackKey(id, scenario))?.thread
165
165
  const scenarios: ScenarioInfo[] = ynode.scenarios.map((s) => ({
166
166
  name: s.name, expected: s.expected,
167
- ...(s.tags?.length ? { tags: s.tags } : {}), ...(s.code?.length ? { code: s.code } : {}),
167
+ ...(s.tags?.length ? { tags: s.tags } : {}), ...(s.test ? { test: s.test } : {}),
168
+ ...(s.code?.length ? { code: s.code } : {}),
168
169
  }))
169
170
  // one raw sidecar read: the effective readings feed the scoreboard rows below; the retraction events ride
170
171
  // along as the undo trace (newest-first, like the readings), the human-ok events as the sign-off overlay.
@@ -30,7 +30,7 @@ export function fileHumanOk(nodeId: string, scenario: string, by: string): OkRes
30
30
  // the ok's one possible target: the latest EFFECTIVE reading — an ok is a judgment on a measurement that
31
31
  // exists and currently counts; an unmeasured (or fully-retracted) scenario has nothing to bless.
32
32
  const forScenario = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
33
- if (!forScenario.length) return { ok: false, error: `'${node.id}' scenario '${scenario}' has no effective reading — nothing to ok` }
33
+ if (!forScenario.length) return { ok: false, error: `'${node.id}' scenario '${scenario}' has no effective eval — nothing to ok` }
34
34
  const latest = forScenario[forScenario.length - 1]
35
35
  // a duplicate ok is idempotent success (the store already IS the requested state — the local-issue
36
36
  // close's `already` semantics), never an error and never a second appended row.