spexcode 0.6.0 → 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.
Files changed (88) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +239 -75
  6. package/spec-cli/src/client.ts +113 -14
  7. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  8. package/spec-cli/src/commit-surgery.ts +2 -1
  9. package/spec-cli/src/contract-filter.ts +58 -42
  10. package/spec-cli/src/delivery-queue.ts +62 -4
  11. package/spec-cli/src/doctor.ts +2 -1
  12. package/spec-cli/src/execution-trace.ts +444 -0
  13. package/spec-cli/src/file-write.ts +22 -0
  14. package/spec-cli/src/gateway-hub.ts +10 -8
  15. package/spec-cli/src/gateway.ts +99 -6
  16. package/spec-cli/src/git.ts +503 -105
  17. package/spec-cli/src/graph.ts +56 -6
  18. package/spec-cli/src/graphCache.ts +86 -17
  19. package/spec-cli/src/graphStream.ts +79 -11
  20. package/spec-cli/src/guide.ts +93 -4
  21. package/spec-cli/src/harness-select.ts +2 -2
  22. package/spec-cli/src/harness.ts +355 -75
  23. package/spec-cli/src/help.ts +30 -23
  24. package/spec-cli/src/host-resources.ts +62 -7
  25. package/spec-cli/src/identity-presets.js +16 -6
  26. package/spec-cli/src/index.ts +109 -14
  27. package/spec-cli/src/init.ts +8 -17
  28. package/spec-cli/src/layout.ts +151 -40
  29. package/spec-cli/src/lint.ts +37 -7
  30. package/spec-cli/src/materialize.ts +160 -112
  31. package/spec-cli/src/plugin-harness.ts +29 -18
  32. package/spec-cli/src/process-identity.ts +13 -0
  33. package/spec-cli/src/reviews.ts +103 -30
  34. package/spec-cli/src/session-execution.ts +68 -0
  35. package/spec-cli/src/session-files.ts +121 -0
  36. package/spec-cli/src/session-follow.ts +4 -4
  37. package/spec-cli/src/session-reparent.ts +33 -0
  38. package/spec-cli/src/session-timeline.ts +163 -14
  39. package/spec-cli/src/session-web.ts +135 -0
  40. package/spec-cli/src/sessions.ts +748 -111
  41. package/spec-cli/src/uninstall.ts +3 -2
  42. package/spec-cli/templates/hooks/post-checkout +3 -0
  43. package/spec-cli/templates/hooks/post-merge +1 -0
  44. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  46. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  47. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  48. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  49. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  50. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  51. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  53. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
  54. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  56. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
  57. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  58. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
  59. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
  60. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
  61. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  62. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
  63. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  64. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  65. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  66. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
  67. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  68. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
  69. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  70. package/spec-dashboard/dist/index.html +2 -2
  71. package/spec-dashboard/src/reviewFilters.js +5 -0
  72. package/spec-dashboard/src/session.js +5 -1
  73. package/spec-eval/src/cli.ts +13 -9
  74. package/spec-eval/src/evaltab.ts +32 -10
  75. package/spec-eval/src/freshness.ts +404 -81
  76. package/spec-eval/src/scenariofresh.ts +58 -8
  77. package/spec-eval/src/scenarios.ts +60 -12
  78. package/spec-eval/src/sessioneval.ts +234 -148
  79. package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
  80. package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
  81. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
  82. package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
  83. package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
  84. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  85. package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
  86. package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
  87. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
  88. package/spec-eval/src/matrix.ts +0 -693
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { git, gitA, gitTry, headSha } from '../../spec-cli/src/git.js'
2
+ import { batchBlobTexts, batchRevisionOids, git, gitA, gitTry, headSha } from '../../spec-cli/src/git.js'
3
3
  import { parseScenarios } from './scenarios.js'
4
4
  import { rootSlots, touchRoot as touchRootLru } from '../../spec-cli/src/root-lru.js'
5
5
 
@@ -159,25 +159,75 @@ function oidAt(root: string, rev: string, path: string): string {
159
159
  return v
160
160
  }
161
161
 
