spexcode 0.5.6 → 0.5.8

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 (34) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/anchors.ts +48 -38
  3. package/spec-cli/src/cli.ts +18 -1
  4. package/spec-cli/src/client.ts +21 -0
  5. package/spec-cli/src/git.ts +380 -198
  6. package/spec-cli/src/harness.ts +116 -24
  7. package/spec-cli/src/help.ts +3 -1
  8. package/spec-cli/src/index.ts +41 -7
  9. package/spec-cli/src/layout.ts +2 -0
  10. package/spec-cli/src/lint.ts +38 -28
  11. package/spec-cli/src/mentions.ts +10 -5
  12. package/spec-cli/src/process-identity.ts +20 -0
  13. package/spec-cli/src/session-maintenance.ts +2 -1
  14. package/spec-cli/src/sessions.ts +898 -140
  15. package/spec-cli/src/specs.ts +3 -3
  16. package/spec-dashboard/dist/assets/{App-B72LuS5I.js → App-u2P7KdSg.js} +2 -2
  17. package/spec-dashboard/dist/assets/{Dashboard-C5X4Va3V.js → Dashboard-B8wp5_61.js} +3 -3
  18. package/spec-dashboard/dist/assets/{EvalsPage-BTvJIW8Q.js → EvalsPage-Bq1Tkb8y.js} +1 -1
  19. package/spec-dashboard/dist/assets/{IssuesPage-Bn94h_HQ.js → IssuesPage-BlkPSkmv.js} +1 -1
  20. package/spec-dashboard/dist/assets/{MobileApp-ClbtwZ1e.js → MobileApp-B1GxRZXK.js} +2 -2
  21. package/spec-dashboard/dist/assets/{Modal-6l_QtCKF.js → Modal-bAkq9IIT.js} +1 -1
  22. package/spec-dashboard/dist/assets/{PageScroll-B2kxcqJJ.js → PageScroll-px_rUZVJ.js} +1 -1
  23. package/spec-dashboard/dist/assets/{ProjectsPage-C8IPsMKV.js → ProjectsPage-8uGqYM12.js} +1 -1
  24. package/spec-dashboard/dist/assets/SessionInterface-CswwbewF.js +39 -0
  25. package/spec-dashboard/dist/assets/{SessionWindow-Dag_GiJB.js → SessionWindow-IspcLjFA.js} +1 -1
  26. package/spec-dashboard/dist/assets/{Settings-J3aibcXo.js → Settings-bpAbfnmS.js} +1 -1
  27. package/spec-dashboard/dist/assets/{Thread-Dg35J-Pu.js → Thread-BpL3N3kw.js} +11 -11
  28. package/spec-dashboard/dist/assets/{TimelineChat-f0UF9fXq.js → TimelineChat-Ckmb1Ez2.js} +1 -1
  29. package/spec-dashboard/dist/assets/{data-SNi0AmVT.js → data-CQFbQEMH.js} +1 -1
  30. package/spec-dashboard/dist/assets/index-CixSnz1H.css +1 -0
  31. package/spec-dashboard/dist/assets/{index-BUKLPN_4.js → index-Di1ch5dd.js} +5 -5
  32. package/spec-dashboard/dist/index.html +2 -2
  33. package/spec-dashboard/dist/assets/SessionInterface-B5jf7dW7.js +0 -39
  34. package/spec-dashboard/dist/assets/index-CzutlTDf.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -505,15 +505,24 @@ export function windowEvents(idx: DriftIndex, sinceHash: string, path: string, n
505
505
  // through the current filename. Several selectors are OR'd and one commit still produces one hit row.
506
506
  // An historical image the designated extractor cannot parse is a conservative hit (`unparseable`).
507
507
  export type AnchorHit = { commit: string; selectors: string[]; unparseable?: string }
508
- export async function anchorHitCommits(root: string, win: DriftPathEvent[], symbols: string[], regs: Extractor[]): Promise<AnchorHit[]> {
509
- const hits = new Map<string, { selectors: Set<string>; unparseable?: string }>()
508
+ export type AnchorHitQuery = { win: DriftPathEvent[]; symbols: string[] }
509
+ type AnchorRevision = { commit: string; path: string }
510
+ const anchorRevisionKey = ({ commit, path }: AnchorRevision) => `${commit}\0${path}`
511
+
512
+ // One lint can judge several selectors with overlapping windows. Their Git images and ordinary hunks are
513
+ // immutable facts, so the batch owns them once and each query keeps its own selector verdict.
514
+ export async function anchorHitQueries(root: string, queries: AnchorHitQuery[], regs: Extractor[]): Promise<AnchorHit[][]> {
515
+ if (!queries.length) return []
510
516
  const objectFormat = gitObjectFormat(root)
511
- type Revision = { commit: string; path: string }
512
- const revisionKey = ({ commit, path }: Revision) => `${commit}\0${path}`
513
- const revisions = new Map<string, Revision>()
514
- for (const event of win) {
517
+ const revisions = new Map<string, AnchorRevision>()
518
+ const ordinaryByPath = new Map<string, Set<string>>()
519
+ for (const { win } of queries) for (const event of win) {
515
520
  const refs = [{ commit: event.commit, path: event.historicalPath }, ...event.parents.map(({ commit, historicalPath }) => ({ commit, path: historicalPath }))]
516
- for (const ref of refs) revisions.set(revisionKey(ref), ref)
521
+ for (const ref of refs) revisions.set(anchorRevisionKey(ref), ref)
522
+ if (event.parents.length > 1 || event.parents.some((parent) => parent.historicalPath !== event.historicalPath)) continue
523
+ const commits = ordinaryByPath.get(event.historicalPath) ?? new Set<string>()
524
+ commits.add(event.commit)
525
+ ordinaryByPath.set(event.historicalPath, commits)
517
526
  }
518
527
  const refs = [...revisions.values()]
519
528
  const oids = batchRevisionOids(root, refs.map(({ commit, path }) => `${commit}:${path}`))
@@ -524,44 +533,45 @@ export async function anchorHitCommits(root: string, win: DriftPathEvent[], symb
524
533
  const x = extractorFor(regs, extOf(ref.path))
525
534
  const ready = x?.ready()
526
535
  if (!x || ready !== true) {
527
- units.set(revisionKey(ref), { unparseable: !x ? `no designated extractor for ${ref.path}` : String(ready) })
536
+ units.set(anchorRevisionKey(ref), { unparseable: !x ? `no designated extractor for ${ref.path}` : String(ready) })
528
537
  continue
529
538
  }
530
- units.set(revisionKey(ref), await unitsAtFileRevision(ref.commit, ref.path, x, objectFormat, oid, oid ? blobs.get(oid) : undefined))
531
- }
532
- const byPath = new Map<string, string[]>()
533
- for (const event of win) {
534
- if (event.parents.length > 1 || event.parents.some((parent) => parent.historicalPath !== event.historicalPath)) continue
535
- const commits = byPath.get(event.historicalPath) ?? []
536
- commits.push(event.commit)
537
- byPath.set(event.historicalPath, commits)
539
+ units.set(anchorRevisionKey(ref), await unitsAtFileRevision(ref.commit, ref.path, x, objectFormat, oid, oid ? blobs.get(oid) : undefined))
538
540
  }
539
541
  const ordinaryHunks = new Map<string, Map<string, HunkRanges>>()
540
- for (const [path, commits] of byPath) ordinaryHunks.set(path, await hunksAtMany(root, commits, path))
542
+ for (const [path, commits] of ordinaryByPath) ordinaryHunks.set(path, await hunksAtMany(root, [...commits], path))
541
543
  const intersects = (ranges: DiffLineRange[], candidates: Unit[]) =>
542
544
  ranges.some(([start, end]) => candidates.some((unit) => start <= unit.end && unit.start <= end))
543
- for (const event of win) {
544
- const after = units.get(revisionKey({ commit: event.commit, path: event.historicalPath }))!
545
- const before = event.parents.map(({ commit, historicalPath }) => units.get(revisionKey({ commit, path: historicalPath }))!)
546
- const ranges = ordinaryHunks.get(event.historicalPath)?.get(event.commit)
547
- ?? await hunksAt(root, event)
548
- if (event.parents.length && ranges.before.length !== before.length)
549
- throw new Error(`anchor diff for ${event.commit}:${event.historicalPath} has ${ranges.before.length} parent ranges for ${before.length} parents`)
550
- const hit = hits.get(event.commit) ?? { selectors: new Set<string>() }
551
- const broken = [after, ...before].find((image) => 'unparseable' in image)
552
- if (broken && 'unparseable' in broken) {
553
- for (const symbol of symbols) hit.selectors.add(symbol)
554
- hit.unparseable = broken.unparseable
555
- } else {
556
- for (const symbol of symbols) {
557
- const afterUnits = 'units' in after ? after.units.filter((unit) => unit.name === symbol) : []
558
- const authoredAfter = intersects(ranges.after, afterUnits)
559
- const authoredBefore = before.length > 0 && before.every((image, parent) =>
560
- 'units' in image && intersects(ranges.before[parent], image.units.filter((unit) => unit.name === symbol)))
561
- if (authoredAfter || authoredBefore) hit.selectors.add(symbol)
545
+ const results: AnchorHit[][] = []
546
+ for (const { win, symbols } of queries) {
547
+ const hits = new Map<string, { selectors: Set<string>; unparseable?: string }>()
548
+ for (const event of win) {
549
+ const after = units.get(anchorRevisionKey({ commit: event.commit, path: event.historicalPath }))!
550
+ const before = event.parents.map(({ commit, historicalPath }) => units.get(anchorRevisionKey({ commit, path: historicalPath }))!)
551
+ const ranges = ordinaryHunks.get(event.historicalPath)?.get(event.commit)
552
+ ?? await hunksAt(root, event)
553
+ if (event.parents.length && ranges.before.length !== before.length)
554
+ throw new Error(`anchor diff for ${event.commit}:${event.historicalPath} has ${ranges.before.length} parent ranges for ${before.length} parents`)
555
+ const hit = hits.get(event.commit) ?? { selectors: new Set<string>() }
556
+ const broken = [after, ...before].find((image) => 'unparseable' in image)
557
+ if (broken && 'unparseable' in broken) {
558
+ for (const symbol of symbols) hit.selectors.add(symbol)
559
+ hit.unparseable = broken.unparseable
560
+ } else {
561
+ for (const symbol of symbols) {
562
+ const afterUnits = 'units' in after ? after.units.filter((unit) => unit.name === symbol) : []
563
+ const authoredAfter = intersects(ranges.after, afterUnits)
564
+ const authoredBefore = before.length > 0 && before.every((image, parent) =>
565
+ 'units' in image && intersects(ranges.before[parent], image.units.filter((unit) => unit.name === symbol)))
566
+ if (authoredAfter || authoredBefore) hit.selectors.add(symbol)
567
+ }
562
568
  }
569
+ if (hit.selectors.size) hits.set(event.commit, hit)
563
570
  }
564
- if (hit.selectors.size) hits.set(event.commit, hit)
571
+ results.push([...hits].map(([commit, hit]) => ({ commit, selectors: [...hit.selectors], ...(hit.unparseable ? { unparseable: hit.unparseable } : {}) })))
565
572
  }
566
- return [...hits].map(([commit, hit]) => ({ commit, selectors: [...hit.selectors], ...(hit.unparseable ? { unparseable: hit.unparseable } : {}) }))
573
+ return results
574
+ }
575
+ export async function anchorHitCommits(root: string, win: DriftPathEvent[], symbols: string[], regs: Extractor[]): Promise<AnchorHit[]> {
576
+ return (await anchorHitQueries(root, [{ win, symbols }], regs))[0]
567
577
  }
@@ -50,7 +50,7 @@ function flushExit(code = 0): Promise<never> {
50
50
  }
51
51
  const has = (name: string) => process.argv.includes(`--${name}`)
52
52
  // bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
53
- const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms'])
53
+ const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms', '--adapter', '--thread', '--tmux', '--worktree', '--branch'])
54
54
  function positionals(from: number): string[] {
55
55
  const out: string[] = []
56
56
  for (let i = from; i < process.argv.length; i++) {
@@ -873,6 +873,23 @@ if (cmd === 'serve') {
873
873
  const closed = await c.clientClose(full)
874
874
  if (!closed) { console.error(`spex session close: no such session ${full} (record remains; no close was committed)`); process.exit(1) }
875
875
  console.log(`closed ${full}`)
876
+ } else if (sub === 'quarantine') {
877
+ rejectUnknownFlags('spex session quarantine', 4, ['adapter', 'thread', 'tmux', 'worktree', 'branch', 'restore', 'api', 'port'])
878
+ if (!id) { console.error('usage: spex session quarantine <ID> --adapter <harness> [--thread <native-id>] --tmux <session-id> --worktree <absent-path> --branch <absent-branch>') ; process.exit(2) }
879
+ if (has('restore')) {
880
+ // Quarantine addresses an unreadable row which selector resolution intentionally excludes. Both the
881
+ // move and its reverse therefore take the literal exact id, with the backend proving record state.
882
+ const restored = await c.clientRestoreQuarantine(id)
883
+ console.log(`restored quarantined record ${restored.id} from ${restored.bundle}`)
884
+ } else {
885
+ const adapter = flag('adapter'), tmux = flag('tmux'), worktree = flag('worktree'), branch = flag('branch')
886
+ if (!adapter || !tmux || !worktree || !branch) {
887
+ console.error('usage: spex session quarantine <ID> --adapter <harness> [--thread <native-id>] --tmux <session-id> --worktree <absent-path> --branch <absent-branch>')
888
+ process.exit(2)
889
+ }
890
+ const quarantined = await c.clientQuarantine(id, { adapter, thread: flag('thread') ?? null, tmux, worktree, branch })
891
+ console.log(`quarantined ${quarantined.id} -> ${quarantined.bundle}`)
892
+ }
876
893
  } else if (sub === 'send') {
877
894
  const full = await resolveSelectorOrExit(id)
878
895
  if (has('keys')) {
@@ -276,6 +276,27 @@ export async function clientClose(id: string): Promise<boolean> {
276
276
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
277
277
  }
278
278
 
279
+ export async function clientQuarantine(
280
+ id: string,
281
+ witness: { adapter: string; thread: string | null; tmux: string; worktree: string; branch: string },
282
+ ): Promise<{ id: string; bundle: string; sha256: string; observedAt: string }> {
283
+ await guarded('session quarantine')
284
+ const r = await apiFetch(`/api/sessions/${seg(id)}/quarantine`, post(witness))
285
+ if (!r.ok) throw new BackendError(`backend refused to quarantine ${id}: ${await r.text()}`, r.status)
286
+ const body = await r.json() as { ok?: boolean; id?: string; bundle?: string; sha256?: string; observedAt?: string }
287
+ if (!body.ok || !body.id || !body.bundle || !body.sha256 || !body.observedAt) throw new BackendError(`backend returned an invalid quarantine result for ${id}`, r.status)
288
+ return { id: body.id, bundle: body.bundle, sha256: body.sha256, observedAt: body.observedAt }
289
+ }
290
+
291
+ export async function clientRestoreQuarantine(id: string): Promise<{ id: string; bundle: string; sha256: string; observedAt: string }> {
292
+ await guarded('session quarantine')
293
+ const r = await apiFetch(`/api/sessions/${seg(id)}/quarantine/restore`, post({}))
294
+ if (!r.ok) throw new BackendError(`backend refused to restore ${id}: ${await r.text()}`, r.status)
295
+ const body = await r.json() as { ok?: boolean; id?: string; bundle?: string; sha256?: string; observedAt?: string }
296
+ if (!body.ok || !body.id || !body.bundle || !body.sha256 || !body.observedAt) throw new BackendError(`backend returned an invalid quarantine restore for ${id}`, r.status)
297
+ return { id: body.id, bundle: body.bundle, sha256: body.sha256, observedAt: body.observedAt }
298
+ }
299
+
279
300
  // POST /api/sessions/:id/archive — cold-archive the session ([[archive]]). The legacy on=false spelling is a
280
301
  // signpost to the same resume transition; it never performs a record-only unarchive.
281
302
  export async function clientArchive(id: string, on = true): Promise<boolean> {