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/layout.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { join, dirname, resolve } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
|
-
import { git, repoRoot, gitA, headSha, worktreeSpecSig, worktreeSpecDelta, type NodeOp } from './git.js'
|
|
4
|
+
import { git, repoRoot, gitA, gitAbortError, currentGitBuildAbortSignal, gitInterpretationIdentity, headSha, worktreeSpecSig, worktreeSpecDelta, worktreeSpecDeltas, withGitAbortSignal, type NodeOp } from './git.js'
|
|
5
5
|
import { guardWorktree } from './resilience.js'
|
|
6
6
|
import { HARNESSES, type HarnessId } from './harness.js'
|
|
7
7
|
import { encodeProject, projectRuntimeRoot, spexcodeHome } from './project-store.js'
|
|
@@ -276,6 +276,7 @@ export type RawRecord = {
|
|
|
276
276
|
launch_cmd?: string // the RESOLVED base launcher command PINNED at creation, so a resume replays the EXACT launcher (and its config-dir env) that made the conversation, never a since-changed default ([[launcher-select]] resume-launcher-pin); absent → old record, fall back to the launcher name / ambient
|
|
277
277
|
create_request_id?: string // SHA-256 digest of the create Idempotency-Key; the raw key is never persisted
|
|
278
278
|
create_payload_hash?: string // normalized create payload bound to create_request_id
|
|
279
|
+
base?: string // the exact fork point pinned at creation; absent/empty → the auto-detected source-of-truth branch
|
|
279
280
|
launch_readiness_pending?: '' | RawLaunchReadinessPending
|
|
280
281
|
}
|
|
281
282
|
|
|
@@ -445,6 +446,10 @@ export function readAliasedRawRecord(id: string): RawRecord | null {
|
|
|
445
446
|
export function readAliasedRecordEntry(id: string): RecordEntry {
|
|
446
447
|
const direct = readRecordEntry(id)
|
|
447
448
|
if (direct.kind !== 'absent') return direct
|
|
449
|
+
// @@@ absence splits in two, and only one half is an alias question - an id owning a store dir is already
|
|
450
|
+
// one of ours (the sentinel-only agent above), so its emptiness is settled; searching would let an unrelated
|
|
451
|
+
// record answer under a live session's own name, and costs a whole-store re-parse per 1s supervisor tick.
|
|
452
|
+
if (existsSync(sessionStoreDir(id))) return { kind: 'absent' }
|
|
448
453
|
for (const sid of listSessionIds()) {
|
|
449
454
|
const r = readRawRecord(sid)
|
|
450
455
|
if (r && r.harness_session_id && r.harness_session_id === id) return { kind: 'ok', raw: r }
|
|
@@ -476,35 +481,99 @@ export function sessionBranchIndex(): Map<string, string> {
|
|
|
476
481
|
return index
|
|
477
482
|
}
|
|
478
483
|
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
// content dissolve a worktree's now-moot ops — the recompute it triggers is cheap because every diff is
|
|
482
|
-
// .spec-scoped.
|
|
484
|
+
// Retain only completed per-worktree overlays. Interpretation + main tip + HEAD + working signature completely
|
|
485
|
+
// determine the merge-base and projection; a landed main tip therefore dissolves now-moot ops on the next read.
|
|
483
486
|
const deltaCache = new Map<string, { key: string; ops: NodeOp[] }>()
|
|
484
487
|
const safeHead = (p: string): string => { try { return headSha(p) } catch { return '' } }
|
|
485
|
-
const safeMergeBase = async (wtPath: string, mainRef: string): Promise<string> => {
|
|
486
|
-
try { return (await gitA(['-C', wtPath, 'merge-base', mainRef, 'HEAD'])).trim() } catch { return '' }
|
|
487
|
-
}
|
|
488
488
|
let layoutHeadWarned = false
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
489
|
+
type LayoutDeltaOutcome = { ops: NodeOp[] } | { error: unknown }
|
|
490
|
+
type LayoutDeltaFlight = {
|
|
491
|
+
promise: Promise<Map<string, LayoutDeltaOutcome>>
|
|
492
|
+
controller: AbortController
|
|
493
|
+
waiters: Set<symbol>
|
|
494
|
+
settled: boolean
|
|
495
|
+
}
|
|
496
|
+
const layoutDeltaFlights = new Map<string, LayoutDeltaFlight>()
|
|
497
|
+
|
|
498
|
+
// One exact public layout generation owns the cold overlay computation. The map is only an in-flight join:
|
|
499
|
+
// the entry is deleted at settlement and deltaCache remains the sole retained result state.
|
|
500
|
+
async function layoutDeltas(paths: string[], main: string, mainRef: string, mainSha: string): Promise<Map<string, LayoutDeltaOutcome>> {
|
|
501
|
+
const snapshots = paths.map((path) => ({ path, head: safeHead(path), sig: worktreeSpecSig(path) }))
|
|
502
|
+
const interpretation = gitInterpretationIdentity(main)
|
|
503
|
+
const flightKey = JSON.stringify([interpretation, mainRef, mainSha, snapshots.map(({ path, head, sig }) => [path, head, sig]).sort((a, b) => a[0].localeCompare(b[0]))])
|
|
504
|
+
let flight = layoutDeltaFlights.get(flightKey)
|
|
505
|
+
if (flight?.controller.signal.aborted) {
|
|
506
|
+
layoutDeltaFlights.delete(flightKey)
|
|
507
|
+
flight = undefined
|
|
508
|
+
}
|
|
509
|
+
if (!flight) {
|
|
510
|
+
const controller = new AbortController()
|
|
511
|
+
const entry: LayoutDeltaFlight = { promise: Promise.resolve(new Map()), controller, waiters: new Set(), settled: false }
|
|
512
|
+
entry.promise = withGitAbortSignal(controller.signal, async () => {
|
|
513
|
+
const outcomes = new Map<string, LayoutDeltaOutcome>()
|
|
514
|
+
const misses: typeof snapshots = []
|
|
515
|
+
for (const snapshot of snapshots) {
|
|
516
|
+
const key = `${interpretation}\0${mainSha}\0${snapshot.head}\0${snapshot.sig}`
|
|
517
|
+
const hit = snapshot.head && mainSha ? deltaCache.get(snapshot.path) : null
|
|
518
|
+
if (hit?.key === key) outcomes.set(snapshot.path, { ops: hit.ops })
|
|
519
|
+
else misses.push(snapshot)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const valid = misses.filter(({ head }) => !!head && !!mainSha)
|
|
523
|
+
const batched = await worktreeSpecDeltas(main, mainSha, valid.map(({ path, head }) => ({ path, head })), interpretation)
|
|
524
|
+
for (const snapshot of valid) {
|
|
525
|
+
const outcome = batched.get(snapshot.path) ?? { error: new Error(`layout overlay batch omitted ${snapshot.path}`) }
|
|
526
|
+
if ('error' in outcome) outcomes.set(snapshot.path, outcome)
|
|
527
|
+
else {
|
|
528
|
+
const key = `${interpretation}\0${mainSha}\0${snapshot.head}\0${snapshot.sig}`
|
|
529
|
+
deltaCache.set(snapshot.path, { key, ops: outcome.ops })
|
|
530
|
+
outcomes.set(snapshot.path, { ops: outcome.ops })
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
await Promise.all(misses.filter(({ head }) => !head || !mainSha).map(async (snapshot) => {
|
|
535
|
+
if (!existsSync(snapshot.path)) {
|
|
536
|
+
outcomes.set(snapshot.path, { error: new Error(`worktree ${snapshot.path} is absent`) })
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
if (!layoutHeadWarned) {
|
|
540
|
+
layoutHeadWarned = true
|
|
541
|
+
console.warn('spec-cli: layout overlay cache bypassed (unreadable HEAD/main tip), recomputing every read')
|
|
542
|
+
}
|
|
543
|
+
try { outcomes.set(snapshot.path, { ops: await worktreeSpecDelta(snapshot.path, mainRef) }) }
|
|
544
|
+
catch (error) { outcomes.set(snapshot.path, { error }) }
|
|
545
|
+
}))
|
|
546
|
+
return outcomes
|
|
547
|
+
}).finally(() => {
|
|
548
|
+
entry.settled = true
|
|
549
|
+
if (layoutDeltaFlights.get(flightKey) === entry) layoutDeltaFlights.delete(flightKey)
|
|
550
|
+
})
|
|
551
|
+
layoutDeltaFlights.set(flightKey, entry)
|
|
552
|
+
flight = entry
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const token = Symbol(flightKey)
|
|
556
|
+
const callerSignal = currentGitBuildAbortSignal()
|
|
557
|
+
flight.waiters.add(token)
|
|
558
|
+
let onAbort: (() => void) | null = null
|
|
559
|
+
try {
|
|
560
|
+
if (!callerSignal) return await flight.promise
|
|
561
|
+
if (callerSignal.aborted) throw gitAbortError()
|
|
562
|
+
return await Promise.race([
|
|
563
|
+
flight.promise,
|
|
564
|
+
new Promise<Map<string, LayoutDeltaOutcome>>((_, reject) => {
|
|
565
|
+
onAbort = () => reject(gitAbortError())
|
|
566
|
+
callerSignal.addEventListener('abort', onAbort, { once: true })
|
|
567
|
+
}),
|
|
568
|
+
])
|
|
569
|
+
} finally {
|
|
570
|
+
if (onAbort) callerSignal?.removeEventListener('abort', onAbort)
|
|
571
|
+
flight.waiters.delete(token)
|
|
572
|
+
if (!flight.settled && flight.waiters.size === 0) flight.controller.abort()
|
|
498
573
|
}
|
|
499
|
-
const key = `${base}\0${wtHead}\0${mainSha}\0${worktreeSpecSig(wtPath)}`
|
|
500
|
-
const hit = deltaCache.get(wtPath)
|
|
501
|
-
if (hit && hit.key === key) return hit.ops
|
|
502
|
-
const ops = await worktreeSpecDelta(wtPath, mainRef, base)
|
|
503
|
-
deltaCache.set(wtPath, { key, ops })
|
|
504
|
-
return ops
|
|
505
574
|
}
|
|
506
575
|
|
|
507
|
-
export async function resolveLayout(): Promise<Layout> {
|
|
576
|
+
export async function resolveLayout(options: { activeSessionIds?: readonly string[] } = {}): Promise<Layout> {
|
|
508
577
|
const root = repoRoot()
|
|
509
578
|
const main = dirname(gitCommonDir()) // the main checkout — same answer from main OR any linked worktree
|
|
510
579
|
const cfg = readConfig(main)
|
|
@@ -524,22 +593,36 @@ export async function resolveLayout(): Promise<Layout> {
|
|
|
524
593
|
const publicEntries = listSessionIds().map((id) => readPublicRecordEntry(id))
|
|
525
594
|
.filter((entry) => entry.kind === 'corrupt' ? entry.governed !== false : entry.kind === 'ok' && entry.raw.governed)
|
|
526
595
|
const records = publicEntries.flatMap((entry) => entry.kind === 'ok' ? [entry] : [])
|
|
596
|
+
const projectedActive = options.activeSessionIds ? new Set(options.activeSessionIds) : null
|
|
597
|
+
const isActive = (record: RawRecord): boolean => projectedActive
|
|
598
|
+
? projectedActive.has(record.session_id)
|
|
599
|
+
: !record.archived
|
|
527
600
|
// main's tip, resolved ONCE per board read — a component of every worktree's overlay cache key
|
|
528
601
|
// ([[worktree-linker]]: landed content must dissolve the ops it made moot).
|
|
529
602
|
const mainSha = await (async () => {
|
|
530
603
|
try { return (await gitA(['-C', main, 'rev-parse', '--verify', `${mainRef}^{commit}`])).trim() } catch { return '' }
|
|
531
604
|
})()
|
|
605
|
+
const activePaths = records.filter(({ raw }) => isActive(raw)).map(({ raw }) => raw.worktree_path)
|
|
606
|
+
const deltas = await layoutDeltas(activePaths, main, mainRef, mainSha)
|
|
532
607
|
const rows = await Promise.all(records.map(({ raw: r, liveness }) => {
|
|
533
608
|
const node = r.node ?? (r.branch && r.branch.startsWith(convention.branchPrefix) ? r.branch.slice(convention.branchPrefix.length) : null)
|
|
534
609
|
const base: Worktree = { path: r.worktree_path, branch: r.branch, node, session: r.session_id, status: r.status, isMain: false, ...(liveness ? { liveness } : {}), ops: [] }
|
|
535
|
-
// @@@
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
|
|
539
|
-
if (r.archived) return Promise.resolve(base)
|
|
610
|
+
// @@@ projected shelves cost nothing - a cold archived session ([[archive]]) keeps its record but leaves
|
|
611
|
+
// the working-set projection and skips the per-worktree spec delta. An archived runtime hazard is still
|
|
612
|
+
// projected active by listSessions, so it deliberately retains the same ops in both full and splice builds.
|
|
613
|
+
if (!isActive(r)) return Promise.resolve(base)
|
|
540
614
|
return guardWorktree<Worktree>(r.worktree_path,
|
|
541
|
-
|
|
542
|
-
|
|
615
|
+
(): Worktree => {
|
|
616
|
+
const outcome = deltas.get(r.worktree_path)
|
|
617
|
+
if (!outcome) throw new Error(`layout overlay missing ${r.worktree_path}`)
|
|
618
|
+
if ('error' in outcome) throw outcome.error
|
|
619
|
+
return { ...base, ops: outcome.ops }
|
|
620
|
+
},
|
|
621
|
+
(): Worktree => {
|
|
622
|
+
const cached = deltaCache.get(r.worktree_path)
|
|
623
|
+
if (!cached) throw new Error(`layout overlay failed before ${r.worktree_path} had a last-known result`)
|
|
624
|
+
return { ...base, ops: cached.ops }
|
|
625
|
+
})
|
|
543
626
|
}))
|
|
544
627
|
const corruptRows: Worktree[] = publicEntries.flatMap((entry) => entry.kind === 'corrupt'
|
|
545
628
|
? [{ path: '', branch: null, node: null, session: entry.sessionId, status: 'corrupt', liveness: 'unknown', isMain: false, ops: [] }]
|
|
@@ -549,9 +632,9 @@ export async function resolveLayout(): Promise<Layout> {
|
|
|
549
632
|
const mainRow: Worktree = { path: main, branch: base, node: null, session: null, status: null, isMain: true, ops: [] }
|
|
550
633
|
const worktrees = [mainRow, ...sessionWorktrees]
|
|
551
634
|
// drop cache entries for worktrees that may no longer hold one — closed sessions (gone from the store) AND
|
|
552
|
-
// newly-archived ones (which no longer compute a delta), so archiving SELF-EVICTS its cached ops instead of
|
|
635
|
+
// newly-cold archived ones (which no longer compute a delta), so archiving SELF-EVICTS its cached ops instead of
|
|
553
636
|
// stranding them in a map nothing prunes.
|
|
554
|
-
const live = new Set(records.filter(({ raw }) =>
|
|
637
|
+
const live = new Set(records.filter(({ raw }) => isActive(raw)).map(({ raw }) => raw.worktree_path))
|
|
555
638
|
for (const k of [...deltaCache.keys()]) if (!live.has(k)) deltaCache.delete(k)
|
|
556
639
|
return { main: convention.main || main || root, convention, worktrees }
|
|
557
640
|
}
|
package/spec-cli/src/lint.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText, type DriftPathEvent } from './git.js'
|
|
3
|
+
import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText, withEventLedgerBuild, type DriftPathEvent } from './git.js'
|
|
4
4
|
import { loadSpecs, parseFrontmatter } from './specs.js'
|
|
5
5
|
import { readJsonConfig } from './layout.js'
|
|
6
6
|
import { extractors, extractorFor, extOf, parseCodeEntry, relationClaimsPath, resolveAnchor, resolveSelectors, windowEvents, anchorHitQueries } from './anchors.js'
|
|
7
7
|
import { DEFAULT_TEST_GLOBS, sourcePolicyDescription, trackedSourceFiles } from './source-files.js'
|
|
8
8
|
|
|
9
9
|
export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
|
|
10
|
+
export const SPEC_LINT_REPORT_PROJECTION = 'spex.spec-lint.report'
|
|
11
|
+
export const SPEC_LINT_REPORT_SCHEMA_VERSION = 1
|
|
12
|
+
export type SpecLintReport = {
|
|
13
|
+
projection: typeof SPEC_LINT_REPORT_PROJECTION
|
|
14
|
+
schemaVersion: typeof SPEC_LINT_REPORT_SCHEMA_VERSION
|
|
15
|
+
sourceFiles: string[]
|
|
16
|
+
findings: Finding[]
|
|
17
|
+
}
|
|
10
18
|
|
|
11
19
|
export type LintConfig = {
|
|
12
20
|
governedRoots: string[] // dirs whose tracked source files must each be governed by a spec. '.' = whole project.
|
|
@@ -69,6 +77,7 @@ export function normalizeConfig(cfg: LintConfig): LintConfig {
|
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
export type SpecLintOptions = { tip?: string }
|
|
80
|
+
type SpecLintRun = { sourceFiles: string[]; findings: Finding[] }
|
|
72
81
|
|
|
73
82
|
function untrackedAdoptionFiles(root: string): string[] {
|
|
74
83
|
if (git(['-C', root, 'rev-parse', '--is-inside-work-tree']).trim() !== 'true') return []
|
|
@@ -126,6 +135,24 @@ export async function pendingTouchesGoverned(root: string, tip: string): Promise
|
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
export async function specLint(root = repoRoot(), regs = extractors(root), options: SpecLintOptions = {}): Promise<Finding[]> {
|
|
138
|
+
return (await specLintRun(root, regs, options)).findings
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function specLintReport(root = repoRoot(), regs = extractors(root), options: SpecLintOptions = {}): Promise<SpecLintReport> {
|
|
142
|
+
const run = await specLintRun(root, regs, options)
|
|
143
|
+
return {
|
|
144
|
+
projection: SPEC_LINT_REPORT_PROJECTION,
|
|
145
|
+
schemaVersion: SPEC_LINT_REPORT_SCHEMA_VERSION,
|
|
146
|
+
sourceFiles: run.sourceFiles,
|
|
147
|
+
findings: run.findings,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function specLintRun(root: string, regs: ReturnType<typeof extractors>, options: SpecLintOptions): Promise<SpecLintRun> {
|
|
152
|
+
return withEventLedgerBuild(root, () => specLintInLedger(root, regs, options))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function specLintInLedger(root: string, regs: ReturnType<typeof extractors>, options: SpecLintOptions): Promise<SpecLintRun> {
|
|
129
156
|
const tip = options.tip ?? 'HEAD'
|
|
130
157
|
const pending = tip !== 'HEAD'
|
|
131
158
|
const changed = pending ? pendingChangedPaths(root, tip) : []
|
|
@@ -145,11 +172,14 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
|
|
|
145
172
|
if (untracked.length) {
|
|
146
173
|
const shown = untracked.slice(0, 6)
|
|
147
174
|
const suffix = untracked.length > shown.length ? ` (+${untracked.length - shown.length} more)` : ''
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
175
|
+
return {
|
|
176
|
+
sourceFiles: [],
|
|
177
|
+
findings: [{
|
|
178
|
+
level: 'error',
|
|
179
|
+
rule: 'integrity',
|
|
180
|
+
msg: `project source of truth is untracked: ${shown.join(', ')}${suffix} — add it with \`git add .spec spexcode.json\` and commit it; generated harness files such as .codex/, .claude/, and AGENTS.md are machine-local`,
|
|
181
|
+
}],
|
|
182
|
+
}
|
|
153
183
|
}
|
|
154
184
|
const governed = trackedSourceFiles(root, cfg.governedRoots, cfg, tip)
|
|
155
185
|
const [hidx, didx] = await sourceIndexes(root, tip)
|
|
@@ -436,7 +466,7 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
|
|
|
436
466
|
out.push({ level: 'warn', rule: 'related-drift', msg: `${rd.length} related file(s) across ${byNode.size} node(s) drifted ahead of their spec (SOFT — a dependency shifted, worth a glance; never blocks, no ack, no eval staleness). Most: ${worst}` })
|
|
437
467
|
}
|
|
438
468
|
|
|
439
|
-
return out
|
|
469
|
+
return { sourceFiles: governed.slice().sort(), findings: out }
|
|
440
470
|
}
|
|
441
471
|
|
|
442
472
|
export const DRIFT_GUIDANCE = `DRIFT — a governed file has moved ahead of its spec. A CHECKPOINT, not a chore: find WHERE the truth
|
|
@@ -85,14 +85,19 @@ function publishSelection(path: string, body: string): void {
|
|
|
85
85
|
renameSync(prepared, path)
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
88
|
+
function managedBlockPattern(comment: readonly [string, string]): RegExp {
|
|
89
|
+
const [open, close] = comment
|
|
90
|
+
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
91
|
+
return new RegExp(`\\n*${escape(`${open}spexcode:start${close}`)}[\\s\\S]*?${escape(`${open}spexcode:end${close}`)}\\n*`)
|
|
92
|
+
}
|
|
93
|
+
export function stripSpexcodeBlock(text: string, comment: readonly [string, string] = ['<!-- ', ' -->']): string {
|
|
94
|
+
const sentinel = managedBlockPattern(comment)
|
|
95
|
+
const m = sentinel.exec(text)
|
|
91
96
|
if (!m) return text
|
|
92
97
|
// mirror removeManagedBlock exactly: our block + its surrounding blanks collapse to one '\n', and only a
|
|
93
98
|
// block sitting at the TOP of the file drops the leading newline (a host file beginning with its own
|
|
94
99
|
// blank lines keeps them — clean(smudge(x)) == x).
|
|
95
|
-
const replaced = text.replace(
|
|
100
|
+
const replaced = text.replace(sentinel, '\n')
|
|
96
101
|
return m.index === 0 ? replaced.replace(/^\n+/, '') : replaced
|
|
97
102
|
}
|
|
98
103
|
function hostContentOf(file: string): string {
|
|
@@ -387,7 +392,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
387
392
|
.map((p) => relative(proj, p)).filter((p) => !p.startsWith('..'))
|
|
388
393
|
const ignoreFile = join(proj, '.gitignore')
|
|
389
394
|
const ignoreTracked = isTrackedHere(ignoreFile)
|
|
390
|
-
const ignoreHost = existsSync(ignoreFile) ? readFileSync(ignoreFile, 'utf8') : ''
|
|
395
|
+
const ignoreHost = existsSync(ignoreFile) ? stripSpexcodeBlock(readFileSync(ignoreFile, 'utf8'), ['# ', '']) : ''
|
|
391
396
|
if (!ignoreTracked && !ignoreHost.trim()) localEntries.push('.gitignore')
|
|
392
397
|
const ignoreBody = entries(localEntries)
|
|
393
398
|
if (writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])) changedMaterialized.add(ignoreFile)
|
|
@@ -4,29 +4,29 @@ import { fileURLToPath } from 'node:url'
|
|
|
4
4
|
import { copyFileIfChanged, writeFileIfChanged } from './file-write.js'
|
|
5
5
|
|
|
6
6
|
// @@@ plugin-harness - the PLUGIN BUNDLE emitter: materialize the whole SpexCode system into ONE self-contained
|
|
7
|
-
// Claude-plugin bundle dropped into the host-agent-scanned folder [[harness-select]] resolved (e.g. `.
|
|
7
|
+
// Claude-plugin bundle dropped into the host-agent-scanned folder [[harness-select]] resolved (e.g. `.adopter-a` /
|
|
8
8
|
// `.claude` → <folder>/plugins/spexcode/). It is the plugin-target counterpart of the native per-harness
|
|
9
9
|
// write [[harness-adapter]] does — chosen INSTEAD of the natives (plugin exclusivity), so [[harness-delivery]]'s
|
|
10
10
|
// materialize prunes every native first, then emits this. The bundle follows the de-facto Claude-plugin schema
|
|
11
11
|
// (a `.claude-plugin/plugin.json` pointing at hooks/skills/commands/agents); the host's discovery order is
|
|
12
|
-
// `.
|
|
13
|
-
// the ONE `.claude-plugin` bundle reaches
|
|
12
|
+
// `.adopter-a-plugin > .claude-plugin > .codex-plugin` and adopter-a/Claude both read a `.claude-plugin` directly, so
|
|
13
|
+
// the ONE `.claude-plugin` bundle reaches AdopterA, Claude, and (future) Codex from a single emit.
|
|
14
14
|
//
|
|
15
15
|
// The contract is NOT delivered by an always-on CLAUDE.md block here (the bundle never touches the repo's own
|
|
16
16
|
// files) — it maps to a SessionStart hook that emits hookSpecificOutput.additionalContext (the harness-neutral
|
|
17
|
-
// injection Claude/
|
|
17
|
+
// injection Claude/adopter-a normalize, the superpowers pattern), so a plugin host gets the contract with no
|
|
18
18
|
// --append-system-prompt. The hooks reuse the SAME dispatch.sh wiring as the natives, located via the host's
|
|
19
19
|
// ${CLAUDE_PLUGIN_ROOT} variable; dispatch.sh's first arg is the harness id `plugin`, so its shell mirror
|
|
20
|
-
// (harness.sh) parses payloads as the claude family (
|
|
20
|
+
// (harness.sh) parses payloads as the claude family (adopter-a/Claude share Claude's tool names + file_path).
|
|
21
21
|
|
|
22
22
|
const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
|
|
23
23
|
const HOOKS_SRC = join(PKG, 'hooks') // the canonical dispatch.sh + harness.sh source
|
|
24
24
|
const PLUGIN_NAME = 'spexcode'
|
|
25
25
|
// the host substitutes ${CLAUDE_PLUGIN_ROOT} with the bundle's own absolute path before running a hook command
|
|
26
|
-
// (the same variable
|
|
26
|
+
// (the same variable adopter-a's hook-compat honours), so dispatch.sh/inject-contract.sh resolve regardless of
|
|
27
27
|
// where the host scanned the bundle from.
|
|
28
28
|
const PLUGIN_ROOT = '${CLAUDE_PLUGIN_ROOT}'
|
|
29
|
-
// the lifecycle events the bundle binds — the Claude/
|
|
29
|
+
// the lifecycle events the bundle binds — the Claude/adopter-a superset; a host that fires fewer (Codex) simply
|
|
30
30
|
// never invokes the extras, so binding all is harmless and one emit serves every host.
|
|
31
31
|
const PLUGIN_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
|
|
32
32
|
|
|
@@ -51,7 +51,7 @@ export function pluginBundleDir(proj: string, folder: string): string {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// the de-facto Claude-plugin manifest: name (the bundle identity clean() gates on), version, description, and
|
|
54
|
-
// the component pointers Claude/
|
|
54
|
+
// the component pointers Claude/adopter-a discover (hooks.json + the skills/commands/agents dirs).
|
|
55
55
|
function pluginManifest(version: string): string {
|
|
56
56
|
return JSON.stringify({
|
|
57
57
|
name: PLUGIN_NAME,
|
|
@@ -64,7 +64,7 @@ function pluginManifest(version: string): string {
|
|
|
64
64
|
}, null, 2)
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// hooks.json in the Claude/
|
|
67
|
+
// hooks.json in the Claude/adopter-a-compatible shape { "hooks": { "<Event>": [{ "hooks": [command…] }] } }. Every
|
|
68
68
|
// event → the SHARED dispatch.sh (`plugin` baked as its harness id, SPEX inherited by handlers); SessionStart
|
|
69
69
|
// ALSO runs inject-contract.sh first, so the contract additionalContext lands alongside the normal dispatch.
|
|
70
70
|
function pluginHooksJson(spex: string): string {
|
|
@@ -90,7 +90,7 @@ function contractContextJson(contract: string): string {
|
|
|
90
90
|
// stand-in for --append-system-prompt). A bare cat — all the encoding happened at materialize time.
|
|
91
91
|
const INJECT_SH = `#!/usr/bin/env bash
|
|
92
92
|
# Emit the SpexCode contract as SessionStart additionalContext — the harness-neutral contract injection (the
|
|
93
|
-
# superpowers pattern; Claude/
|
|
93
|
+
# superpowers pattern; Claude/adopter-a normalize hookSpecificOutput.additionalContext) that replaces a plugin
|
|
94
94
|
# host's missing --append-system-prompt. The JSON was written at materialize time, so this is a trivial cat.
|
|
95
95
|
here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
96
96
|
[ -f "$here/contract-context.json" ] && cat "$here/contract-context.json"
|
package/spec-cli/src/reviews.ts
CHANGED
|
@@ -42,6 +42,9 @@ export type PagedReview<T extends ReviewItem = ReviewItem> = {
|
|
|
42
42
|
|
|
43
43
|
export type EvalDetailReview = {
|
|
44
44
|
scope: string | null
|
|
45
|
+
requestedScope: string | null
|
|
46
|
+
scopeFallback: 'trunk' | null
|
|
47
|
+
availability: 'measured' | 'unmeasured' | 'missing'
|
|
45
48
|
selected: ReviewItem | null
|
|
46
49
|
history: ReviewItem[]
|
|
47
50
|
neighbors: {
|
|
@@ -56,6 +59,16 @@ export type EvalDetailReview = {
|
|
|
56
59
|
evalRevision?: SessionEvals['evalRevision']
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
type EvalDetailMetadata = {
|
|
63
|
+
scope?: string | null
|
|
64
|
+
requestedScope?: string | null
|
|
65
|
+
scopeFallback?: 'trunk' | null
|
|
66
|
+
summary?: SessionEvals['summary']
|
|
67
|
+
evalRevision?: SessionEvals['evalRevision']
|
|
68
|
+
// the scope's whole measured sequence, when `items` deliberately holds only the rendered window
|
|
69
|
+
sequence?: { node: string; scenario: string }[]
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
const revisionOf = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
60
73
|
|
|
61
74
|
export function reviewPageNumber(value: unknown): number {
|
|
@@ -113,7 +126,7 @@ export function paginateReview<T extends ReviewItem>(
|
|
|
113
126
|
pageCount,
|
|
114
127
|
prev: page > 1 ? page - 1 : null,
|
|
115
128
|
next: page < pageCount || overflow ? page + 1 : null,
|
|
116
|
-
revision: revisionOf(revisionInputs),
|
|
129
|
+
revision: revisionOf({ page, source: revisionInputs }),
|
|
117
130
|
...responseModel(model),
|
|
118
131
|
}
|
|
119
132
|
}
|
|
@@ -259,16 +272,15 @@ export function projectEvalDetail(
|
|
|
259
272
|
historySource: ReviewItem[],
|
|
260
273
|
node: string,
|
|
261
274
|
scenario: string,
|
|
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
|
-
} = {},
|
|
275
|
+
metadata: EvalDetailMetadata = {},
|
|
269
276
|
): EvalDetailReview {
|
|
270
277
|
const results = items.filter((item: any) => item.filterKind === EVAL_FILTER_KIND.RESULT)
|
|
271
278
|
const selected = results.find((item) => evalItemKey(item) === `${node}\0${scenario}`) ?? null
|
|
279
|
+
const availability = selected
|
|
280
|
+
? 'measured'
|
|
281
|
+
: items.some((item: any) => evalItemKey(item) === `${node}\0${scenario}` && item.filterKind === EVAL_FILTER_KIND.BLIND)
|
|
282
|
+
? 'unmeasured'
|
|
283
|
+
: 'missing'
|
|
272
284
|
const history = historySource.filter((reading: any) => String(reading.scenario) === scenario)
|
|
273
285
|
const stateByKey = new Map(results.map((item: any) => [evalItemKey(item), String(item.state ?? evalReviewState(item))]))
|
|
274
286
|
const sequence = metadata.sequence
|
|
@@ -276,12 +288,17 @@ export function projectEvalDetail(
|
|
|
276
288
|
const neighbors = boundedEvalNeighbors(sequence, node, scenario,
|
|
277
289
|
(row) => stateByKey.get(evalItemKey(row)) ?? 'empty')
|
|
278
290
|
const scope = metadata.scope ?? null
|
|
291
|
+
const requestedScope = metadata.requestedScope ?? scope
|
|
292
|
+
const scopeFallback = metadata.scopeFallback ?? null
|
|
279
293
|
return {
|
|
280
294
|
scope,
|
|
295
|
+
requestedScope,
|
|
296
|
+
scopeFallback,
|
|
297
|
+
availability,
|
|
281
298
|
selected,
|
|
282
299
|
history,
|
|
283
300
|
neighbors,
|
|
284
|
-
revision: revisionOf({ scope, selected, history, neighbors, summary: metadata.summary, evalRevision: metadata.evalRevision }),
|
|
301
|
+
revision: revisionOf({ scope, requestedScope, scopeFallback, availability, selected, history, neighbors, summary: metadata.summary, evalRevision: metadata.evalRevision }),
|
|
285
302
|
...(metadata.summary ? { summary: metadata.summary } : {}),
|
|
286
303
|
...(metadata.evalRevision ? { evalRevision: metadata.evalRevision } : {}),
|
|
287
304
|
}
|
|
@@ -306,45 +323,51 @@ export function focusNodes(order: SessionEvalOrderRow[], node: string, scenario:
|
|
|
306
323
|
return [...new Set([node, ...sequence.slice(Math.max(0, index - 6), index + 7).map((row) => row.node)])]
|
|
307
324
|
}
|
|
308
325
|
|
|
309
|
-
|
|
326
|
+
async function trunkEvalDetailReview(node: string, scenario: string, metadata: EvalDetailMetadata = {}): Promise<EvalDetailReview> {
|
|
327
|
+
await getBoard()
|
|
328
|
+
const snapshot = readReviewSnapshot()
|
|
329
|
+
const sourceNode = snapshot.evalNodes.find((candidate) => candidate.id === node)
|
|
330
|
+
return projectEvalDetail(trunkEvalReviewItems(snapshot.evalNodes), sourceNode?.readings ?? [], node, scenario, metadata)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function evalDetailReview(node: string, scenario: string, scope?: string | null): Promise<EvalDetailReview> {
|
|
310
334
|
if (scope) {
|
|
311
335
|
// A detail renders ONE row plus at most five neighbours, but owes the whole population's index/total.
|
|
312
336
|
// So name the window from the freshness-free sequence and let only those nodes pay the freshness pass;
|
|
313
337
|
// a build that finds a full cached model ignores the pick and answers from it instead.
|
|
314
338
|
const model = await buildSessionEvals(scope, (order) => focusNodes(order, node, scenario))
|
|
315
|
-
if (!model) return
|
|
339
|
+
if (!model) return trunkEvalDetailReview(node, scenario, { requestedScope: scope, scopeFallback: 'trunk' })
|
|
316
340
|
const sourceNode = model.nodes.find((candidate) => candidate.id === node)
|
|
317
341
|
return projectEvalDetail(scopedEvalReviewItems(model), sourceNode?.evals ?? [], node, scenario, {
|
|
318
342
|
scope,
|
|
343
|
+
requestedScope: scope,
|
|
319
344
|
summary: model.summary,
|
|
320
345
|
evalRevision: model.evalRevision,
|
|
321
346
|
...(model.order ? { sequence: measuredSequence(model.order) } : {}),
|
|
322
347
|
})
|
|
323
348
|
}
|
|
324
|
-
|
|
325
|
-
const snapshot = readReviewSnapshot()
|
|
326
|
-
const sourceNode = snapshot.evalNodes.find((candidate) => candidate.id === node)
|
|
327
|
-
return projectEvalDetail(trunkEvalReviewItems(snapshot.evalNodes), sourceNode?.readings ?? [], node, scenario)
|
|
349
|
+
return trunkEvalDetailReview(node, scenario)
|
|
328
350
|
}
|
|
329
351
|
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
352
|
+
export function timelineEvalReviewItems(timeline: Awaited<ReturnType<typeof evalTimeline>>, node: string): ReviewItem[] {
|
|
353
|
+
const declared = new Set(timeline.scenarios.map((scenario) => scenario.name))
|
|
354
|
+
const latest = new Map<string, any>()
|
|
355
|
+
for (const reading of timeline.readings) {
|
|
356
|
+
if (declared.has(reading.scenario) && !latest.has(reading.scenario)) latest.set(reading.scenario, reading)
|
|
357
|
+
}
|
|
358
|
+
return [
|
|
359
|
+
...timeline.scenarios.filter((scenario) => !latest.has(scenario.name)).map((scenario) => ({
|
|
337
360
|
...scenario,
|
|
338
361
|
scenario: scenario.name,
|
|
339
362
|
node,
|
|
340
363
|
filterKind: EVAL_FILTER_KIND.UNMEASURED,
|
|
341
364
|
})),
|
|
342
|
-
...
|
|
365
|
+
...[...latest.values()].map((reading) => ({
|
|
343
366
|
...reading,
|
|
344
367
|
state: evalReviewState(reading),
|
|
345
368
|
node,
|
|
346
369
|
filterKind: EVAL_FILTER_KIND.RESULT,
|
|
347
|
-
filterKey: `${EVAL_FILTER_KIND.RESULT}:${
|
|
370
|
+
filterKey: `${EVAL_FILTER_KIND.RESULT}:${reading.scenario}`,
|
|
348
371
|
})),
|
|
349
372
|
...(timeline.dangling ?? []).map((track) => ({
|
|
350
373
|
...track,
|
|
@@ -353,6 +376,13 @@ async function timelineEvalReview(text: string, requestedPage: unknown) {
|
|
|
353
376
|
filterKey: `${EVAL_FILTER_KIND.DANGLING}:${track.threadId}`,
|
|
354
377
|
})),
|
|
355
378
|
]
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function timelineEvalReview(text: string, requestedPage: unknown) {
|
|
382
|
+
const node = readToken(text, 'node')
|
|
383
|
+
if (!node) return null
|
|
384
|
+
const [timeline, sessions] = await Promise.all([evalTimeline(node), listSessions()])
|
|
385
|
+
const items = timelineEvalReviewItems(timeline, node)
|
|
356
386
|
const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
|
|
357
387
|
return {
|
|
358
388
|
scope: null,
|
|
@@ -379,14 +409,13 @@ export async function evalsReview(query: string | undefined, requestedPage: unkn
|
|
|
379
409
|
const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
|
|
380
410
|
return {
|
|
381
411
|
scope,
|
|
382
|
-
gates:
|
|
412
|
+
gates: [],
|
|
383
413
|
unknown: model.nodes.reduce((count, node) => count + (node.unknownCoverage?.length ?? 0), 0),
|
|
384
414
|
summary: model.summary,
|
|
385
415
|
evalRevision: model.evalRevision,
|
|
386
|
-
impact: model.impact,
|
|
387
416
|
...paginateReview(items, filtered.shown, filtered, requestedPage, {
|
|
388
|
-
domain: 'evals', scope,
|
|
389
|
-
evalRevision: model.evalRevision,
|
|
417
|
+
domain: 'evals', scope, query: text, gates: [], summary: model.summary,
|
|
418
|
+
evalRevision: model.evalRevision, sessions: sessions.map((session) => session.id),
|
|
390
419
|
}),
|
|
391
420
|
}
|
|
392
421
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { streamSSE } from 'hono/streaming'
|
|
2
|
+
import type { Context } from 'hono'
|
|
3
|
+
import { harnessById } from './harness.js'
|
|
4
|
+
import { readAliasedRawRecord } from './layout.js'
|
|
5
|
+
import { currentHumanTurn } from './session-timeline.js'
|
|
6
|
+
import type { ExecutionStep, ExecutionTurn } from './execution-trace.js'
|
|
7
|
+
|
|
8
|
+
export type SessionExecution = Readonly<{
|
|
9
|
+
revision: string
|
|
10
|
+
turnId: string | null
|
|
11
|
+
workingNote: string | null
|
|
12
|
+
steps: readonly ExecutionStep[]
|
|
13
|
+
}>
|
|
14
|
+
|
|
15
|
+
const absentExecution = (turn: ExecutionTurn | null = null): SessionExecution => ({ revision: `turn:${turn?.token ?? '0'}`, turnId: turn?.token ?? null, workingNote: null, steps: [] })
|
|
16
|
+
|
|
17
|
+
// This is deliberately a read projection. Native transcript bytes stay within the adapter, while the API owns
|
|
18
|
+
// only enough information for the frontend's fixed renderer to paint the current execution slice.
|
|
19
|
+
export function readSessionExecution(id: string): SessionExecution | null {
|
|
20
|
+
let record: ReturnType<typeof readAliasedRawRecord>
|
|
21
|
+
try { record = readAliasedRawRecord(id) } catch { return null }
|
|
22
|
+
if (!record?.governed) return null
|
|
23
|
+
const turn = currentHumanTurn(id)
|
|
24
|
+
const threadId = typeof record.harness_session_id === 'string' ? record.harness_session_id : ''
|
|
25
|
+
if (!threadId) return absentExecution(turn)
|
|
26
|
+
try {
|
|
27
|
+
const trace = harnessById(typeof record.harness === 'string' && record.harness ? record.harness : 'claude')
|
|
28
|
+
.executionTrace(threadId, turn)
|
|
29
|
+
return trace || absentExecution(turn)
|
|
30
|
+
} catch {
|
|
31
|
+
// A missing/unreadable native source is an absent transient trace, not a synthetic conversation failure.
|
|
32
|
+
return absentExecution(turn)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TRACE_TICK_MS = 500
|
|
37
|
+
const HEARTBEAT_TICKS = 20
|
|
38
|
+
|
|
39
|
+
export async function sessionExecutionStream(c: Context) {
|
|
40
|
+
const id = c.req.param('id') || ''
|
|
41
|
+
if (!readSessionExecution(id)) return c.json({ error: 'no such session' }, 404)
|
|
42
|
+
return streamSSE(c, async (stream) => {
|
|
43
|
+
let aborted = false
|
|
44
|
+
let lastRevision = ''
|
|
45
|
+
let ticks = 0
|
|
46
|
+
const publish = async (): Promise<boolean> => {
|
|
47
|
+
const execution = readSessionExecution(id)
|
|
48
|
+
if (!execution) return false
|
|
49
|
+
if (execution.revision === lastRevision) return true
|
|
50
|
+
lastRevision = execution.revision
|
|
51
|
+
await stream.writeSSE({ event: 'execution', data: JSON.stringify(execution) })
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
stream.onAbort(() => { aborted = true })
|
|
56
|
+
try {
|
|
57
|
+
if (!await publish()) return
|
|
58
|
+
while (!aborted) {
|
|
59
|
+
await stream.sleep(TRACE_TICK_MS)
|
|
60
|
+
if (aborted) break
|
|
61
|
+
if (!await publish()) break
|
|
62
|
+
if (++ticks % HEARTBEAT_TICKS === 0) await stream.writeSSE({ event: 'ping', data: 'x' })
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// EventSource reconnects a dropped stream. A native trace read must never take down the session server.
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
}
|