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.
- package/README.md +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +163 -30
- package/spec-cli/src/cli.ts +97 -42
- package/spec-cli/src/client.ts +34 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +31 -16
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +53 -36
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +157 -76
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +266 -67
- package/spec-cli/src/help.ts +84 -41
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +163 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +115 -169
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +162 -0
- package/spec-cli/src/sessions.ts +210 -63
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +47 -24
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
- package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +80 -45
- package/spec-eval/src/evaltab.ts +4 -3
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -398
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
- package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
- package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
- package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
- package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
- package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
- /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
|
+
}
|
package/spec-eval/src/cli.ts
CHANGED
|
@@ -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
|
|
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
|
|
57
|
-
// the
|
|
58
|
-
//
|
|
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
|
-
//
|
|
75
|
-
//
|
|
76
|
-
export function nodeChanged(dirRel: string, codeFiles: string[], changed: Set<string>): boolean {
|
|
77
|
-
|
|
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 list — node 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
|
|
91
|
-
// frontend — so
|
|
92
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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) =>
|
|
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
|
|
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}'
|
|
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}'
|
|
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
|
-
|
|
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
|
|
package/spec-eval/src/evaltab.ts
CHANGED
|
@@ -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.
|
|
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.
|
package/spec-eval/src/humanok.ts
CHANGED
|
@@ -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
|
|
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.
|