spexcode 0.6.1 → 0.6.2
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 +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +171 -28
- package/spec-cli/src/client.ts +112 -13
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +477 -101
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +1 -1
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +344 -66
- package/spec-cli/src/help.ts +22 -10
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +101 -13
- package/spec-cli/src/layout.ts +116 -33
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +10 -5
- package/spec-cli/src/plugin-harness.ts +10 -10
- package/spec-cli/src/reviews.ts +57 -28
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +584 -104
- package/spec-cli/src/uninstall.ts +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-eval/src/cli.ts +6 -1
- package/spec-eval/src/evaltab.ts +7 -5
- package/spec-eval/src/freshness.ts +383 -104
- package/spec-eval/src/scenariofresh.ts +2 -2
- package/spec-eval/src/sessioneval.ts +126 -132
- package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
- package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
- package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
- package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
package/spec-cli/src/graph.ts
CHANGED
|
@@ -24,6 +24,8 @@ function resolveParent(path: string, byDir: Record<string, string>): string | nu
|
|
|
24
24
|
return null
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
const OVERLAY_TO_PATH = Symbol('overlay-to-path')
|
|
28
|
+
|
|
27
29
|
// The server-only review snapshot keeps latest readings verbatim. Graph JSON receives only counts.
|
|
28
30
|
export function latestPerScenario<T extends { scenario: string }>(readings: T[]): T[] {
|
|
29
31
|
const seen = new Set<string>()
|
|
@@ -51,12 +53,17 @@ const rowOps = (s: { path: string; archived?: boolean }, opsByPath: Record<strin
|
|
|
51
53
|
(s.archived ? [] : opsByPath[s.path] || [])
|
|
52
54
|
|
|
53
55
|
export async function buildBoard() {
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// OVERLAY), and listSessions takes its liveness from ONE batched tmux snapshot. Nothing here re-walks git.
|
|
56
|
+
// loadSpecs starts alongside the public session census and REUSES the HEAD-keyed spec-history cache. Layout
|
|
57
|
+
// then receives that census's projected-active ids so an archived runtime hazard contributes the same ops to
|
|
58
|
+
// the full producer that spliceSessions preserves. Its per-worktree cache still recomputes only changed deltas.
|
|
58
59
|
const root = repoRoot()
|
|
59
|
-
const
|
|
60
|
+
const sessionsPromise = listSessions()
|
|
61
|
+
const specsPromise = loadSpecs()
|
|
62
|
+
const sessions = await sessionsPromise
|
|
63
|
+
const [layout, specs] = await Promise.all([
|
|
64
|
+
resolveLayout({ activeSessionIds: sessions.map((session) => session.id) }),
|
|
65
|
+
specsPromise,
|
|
66
|
+
])
|
|
60
67
|
// the eval fold's freshness axes: WARM hits — loadSpecs already computed this HEAD's drift + history
|
|
61
68
|
// indices, so these are the same cached walks, fetched once and reused for every measurable node (the history
|
|
62
69
|
// index drives the rename-safe scenario axis, mirroring a spec node's own freshness).
|
|
@@ -88,6 +95,7 @@ export async function buildBoard() {
|
|
|
88
95
|
op: op.op, source, label, branch: w.branch, seed,
|
|
89
96
|
committed: op.committed, dirty: op.dirty,
|
|
90
97
|
toParent: op.op === 'moved' ? resolveParent(op.toPath || op.path, byDir) : null,
|
|
98
|
+
[OVERLAY_TO_PATH]: op.op === 'moved' ? op.toPath || op.path : null,
|
|
91
99
|
}
|
|
92
100
|
if (op.op === 'added' && !byId[op.nodeId]) {
|
|
93
101
|
if (ghostById[op.nodeId]) { ghostById[op.nodeId].overlays.push(ov); continue }
|
|
@@ -191,6 +199,7 @@ export async function buildBoard() {
|
|
|
191
199
|
export async function spliceSessions(prev: Awaited<ReturnType<typeof buildBoard>>): Promise<Awaited<ReturnType<typeof buildBoard>>> {
|
|
192
200
|
const sessions = await listSessions()
|
|
193
201
|
const evalProjections = sessionEvalProjections(sessions)
|
|
202
|
+
const activeSources = new Set(sessions.map((session) => session.path))
|
|
194
203
|
const opsByPath: Record<string, any[]> = {}
|
|
195
204
|
for (const s of prev.sessions) opsByPath[s.source] = s.ops
|
|
196
205
|
const sess = sessions.map((s) => ({
|
|
@@ -199,7 +208,48 @@ export async function spliceSessions(prev: Awaited<ReturnType<typeof buildBoard>
|
|
|
199
208
|
ops: rowOps(s, opsByPath),
|
|
200
209
|
evalSummary: evalProjections.get(s.id),
|
|
201
210
|
}))
|
|
202
|
-
|
|
211
|
+
// Archive and close are subtractive topology changes: their worktree leaves the working set, so its
|
|
212
|
+
// overlays must leave in the same cheap publication as its row. Filtering the already-built units is exact
|
|
213
|
+
// and forks nothing. A newly-active source is intentionally absent here; graphCache keeps a full obligation
|
|
214
|
+
// for additions/resume so resolveLayout can discover that worktree's current delta.
|
|
215
|
+
let nodeProjectionMoved = false
|
|
216
|
+
const projectedNodes = prev.nodes.flatMap((node: any) => {
|
|
217
|
+
const overlays = (node.overlays || []).filter((overlay: any) => activeSources.has(overlay.source))
|
|
218
|
+
if (node.ghost && overlays.length === 0) { nodeProjectionMoved = true; return [] }
|
|
219
|
+
if (overlays.length === (node.overlays || []).length) return [node]
|
|
220
|
+
nodeProjectionMoved = true
|
|
221
|
+
if (node.ghost) return [{ ...node, overlays, status: deriveStatus({ version: 0, drift: 0, hasOverlay: true }) }]
|
|
222
|
+
return [{ ...node, overlays, status: deriveStatus({
|
|
223
|
+
version: node.version,
|
|
224
|
+
drift: node.drift,
|
|
225
|
+
hasOverlay: overlays.length > 0,
|
|
226
|
+
hasCode: (node.code?.length ?? 0) > 0,
|
|
227
|
+
fmStatus: node.fmStatus ?? undefined,
|
|
228
|
+
}) }]
|
|
229
|
+
})
|
|
230
|
+
const nodes = nodeProjectionMoved ? projectedNodes : prev.nodes
|
|
231
|
+
if (nodeProjectionMoved) {
|
|
232
|
+
const byDir: Record<string, string> = {}
|
|
233
|
+
for (const node of nodes) if (node.path) byDir[node.path.replace(/\/spec\.md$/, '')] = node.id
|
|
234
|
+
const retainedIds = new Set(nodes.map((node: any) => node.id))
|
|
235
|
+
for (let index = 0; index < nodes.length; index++) {
|
|
236
|
+
const node: any = nodes[index]
|
|
237
|
+
let changed = false
|
|
238
|
+
let parent = node.parent
|
|
239
|
+
if (node.ghost) {
|
|
240
|
+
const next = resolveParent(node.path, byDir)
|
|
241
|
+
if (next !== parent) { parent = next; changed = true }
|
|
242
|
+
}
|
|
243
|
+
const overlays = (node.overlays || []).map((overlay: any) => {
|
|
244
|
+
if (!overlay.toParent || retainedIds.has(overlay.toParent)) return overlay
|
|
245
|
+
const toPath = overlay[OVERLAY_TO_PATH]
|
|
246
|
+
changed = true
|
|
247
|
+
return { ...overlay, toParent: typeof toPath === 'string' ? resolveParent(toPath, byDir) : null }
|
|
248
|
+
})
|
|
249
|
+
if (changed) nodes[index] = { ...node, parent, overlays }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { ...prev, nodes, sessions: sess }
|
|
203
253
|
}
|
|
204
254
|
|
|
205
255
|
// A full producer may finish after the session lane has already shown a newer row. Reuse that published
|
|
@@ -27,9 +27,10 @@ type BoardInputRevision = {
|
|
|
27
27
|
projections: string
|
|
28
28
|
combined: string
|
|
29
29
|
fullParts: Record<string, string>
|
|
30
|
+
worktreeParts: Record<string, string>
|
|
30
31
|
projectionIds: string[]
|
|
31
32
|
}
|
|
32
|
-
type SessionInputRevision = Pick<BoardInputRevision, 'sessions' | 'projections' | 'projectionIds'>
|
|
33
|
+
type SessionInputRevision = Pick<BoardInputRevision, 'sessions' | 'projections' | 'projectionIds'> & { activeRoots: string[] }
|
|
33
34
|
const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1'
|
|
34
35
|
|
|
35
36
|
function textOrNull(path: string): string | null {
|
|
@@ -144,28 +145,38 @@ function digest(value: unknown): string {
|
|
|
144
145
|
// The hot/warm liveness signatures and eval generations remain graph-stream's canonical event-owned axes.
|
|
145
146
|
function sessionInputRevision(): SessionInputRevision {
|
|
146
147
|
const ids = listSessionIds().sort()
|
|
147
|
-
// listSessions projects
|
|
148
|
-
// board row. Fold both exact artifacts so a missed store event cannot leave a stale
|
|
148
|
+
// listSessions projects the structured record plus a one-line preview of the separately-stored originating
|
|
149
|
+
// prompt into each board row. Fold both exact artifacts so a missed store event cannot leave a stale
|
|
150
|
+
// label/preview forever — the prompt file is folded because the preview is derived from it.
|
|
149
151
|
const sessionInputs = ids.map((id) => [
|
|
150
152
|
id,
|
|
151
153
|
textOrNull(sessionRecordPath(id)),
|
|
152
154
|
textOrNull(sessionArtifactPath(id, 'prompt')),
|
|
153
155
|
] as const)
|
|
154
156
|
const projections = digest(ids.map((id) => [id, sessionEvalProjection(id)]))
|
|
155
|
-
|
|
157
|
+
const activeRoots = [...new Set(ids.flatMap((id) => {
|
|
158
|
+
const entry = readPublicRecordEntry(id)
|
|
159
|
+
return entry.kind === 'ok' && entry.raw.governed && !entry.raw.archived ? [entry.raw.worktree_path] : []
|
|
160
|
+
}))].sort()
|
|
161
|
+
return { sessions: digest(sessionInputs), projections, projectionIds: ids, activeRoots }
|
|
156
162
|
}
|
|
157
163
|
|
|
158
164
|
function boardInputRevision(board: Board | null): BoardInputRevision {
|
|
159
165
|
const root = repoRoot()
|
|
160
166
|
const session = sessionInputRevision()
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
// Durable active records are the current root set; a cached ordinary row may be stale and must not replace
|
|
168
|
+
// them. The one projection-only addition is explicit: listSessions republishes an archived-runtime hazard
|
|
169
|
+
// into the working set, and that marked row keeps its root monitored until cold state is repaired.
|
|
170
|
+
const activeRoots = [...new Set([
|
|
171
|
+
...session.activeRoots,
|
|
172
|
+
...(board?.sessions.flatMap((row) => row.archiveHazard ? [row.path] : []) ?? []),
|
|
173
|
+
])].sort()
|
|
164
174
|
const main = mainCheckout()
|
|
165
175
|
const base = mainBranch()
|
|
166
176
|
const mainTip = refSha(main, base)
|
|
167
177
|
const nodeIds = (board?.nodes ?? []).map((node) => node.id)
|
|
168
178
|
const issuesStamp = boardThreads({ host: resolveForgeHost(), state: residentForgeState() }, nodeIds).stamp
|
|
179
|
+
const worktreeParts = Object.fromEntries(activeRoots.map((worktree) => [worktree, digest(worktreeRevision(worktree))]))
|
|
169
180
|
const fullInputs = {
|
|
170
181
|
root: worktreeRevision(root),
|
|
171
182
|
config: [
|
|
@@ -175,7 +186,7 @@ function boardInputRevision(board: Board | null): BoardInputRevision {
|
|
|
175
186
|
[join(main, 'spexcode.local.json'), textOrNull(join(main, 'spexcode.local.json'))],
|
|
176
187
|
],
|
|
177
188
|
main: { root: main, branch: base, tip: mainTip },
|
|
178
|
-
worktrees:
|
|
189
|
+
worktrees: worktreeParts,
|
|
179
190
|
issuesStamp,
|
|
180
191
|
identity: resolveProjectIdentity(root, root),
|
|
181
192
|
}
|
|
@@ -186,6 +197,7 @@ function boardInputRevision(board: Board | null): BoardInputRevision {
|
|
|
186
197
|
...session,
|
|
187
198
|
combined: digest([full, session.sessions, session.projections]),
|
|
188
199
|
fullParts,
|
|
200
|
+
worktreeParts,
|
|
189
201
|
}
|
|
190
202
|
}
|
|
191
203
|
|
|
@@ -204,13 +216,19 @@ function revisionCarriedByBoard(sample: BoardInputRevision, board: Board): Board
|
|
|
204
216
|
projections,
|
|
205
217
|
combined: digest([full, sample.sessions, projections]),
|
|
206
218
|
fullParts,
|
|
219
|
+
worktreeParts: sample.worktreeParts,
|
|
207
220
|
projectionIds: sample.projectionIds,
|
|
208
221
|
}
|
|
209
222
|
}
|
|
210
223
|
|
|
211
224
|
// A sessions splice reuses its base topology. It may sample fresh record/projection inputs, but it must never
|
|
212
225
|
// certify that those old nodes carry a full revision sampled after the base was built.
|
|
213
|
-
function revisionCarriedBySessionSplice(
|
|
226
|
+
function revisionCarriedBySessionSplice(
|
|
227
|
+
base: BoardInputRevision,
|
|
228
|
+
sample: Pick<SessionInputRevision, 'sessions' | 'projections' | 'projectionIds'>,
|
|
229
|
+
board: Board,
|
|
230
|
+
stable: boolean,
|
|
231
|
+
): BoardInputRevision {
|
|
214
232
|
const boardProjections = new Map(board.sessions.map((session) => [session.id, session.evalSummary ?? null]))
|
|
215
233
|
const projections = stable
|
|
216
234
|
? digest(sample.projectionIds.map((id) => [id, boardProjections.get(id) ?? null]))
|
|
@@ -221,6 +239,7 @@ function revisionCarriedBySessionSplice(base: BoardInputRevision, sample: Sessio
|
|
|
221
239
|
projections,
|
|
222
240
|
combined: digest([base.full, sample.sessions, projections]),
|
|
223
241
|
fullParts: base.fullParts,
|
|
242
|
+
worktreeParts: base.worktreeParts,
|
|
224
243
|
projectionIds: sample.projectionIds,
|
|
225
244
|
}
|
|
226
245
|
}
|
|
@@ -236,10 +255,40 @@ function revisionCarriedByPublishedSessionRebase(full: BoardInputRevision, publi
|
|
|
236
255
|
projections: published.projections,
|
|
237
256
|
combined: digest([full.full, published.sessions, published.projections]),
|
|
238
257
|
fullParts: full.fullParts,
|
|
258
|
+
worktreeParts: full.worktreeParts,
|
|
239
259
|
projectionIds: published.projectionIds,
|
|
240
260
|
}
|
|
241
261
|
}
|
|
242
262
|
|
|
263
|
+
function carrySubtractiveWorktrees(base: BoardInputRevision, activeRoots: readonly string[]): { revision: BoardInputRevision; added: boolean } {
|
|
264
|
+
const active = new Set(activeRoots)
|
|
265
|
+
const known = Object.keys(base.worktreeParts)
|
|
266
|
+
if (activeRoots.some((root) => !(root in base.worktreeParts))) return { revision: base, added: true }
|
|
267
|
+
if (known.every((root) => active.has(root))) return { revision: base, added: false }
|
|
268
|
+
const worktreeParts = Object.fromEntries(Object.entries(base.worktreeParts).filter(([root]) => active.has(root)))
|
|
269
|
+
const fullParts = { ...base.fullParts, worktrees: digest(worktreeParts) }
|
|
270
|
+
const full = digest(fullParts)
|
|
271
|
+
return {
|
|
272
|
+
revision: {
|
|
273
|
+
...base,
|
|
274
|
+
full,
|
|
275
|
+
fullParts,
|
|
276
|
+
worktreeParts,
|
|
277
|
+
combined: digest([full, base.sessions, base.projections]),
|
|
278
|
+
},
|
|
279
|
+
added: false,
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isPureSubtractiveWorktreeMove(base: BoardInputRevision, sample: BoardInputRevision): boolean {
|
|
284
|
+
const moved = Object.keys(sample.fullParts).filter((key) => sample.fullParts[key] !== base.fullParts[key])
|
|
285
|
+
if (moved.length !== 1 || moved[0] !== 'worktrees') return false
|
|
286
|
+
const current = Object.keys(sample.worktreeParts)
|
|
287
|
+
const previous = Object.keys(base.worktreeParts)
|
|
288
|
+
return current.length < previous.length
|
|
289
|
+
&& current.every((root) => sample.worktreeParts[root] === base.worktreeParts[root])
|
|
290
|
+
}
|
|
291
|
+
|
|
243
292
|
// a build slower than this is LOGGED, never silently tolerated — the fail-loud regression alarm. Sized
|
|
244
293
|
// above a warm build (~sub-second once the fs walks yield) but below the cold two-walk first build, so a
|
|
245
294
|
// genuinely-degraded hot path shouts while an ordinary cold start stays quiet-ish.
|
|
@@ -319,11 +368,16 @@ function startSessionSplice(): Flight | null {
|
|
|
319
368
|
const board = await spliceSessions(base)
|
|
320
369
|
const after = sessionInputRevision()
|
|
321
370
|
if (base !== cached || revision !== cachedRevision || generation !== topologyGeneration) continue
|
|
322
|
-
const stable = before.sessions === after.sessions && before.projections === after.projections
|
|
371
|
+
const stable = before.sessions === after.sessions && before.projections === after.projections &&
|
|
372
|
+
JSON.stringify(before.activeRoots) === JSON.stringify(after.activeRoots)
|
|
373
|
+
const projectedRoots = [...new Set(board.sessions.map((row) => row.path))].sort()
|
|
374
|
+
const rootTransition = carrySubtractiveWorktrees(revision, projectedRoots)
|
|
375
|
+
const carried = stable ? rootTransition.revision : revision
|
|
323
376
|
cached = board
|
|
324
377
|
cachedJson = null
|
|
325
|
-
cachedRevision = revisionCarriedBySessionSplice(
|
|
378
|
+
cachedRevision = revisionCarriedBySessionSplice(carried, before, board, stable)
|
|
326
379
|
sessionProjectionPublication++
|
|
380
|
+
if (rootTransition.added) mergeDirty('full')
|
|
327
381
|
if (!stable) {
|
|
328
382
|
sessionOwed = true
|
|
329
383
|
sessionGeneration++
|
|
@@ -383,6 +437,7 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
|
|
|
383
437
|
let buildSessionGeneration = sessionGeneration
|
|
384
438
|
let buildSessionProjectionPublication = sessionProjectionPublication
|
|
385
439
|
let buildStartedWithSessionOwed = false
|
|
440
|
+
let buildAddedSessionRoot = false
|
|
386
441
|
let completedRevision: BoardInputRevision | null = null
|
|
387
442
|
// Do not invoke the producer inline. buildBoard() has an asynchronous signature but performs a sizeable
|
|
388
443
|
// synchronous setup before its first await (Promise.all evaluates its arguments immediately). A stale HTTP
|
|
@@ -420,21 +475,23 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
|
|
|
420
475
|
// the watcher event was about; an invalidation arriving after the sample keeps its own dirty window.
|
|
421
476
|
if (prev && anchor && gen === sampledGen) {
|
|
422
477
|
const fullMoved = before.full !== anchor.full
|
|
478
|
+
const subtractiveWorktreeMove = fullMoved && isPureSubtractiveWorktreeMove(anchor, before)
|
|
479
|
+
const structuralMoved = fullMoved && !subtractiveWorktreeMove
|
|
423
480
|
const projectionMoved = before.sessions !== anchor.sessions || before.projections !== anchor.projections
|
|
424
481
|
// A projection move nobody signalled is this validation doing the patrol's repair job.
|
|
425
|
-
if (projectionMoved && !sessionOwed && dirty !== 'sessions') {
|
|
482
|
+
if ((projectionMoved || subtractiveWorktreeMove) && !sessionOwed && dirty !== 'sessions') {
|
|
426
483
|
gen++
|
|
427
484
|
sessionOwed = true
|
|
428
485
|
sessionGeneration++
|
|
429
486
|
}
|
|
430
|
-
const owed =
|
|
487
|
+
const owed = structuralMoved ? 'full' : (sessionOwed || dirty === 'sessions') ? 'sessions' : 'none'
|
|
431
488
|
if (DEBUG && owed !== 'none') {
|
|
432
489
|
const moved = Object.keys(before.fullParts).filter((key) => before.fullParts[key] !== anchor.fullParts[key])
|
|
433
490
|
console.warn(`spec-cli: graph refresh revision moved — signalled=${dirty} scope=${owed} inputs=[${moved.join(', ')}]`)
|
|
434
491
|
} else if (DEBUG && dirty !== 'none') {
|
|
435
|
-
console.warn(`spec-cli: graph refresh discharged a ${dirty} signal — no
|
|
492
|
+
console.warn(`spec-cli: graph refresh discharged a ${dirty} signal — no graph input moved`)
|
|
436
493
|
}
|
|
437
|
-
if (
|
|
494
|
+
if (structuralMoved) dirty = 'full'
|
|
438
495
|
else if (sessionOwed || dirty === 'sessions') dirty = 'sessions'
|
|
439
496
|
// Discharging CONSUMES the claim. Leaving it standing would make every later read re-enter this
|
|
440
497
|
// validation, hold the board permanently stale/refreshing, and never converge.
|
|
@@ -474,7 +531,19 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
|
|
|
474
531
|
// against a revision this board never carried, and the cache would converge on nothing. So a moved
|
|
475
532
|
// half anchors on `before` — the conservative direction, which costs at most one extra rebuild and can
|
|
476
533
|
// never certify unread bytes.
|
|
477
|
-
|
|
534
|
+
if (sessionsOnly && anchor) {
|
|
535
|
+
const projectedRoots = [...new Set(board.sessions.map((row) => row.path))].sort()
|
|
536
|
+
const rootTransition = carrySubtractiveWorktrees(anchor, projectedRoots)
|
|
537
|
+
buildAddedSessionRoot = rootTransition.added
|
|
538
|
+
completedRevision = revisionCarriedBySessionSplice(
|
|
539
|
+
rootTransition.revision,
|
|
540
|
+
before,
|
|
541
|
+
board,
|
|
542
|
+
buildFullStable && buildSessionsStable && !rootTransition.added,
|
|
543
|
+
)
|
|
544
|
+
} else {
|
|
545
|
+
completedRevision = revisionCarriedByBoard(buildFullStable && buildSessionsStable ? after : before, board)
|
|
546
|
+
}
|
|
478
547
|
return board
|
|
479
548
|
}))
|
|
480
549
|
.then(resolveBuild, rejectBuild)
|
|
@@ -510,7 +579,7 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
|
|
|
510
579
|
cachedRevision = completedRevision
|
|
511
580
|
if (buildScope === 'full') topologyGeneration++
|
|
512
581
|
else sessionProjectionPublication++
|
|
513
|
-
if (!buildFullStable) mergeDirty('full')
|
|
582
|
+
if (!buildFullStable || buildAddedSessionRoot) mergeDirty('full')
|
|
514
583
|
traceCacheCommit(buildScope, buildStartedAt)
|
|
515
584
|
}
|
|
516
585
|
retryAt = 0
|
|
@@ -504,7 +504,7 @@ const REFS_OBSERVER = 'graph:refs'
|
|
|
504
504
|
|
|
505
505
|
// @@@ the moved ref NAMES its scope - the watcher has always known which ref moved and threw it away, so
|
|
506
506
|
// every ref movement anywhere invalidated every session's evaluation. On a host carrying dozens of branches
|
|
507
|
-
// and a bot that commits continuously that means nothing is ever warm: observed on
|
|
507
|
+
// and a bot that commits continuously that means nothing is ever warm: observed on adopter-a as an input
|
|
508
508
|
// generation of 1208 against a last-known 254. A session's fingerprint reads exactly three refs — its own
|
|
509
509
|
// tip, the base tip, and their merge-base — so a ref that is neither cannot move it. `packed-refs` and
|
|
510
510
|
// `HEAD` stay broad on purpose: a packed update rewrites many refs behind ONE event, so it names nothing.
|
package/spec-cli/src/guide.ts
CHANGED
|
@@ -14,7 +14,7 @@ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
|
|
|
14
14
|
startup commands.)
|
|
15
15
|
|
|
16
16
|
2. Adopt a repo
|
|
17
|
-
cd <your-repo> && spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless,codex-headless # seeds .spec/ + git hooks (additive, never overwrites)
|
|
17
|
+
cd <your-repo> && spex init --harness claude,codex,opencode,pi,zcode,claude-headless,opencode-headless,pi-headless,codex-headless # seeds .spec/ + git hooks (additive, never overwrites)
|
|
18
18
|
--harness is required and has no default — the explicit choice of which harness(es) materialize
|
|
19
19
|
delivers into. The example lists every built-in; drop the ones you don't use (any one id or
|
|
20
20
|
comma-separated subset is valid).
|
|
@@ -101,6 +101,13 @@ code does it. Two optional level-2 headings split ground truth from detail:
|
|
|
101
101
|
Bodies without those headings are read whole. Link sibling nodes with [[node-id]] — every link must name
|
|
102
102
|
a REAL node (lint's mention rule; backtick a placeholder like \`[[node]]\` so it reads as sample text).
|
|
103
103
|
|
|
104
|
+
COMMENT ALTITUDE: specs own intent, invariants, policy, and observable contracts; comments only navigate
|
|
105
|
+
non-obvious local decisions. Put contract or intent in the owning spec body, not a code comment. Keep a short
|
|
106
|
+
nearby comment only for ordering, platform behavior, measured pitfalls, or why a plausible alternative is
|
|
107
|
+
unsafe; delete commentary that merely translates code or repeats a name/type. Preserve measured values,
|
|
108
|
+
version-specific behavior, and rejected alternatives not in the spec. \`@@@title - explanation\` is reserved for
|
|
109
|
+
genuinely tricky surviving local reasoning.
|
|
110
|
+
|
|
104
111
|
WHAT lint CHECKS (spex spec lint; the pre-commit hook gates on errors):
|
|
105
112
|
integrity (error) every code:/related: path exists — and every anchor RESOLVES: a dead anchor (unit
|
|
106
113
|
deleted/renamed), an ambiguous one (two same-named units in one file), a file that
|
|
@@ -144,7 +151,10 @@ Heuristic spec health is deliberately outside this production gate. Bare \`spex
|
|
|
144
151
|
altitude and breadth findings; the tidy workflow consumes that report and adds semantic judgment.
|
|
145
152
|
|
|
146
153
|
LIFECYCLE: author each node on a node/<id> branch, one node per commit; \`spex spec lint\` must reach 0 errors
|
|
147
|
-
before merge. \`spex init\` seeds the first tree; \`spex guide eval\` covers the sibling eval.md, the measurement file
|
|
154
|
+
before merge. \`spex init\` seeds the first tree; \`spex guide eval\` covers the sibling eval.md, the measurement file.
|
|
155
|
+
|
|
156
|
+
SHARED LANDING: if the shared checkout is mid-merge, wait. Never abort or resolve someone else's merge; if your
|
|
157
|
+
own landing stops half-merged, abort it and report.`
|
|
148
158
|
|
|
149
159
|
const EVAL = `spex guide eval — the eval.md file format
|
|
150
160
|
|
|
@@ -187,6 +197,13 @@ pre-commit \`internal check-staged\` BLOCKS the commit.
|
|
|
187
197
|
BODY (after the frontmatter): prose naming the measurement method — YATU ("You As The User"): the agent
|
|
188
198
|
looks at / calls the real product surface, not an internal helper chosen to make the evidence easy.
|
|
189
199
|
|
|
200
|
+
REPAIR PROOF: for a bug fix, use one scenario's fail→pass pair. A, BEFORE EDITING: find the violated scenario
|
|
201
|
+
or add one to \`eval.md\`, run it against the old committed behavior, and file
|
|
202
|
+
\`spex eval add <node> --scenario <s> --fail\` with evidence of the failure. B, AFTER EDITING: run that same
|
|
203
|
+
scenario against the working tree until it passes; commit the verified tree; then file
|
|
204
|
+
\`spex eval add <node> --scenario <s> --pass\`. The reading's \`codeSha\` must be that commit. New intent has no
|
|
205
|
+
prior failure to reproduce.
|
|
206
|
+
|
|
190
207
|
MEASURING AND FILING: the agent runs the scenario however it likes (a browser run, an API
|
|
191
208
|
transcript, a by-hand pass), compares the result to \`expected\`, and files it:
|
|
192
209
|
spex eval add <node> [--scenario <name>] (--pass | --fail) [--note <text>]
|
|
@@ -227,6 +244,31 @@ PICK THE EVIDENCE KIND BY WHAT THE BEHAVIOUR DOES OVER TIME:
|
|
|
227
244
|
\`data\` — rendered as a validatable data block, not flattened into scrolling transcript
|
|
228
245
|
text; free-form output stays a transcript. You pick the flag; the KIND follows the bytes.
|
|
229
246
|
The flags combine in ONE filing — several stills can ride beside the clip of the same run.
|
|
247
|
+
POPULATION DISCIPLINE: when \`expected\` quantifies over a set ("every X is Y", "nothing under Z"), the
|
|
248
|
+
measurement reports the set's SIZE, not only the verdict. An empty set satisfies a universal claim
|
|
249
|
+
VACUOUSLY, so a pass over nothing and a real pass are indistinguishable — and the universal form reads
|
|
250
|
+
STRICTER than an enumeration, which is what makes it the hardest wrong-population case to catch. File
|
|
251
|
+
\`N of N\`, never a bare boolean: \`0 of 0\` shows itself, \`true\` does not. The denominator must come from a
|
|
252
|
+
source that can DISAGREE with the numerator — count the population off the surface that does NOT decide the
|
|
253
|
+
outcome (a backend export, a ledger, the enumeration upstream of the thing under test) and the passing members
|
|
254
|
+
off the surface under test. A ratio whose halves share one source says only "what I selected, I selected": a
|
|
255
|
+
selector that silently drops half the population reports \`3 of 3\` when the truth is 6. Two weaker defences
|
|
256
|
+
that do not substitute — a precondition sentence depends on the next author remembering it, a printed
|
|
257
|
+
denominator depends on a reader noticing it. Nothing here enforces either (the schema has no population
|
|
258
|
+
field), so treat a zero-population run as a NON-reading and do not file it. Cheaper than any of that, when
|
|
259
|
+
you can get it: restate the claim over something the product CANNOT make empty. "every active node's name is
|
|
260
|
+
readable" needs someone to arrange activity and goes vacuous when nobody does; "the rendered size never falls
|
|
261
|
+
below the authored size" is a property of the viewport itself, true of a one-node graph, and has no
|
|
262
|
+
population to get wrong. A claim with no population beats a well-reported one.
|
|
263
|
+
RENDERED GEOMETRY (browser): measure the rendered BOX, never the authored STYLE. An ancestor CSS \`transform\`
|
|
264
|
+
— the ordinary zoom/pan wrapper on a canvas or graph view — does NOT change computed style, so
|
|
265
|
+
\`getComputedStyle(el).fontSize\` answers the AUTHORED size while the screen shows that size times the
|
|
266
|
+
ancestor scale, and the reading is a SILENT FALSE PASS. (No numbers here on purpose: the authored size is
|
|
267
|
+
whatever this project's stylesheet says today, and a manual that hard-codes one teaches a constant that
|
|
268
|
+
drifts — read it off the tree you are measuring.) Go through \`getBoundingClientRect()\` (it carries the
|
|
269
|
+
ancestor scale) and derive the effective size from the box, or from a known string's width. This is also why
|
|
270
|
+
a geometric claim ships WITH its \`--image\`: a rect can be computed wrong, while "is that text legible" is
|
|
271
|
+
human-judgeable, so the two evidences cover each other.
|
|
230
272
|
ANCHOR DISCIPLINE: an eval's \`codeSha\` is HEAD at filing time, and a git sha names only a COMMIT — an
|
|
231
273
|
uncommitted change has none. So measure the tree you are about to commit, COMMIT it, then file; confidence
|
|
232
274
|
is earned on the working tree, but the anchor can only land after the commit. Filing from a dirty tree
|
|
@@ -285,7 +327,8 @@ see LAUNCHERS.
|
|
|
285
327
|
── LAYOUT (spexcode.json — portable; set only for a NON-DEFAULT repo layout) ──
|
|
286
328
|
main path to the source-of-truth checkout. Default: the \`main\` worktree.
|
|
287
329
|
mainBranch the stable source-of-truth BRANCH worktrees fork from. spex init stamps the root checkout's
|
|
288
|
-
branch at adoption
|
|
330
|
+
branch at adoption and the stamped value stays put for every later checkout, so a clone or a
|
|
331
|
+
fresh worktree does not re-guess it; an older omitted value uses the conventional main.
|
|
289
332
|
branchPrefix how a node branch is named. Default "node/".
|
|
290
333
|
Example — a repo whose trunk is \`staging\`, not \`main\`:
|
|
291
334
|
{ "mainBranch": "staging" }
|
|
@@ -575,7 +618,47 @@ not a flag flip.
|
|
|
575
618
|
elsewhere cannot be recalled.
|
|
576
619
|
back out entirely \`spex uninstall\` (add --hooks to also remove the spexcode git hooks).`
|
|
577
620
|
|
|
578
|
-
const
|
|
621
|
+
const FILES = `spex guide files — hand an agent artifact back to the human
|
|
622
|
+
|
|
623
|
+
Use the session's file list when an artifact belongs in the human's hands:
|
|
624
|
+
|
|
625
|
+
spex session files add <path> publish one current file path
|
|
626
|
+
spex session files ls read your published paths
|
|
627
|
+
spex session files retract <path> withdraw one path
|
|
628
|
+
|
|
629
|
+
Posting resolves a relative path from your current directory and records its absolute path beside the global
|
|
630
|
+
session record. It copies, moves, stages, and uploads NOTHING. The path is live: editing the file after
|
|
631
|
+
posting changes what the human downloads. The reference is host-local; opening the session elsewhere cannot
|
|
632
|
+
make its path point at another machine's file.
|
|
633
|
+
|
|
634
|
+
The session page's top-right files icon is grey while the list is empty. Once live, it opens the posted list;
|
|
635
|
+
choosing a path previews its current text or raster-image bytes in a pop-out, while the adjacent download tool
|
|
636
|
+
downloads it through the backend at that moment. Previews are limited to 2 MiB, text and PNG/JPEG/GIF/WebP;
|
|
637
|
+
other types and larger files say to download instead. A missing, moved, or unreadable target stays listed but
|
|
638
|
+
reports that it no longer exists. The backend refuses a preview or download for any path not on that session's
|
|
639
|
+
list.
|
|
640
|
+
|
|
641
|
+
This is the reverse of a dashboard attachment: [[file-attach]] sends human bytes to an agent. Files publishes
|
|
642
|
+
an agent-owned path for a human to download.`
|
|
643
|
+
|
|
644
|
+
const WEB = `spex guide web — hand a running local web service to the human
|
|
645
|
+
|
|
646
|
+
Use the session web list after starting a local page the human should inspect:
|
|
647
|
+
|
|
648
|
+
spex session web add http://127.0.0.1:5173/
|
|
649
|
+
spex session web ls
|
|
650
|
+
spex session web retract http://127.0.0.1:5173/
|
|
651
|
+
|
|
652
|
+
Posting records the canonical loopback HTTP URL beside the global session record. It does not fetch the page,
|
|
653
|
+
start a process, or move bytes. The dashboard uses its own same-origin gateway only when the human opens the
|
|
654
|
+
preview, including WebSocket traffic, so changes to the running service are visible at that moment.
|
|
655
|
+
|
|
656
|
+
Only loopback HTTP URLs with an explicit port are accepted: 127.0.0.1, localhost, or ::1. The service remains
|
|
657
|
+
host-local and must stay running. A stopped service remains listed and reports that its upstream is unavailable.
|
|
658
|
+
The proxy has a path prefix, so use relative asset/navigation URLs or configure your dev server's base path;
|
|
659
|
+
hard-coded root-absolute Vite/Next assets cannot be made portable by a proxy guessing at JavaScript.`
|
|
660
|
+
|
|
661
|
+
const TOPICS: Record<string, string> = { spec: SPEC, eval: EVAL, settings: SETTINGS, footprint: FOOTPRINT, files: FILES, web: WEB }
|
|
579
662
|
|
|
580
663
|
// every guide page ends by naming the OTHER help layer, so a reader never dead-ends here: guide is
|
|
581
664
|
// the skill layer (workflows · formats · settings); command usage lives in help.ts's two layers.
|
|
@@ -588,4 +671,10 @@ export function guideText(topic?: string): string | null {
|
|
|
588
671
|
const t = TOPICS[topic]
|
|
589
672
|
return t ? t + FOOTER : null
|
|
590
673
|
}
|
|
674
|
+
|
|
675
|
+
// @@@ the unknown-topic list is DERIVED, never re-typed - a hand-kept enumeration goes blind the moment a
|
|
676
|
+
// topic is added beside it, and it did: `files` and `web` shipped while the error still named four.
|
|
677
|
+
export function guideTopics(): string[] {
|
|
678
|
+
return Object.keys(TOPICS)
|
|
679
|
+
}
|
|
591
680
|
import { uploadPolicyDefaults } from './layout.js'
|
|
@@ -44,14 +44,14 @@ export function resolveHarnessTargets(raw: unknown): HarnessTarget[] {
|
|
|
44
44
|
for (const m of raw) {
|
|
45
45
|
if (typeof m === 'string') {
|
|
46
46
|
if (m === 'plugin')
|
|
47
|
-
throw new Error(`spexcode.json "harnesses": a plugin target needs an EXPLICIT landing folder — write {"plugin":"<folder>"} (e.g. {"plugin":".
|
|
47
|
+
throw new Error(`spexcode.json "harnesses": a plugin target needs an EXPLICIT landing folder — write {"plugin":"<folder>"} (e.g. {"plugin":".adopter-a"}), not the bare string "plugin", because each host agent reads a different plugins dir.`)
|
|
48
48
|
if (!KNOWN.includes(m))
|
|
49
49
|
throw new Error(`spexcode.json "harnesses": unknown harness id "${m}" — known native ids are ${KNOWN.join(', ')}, or use {"plugin":"<folder>"}.`)
|
|
50
50
|
targets.push({ kind: 'native', id: m as HarnessId })
|
|
51
51
|
} else if (m && typeof m === 'object' && !Array.isArray(m) && 'plugin' in m) {
|
|
52
52
|
const folder = (m as { plugin?: unknown }).plugin
|
|
53
53
|
if (typeof folder !== 'string' || !folder.trim())
|
|
54
|
-
throw new Error(`spexcode.json "harnesses": a {"plugin":…} target needs a NON-EMPTY folder string (e.g. {"plugin":".
|
|
54
|
+
throw new Error(`spexcode.json "harnesses": a {"plugin":…} target needs a NON-EMPTY folder string (e.g. {"plugin":".adopter-a"}) — each host agent reads a different plugins dir, so the folder must be explicit.`)
|
|
55
55
|
targets.push({ kind: 'plugin', folder: folder.trim() })
|
|
56
56
|
} else {
|
|
57
57
|
throw new Error(`spexcode.json "harnesses": each member must be a native id string (${KNOWN.join(', ')}) or a {"plugin":"<folder>"} object — got ${JSON.stringify(m)}.`)
|