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.
- package/package.json +1 -1
- package/spec-cli/src/anchors.ts +48 -38
- package/spec-cli/src/cli.ts +18 -1
- package/spec-cli/src/client.ts +21 -0
- package/spec-cli/src/git.ts +380 -198
- package/spec-cli/src/harness.ts +116 -24
- package/spec-cli/src/help.ts +3 -1
- package/spec-cli/src/index.ts +41 -7
- package/spec-cli/src/layout.ts +2 -0
- package/spec-cli/src/lint.ts +38 -28
- package/spec-cli/src/mentions.ts +10 -5
- package/spec-cli/src/process-identity.ts +20 -0
- package/spec-cli/src/session-maintenance.ts +2 -1
- package/spec-cli/src/sessions.ts +898 -140
- package/spec-cli/src/specs.ts +3 -3
- package/spec-dashboard/dist/assets/{App-B72LuS5I.js → App-u2P7KdSg.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-C5X4Va3V.js → Dashboard-B8wp5_61.js} +3 -3
- package/spec-dashboard/dist/assets/{EvalsPage-BTvJIW8Q.js → EvalsPage-Bq1Tkb8y.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-Bn94h_HQ.js → IssuesPage-BlkPSkmv.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-ClbtwZ1e.js → MobileApp-B1GxRZXK.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-6l_QtCKF.js → Modal-bAkq9IIT.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-B2kxcqJJ.js → PageScroll-px_rUZVJ.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-C8IPsMKV.js → ProjectsPage-8uGqYM12.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-CswwbewF.js +39 -0
- package/spec-dashboard/dist/assets/{SessionWindow-Dag_GiJB.js → SessionWindow-IspcLjFA.js} +1 -1
- package/spec-dashboard/dist/assets/{Settings-J3aibcXo.js → Settings-bpAbfnmS.js} +1 -1
- package/spec-dashboard/dist/assets/{Thread-Dg35J-Pu.js → Thread-BpL3N3kw.js} +11 -11
- package/spec-dashboard/dist/assets/{TimelineChat-f0UF9fXq.js → TimelineChat-Ckmb1Ez2.js} +1 -1
- package/spec-dashboard/dist/assets/{data-SNi0AmVT.js → data-CQFbQEMH.js} +1 -1
- package/spec-dashboard/dist/assets/index-CixSnz1H.css +1 -0
- package/spec-dashboard/dist/assets/{index-BUKLPN_4.js → index-Di1ch5dd.js} +5 -5
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/dist/assets/SessionInterface-B5jf7dW7.js +0 -39
- 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.
|
|
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",
|
package/spec-cli/src/anchors.ts
CHANGED
|
@@ -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
|
|
509
|
-
|
|
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
|
-
|
|
512
|
-
const
|
|
513
|
-
const
|
|
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(
|
|
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(
|
|
536
|
+
units.set(anchorRevisionKey(ref), { unparseable: !x ? `no designated extractor for ${ref.path}` : String(ready) })
|
|
528
537
|
continue
|
|
529
538
|
}
|
|
530
|
-
units.set(
|
|
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
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
'units' in
|
|
561
|
-
|
|
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
|
-
|
|
571
|
+
results.push([...hits].map(([commit, hit]) => ({ commit, selectors: [...hit.selectors], ...(hit.unparseable ? { unparseable: hit.unparseable } : {}) })))
|
|
565
572
|
}
|
|
566
|
-
return
|
|
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
|
}
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -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')) {
|
package/spec-cli/src/client.ts
CHANGED
|
@@ -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> {
|