162
+ // @@@ the memo holds SETTLED oids, so concurrent askers need a flight to join - primes run concurrently
163
+ // across the whole scope, and every one of them missed the memo while the first child was still running:
164
+ // measured on adopter-a's 415-node scope, 2695 `rev-parse` children resolved 1212 distinct (rev, path) pairs.
165
+ // `rev:path` under a full sha names an immutable object, so joining answers the same question; the LRU
166
+ // write still happens once, on settle, exactly as the sync path does it.
167
+ const oidFlight = new Map<string, Promise<string>>()
162
168
  async function oidAtAsync(root: string, rev: string, path: string): Promise<string> {
163
169
  if (!FULL_SHA.test(rev)) return (await gitTry(['-C', root, 'rev-parse', `${rev}:${path}`])).stdout.trim()
164
170
  const k = `${root}\x1f${rev}\x1f${path}`
165
171
  const hit = oidMemo.get(k)
166
172
  if (hit !== undefined) { oidMemo.delete(k); oidMemo.set(k, hit); return hit }
167
- const result = await gitTry(['-C', root, 'rev-parse', `${rev}:${path}`])
168
- const oid = result.ok ? result.stdout.trim() : ''
169
- oidMemo.set(k, oid)
170
- if (oidMemo.size > 4096) oidMemo.delete(oidMemo.keys().next().value!)
171
- return oid
173
+ const joined = oidFlight.get(k)
174
+ if (joined) return joined
175
+ const run = async (): Promise<string> => {
176
+ const result = await gitTry(['-C', root, 'rev-parse', `${rev}:${path}`])
177
+ const oid = result.ok ? result.stdout.trim() : ''
178
+ oidMemo.set(k, oid)
179
+ if (oidMemo.size > 4096) oidMemo.delete(oidMemo.keys().next().value!)
180
+ return oid
181
+ }
182
+ const flight = run().finally(() => { if (oidFlight.get(k) === flight) oidFlight.delete(k) })
183
+ oidFlight.set(k, flight)
184
+ return flight
172
185
  }
173
186
 
174
187
 
188
+ // @@@ the PLURAL prime, mirroring the anchor probe's - a caller hands over every (rev, path) its whole read
189
+ // will ask about and TWO children answer them all: `cat-file --batch-check` for the object ids, then
190
+ // `cat-file --batch` for the blobs. The singular form below is one demand's worth of the same thing, kept
191
+ // for callers that genuinely learn their demand one at a time. Priming one at a time is what this replaces:
192
+ // on adopter-a's 415-node scope it forked 1212 `rev-parse` children plus a `cat-file` each, and the batch
193
+ // answers the identical set — same oids, same blocks, same verdicts, two children.
194
+ export async function primeScenarioBlocks(root: string, demands: readonly { rev: string; path: string }[]): Promise<void> {
195
+ const wanted = new Map<string, { rev: string; path: string }>()
196
+ for (const d of demands) {
197
+ if (!FULL_SHA.test(d.rev) || !d.path) continue // a symbolic rev is never memoized, so it can't ride a batch
198
+ const k = `${root}\x1f${d.rev}\x1f${d.path}`
199
+ if (!oidMemo.has(k) && !wanted.has(k)) wanted.set(k, d)
200
+ }
201
+ if (!wanted.size) return
202
+ const keys = [...wanted.keys()]
203
+ const rows = [...wanted.values()]
204
+ const oids = await batchRevisionOids(root, rows.map((d) => `${d.rev}:${d.path}`))
205
+ oids.forEach((oid, i) => {
206
+ oidMemo.set(keys[i], oid ?? '')
207
+ if (oidMemo.size > 4096) oidMemo.delete(oidMemo.keys().next().value!)
208
+ })
209
+ const missing = [...new Set(oids.filter((o): o is string => !!o && !blockByOid.has(o)))]
210
+ if (!missing.length) return
211
+ const texts = await batchBlobTexts(root, missing)
212
+ for (const [oid, src] of texts) if (src) blockByOid.set(oid, blockContent(src))
213
+ }
214
+
215
+ // the blob read needs the same flight as the oid lookup above it: an oid is content, so two primes that
216
+ // resolved the same one must not each read it.
217
+ const blockFlight = new Map<string, Promise<void>>()
175
218
  export async function primeScenarioBlocksAt(root: string, revs: string[], path: string): Promise<void> {
176
219
  for (const rev of revs) {
177
220
  const oid = await oidAtAsync(root, rev, path)
178
221
  if (!oid || blockByOid.has(oid)) continue
179
- const src = await gitA(['-C', root, 'cat-file', 'blob', oid]) // dead-words-ok: git plumbing
180
- if (src) blockByOid.set(oid, blockContent(src))
222
+ const joined = blockFlight.get(oid)
223
+ if (joined) { await joined; continue }
224
+ const run = async (): Promise<void> => {
225
+ const src = await gitA(['-C', root, 'cat-file', 'blob', oid]) // dead-words-ok: git plumbing
226
+ if (src) blockByOid.set(oid, blockContent(src))
227
+ }
228
+ const flight = run().finally(() => { if (blockFlight.get(oid) === flight) blockFlight.delete(oid) })
229
+ blockFlight.set(oid, flight)
230
+ await flight
181
231
  }
182
232
  }
183
233
 
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs'
2
2
  import { readFile, readdir } from 'node:fs/promises'
