spexcode 0.6.0 → 0.6.1
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/package.json +1 -1
- package/spec-cli/src/cli.ts +68 -47
- package/spec-cli/src/client.ts +1 -1
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/git.ts +30 -8
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/harness.ts +11 -9
- package/spec-cli/src/help.ts +8 -13
- package/spec-cli/src/index.ts +8 -1
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +35 -7
- package/spec-cli/src/materialize.ts +150 -107
- package/spec-cli/src/plugin-harness.ts +19 -8
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +55 -11
- package/spec-cli/src/sessions.ts +166 -9
- package/spec-cli/src/uninstall.ts +2 -1
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
- package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
- package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
- package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
- package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +7 -8
- package/spec-eval/src/evaltab.ts +25 -5
- package/spec-eval/src/freshness.ts +52 -8
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +122 -30
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-eval/src/matrix.ts +0 -693
|
@@ -5,11 +5,12 @@ import { execFileSync } from 'node:child_process'
|
|
|
5
5
|
import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadConfig } from './specs.js'
|
|
6
6
|
import { compileManifest } from './hooks.js'
|
|
7
7
|
import { writeManagedBlock, removeManagedBlock, HARNESSES, type HarnessArtifacts } from './harness.js'
|
|
8
|
-
import { git } from './git.js'
|
|
9
|
-
import { runtimeRoot, treeSlotDir, mainCheckout, readConfig
|
|
8
|
+
import { git, gitBinary } from './git.js'
|
|
9
|
+
import { runtimeRoot, treeSlotDir, mainCheckout, readConfig } from './layout.js'
|
|
10
10
|
import { resolveHarnessTargets, partitionHarnesses } from './harness-select.js'
|
|
11
11
|
import { emitPlugin, cleanPlugin, pluginBundleDir, pluginVersion } from './plugin-harness.js'
|
|
12
|
-
import { plantContractFilter, removeContractFilter,
|
|
12
|
+
import { clearContractFilterPayload, contractFilterPlanted, plantContractFilter, removeContractFilter, settleIndexStat, type ContractFilterBinding, type ContractFilterPayload } from './contract-filter.js'
|
|
13
|
+
import { writeFileIfChanged } from './file-write.js'
|
|
13
14
|
|
|
14
15
|
export type MaterializedArtifact = {
|
|
15
16
|
kind: 'hook manifest' | 'contract' | 'shim' | 'skill' | 'agent' | 'plugin bundle' | 'trust'
|
|
@@ -37,7 +38,9 @@ const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
|
37
38
|
export function contentHash(proj: string): string {
|
|
38
39
|
try {
|
|
39
40
|
const harnessSh = join(PKG, 'hooks', 'harness.sh')
|
|
40
|
-
|
|
41
|
+
const gitDir = dirname(gitBinary(process.env))
|
|
42
|
+
const env = { ...process.env, PATH: `${gitDir}:${process.env.PATH || ''}` }
|
|
43
|
+
return execFileSync('bash', ['-c', `cd "${proj}" && . "${harnessSh}" && hp_config_hash`], { env }).toString().trim()
|
|
41
44
|
} catch { return '' }
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -61,34 +64,22 @@ function infoExcludePath(proj: string): string {
|
|
|
61
64
|
function isTracked(proj: string, file: string): boolean {
|
|
62
65
|
try { git(['-C', proj, 'ls-files', '--error-unmatch', file]); return true } catch { return false }
|
|
63
66
|
}
|
|
67
|
+
function clearSkipWorktree(proj: string, file: string): void {
|
|
68
|
+
if (!isTracked(proj, file)) return
|
|
69
|
+
try { git(['-C', proj, 'update-index', '--no-skip-worktree', file]) } catch {}
|
|
70
|
+
}
|
|
64
71
|
|
|
65
72
|
function registeredTrees(proj: string): string[] {
|
|
66
73
|
const rows = git(['-C', mainCheckout(proj), 'worktree', 'list', '--porcelain', '-z']).split('\0')
|
|
67
74
|
return rows.filter((row) => row.startsWith('worktree ')).map((row) => row.slice('worktree '.length))
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
const TREE_IGNORE_RECEIPT = 'tree-ignore-v1'
|
|
71
|
-
|
|
72
|
-
function hasLegacyTreeIgnore(proj: string): boolean {
|
|
73
|
-
return registeredTrees(proj).some((tree) => {
|
|
74
|
-
const slot = join(runtimeRoot(proj), 'trees', encodeProject(tree))
|
|
75
|
-
return existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, TREE_IGNORE_RECEIPT))
|
|
76
|
-
})
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function managedExcludeEntries(file: string): string[] {
|
|
80
|
-
if (!existsSync(file)) return []
|
|
81
|
-
const lines = readFileSync(file, 'utf8').split('\n')
|
|
82
|
-
const start = lines.indexOf('# spexcode:start')
|
|
83
|
-
const end = lines.indexOf('# spexcode:end', start + 1)
|
|
84
|
-
return start >= 0 && end > start ? lines.slice(start + 1, end).filter(Boolean) : []
|
|
85
|
-
}
|
|
86
|
-
|
|
87
77
|
function selectionBody(selected: typeof HARNESSES, plugin = false): string {
|
|
88
78
|
return [...new Set([...selected.map((h) => h.dispatchId), ...(plugin ? ['plugin'] : [])])].sort().join('\n') + '\n'
|
|
89
79
|
}
|
|
90
80
|
|
|
91
81
|
function publishSelection(path: string, body: string): void {
|
|
82
|
+
try { if (readFileSync(path, 'utf8') === body) return } catch (error: any) { if (error?.code !== 'ENOENT') throw error }
|
|
92
83
|
const prepared = `${path}.${process.pid}.tmp`
|
|
93
84
|
writeFileSync(prepared, body)
|
|
94
85
|
renameSync(prepared, path)
|
|
@@ -108,46 +99,96 @@ function hostContentOf(file: string): string {
|
|
|
108
99
|
if (!existsSync(file)) return ''
|
|
109
100
|
return stripSpexcodeBlock(readFileSync(file, 'utf8'))
|
|
110
101
|
}
|
|
111
|
-
// clear a legacy skip-worktree bit (the retired private-overlay mechanism; erase-only now — nothing asserts
|
|
112
|
-
// it). Best-effort: an index race or a non-repo must not fail the materialize.
|
|
113
|
-
function clearSkipWorktree(proj: string, file: string): void {
|
|
114
|
-
if (!isTracked(proj, file)) return
|
|
115
|
-
try { git(['-C', proj, 'update-index', '--no-skip-worktree', file]) } catch { /* best-effort */ }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
102
|
// the identity stamp on every generated skill/agent file — what lets the erase phase forget a product whose
|
|
119
103
|
// NODE was renamed or deleted (the name-scoped sweep can only reconstruct paths the LIVE config still names).
|
|
120
104
|
export const GENERATED_MARK = '<!-- spexcode:generated -->'
|
|
121
|
-
function
|
|
122
|
-
if (!dir || !existsSync(dir)) return
|
|
105
|
+
function pruneGeneratedSkills(dir: string | null, keep: ReadonlySet<string>): boolean {
|
|
106
|
+
if (!dir || !existsSync(dir)) return false
|
|
107
|
+
let changed = false
|
|
123
108
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
124
109
|
if (!e.isDirectory()) continue
|
|
125
110
|
const f = join(dir, e.name, 'SKILL.md')
|
|
126
|
-
try {
|
|
111
|
+
try {
|
|
112
|
+
if (!keep.has(e.name) && existsSync(f) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) {
|
|
113
|
+
rmSync(join(dir, e.name), { recursive: true, force: true })
|
|
114
|
+
changed = true
|
|
115
|
+
}
|
|
116
|
+
} catch { /* unreadable → not provably ours */ }
|
|
127
117
|
}
|
|
118
|
+
return changed
|
|
128
119
|
}
|
|
129
|
-
function
|
|
130
|
-
if (!dir || !existsSync(dir)) return
|
|
120
|
+
function pruneGeneratedAgents(dir: string | null, keep: ReadonlySet<string>): boolean {
|
|
121
|
+
if (!dir || !existsSync(dir)) return false
|
|
122
|
+
let changed = false
|
|
131
123
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
132
124
|
if (!e.isFile() || !e.name.endsWith('.md')) continue
|
|
133
125
|
const f = join(dir, e.name)
|
|
134
|
-
try {
|
|
126
|
+
try {
|
|
127
|
+
if (!keep.has(e.name.slice(0, -3)) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) {
|
|
128
|
+
rmSync(f, { force: true })
|
|
129
|
+
changed = true
|
|
130
|
+
}
|
|
131
|
+
} catch { /* unreadable → not provably ours */ }
|
|
132
|
+
}
|
|
133
|
+
return changed
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type TreeTargets = {
|
|
137
|
+
contracts: ReadonlySet<string>
|
|
138
|
+
treeShims: ReadonlySet<string>
|
|
139
|
+
anchors: ReadonlySet<string>
|
|
140
|
+
skills: ReadonlyMap<string, ReadonlySet<string>>
|
|
141
|
+
agents: ReadonlyMap<string, ReadonlySet<string>>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function cleanupHarnessDirs(proj: string): void {
|
|
145
|
+
const roots = new Set([proj, mainCheckout(proj)])
|
|
146
|
+
const dirs = HARNESSES.flatMap((h) => {
|
|
147
|
+
const anchor = h.worktreeHookAnchor(proj)
|
|
148
|
+
return [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null]
|
|
149
|
+
}).filter((d): d is string => !!d)
|
|
150
|
+
for (const d of [...new Set([...dirs, ...dirs.map((dir) => dirname(dir))])]
|
|
151
|
+
.filter((dir) => !roots.has(dir)).sort((a, b) => b.length - a.length)) {
|
|
152
|
+
try { rmdirSync(d) } catch {}
|
|
135
153
|
}
|
|
136
154
|
}
|
|
137
155
|
|
|
156
|
+
function reconcileTree(proj: string, targets: TreeTargets, tracked: (file: string) => boolean): void {
|
|
157
|
+
let removed = false
|
|
158
|
+
const contractFiles = new Set(HARNESSES.flatMap((h) => h.contractFiles(proj)))
|
|
159
|
+
for (const file of contractFiles) {
|
|
160
|
+
if (targets.contracts.has(file) || !existsSync(file)) continue
|
|
161
|
+
const text = readFileSync(file, 'utf8')
|
|
162
|
+
if (!text.includes('<!-- spexcode:start -->')) continue
|
|
163
|
+
removeManagedBlock(file, ['<!-- ', ' -->'], !tracked(file))
|
|
164
|
+
removed = true
|
|
165
|
+
}
|
|
166
|
+
const treeShims = new Set(HARNESSES.filter((h) => h.shimScope === 'tree').map((h) => h.shimFile(proj)))
|
|
167
|
+
const anchors = new Set(HARNESSES.map((h) => h.worktreeHookAnchor(proj)).filter((path): path is string => !!path))
|
|
168
|
+
for (const file of [...treeShims, ...anchors]) {
|
|
169
|
+
if (targets.treeShims.has(file) || targets.anchors.has(file) || !existsSync(file)) continue
|
|
170
|
+
if (readFileSync(file, 'utf8').includes('dispatch.sh')) { rmSync(file, { force: true }); removed = true }
|
|
171
|
+
}
|
|
172
|
+
for (const dir of new Set(HARNESSES.map((h) => h.skillDir(proj)).filter((path): path is string => !!path)))
|
|
173
|
+
removed = pruneGeneratedSkills(dir, targets.skills.get(dir) ?? new Set()) || removed
|
|
174
|
+
for (const dir of new Set(HARNESSES.map((h) => h.agentDir(proj)).filter((path): path is string => !!path)))
|
|
175
|
+
removed = pruneGeneratedAgents(dir, targets.agents.get(dir) ?? new Set()) || removed
|
|
176
|
+
if (removed) cleanupHarnessDirs(proj)
|
|
177
|
+
}
|
|
178
|
+
|
|
138
179
|
function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolean): void {
|
|
139
180
|
for (const h of HARNESSES) {
|
|
140
181
|
// h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
|
|
141
182
|
// stamped shim + worktree anchor, the trust block, and the arts-named skill/agent files.
|
|
142
183
|
h.clean(proj, arts, preserveProject)
|
|
143
|
-
for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f)
|
|
144
|
-
|
|
145
|
-
|
|
184
|
+
for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f)
|
|
185
|
+
pruneGeneratedSkills(h.skillDir(proj), new Set())
|
|
186
|
+
pruneGeneratedAgents(h.agentDir(proj), new Set())
|
|
146
187
|
}
|
|
147
188
|
// same authorship rule as the contract files: deleteIfEmpty only when .gitignore is UNTRACKED (wholly-ours
|
|
148
189
|
// generated file); a HOST-TRACKED .gitignore that carried nothing but our block is stripped, never deleted.
|
|
149
190
|
removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], !isTracked(proj, '.gitignore'))
|
|
150
|
-
|
|
191
|
+
clearContractFilterPayload(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
|
|
151
192
|
// the block-strip left tracked contract files stat-dirty (under a filter git NEVER content-verifies them,
|
|
152
193
|
// and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
|
|
153
194
|
// unstaged edit is never staged ([[content-filter]] edge 2).
|
|
@@ -157,16 +198,7 @@ function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolea
|
|
|
157
198
|
// because a harness may nest its shim a level below its home (opencode's .opencode/plugins/, pi's
|
|
158
199
|
// .pi/extensions/) — but never the checkout roots themselves. rmdirSync is NON-recursive, so a dir holding
|
|
159
200
|
// any user file survives untouched; `.git/spexcode/` is deliberately NOT swept (shared per-clone home).
|
|
160
|
-
|
|
161
|
-
const anchor = h.worktreeHookAnchor(proj)
|
|
162
|
-
const dirs = [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null]
|
|
163
|
-
.filter((d): d is string => !!d)
|
|
164
|
-
const roots = new Set([proj, mainCheckout(proj)])
|
|
165
|
-
const sweep = [...new Set([...dirs, ...dirs.map((d) => dirname(d))])]
|
|
166
|
-
.filter((d) => !roots.has(d))
|
|
167
|
-
.sort((a, b) => b.length - a.length)
|
|
168
|
-
for (const d of sweep) { try { rmdirSync(d) } catch { /* non-empty or absent — keep */ } }
|
|
169
|
-
}
|
|
201
|
+
cleanupHarnessDirs(proj)
|
|
170
202
|
}
|
|
171
203
|
|
|
172
204
|
export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
|
|
@@ -177,7 +209,7 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
|
|
|
177
209
|
git(['-C', tree, 'rev-parse', '--show-toplevel'])
|
|
178
210
|
}
|
|
179
211
|
for (const tree of trees) {
|
|
180
|
-
// Only the caller's live spec may widen the
|
|
212
|
+
// Only the caller's live spec may widen the name sweep. Siblings are identity-stamp-only: the
|
|
181
213
|
// same name there may be user-owned or may not exist in this tree's divergent spec at all.
|
|
182
214
|
eraseTree(tree, tree === current ? arts : { skills: [], agents: [] }, false)
|
|
183
215
|
}
|
|
@@ -196,7 +228,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
196
228
|
}
|
|
197
229
|
// (1) hook manifest (persistent — the dispatcher reads it; regenerated only here, on change).
|
|
198
230
|
const manifest = join(rt, 'hooks-manifest')
|
|
199
|
-
|
|
231
|
+
writeFileIfChanged(manifest, compileManifest())
|
|
200
232
|
record('hook manifest', manifest)
|
|
201
233
|
// (2) the contract = the surface:system plugin bodies (in name order), written WHOLE into EACH harness's
|
|
202
234
|
// contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
|
|
@@ -213,14 +245,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
213
245
|
const skillNodes = loadSkillConfig()
|
|
214
246
|
const agentNodes = loadAgentConfig()
|
|
215
247
|
const commandNodes = loadConfig()
|
|
216
|
-
const arts: HarnessArtifacts = { skills: skillNodes.map((s) => s.name), agents: agentNodes.map((a) => a.name) }
|
|
217
248
|
|
|
218
|
-
// ---- ERASE (the forgetting law): every landing point cleared by identity stamp, whatever policy — or
|
|
219
|
-
// legacy mode — wrote it last. Unselected harnesses need no separate prune branch: the erase already
|
|
220
|
-
// forgot them, and only the selected ones are asserted below.
|
|
221
|
-
eraseTree(proj, arts, true)
|
|
222
|
-
|
|
223
|
-
// ---- ASSERT: rewrite each landing point per the CURRENT policy.
|
|
224
249
|
// a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
|
|
225
250
|
// over the body instructions, closed by the GENERATED_MARK identity stamp (what the erase phase keys on).
|
|
226
251
|
// One pure artifact builder shared by every harness — divergence is only its skillDir.
|
|
@@ -239,53 +264,80 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
239
264
|
// because their residence is the live three-state kind detection below, not a static entry.
|
|
240
265
|
const artifactPaths: string[] = []
|
|
241
266
|
const machinePaths: string[] = []
|
|
242
|
-
const
|
|
267
|
+
const contractTargets = new Map<string, string>()
|
|
268
|
+
const treeShimTargets = new Map<string, string>()
|
|
269
|
+
const anchorTargets = new Map<string, string>()
|
|
270
|
+
const skillTargets = new Map<string, string>()
|
|
271
|
+
const agentTargets = new Map<string, string>()
|
|
272
|
+
const skillsByDir = new Map<string, Set<string>>()
|
|
273
|
+
const agentsByDir = new Map<string, Set<string>>()
|
|
274
|
+
const addTarget = (targets: Map<string, string>, path: string, content: string) => {
|
|
275
|
+
const prior = targets.get(path)
|
|
276
|
+
if (prior !== undefined && prior !== content) throw new Error(`conflicting materialize targets for ${path}`)
|
|
277
|
+
targets.set(path, content)
|
|
278
|
+
}
|
|
243
279
|
for (const h of selected) {
|
|
244
|
-
if (contract) for (const f of h.contractFiles(proj))
|
|
280
|
+
if (contract) for (const f of h.contractFiles(proj)) addTarget(contractTargets, f, contract)
|
|
245
281
|
const shim = h.shim(DISPATCH, SPEX)
|
|
246
282
|
if (h.shimScope === 'tree') {
|
|
247
|
-
|
|
248
|
-
mkdirSync(dirname(shimFile), { recursive: true })
|
|
249
|
-
writeFileSync(shimFile, shim.content)
|
|
250
|
-
record('shim', shimFile)
|
|
251
|
-
machinePaths.push(shimFile)
|
|
283
|
+
addTarget(treeShimTargets, h.shimFile(proj), shim.content)
|
|
252
284
|
}
|
|
253
285
|
// a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
|
|
254
286
|
// checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
|
|
255
287
|
const anchor = h.worktreeHookAnchor(proj)
|
|
256
|
-
if (anchor)
|
|
288
|
+
if (anchor) addTarget(anchorTargets, anchor, shim.content)
|
|
289
|
+
}
|
|
290
|
+
for (const sk of skillNodes) for (const h of selected) {
|
|
291
|
+
const dir = h.skillDir(proj); if (!dir) continue
|
|
292
|
+
addTarget(skillTargets, join(dir, sk.name, 'SKILL.md'), skillArtifact(sk))
|
|
293
|
+
const names = skillsByDir.get(dir) ?? new Set<string>(); names.add(sk.name); skillsByDir.set(dir, names)
|
|
294
|
+
}
|
|
295
|
+
for (const ag of agentNodes) for (const h of selected) {
|
|
296
|
+
const dir = h.agentDir(proj); if (!dir) continue
|
|
297
|
+
addTarget(agentTargets, join(dir, `${ag.name}.md`), agentArtifact(ag))
|
|
298
|
+
const names = agentsByDir.get(dir) ?? new Set<string>(); names.add(ag.name); agentsByDir.set(dir, names)
|
|
299
|
+
}
|
|
300
|
+
const tracked = new Map<string, boolean>()
|
|
301
|
+
const isTrackedHere = (file: string) => {
|
|
302
|
+
const known = tracked.get(file)
|
|
303
|
+
if (known !== undefined) return known
|
|
304
|
+
const value = isTracked(proj, file); tracked.set(file, value); return value
|
|
305
|
+
}
|
|
306
|
+
reconcileTree(proj, {
|
|
307
|
+
contracts: new Set(contractTargets.keys()), treeShims: new Set(treeShimTargets.keys()), anchors: new Set(anchorTargets.keys()),
|
|
308
|
+
skills: skillsByDir, agents: agentsByDir,
|
|
309
|
+
}, isTrackedHere)
|
|
310
|
+
const changedMaterialized = new Set<string>()
|
|
311
|
+
for (const [file, content] of contractTargets) {
|
|
312
|
+
if (writeManagedBlock(file, content)) changedMaterialized.add(file)
|
|
313
|
+
record('contract', file)
|
|
314
|
+
}
|
|
315
|
+
const contractPaths = [...contractTargets.keys()]
|
|
316
|
+
for (const [file, content] of treeShimTargets) {
|
|
317
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
|
|
318
|
+
record('shim', file); machinePaths.push(file)
|
|
319
|
+
}
|
|
320
|
+
for (const [file, content] of anchorTargets) {
|
|
321
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
|
|
322
|
+
record('shim', file); machinePaths.push(file)
|
|
257
323
|
}
|
|
258
324
|
const selectedByDispatch = new Map(selected.map((h) => [h.dispatchId, h]))
|
|
259
325
|
for (const h of selectedByDispatch.values()) {
|
|
260
326
|
const shim = h.shim(DISPATCH, SPEX)
|
|
261
327
|
if (h.shimScope === 'project') {
|
|
262
328
|
const file = h.shimFile(proj)
|
|
263
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
329
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, shim.content)
|
|
264
330
|
record('shim', file)
|
|
265
331
|
}
|
|
266
332
|
for (const file of h.writeTrust(proj, shim.cmd)) record('trust', file)
|
|
267
333
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
for (const h of selected) {
|
|
272
|
-
const dir = h.skillDir(proj); if (!dir) continue
|
|
273
|
-
const f = join(dir, sk.name, 'SKILL.md')
|
|
274
|
-
mkdirSync(dirname(f), { recursive: true })
|
|
275
|
-
writeFileSync(f, skillArtifact(sk))
|
|
276
|
-
artifactPaths.push(f)
|
|
277
|
-
record('skill', f)
|
|
278
|
-
}
|
|
334
|
+
for (const [file, content] of skillTargets) {
|
|
335
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
|
|
336
|
+
artifactPaths.push(file); record('skill', file)
|
|
279
337
|
}
|
|
280
|
-
for (const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const f = join(dir, `${ag.name}.md`)
|
|
284
|
-
mkdirSync(dirname(f), { recursive: true })
|
|
285
|
-
writeFileSync(f, agentArtifact(ag))
|
|
286
|
-
artifactPaths.push(f)
|
|
287
|
-
record('agent', f)
|
|
288
|
-
}
|
|
338
|
+
for (const [file, content] of agentTargets) {
|
|
339
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
|
|
340
|
+
artifactPaths.push(file); record('agent', file)
|
|
289
341
|
}
|
|
290
342
|
// (8) the PLUGIN target ([[plugin-harness]]): materialize the whole system into one self-contained Claude-plugin
|
|
291
343
|
// bundle per selected folder. A plugin is EXCLUSIVE (`selected` is empty then). Pruning a DESELECTED
|
|
@@ -294,11 +346,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
294
346
|
// global store records the folders emitted last run; any prev folder absent from the current set is
|
|
295
347
|
// clean()ed, then the current folders are emitted and the ledger rewritten.
|
|
296
348
|
const ledger = join(rt, 'plugin-folders')
|
|
297
|
-
|
|
298
|
-
// the prev set so a deselected folder is still pruned; every write lands in the slot from here on.
|
|
299
|
-
const legacyLedger = join(runtimeRoot(proj), 'plugin-folders')
|
|
300
|
-
const ledgerSrc = existsSync(ledger) ? ledger : legacyLedger
|
|
301
|
-
const prevFolders = existsSync(ledgerSrc) ? readFileSync(ledgerSrc, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : []
|
|
349
|
+
const prevFolders = existsSync(ledger) ? readFileSync(ledger, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : []
|
|
302
350
|
const curFolders = plugins.map((p) => p.folder)
|
|
303
351
|
for (const f of prevFolders) if (!curFolders.includes(f)) cleanPlugin(proj, f)
|
|
304
352
|
if (plugins.length) {
|
|
@@ -315,7 +363,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
315
363
|
record('plugin bundle', pluginBundleDir(proj, p.folder))
|
|
316
364
|
}
|
|
317
365
|
}
|
|
318
|
-
|
|
366
|
+
writeFileIfChanged(ledger, curFolders.join('\n'))
|
|
319
367
|
// (9) ignore + mixed text. Only checkout-invariant residue and project-shared shims belong in the COMMON
|
|
320
368
|
// info/exclude; selection-dependent paths live in this tree's filtered working .gitignore.
|
|
321
369
|
const mc = mainCheckout(proj)
|
|
@@ -327,43 +375,38 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
327
375
|
'spexcode.local.json', '.worktrees/', '.session',
|
|
328
376
|
]
|
|
329
377
|
const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
|
|
330
|
-
const priorCommonEntries = managedExcludeEntries(infoExcludePath(proj))
|
|
331
|
-
|
|
332
378
|
// Contract residence stays a live fact. Selection-dependent untracked products are ignored by this tree's
|
|
333
379
|
// working .gitignore, whose own managed block is filtered when the host tracks/owns that file.
|
|
334
380
|
const filterContracts: string[] = []
|
|
335
381
|
const oursContracts: string[] = []
|
|
336
382
|
for (const f of contractPaths) {
|
|
337
|
-
if (
|
|
383
|
+
if (isTrackedHere(f) || hostContentOf(f).trim()) filterContracts.push(f)
|
|
338
384
|
else oursContracts.push(f)
|
|
339
385
|
}
|
|
340
386
|
const localEntries = [...machinePaths, ...bundlePaths, ...artifactPaths, ...oursContracts]
|
|
341
387
|
.map((p) => relative(proj, p)).filter((p) => !p.startsWith('..'))
|
|
342
388
|
const ignoreFile = join(proj, '.gitignore')
|
|
343
|
-
const ignoreTracked =
|
|
389
|
+
const ignoreTracked = isTrackedHere(ignoreFile)
|
|
344
390
|
const ignoreHost = existsSync(ignoreFile) ? readFileSync(ignoreFile, 'utf8') : ''
|
|
345
391
|
if (!ignoreTracked && !ignoreHost.trim()) localEntries.push('.gitignore')
|
|
346
392
|
const ignoreBody = entries(localEntries)
|
|
347
|
-
writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])
|
|
393
|
+
if (writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])) changedMaterialized.add(ignoreFile)
|
|
348
394
|
|
|
349
395
|
const payloads: ContractFilterPayload[] = filterContracts.map((file) => ({ file: relative(proj, file), content: contract }))
|
|
350
396
|
if (ignoreTracked || ignoreHost.trim()) payloads.push({ file: '.gitignore', content: ignoreBody })
|
|
351
397
|
const bindings: ContractFilterBinding[] = [
|
|
352
398
|
...[...new Set(HARNESSES.flatMap((h) => h.contractFiles(proj).map((file) => relative(proj, file))))]
|
|
353
|
-
.map((file) => ({ file, start: '<!-- spexcode:start -->', end: '<!-- spexcode:end -->'
|
|
399
|
+
.map((file) => ({ file, start: '<!-- spexcode:start -->', end: '<!-- spexcode:end -->' })),
|
|
354
400
|
{ file: '.gitignore', start: '# spexcode:start', end: '# spexcode:end' },
|
|
355
401
|
]
|
|
356
|
-
if (payloads.length) plantContractFilter(proj, payloads, bindings)
|
|
357
|
-
|
|
402
|
+
if (payloads.length) plantContractFilter(proj, payloads, bindings, [...changedMaterialized])
|
|
403
|
+
else if (contractFilterPlanted(proj)) removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
|
|
404
|
+
// (5) finish diagnostics, then atomically publish the allowlist LAST. Dispatch consumes only that
|
|
358
405
|
// final receipt; a killed writer leaves the preceding successful selection intact.
|
|
359
406
|
const h = contentHash(proj)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
writeFileSync(join(runtimeRoot(proj), 'harness-selection-v1'), '')
|
|
364
|
-
retireLegacyContractBlock(proj)
|
|
365
|
-
const legacyEntries = hasLegacyTreeIgnore(proj) ? priorCommonEntries : []
|
|
366
|
-
writeManagedBlock(infoExcludePath(proj), entries([...commonEntries, ...legacyEntries]), ['# ', ''])
|
|
407
|
+
writeFileIfChanged(join(rt, 'content-hash'), h)
|
|
408
|
+
writeFileIfChanged(join(runtimeRoot(proj), 'harness-selection-v1'), '')
|
|
409
|
+
writeManagedBlock(infoExcludePath(proj), entries(commonEntries), ['# ', ''])
|
|
367
410
|
publishSelection(join(rt, 'harnesses'), selectionBody(selected, plugins.length > 0))
|
|
368
411
|
return { contentHash: h, planted }
|
|
369
412
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdirSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs'
|
|
2
2
|
import { join, dirname } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { copyFileIfChanged, writeFileIfChanged } from './file-write.js'
|
|
4
5
|
|
|
5
6
|
// @@@ plugin-harness - the PLUGIN BUNDLE emitter: materialize the whole SpexCode system into ONE self-contained
|
|
6
7
|
// Claude-plugin bundle dropped into the host-agent-scanned folder [[harness-select]] resolved (e.g. `.zcode` /
|
|
@@ -103,23 +104,33 @@ export function emitPlugin(proj: string, folder: string, r: PluginBundle): void
|
|
|
103
104
|
const hooksDir = join(bundle, 'hooks')
|
|
104
105
|
mkdirSync(meta, { recursive: true })
|
|
105
106
|
mkdirSync(hooksDir, { recursive: true })
|
|
106
|
-
|
|
107
|
+
writeFileIfChanged(join(meta, 'plugin.json'), pluginManifest(r.version))
|
|
107
108
|
// hooks: the SHARED dispatcher + its shell mirror (copied verbatim — the exact native wiring), the contract
|
|
108
109
|
// injector + its pre-encoded payload, and the event→dispatch binding.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
copyFileIfChanged(join(HOOKS_SRC, 'dispatch.sh'), join(hooksDir, 'dispatch.sh'))
|
|
111
|
+
copyFileIfChanged(join(HOOKS_SRC, 'harness.sh'), join(hooksDir, 'harness.sh'))
|
|
112
|
+
writeFileIfChanged(join(hooksDir, 'inject-contract.sh'), INJECT_SH)
|
|
113
|
+
writeFileIfChanged(join(hooksDir, 'contract-context.json'), contractContextJson(r.contract))
|
|
114
|
+
writeFileIfChanged(join(hooksDir, 'hooks.json'), pluginHooksJson(r.spex))
|
|
114
115
|
// skills / agents / commands — the Claude-plugin layout, the SAME materialized contents as the native dirs.
|
|
116
|
+
reconcileBundleDirectory(join(bundle, 'skills'), new Set(r.skills.map((s) => s.name)))
|
|
117
|
+
reconcileBundleDirectory(join(bundle, 'agents'), new Set(r.agents.map((a) => `${a.name}.md`)))
|
|
118
|
+
reconcileBundleDirectory(join(bundle, 'commands'), new Set(r.commands.map((c) => `${c.name}.md`)))
|
|
115
119
|
for (const s of r.skills) writeBundleFile(join(bundle, 'skills', s.name, 'SKILL.md'), s.content)
|
|
116
120
|
for (const a of r.agents) writeBundleFile(join(bundle, 'agents', `${a.name}.md`), a.content)
|
|
117
121
|
for (const c of r.commands) writeBundleFile(join(bundle, 'commands', `${c.name}.md`), c.content)
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
function reconcileBundleDirectory(dir: string, expectedNames: ReadonlySet<string>): void {
|
|
125
|
+
if (!existsSync(dir)) return
|
|
126
|
+
for (const entry of readdirSync(dir)) {
|
|
127
|
+
if (!expectedNames.has(entry)) rmSync(join(dir, entry), { recursive: true, force: true })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
120
131
|
function writeBundleFile(f: string, content: string): void {
|
|
121
132
|
mkdirSync(dirname(f), { recursive: true })
|
|
122
|
-
|
|
133
|
+
writeFileIfChanged(f, content)
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
// the INVERSE of emitPlugin — prune the bundle when its folder is DESELECTED ([[harness-delivery]] tracks the
|
|
@@ -142,6 +142,19 @@ export function verifyDetachedRuntime(pid: number, receiptFile: string, adapter:
|
|
|
142
142
|
return live
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// The inverse question of verifyDetachedRuntime: not "is the recorded process running right now" but "is it
|
|
146
|
+
// provably GONE". The two are not complements — a missing receipt, an unreadable identity, or a PID we never
|
|
147
|
+
// recorded answers neither, and a caller that treats "unproven" as "dead" would retire a live runtime it can
|
|
148
|
+
// simply no longer address. Only a recorded start identity that the live PID no longer matches (including a
|
|
149
|
+
// PID that is not running at all) is proof of death.
|
|
150
|
+
export function detachedRuntimeIsGone(pid: number, receiptFile: string, adapter: ProcessAdapter = hostProcessAdapter): boolean {
|
|
151
|
+
let receipt: DetachedLaunchReceiptV4 | null
|
|
152
|
+
try { receipt = parseDetachedLaunchReceipt(readFileSync(receiptFile, 'utf8'), adapter.platform) }
|
|
153
|
+
catch { return false }
|
|
154
|
+
if (!receipt || receipt.pid !== pid) return false
|
|
155
|
+
return adapter.startToken(pid) !== receipt.startToken
|
|
156
|
+
}
|
|
157
|
+
|
|
145
158
|
// A v3 scope is only a migration witness when every recorded and live Linux identity agrees. Readers never
|
|
146
159
|
// call this: minting a v4 receipt belongs to the write admission path that needs the shared runtime.
|
|
147
160
|
export function migrateLegacyDetachedRuntimeReceipt(
|
package/spec-cli/src/reviews.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { listSessions } from './sessions.js'
|
|
3
3
|
import { getBoard, getBoardForForgeRevision } from './graphCache.js'
|
|
4
|
-
import { buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
|
|
4
|
+
import { type SessionEvalOrderRow, buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
|
|
5
5
|
import { evalTimeline } from '../../spec-eval/src/evaltab.js'
|
|
6
6
|
import { issuesEnabled as issuesEnabledForReview } from './localIssues.js'
|
|
7
7
|
import { issueStores as issueStoresForReview } from './issues.js'
|
|
@@ -217,15 +217,26 @@ export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
|
|
|
217
217
|
|
|
218
218
|
const evalItemKey = (item: any): string => `${String(item?.node ?? '')}\0${String(item?.scenario ?? '')}`
|
|
219
219
|
|
|
220
|
-
function evalNeighbor(item: any): EvalNeighbor {
|
|
220
|
+
function evalNeighbor(item: any, stateOf: (row: { node: string; scenario: string }) => string): EvalNeighbor {
|
|
221
221
|
return {
|
|
222
222
|
node: String(item.node),
|
|
223
223
|
scenario: String(item.scenario),
|
|
224
|
-
state:
|
|
224
|
+
state: stateOf(item),
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
|
|
228
|
+
// `sequence` is the whole measured population in list order; `stateOf` answers only for rows whose freshness
|
|
229
|
+
// was actually computed. On a focused build those are two different sets — the sequence spans the scope, the
|
|
230
|
+
// states cover the selected row and its window — which is exactly why the state is looked up rather than
|
|
231
|
+
// carried: a row this response does not render contributes its POSITION and nothing else.
|
|
232
|
+
export function boundedEvalNeighbors(
|
|
233
|
+
sequence: { node: string; scenario: string }[],
|
|
234
|
+
node: string,
|
|
235
|
+
scenario: string,
|
|
236
|
+
stateOf: (row: { node: string; scenario: string }) => string,
|
|
237
|
+
want = 5,
|
|
238
|
+
) {
|
|
239
|
+
const items = sequence
|
|
229
240
|
const key = `${node}\0${scenario}`
|
|
230
241
|
const index = items.findIndex((item) => evalItemKey(item) === key)
|
|
231
242
|
if (index < 0) return { prev: [], next: [], total: items.length, index: null, order: 'default' as const }
|
|
@@ -235,8 +246,8 @@ export function boundedEvalNeighbors(items: ReviewItem[], node: string, scenario
|
|
|
235
246
|
const nextN = Math.min(after, Math.max(Math.ceil(take / 2), take - before))
|
|
236
247
|
const prevN = Math.min(before, take - nextN)
|
|
237
248
|
return {
|
|
238
|
-
prev: items.slice(index - prevN, index).reverse().map(evalNeighbor),
|
|
239
|
-
next: items.slice(index + 1, index + 1 + nextN).map(evalNeighbor),
|
|
249
|
+
prev: items.slice(index - prevN, index).reverse().map((item) => evalNeighbor(item, stateOf)),
|
|
250
|
+
next: items.slice(index + 1, index + 1 + nextN).map((item) => evalNeighbor(item, stateOf)),
|
|
240
251
|
total: items.length,
|
|
241
252
|
index,
|
|
242
253
|
order: 'default' as const,
|
|
@@ -248,12 +259,22 @@ export function projectEvalDetail(
|
|
|
248
259
|
historySource: ReviewItem[],
|
|
249
260
|
node: string,
|
|
250
261
|
scenario: string,
|
|
251
|
-
metadata: {
|
|
262
|
+
metadata: {
|
|
263
|
+
scope?: string | null
|
|
264
|
+
summary?: SessionEvals['summary']
|
|
265
|
+
evalRevision?: SessionEvals['evalRevision']
|
|
266
|
+
// the scope's whole measured sequence, when `items` deliberately holds only the rendered window
|
|
267
|
+
sequence?: { node: string; scenario: string }[]
|
|
268
|
+
} = {},
|
|
252
269
|
): EvalDetailReview {
|
|
253
270
|
const results = items.filter((item: any) => item.filterKind === EVAL_FILTER_KIND.RESULT)
|
|
254
271
|
const selected = results.find((item) => evalItemKey(item) === `${node}\0${scenario}`) ?? null
|
|
255
272
|
const history = historySource.filter((reading: any) => String(reading.scenario) === scenario)
|
|
256
|
-
const
|
|
273
|
+
const stateByKey = new Map(results.map((item: any) => [evalItemKey(item), String(item.state ?? evalReviewState(item))]))
|
|
274
|
+
const sequence = metadata.sequence
|
|
275
|
+
?? results.map((item: any) => ({ node: String(item.node), scenario: String(item.scenario) }))
|
|
276
|
+
const neighbors = boundedEvalNeighbors(sequence, node, scenario,
|
|
277
|
+
(row) => stateByKey.get(evalItemKey(row)) ?? 'empty')
|
|
257
278
|
const scope = metadata.scope ?? null
|
|
258
279
|
return {
|
|
259
280
|
scope,
|
|
@@ -266,15 +287,38 @@ export function projectEvalDetail(
|
|
|
266
287
|
}
|
|
267
288
|
}
|
|
268
289
|
|
|
290
|
+
// the measured population in list order, from the freshness-free rows: a filed reading leads (newest first),
|
|
291
|
+
// and the tie-breaks are the identity ones — the SAME comparison `byNewest` applies, over the only fields it
|
|
292
|
+
// actually reads. Blind rows never enter, exactly as the detail's own `results` filter excludes them.
|
|
293
|
+
export function measuredSequence(order: SessionEvalOrderRow[]): { node: string; scenario: string }[] {
|
|
294
|
+
return order.filter((row) => row.ts)
|
|
295
|
+
.sort((a, b) => String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
|
|
296
|
+
|| a.node.localeCompare(b.node) || a.scenario.localeCompare(b.scenario))
|
|
297
|
+
.map((row) => ({ node: row.node, scenario: row.scenario }))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// the nodes whose verdicts the response will publish: the selected row's, plus a window wide enough to
|
|
301
|
+
// contain any neighbour boundedEvalNeighbors can choose (it takes at most five, split around the index).
|
|
302
|
+
export function focusNodes(order: SessionEvalOrderRow[], node: string, scenario: string): string[] {
|
|
303
|
+
const sequence = measuredSequence(order)
|
|
304
|
+
const index = sequence.findIndex((row) => row.node === node && row.scenario === scenario)
|
|
305
|
+
if (index < 0) return [node]
|
|
306
|
+
return [...new Set([node, ...sequence.slice(Math.max(0, index - 6), index + 7).map((row) => row.node)])]
|
|
307
|
+
}
|
|
308
|
+
|
|
269
309
|
export async function evalDetailReview(node: string, scenario: string, scope?: string | null): Promise<EvalDetailReview | null> {
|
|
270
310
|
if (scope) {
|
|
271
|
-
|
|
311
|
+
// A detail renders ONE row plus at most five neighbours, but owes the whole population's index/total.
|
|
312
|
+
// So name the window from the freshness-free sequence and let only those nodes pay the freshness pass;
|
|
313
|
+
// a build that finds a full cached model ignores the pick and answers from it instead.
|
|
314
|
+
const model = await buildSessionEvals(scope, (order) => focusNodes(order, node, scenario))
|
|
272
315
|
if (!model) return null
|
|
273
316
|
const sourceNode = model.nodes.find((candidate) => candidate.id === node)
|
|
274
317
|
return projectEvalDetail(scopedEvalReviewItems(model), sourceNode?.evals ?? [], node, scenario, {
|
|
275
318
|
scope,
|
|
276
319
|
summary: model.summary,
|
|
277
320
|
evalRevision: model.evalRevision,
|
|
321
|
+
...(model.order ? { sequence: measuredSequence(model.order) } : {}),
|
|
278
322
|
})
|
|
279
323
|
}
|
|
280
324
|
await getBoard()
|
|
@@ -335,13 +379,13 @@ export async function evalsReview(query: string | undefined, requestedPage: unkn
|
|
|
335
379
|
const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
|
|
336
380
|
return {
|
|
337
381
|
scope,
|
|
338
|
-
gates: model.gates,
|
|
382
|
+
gates: model.gates ?? [],
|
|
339
383
|
unknown: model.nodes.reduce((count, node) => count + (node.unknownCoverage?.length ?? 0), 0),
|
|
340
384
|
summary: model.summary,
|
|
341
385
|
evalRevision: model.evalRevision,
|
|
342
386
|
impact: model.impact,
|
|
343
387
|
...paginateReview(items, filtered.shown, filtered, requestedPage, {
|
|
344
|
-
domain: 'evals', scope, items, gates: model.gates, summary: model.summary,
|
|
388
|
+
domain: 'evals', scope, items, gates: model.gates ?? [], summary: model.summary,
|
|
345
389
|
evalRevision: model.evalRevision, impact: model.impact, sessions: sessions.map((session) => session.id),
|
|
346
390
|
}),
|
|
347
391
|
}
|