3
3
  import { createHash } from 'node:crypto'
4
4
  import { join, relative, basename } from 'node:path'
5
- import { mintIds } from '../../spec-cli/src/specs.js'
5
+ import { mintIds, parseFrontmatter } from '../../spec-cli/src/specs.js'
6
6
  import { parseRelation, type RelationEntry } from '../../spec-cli/src/anchors.js'
7
7
  import { treeTextFiles } from '../../spec-cli/src/git.js'
8
8
 
@@ -41,13 +41,20 @@ export type ScenarioProjectionRow = {
41
41
  semantic: ScenarioSemanticRow
42
42
  measurement: ScenarioMeasurementRow
43
43
  }
44
+ export type ScenarioProjectionNode = {
45
+ id: string
46
+ code: RelationEntry[]
47
+ related: RelationEntry[]
48
+ }
44
49
  export type ScenarioProjectionProvenance = { head: string | null; treeSha: string | null }
45
50
  export type ScenarioProjection = {
46
51
  projection: typeof SCENARIO_PROJECTION
47
52
  schemaVersion: typeof SCENARIO_SCHEMA_VERSION
48
53
  provenance: ScenarioProjectionProvenance
54
+ nodes: ScenarioProjectionNode[]
49
55
  semanticIndexHash: string
50
56
  fullIndexHash: string
57
+ planningIndexHash: string
51
58
  rows: ScenarioProjectionRow[]
52
59
  }
53
60
 
@@ -58,6 +65,7 @@ export type EvalNode = {
58
65
  sidecarPath: string
59
66
  scenarios: Scenario[]
60
67
  evalSource?: string
68
+ specSource?: string
61
69
  }
62
70
 
63
71
  const SCENARIO_KEYS = ['name', 'description', 'expected', 'tags', 'test', 'code', 'related'] as const
@@ -349,11 +357,29 @@ const hashProjection = (value: unknown): string =>
349
357
  createHash('sha256').update(JSON.stringify(value), 'utf8').digest('hex')
350
358
 
351
359
  const semanticOnly = (row: ScenarioProjectionRow): ScenarioSemanticRow => row.semantic
360
+ const frontmatterList = (value: string | string[] | undefined): string[] =>
361
+ Array.isArray(value) ? value : value ? [value] : []
362
+
363
+ function projectionNode(node: Pick<EvalNode, 'id' | 'specSource'>): ScenarioProjectionNode {
364
+ const fm = node.specSource ? parseFrontmatter(node.specSource).fm : {}
365
+ const code = parseRelation(frontmatterList(fm.code), 'code')
366
+ const related = parseRelation(frontmatterList(fm.related), 'related')
367
+ const problems = [...code.problems, ...related.problems]
368
+ if (problems.length) {
369
+ throw new Error(`node '${node.id}' has malformed spec relations:\n${problems.map((e) => ` - ${e}`).join('\n')}`)
370
+ }
371
+ return {
372
+ id: node.id,
373
+ code: code.entries.map((entry) => ({ path: entry.path, selectors: [...entry.selectors] })),
374
+ related: related.entries.map((entry) => ({ path: entry.path, selectors: [...entry.selectors] })),
375
+ }
376
+ }
352
377
 
353
378
  export function scenarioProjection(
354
- nodes: readonly Pick<EvalNode, 'id' | 'scenarios' | 'evalSource'>[],
379
+ nodes: readonly Pick<EvalNode, 'id' | 'scenarios' | 'evalSource' | 'specSource'>[],
355
380
  provenance: Partial<ScenarioProjectionProvenance> = {},
356
381
  ): ScenarioProjection {
382
+ const nodeRows = nodes.map(projectionNode).sort((a, b) => compareStable(a.id, b.id))
357
383
  const rows: ScenarioProjectionRow[] = []
358
384
  for (const node of nodes) {
359
385
  if ('evalSource' in node && node.evalSource !== undefined) {
@@ -388,8 +414,10 @@ export function scenarioProjection(
388
414
  projection: SCENARIO_PROJECTION,
389
415
  schemaVersion: SCENARIO_SCHEMA_VERSION,
390
416
  provenance: { head: provenance.head ?? null, treeSha: provenance.treeSha ?? null },
417
+ nodes: nodeRows,
391
418
  semanticIndexHash: hashProjection(semanticRows),
392
419
  fullIndexHash: hashProjection(rows),
420
+ planningIndexHash: hashProjection({ nodes: nodeRows, rows }),
393
421
  rows,
394
422
  }
395
423
  }
@@ -543,18 +571,19 @@ export function writeScenarioMeasurementMetadata(source: string, request: unknow
543
571
  return proposed
544
572
  }
545
573
 
546
- function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string }[]): EvalNode[] {
574
+ function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string; specSource?: string }[]): EvalNode[] {
547
575
  const specBase = join(root, '.spec')
548
576
  const ids = mintIds(specDirs.map((d) => relative(specBase, d).split(/[/\\]/)))
549
577
  const idByDir = new Map(specDirs.map((d, i) => [d, ids[i]]))
550
578
  return hits
551
- .map(({ dir, src }) => ({
579
+ .map(({ dir, src, specSource }) => ({
552
580
  id: idByDir.get(dir) ?? basename(dir),
553
581
  dir,
554
582
  evalPath: relative(root, join(dir, EVAL_FILE)),
555
583
  sidecarPath: join(dir, SIDECAR_FILE),
556
584
  scenarios: parseScenarios(src),
557
585
  evalSource: src,
586
+ ...(specSource !== undefined ? { specSource } : {}),
558
587
  }))
559
588
  .sort((a, b) => a.id.localeCompare(b.id))
560
589
  }
@@ -562,14 +591,21 @@ function assembleNodes(root: string, specDirs: string[], hits: { dir: string; sr
562
591
  export function evalNodes(root: string): EvalNode[] {
563
592
  const specDir = join(root, '.spec')
564
593
  const specDirs: string[] = []
565
- const hits: { dir: string; src: string }[] = []
594
+ const hits: { dir: string; src: string; specSource?: string }[] = []
566
595
  const stack = existsSync(specDir) ? [specDir] : []
567
596
  while (stack.length) {
568
597
  const dir = stack.pop()!
569
598
  let ents
570
599
  try { ents = readdirSync(dir, { withFileTypes: true }) } catch { continue }
571
600
  if (existsSync(join(dir, 'spec.md'))) specDirs.push(dir)
572
- if (existsSync(join(dir, EVAL_FILE))) hits.push({ dir, src: readFileSync(join(dir, EVAL_FILE), 'utf8') })
601
+ if (existsSync(join(dir, EVAL_FILE))) {
602
+ const specPath = join(dir, 'spec.md')
603
+ hits.push({
604
+ dir,
605
+ src: readFileSync(join(dir, EVAL_FILE), 'utf8'),
606
+ ...(existsSync(specPath) ? { specSource: readFileSync(specPath, 'utf8') } : {}),
607
+ })
608
+ }
573
609
  for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
574
610
  }
575
611
  return assembleNodes(root, specDirs, hits)
@@ -583,10 +619,15 @@ export function evalNodesAt(root: string, tip: string): EvalNode[] {
583
619
  .map((path) => join(root, path.slice(0, -'/spec.md'.length)))
584
620
  const hits = paths
585
621
  .filter((path) => path.endsWith(`/${EVAL_FILE}`))
586
- .map((path) => ({
587
- dir: join(root, path.slice(0, -`/${EVAL_FILE}`.length)),
588
- src: files.get(path)!,
589
- }))
622
+ .map((path) => {
623
+ const relDir = path.slice(0, -`/${EVAL_FILE}`.length)
624
+ const specSource = files.get(`${relDir}/spec.md`)
625
+ return {
626
+ dir: join(root, relDir),
627
+ src: files.get(path)!,
628
+ ...(specSource !== undefined ? { specSource } : {}),
629
+ }
630
+ })
590
631
  return assembleNodes(root, specDirs, hits)
591
632
  }
592
633
 
@@ -597,14 +638,21 @@ export function evalNodesAt(root: string, tip: string): EvalNode[] {
597
638
  export async function evalNodesAsync(root: string): Promise<EvalNode[]> {
598
639
  const specDir = join(root, '.spec')
599
640
  const specDirs: string[] = []
600
- const hits: { dir: string; src: string }[] = []
641
+ const hits: { dir: string; src: string; specSource?: string }[] = []
601
642
  const stack = existsSync(specDir) ? [specDir] : []
602
643
  while (stack.length) {
603
644
  const dir = stack.pop()!
604
645
  let ents
605
646
  try { ents = await readdir(dir, { withFileTypes: true }) } catch { continue }
606
647
  if (existsSync(join(dir, 'spec.md'))) specDirs.push(dir)
607
- if (existsSync(join(dir, EVAL_FILE))) hits.push({ dir, src: await readFile(join(dir, EVAL_FILE), 'utf8') })
648
+ if (existsSync(join(dir, EVAL_FILE))) {
649
+ const specPath = join(dir, 'spec.md')
650
+ hits.push({
651
+ dir,
652
+ src: await readFile(join(dir, EVAL_FILE), 'utf8'),
653
+ ...(existsSync(specPath) ? { specSource: await readFile(specPath, 'utf8') } : {}),
654
+ })
655
+ }
608
656
  for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
609
657
  }
610
658
  return assembleNodes(root, specDirs, hits)