spexcode 0.3.0 → 0.4.1

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 (59) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/anchors.ts +341 -0
  3. package/spec-cli/src/cli.ts +20 -20
  4. package/spec-cli/src/gateway.ts +8 -1
  5. package/spec-cli/src/git.ts +21 -13
  6. package/spec-cli/src/graph.ts +13 -7
  7. package/spec-cli/src/guide.ts +63 -15
  8. package/spec-cli/src/harness.ts +85 -34
  9. package/spec-cli/src/help.ts +33 -17
  10. package/spec-cli/src/index.ts +39 -7
  11. package/spec-cli/src/init.ts +5 -4
  12. package/spec-cli/src/lint.ts +103 -28
  13. package/spec-cli/src/localIssues.ts +19 -0
  14. package/spec-cli/src/migrate-table.ts +27 -16
  15. package/spec-cli/src/search.bench.mjs +2 -2
  16. package/spec-cli/src/session-timeline.ts +148 -0
  17. package/spec-cli/src/sessions.ts +29 -4
  18. package/spec-cli/src/specs.ts +44 -21
  19. package/spec-cli/templates/hooks/prepare-commit-msg +6 -1
  20. package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +1 -1
  21. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +16 -0
  22. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  23. package/spec-cli/templates/spec/project/.plugins/core/spec.md +1 -1
  24. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
  25. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +17 -0
  26. package/spec-cli/templates/spec/project/.plugins/spec.md +5 -2
  27. package/spec-cli/templates/spec/project/spec.md +1 -1
  28. package/spec-dashboard/dist/assets/{Dashboard-C7Bzsv86.js → Dashboard-C_fGmOKK.js} +3 -3
  29. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +2 -0
  30. package/spec-dashboard/dist/assets/{FoldToggle-D5iB4Ac2.js → FoldToggle-x9gtO1OQ.js} +1 -1
  31. package/spec-dashboard/dist/assets/{IssuesPage-CMFTsQhg.js → IssuesPage-5f_vL-JV.js} +1 -1
  32. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +1 -0
  33. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +66 -0
  34. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +13 -0
  35. package/spec-dashboard/dist/assets/{Settings-BW5f0OaW.js → Settings-DKb5Ji_X.js} +1 -1
  36. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +41 -0
  37. package/spec-dashboard/dist/assets/index-BbMkwuix.css +1 -0
  38. package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +6 -0
  39. package/spec-dashboard/dist/index.html +2 -2
  40. package/spec-eval/src/cli.ts +82 -21
  41. package/spec-eval/src/evaltab.ts +15 -6
  42. package/spec-eval/src/humanok.ts +43 -0
  43. package/spec-eval/src/scenarios.ts +116 -4
  44. package/spec-eval/src/sidecar.ts +35 -9
  45. package/spec-cli/templates/presets/careful/.plugins/clarify-before-code/spec.md +0 -11
  46. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
  47. package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +0 -2
  48. package/spec-dashboard/dist/assets/MobileApp-DwuTKgdP.js +0 -1
  49. package/spec-dashboard/dist/assets/SessionInterface-CBS5_cmK.js +0 -71
  50. package/spec-dashboard/dist/assets/SessionWindow-CqAnjWfI.js +0 -9
  51. package/spec-dashboard/dist/assets/index-Cc26X4ce.css +0 -1
  52. package/spec-dashboard/dist/assets/index-Ce0wDyQS.js +0 -41
  53. /package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +0 -0
  54. /package/spec-cli/templates/spec/project/.plugins/{tidy → commands/tidy}/spec.md +0 -0
  55. /package/spec-cli/templates/spec/project/.plugins/{forge-link → prompts/forge-link}/spec.md +0 -0
  56. /package/spec-cli/templates/spec/project/.plugins/{memory-hygiene → prompts/memory-hygiene}/spec.md +0 -0
  57. /package/spec-cli/templates/spec/project/.plugins/{reproduce-before-fix → prompts/reproduce-before-fix}/spec.md +0 -0
  58. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
  59. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/spec.md +0 -0
@@ -1,8 +1,9 @@
1
- import { readdirSync, readFileSync, existsSync } from 'node:fs'
1
+ import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { repoRoot, stagedFiles, git } from './git.js'
3
+ import { repoRoot, git, driftIndex, historyIndex, rowsFor } from './git.js'
4
4
  import { loadSpecs } from './specs.js'
5
5
  import { readJsonConfig } from './layout.js'
6
+ import { extractors, extractorFor, extOf, resolveAnchor, windowCommits, anchorHitCommits } from './anchors.js'
6
7
 
7
8
  export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
8
9
 
@@ -13,9 +14,12 @@ export type LintConfig = {
13
14
  identifierExtensions: string[]// extensions the altitude bare-filename signal recognises (see IDENT below)
14
15
  altitude: { lineBudget: number; charBudget: number; sizeable: number; dense: number; steps: number }
15
16
  maxChildren: number // breadth budget: warn at >= this many direct children
16
- driftErrorThreshold: number// commit-local gate HARD-BLOCKS a commit touching a node >= this many commits behind
17
17
  maxOwners: number // warn when a file is governed (code:) by > this many nodes
18
18
  scenarioTags: string[] // the closed vocabulary an eval scenario's `tags:` must draw from; extend it to mint a new tag
19
+ scopedCodeMiss: 'warn' | 'ignore' // the file-level drift ADVISORY on a selector-scoped code: file whose window has no
20
+ // selector hit ([[code-anchor]]). 'warn' (default) keeps today's drift warning; 'ignore'
21
+ // silences ONLY that advisory — hit blocks, bare code drift, integrity, acks, related
22
+ // semantics, and eval freshness are all untouched by this knob.
19
23
  }
20
24
  const DEFAULT_CONFIG: LintConfig = {
21
25
  governedRoots: ['spec-dashboard/src', 'spec-cli/src'],
@@ -24,9 +28,9 @@ const DEFAULT_CONFIG: LintConfig = {
24
28
  identifierExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'md'],
25
29
  altitude: { lineBudget: 50, charBudget: 4200, sizeable: 35, dense: 1.3, steps: 3 },
26
30
  maxChildren: 8,
27
- driftErrorThreshold: 3,
28
31
  maxOwners: 3,
29
32
  scenarioTags: ['frontend-e2e', 'backend-api', 'cli', 'desktop', 'mobile'],
33
+ scopedCodeMiss: 'warn',
30
34
  }
31
35
  export function loadConfig(root: string): LintConfig {
32
36
  // Absent spexcode.json → tuned defaults; a MALFORMED one throws LOUD (readJsonConfig) rather than
@@ -45,6 +49,10 @@ export function loadConfig(root: string): LintConfig {
45
49
  // ROOT-level files and leaks every nested test into coverage. A slash-less glob is a basename intent →
46
50
  // prepend "**/" so it matches that basename at any depth (the default "**/*.test.*" already does).
47
51
  export function normalizeConfig(cfg: LintConfig): LintConfig {
52
+ // a mistyped enum silently reverting to the default would green-wash (or over-warn) exactly the
53
+ // advisory the author meant to tune — same fail-loud rule as a malformed spexcode.json.
54
+ if (cfg.scopedCodeMiss !== 'warn' && cfg.scopedCodeMiss !== 'ignore')
55
+ throw new Error(`spexcode.json lint.scopedCodeMiss must be "warn" or "ignore", got ${JSON.stringify(cfg.scopedCodeMiss)}`)
48
56
  const dedot = (xs: string[]) => xs.map((x) => x.replace(/^\.+/, ''))
49
57
  return {
50
58
  ...cfg,
@@ -132,15 +140,25 @@ export async function specLint(): Promise<Finding[]> {
132
140
  const specs = await loadSpecs()
133
141
  const out: Finding[] = []
134
142
 
135
- // integrity + build the file -> owners map.
143
+ // integrity + build the file -> owners map. A relation's STRUCTURAL problems (a duplicate entry,
144
+ // bare/scoped mixing on one base path, a selector on a glob — all from parseRelation,
145
+ // [[code-anchor]]) are integrity errors: malformed edges block like broken ones.
136
146
  const owners = new Map<string, string[]>()
147
+ const claimed = new Set<string>()
137
148
  for (const s of specs) {
149
+ for (const p of s.relationProblems)
150
+ out.push({ level: 'error', rule: 'integrity', spec: s.id, msg: `'${s.id}' ${p}` })
151
+ const scopedPaths = new Set(s.codeScoped.map((e) => e.path))
138
152
  for (const f of s.code) {
139
153
  if (!existsSync(join(root, f)))
140
154
  out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing file: ${f}` })
141
- owners.set(f, [...(owners.get(f) ?? []), s.id])
155
+ claimed.add(f)
156
+ // a selector-SCOPED entry claims named units, not the whole file, so it stays out of the owners
157
+ // bound below ([[code-anchor]]) — `spex spec owner` still displays it as a (scoped) governor.
158
+ if (!scopedPaths.has(f)) owners.set(f, [...(owners.get(f) ?? []), s.id])
142
159
  }
143
- // one-govern: a node is source of truth for at most ONE file, so drift/eval/ack have a single
160
+ // one-govern: a node is source of truth for at most ONE file DISTINCT base paths; several
161
+ // `path#symbol` selectors on the same file are one subject — so drift/eval/ack have a single
144
162
  // unambiguous subject (see [[governed-related]]). >1 is a defect — pick the true subject, demote the
145
163
  // rest to related. ERROR (the node-side twin of too-many-owners' file-side bound). 0 is fine.
146
164
  if (s.code.length > 1)
@@ -149,7 +167,6 @@ export async function specLint(): Promise<Finding[]> {
149
167
  // a file is COVERED if any node GOVERNS (code:) or merely REFERENCES (related:) it; integrity covers both.
150
168
  // `related:` is the coverage net: govern is a sharp ideally-one-file pointer, so most files are reached by
151
169
  // related, not govern (see [[governed-related]]). It carries coverage but never drift, never eval freshness.
152
- const claimed = new Set<string>(owners.keys())
153
170
  for (const s of specs) for (const f of s.related) {
154
171
  if (!existsSync(join(root, f)))
155
172
  out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing related file: ${f}` })
@@ -277,14 +294,91 @@ export async function specLint(): Promise<Finding[]> {
277
294
  out.push({ level: 'warn', rule: 'owners', msg: `${over.length} file(s) are governed by > ${cfg.maxOwners} nodes — each holds more separately-specified functionality than one file should. Worst: ${top}. SPLIT the file so each governor owns its own module (or merge the nodes, or give it a single foundation owner + related:).` })
278
295
  }
279
296
 
297
+ // code anchors ([[code-anchor]]): a code:/related: entry may pin named units (`path#symbol` — any
298
+ // number per base file, OR'd). On code:, the anchor is the BLOCKING tier of drift: a window
299
+ // commit (spec's last version..HEAD, non-merge, touching the governed file) whose --unified=0 hunks
300
+ // intersect any pinned unit's line range — extracted from the file AS OF that commit, by the
301
+ // extension's ONE designated extractor — is ONE anchor-drift ERROR naming the hit selectors, unless a
302
+ // Spec-OK ack covers it. On related:, the SAME engine yields only a soft warn on a hit — a scoped
303
+ // related miss is silent (never blocks, no ack, no eval freshness). Resolution failures are never
304
+ // silent for either relation: a dead or ambiguous selector, a selector on a directory, an unparseable
305
+ // working-tree file, an extension with no designated extractor, and a designated extractor that can't
306
+ // run here (no host typescript) all ERROR with the repair spelled out.
307
+ const regs = extractors(root)
308
+ const [didx, hidx] = await Promise.all([driftIndex(root), historyIndex(root)])
309
+ const readyWarned = new Set<string>()
310
+ for (const s of specs) {
311
+ for (const { relation, entries } of [{ relation: 'code' as const, entries: s.codeScoped }, { relation: 'related' as const, entries: s.relatedScoped }]) {
312
+ for (const { path, selectors } of entries) {
313
+ const x = extractorFor(regs, extOf(path))
314
+ if (!x) {
315
+ out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' anchors ${path}#${selectors.join(', #')} (${relation}:), but no extractor is designated for '.${extOf(path)}' files — this language has no anchor support yet: add a LangSpec row (anchors.ts) or drop the selector(s)` })
316
+ continue
317
+ }
318
+ const ready = x.ready()
319
+ if (ready !== true) {
320
+ // once per (extractor, reason), even across several anchored nodes — one repair, one message.
321
+ if (!readyWarned.has(x.id + ready)) { readyWarned.add(x.id + ready); out.push({ level: 'error', rule: 'integrity', msg: `anchor extractor '${x.id}' cannot run: ${ready}` }) }
322
+ continue
323
+ }
324
+ if (!existsSync(join(root, path))) continue // the missing FILE already errored above
325
+ if (statSync(join(root, path)).isDirectory()) {
326
+ out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' puts a selector on a directory (${relation}: ${path}#${selectors[0]}) — a selector scopes ONE real file` })
327
+ continue
328
+ }
329
+ let units
330
+ try { units = x.extract(readFileSync(join(root, path), 'utf8'), path) } catch (e: any) {
331
+ out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `anchor ${path}#${selectors.join(', #')} ('${s.id}') is unverifiable — the current file does not parse: ${e?.message ?? e}` })
332
+ continue
333
+ }
334
+ // each selector resolves (or errors) on its own; only the live ones feed the window engine.
335
+ const live: string[] = []
336
+ for (const sym of selectors) {
337
+ const res = resolveAnchor(units, sym)
338
+ if ('dead' in res) {
339
+ out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `dead anchor: ${path}#${sym} ('${s.id}') names no unit on the current tree — the unit was deleted or renamed; update the spec's ${relation}: entry to follow it` })
340
+ continue
341
+ }
342
+ if ('ambiguous' in res) {
343
+ out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `ambiguous anchor: ${path}#${sym} ('${s.id}') names ${res.ambiguous} same-named units in one file — an anchor must be unique; rename one unit` })
344
+ continue
345
+ }
346
+ if (res.ok.typeOnly)
347
+ out.push({ level: 'warn', rule: 'anchor', spec: s.id, file: path, msg: `${path}#${sym} anchors a ${res.ok.kind} — anchoring a type is usually wrong (types reshape with every refactor); anchor the behaviour-bearing unit instead` })
348
+ live.push(sym)
349
+ }
350
+ if (!live.length) continue
351
+ const since = rowsFor(hidx, s.path)[0]?.hash || ''
352
+ const win = windowCommits(didx, since, path)
353
+ if (!win.length) continue
354
+ const hits = await anchorHitCommits(root, win, path, live, x)
355
+ if (!hits.length) continue
356
+ const hitSyms = [...new Set(hits.flatMap((h) => h.selectors))]
357
+ const shas = hits.map((h) => h.commit.slice(0, 8)).join(', ')
358
+ const unparseable = hits.filter((h) => h.unparseable)
359
+ const parseNote = unparseable.length ? ` (${unparseable.length} of these could not be parsed at that commit — counted as hits conservatively)` : ''
360
+ if (relation === 'code')
361
+ out.push({ level: 'error', rule: 'anchor-drift', spec: s.id, file: path, msg: `${path}#${hitSyms.join(', #')} was changed by ${hits.length} commit(s) since spec '${s.id}' v${s.version} [${shas}]${parseNote} — the anchored contract's code moved: update the spec, or 'spex spec ack ${s.id} --reason "…"' if the contract still holds` })
362
+ else
363
+ out.push({ level: 'warn', rule: 'related-drift', spec: s.id, file: path, msg: `related ${path}#${hitSyms.join(', #')} ('${s.id}') was changed by ${hits.length} commit(s) since v${s.version} [${shas}]${parseNote} — a scoped dependency shifted, worth a glance (SOFT: never blocks, no ack, no eval staleness)` })
364
+ }
365
+ }
366
+ }
367
+
280
368
  // drift: a governed file has commits NOT yet reflected in its spec. Judged by true git ancestry —
281
369
  // loadSpecs computes `driftFiles` via driftFor() over the one cached driftIndex walk (git.ts): a
282
370
  // commit to the file counts iff it is NOT reachable from the spec's latest version (in-memory
283
371
  // parent-edge reachability, the equivalent of `rev-list <version>..HEAD -- <file>`), never a
284
372
  // log-position or timestamp guess.
373
+ // A selector-SCOPED code file keeps this file-level advisory by default (a miss still nudges); the
374
+ // committed `lint.scopedCodeMiss: "ignore"` silences ONLY this advisory for scoped entries — the
375
+ // anchor engine's verdicts above (hit = block, resolution failures = integrity) are untouched.
285
376
  for (const s of specs) {
286
- for (const d of s.driftFiles)
377
+ const scopedPaths = new Set(s.codeScoped.map((e) => e.path))
378
+ for (const d of s.driftFiles) {
379
+ if (cfg.scopedCodeMiss === 'ignore' && scopedPaths.has(d.file)) continue
287
380
  out.push({ level: 'warn', rule: 'drift', spec: s.id, file: d.file, msg: `${d.file} is ${d.behind} commit(s) ahead of spec '${s.id}' (v${s.version}) — may be stale` })
381
+ }
288
382
  }
289
383
 
290
384
  // related drift: the SOFT tier ([[governed-related]]). A referenced file moved ahead of the node's
@@ -321,22 +415,3 @@ Diagnose, then apply the one honest remedy:
321
415
  maps to a node, or file an issue and link it (defer honestly)
322
416
 
323
417
  Never patch. A reasoned ack or a real fix are recorded and re-judged at review; a blind ack is a lie.`
324
-
325
- // commit-local: an empty staged index (CI, audit) → no blockers, drift stays advisory; non-empty → block
326
- // only when an OWN staged file belongs to a node already >= driftErrorThreshold behind. Sub-threshold drift
327
- // on a touched node is returned for an advisory nudge; the backlog on untouched nodes never blocks.
328
- export async function driftGate(): Promise<{ blocked: string[]; touched: { id: string; drift: number }[]; threshold: number }> {
329
- const root = repoRoot()
330
- const cfg = loadConfig(root)
331
- const staged = stagedFiles(root)
332
- if (!staged.length) return { blocked: [], touched: [], threshold: cfg.driftErrorThreshold }
333
- const specs = await loadSpecs()
334
- const owners = new Map<string, string[]>()
335
- for (const s of specs) for (const f of s.code) owners.set(f, [...(owners.get(f) ?? []), s.id])
336
- const byId = new Map(specs.map((s) => [s.id, s]))
337
- const ids = new Set<string>()
338
- for (const f of staged) for (const o of owners.get(f) ?? []) ids.add(o)
339
- const touched = [...ids].map((id) => byId.get(id)!).filter((s) => s && s.drift > 0)
340
- .map((s) => ({ id: s.id, drift: s.drift })).sort((a, b) => b.drift - a.drift)
341
- return { blocked: touched.filter((t) => t.drift >= cfg.driftErrorThreshold).map((t) => t.id), touched, threshold: cfg.driftErrorThreshold }
342
- }
@@ -272,6 +272,25 @@ function writeStoreFile(p: Issue, message: string): boolean {
272
272
  return true
273
273
  }
274
274
 
275
+ // @@@ the shared trunk-data commit — the committing half of the store discipline, exported for OTHER
276
+ // git-tracked data writes that need a durable landing with no worktree ritual behind them (the eval
277
+ // sidecar's human-ok, [[human-ok]]). Same rules as the issue store's own write: only the trunk checkout
278
+ // itself commits (a linked-worktree caller gets 'not-primary' back and leaves its append for its own
279
+ // session's ritual commit — the sidecar, unlike the issue store, legitimately lives per-branch and
280
+ // merges); the commit is `--no-verify` and provably scoped to the one data path; the shared store lock
281
+ // serializes it against every other trunk data write so racing writers never fight over the git index.
282
+ export function commitTrunkData(relPath: string, message: string): 'committed' | 'no-op' | 'not-primary' {
283
+ if (overrideStoreDir()) return 'not-primary' // a disposable-store test rig commits nothing, ever
284
+ if (!isPrimaryCheckout()) return 'not-primary'
285
+ return withStoreLock(() => {
286
+ const root = mainCheckout()
287
+ git(['-C', root, 'add', '--', relPath])
288
+ if (!git(['-C', root, 'status', '--porcelain', '--', relPath]).trim()) return 'no-op'
289
+ git(['-C', root, 'commit', '--no-verify', '-m', message, '--', relPath])
290
+ return 'committed'
291
+ })
292
+ }
293
+
275
294
  // prepare (a FRESH read-modify or a new thread) + write + commit a single store file, all under the store
276
295
  // lock so the read-modify-write is atomic. prepare() runs INSIDE the lock, so a reply/close reads the
277
296
  // current thread, never a stale copy. A pre-rename store migrates first (before the lock — ensure takes it).
@@ -6,11 +6,9 @@
6
6
  export type MigrateAsset = { rel: string; template: string; oldShas: string[] }
7
7
  export const TEMPLATE_ASSETS: MigrateAsset[] = [
8
8
  {
9
- "rel": "clarify-before-code/spec.md",
10
- "template": "templates/presets/careful/.plugins/clarify-before-code/spec.md",
11
- "oldShas": [
12
- "a1b9c7e189df9b75a27005d381b43682d717ee6f"
13
- ]
9
+ "rel": "commands/spec.md",
10
+ "template": "templates/spec/project/.plugins/commands/spec.md",
11
+ "oldShas": []
14
12
  },
15
13
  {
16
14
  "rel": "core/idle/idle.sh",
@@ -162,7 +160,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
162
160
  },
163
161
  {
164
162
  "rel": "distill/digest.mjs",
165
- "template": "templates/spec/project/.plugins/distill/digest.mjs",
163
+ "template": "templates/spec/project/.plugins/skills/distill/digest.mjs",
166
164
  "oldShas": [
167
165
  "b596d196d35688130157698ca90950d412bf527f",
168
166
  "faff3509b004e8529d5c7f3abd29512fb389ceb9"
@@ -170,7 +168,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
170
168
  },
171
169
  {
172
170
  "rel": "distill/spec.md",
173
- "template": "templates/spec/project/.plugins/distill/spec.md",
171
+ "template": "templates/spec/project/.plugins/skills/distill/spec.md",
174
172
  "oldShas": [
175
173
  "3e3fd25db1d465f72b636e6af8bcd873c0ceb3c2",
176
174
  "4a0b547ad63e15aa80ed7f826581ad90ca02d0f4",
@@ -179,7 +177,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
179
177
  },
180
178
  {
181
179
  "rel": "extract/spec.md",
182
- "template": "templates/spec/project/.plugins/extract/spec.md",
180
+ "template": "templates/spec/project/.plugins/commands/extract/spec.md",
183
181
  "oldShas": [
184
182
  "064efd8321925bd0cff7ee725771b500a7baf6e7",
185
183
  "45c2b0fe12224ebc124b5f5fecafd1e8d82de93e",
@@ -196,7 +194,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
196
194
  },
197
195
  {
198
196
  "rel": "forge-link/spec.md",
199
- "template": "templates/spec/project/.plugins/forge-link/spec.md",
197
+ "template": "templates/spec/project/.plugins/prompts/forge-link/spec.md",
200
198
  "oldShas": [
201
199
  "2472c085e6f72364eea004ba1d1b1f8d6b4e13ac",
202
200
  "c86439c5a3fe6cb3ece2c40186da0f476d1c52d4"
@@ -204,14 +202,19 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
204
202
  },
205
203
  {
206
204
  "rel": "memory-hygiene/spec.md",
207
- "template": "templates/spec/project/.plugins/memory-hygiene/spec.md",
205
+ "template": "templates/spec/project/.plugins/prompts/memory-hygiene/spec.md",
208
206
  "oldShas": [
209
207
  "2b5313e770c7d2e857817adcf7bb72aecaeca45f"
210
208
  ]
211
209
  },
210
+ {
211
+ "rel": "prompts/spec.md",
212
+ "template": "templates/spec/project/.plugins/prompts/spec.md",
213
+ "oldShas": []
214
+ },
212
215
  {
213
216
  "rel": "regroup/spec.md",
214
- "template": "templates/spec/project/.plugins/regroup/spec.md",
217
+ "template": "templates/spec/project/.plugins/commands/regroup/spec.md",
215
218
  "oldShas": [
216
219
  "37f632450faba3e9c5290468b52535e766faf45e",
217
220
  "aa3097a1b7ff51c4a8a0bfab82ac06a585081caa",
@@ -220,7 +223,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
220
223
  },
221
224
  {
222
225
  "rel": "reproduce-before-fix/spec.md",
223
- "template": "templates/spec/project/.plugins/reproduce-before-fix/spec.md",
226
+ "template": "templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md",
224
227
  "oldShas": [
225
228
  "28eef30893a83b634900e43dfe729043c8f60ea8",
226
229
  "2d056b7344cbc0c4eb0dbeb0ba69ccee0e8b47db",
@@ -229,6 +232,11 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
229
232
  "fc3c060af70b01b2073163880a42d002e285ddc5"
230
233
  ]
231
234
  },
235
+ {
236
+ "rel": "skills/spec.md",
237
+ "template": "templates/spec/project/.plugins/skills/spec.md",
238
+ "oldShas": []
239
+ },
232
240
  {
233
241
  "rel": "spec.md",
234
242
  "template": "templates/spec/project/.plugins/spec.md",
@@ -249,7 +257,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
249
257
  },
250
258
  {
251
259
  "rel": "supervisor/spec.md",
252
- "template": "templates/spec/project/.plugins/supervisor/spec.md",
260
+ "template": "templates/spec/project/.plugins/commands/supervisor/spec.md",
253
261
  "oldShas": [
254
262
  "036d5602a038e2565cd968e4a8a443ce80026025",
255
263
  "05963c00bc1031ba0d4f91ea68d60b1731f475f2",
@@ -271,7 +279,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
271
279
  },
272
280
  {
273
281
  "rel": "tidy/spec.md",
274
- "template": "templates/spec/project/.plugins/tidy/spec.md",
282
+ "template": "templates/spec/project/.plugins/commands/tidy/spec.md",
275
283
  "oldShas": [
276
284
  "096fc1dd48a1692aeb87614a93bcce2473f2050a",
277
285
  "16ccbf044e74765a5c9cc212fba38356950a7e69",
@@ -286,6 +294,9 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
286
294
  ]
287
295
  // old-world template assets the shipped package no longer carries (recognized → reported, not replaced):
288
296
  export const RETIRED_ASSETS: Record<string, string[]> = {
297
+ "clarify-before-code/spec.md": [
298
+ "a1b9c7e189df9b75a27005d381b43682d717ee6f"
299
+ ],
289
300
  "core/harness-session-id/harness-session-id.sh": [
290
301
  "32b3b963ba6c2c659f0543e82a2a34128c4860bf",
291
302
  "626d7f3c56d6430c5dafb7713880a606b4ee8b03"
@@ -296,11 +307,11 @@ export const RETIRED_ASSETS: Record<string, string[]> = {
296
307
  "c05f182fc92fcd224f4c7953dc118e59665971aa",
297
308
  "c617fc05780f921c55280b68d9f55e60110f74eb"
298
309
  ],
299
- "core/stop-gate/yatsu.evals.ndjson": [ // dead-words-ok: the migrator names the old vocabulary it migrates from
310
+ "core/stop-gate/yatsu.evals.ndjson": [
300
311
  "268c06c27f3b2dc71da8c0aca4d0507a227829f9",
301
312
  "e681fe71b8519577c19bb4f13bc0f310fb92dc31"
302
313
  ],
303
- "core/stop-gate/yatsu.md": [ // dead-words-ok: the migrator names the old vocabulary it migrates from
314
+ "core/stop-gate/yatsu.md": [
304
315
  "f83f6b2406eb359beb1181bf0145558241806116"
305
316
  ],
306
317
  "e2e-review/spec.md": [
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // Labels are node LEAF names, matched with the same de-collision rule the loader applies (specs.ts reId):
5
5
  // a returned id matches a label if it IS the label or ends with `_<label>` — so a bare leaf keeps matching
6
- // after collision-qualification renames it (e.g. `spec-scout` → `injected-context_spec-scout`). A label may
6
+ // after collision-qualification renames it (e.g. a colliding `probe` leaf → `a_probe`). A label may
7
7
  // also be written pre-qualified to pin one collision branch.
8
8
  import { execFileSync } from 'node:child_process'
9
9
  import { fileURLToPath } from 'node:url'
@@ -26,7 +26,7 @@ const CASES = [
26
26
  ['read-before-code', 'the one-shot nudge that makes an agent read its spec before touching code', ['spec-first']],
27
27
  ['hot-reload', 'zero-downtime backend reload without dropping connections', ['supervisor']],
28
28
  ['many-owners', 'can several specs own the same code file, and what happens if too many do?', ['governed-related']],
29
- ['active-spec-search', 'an injected sub-agent that searches specs for the agent, the spec analog of Explore', ['spec-scout']],
29
+ ['active-spec-search', 'an injected sub-agent that searches specs for the agent, the spec analog of Explore', ['spec-search']],
30
30
  ['declare-done', 'how does a worker declare it is done', ['state']],
31
31
  ]
32
32
 
@@ -0,0 +1,148 @@
1
+ import { watch, existsSync, readFileSync, appendFileSync, mkdirSync, type FSWatcher } from 'node:fs'
2
+ import { sessionsRoot, sessionStoreDir, sessionArtifactPath, listSessionIds, readAliasedRawRecord } from './layout.js'
3
+ import type { Lifecycle, Proposal } from './sessions.js'
4
+
5
+ // @@@ session-timeline - the PERSISTED interaction history of a session: every authored-lifecycle
6
+ // transition (status + proposal + the FULL note text) and every delivered prompt, timestamped, appended to
7
+ // `timeline.ndjson` in the session's global store dir. This is what a TERMINAL-FREE surface (the phone face,
8
+ // [[mobile-ui]]) renders instead of a live pane: without the terminal, the declaration notes ARE the agent's
9
+ // replies, and the timeline is the whole conversation.
10
+ //
11
+ // WHY an observer, not writer instrumentation: the lifecycle has a writer the TS layer never sees — the
12
+ // mark-active hook value-replaces status/proposal/note in session.json with pure-shell sed ([[state]]).
13
+ // Instrumenting every writer would always miss that one, so the recorder OBSERVES the store instead: one
14
+ // fs.watch on the sessions root (debounced) plus a slow reconcile tick (the fs.watch is best-effort, same
15
+ // stance as [[graph-stream]]'s source 1), and on each tick it diffs every governed record's
16
+ // (status, proposal, note) against the last seen and appends what moved. One mechanism covers every writer
17
+ // by construction. Granularity is the debounce window — a flap faster than ~100ms can collapse, exactly like
18
+ // the board itself.
19
+ //
20
+ // The recorder runs ONLY in the serve process (superviseTimeline is called from index.ts) so exactly one
21
+ // process appends; timestamps are observation times, honest to within the debounce. Only the AUTHORED axis
22
+ // is recorded — liveness (offline/starting/unknown) is a present-tense derivation ([[state]]), re-derived
23
+ // per probe and never history, so it stays off the durable log; a surface shows the CURRENT liveness from
24
+ // the board row. The timeline lives and dies with the session record (close sweeps the store dir), like
25
+ // comms.ndjson. `sent` events are appended by sendText on a CONFIRMED delivery (all prompt deliveries flow
26
+ // through it: dashboard/phone input, `spex session send`, the merge dispatch); `from` is the sending
27
+ // session's id, null = a human surface.
28
+
29
+ export type TimelineEvent =
30
+ | { ts: string; kind: 'status'; status: Lifecycle; proposal: Proposal | null; note: string | null; display?: string }
31
+ | { ts: string; kind: 'sent'; text: string; from: string | null; replyVia?: 'note' }
32
+
33
+ const timelinePath = (id: string): string => sessionArtifactPath(id, 'timeline.ndjson')
34
+
35
+ function append(id: string, ev: TimelineEvent): void {
36
+ try {
37
+ mkdirSync(sessionStoreDir(id), { recursive: true })
38
+ appendFileSync(timelinePath(id), JSON.stringify(ev) + '\n')
39
+ } catch { /* best-effort: a failed history append must never break the state machine or a delivery */ }
40
+ }
41
+
42
+ function readEvents(id: string): TimelineEvent[] {
43
+ try {
44
+ const p = timelinePath(id)
45
+ if (!existsSync(p)) return []
46
+ return readFileSync(p, 'utf8').split('\n').filter(Boolean).map((l) => {
47
+ try { return JSON.parse(l) as TimelineEvent } catch { return null }
48
+ }).filter((e): e is TimelineEvent => e != null && (e.kind === 'status' || e.kind === 'sent'))
49
+ } catch { return [] }
50
+ }
51
+
52
+ // the display word for an authored state — the SAME composition reconcile uses for the authored axis
53
+ // (awaiting → its proposal's label, active → working), duplicated here as a tiny read-time map rather than
54
+ // importing the state machine (sessions.ts imports THIS module for recordSent; a value import back would
55
+ // be a cycle — the Lifecycle/Proposal imports above are type-only, erased at runtime).
56
+ const PROPOSAL_DISPLAY: Record<string, string> = { merge: 'review', nothing: 'done', close: 'close-pending' }
57
+ const displayOf = (e: { status: Lifecycle; proposal: Proposal | null }): string =>
58
+ e.status === 'awaiting' ? (PROPOSAL_DISPLAY[e.proposal ?? 'nothing'] ?? 'done')
59
+ : e.status === 'active' ? 'working' : e.status
60
+
61
+ // ---- the recorder (serve-process only) ----
62
+
63
+ // id → fingerprint of the last recorded (status, proposal, note); seeded per id from the persisted last
64
+ // status line so a server restart appends nothing for a session that didn't move while the server was down —
65
+ // and DOES append (with an honest observed-now timestamp) when it did.
66
+ const lastSeen = new Map<string, string>()
67
+ const fpOf = (status: string, proposal: string | null, note: string | null): string => JSON.stringify([status, proposal, note])
68
+
69
+ function lastStatusEvent(id: string): { status: string; proposal: string | null; note: string | null } | null {
70
+ const evs = readEvents(id)
71
+ for (let i = evs.length - 1; i >= 0; i--) { const e = evs[i]; if (e.kind === 'status') return e }
72
+ return null
73
+ }
74
+
75
+ function scan(): void {
76
+ let ids: string[] = []
77
+ try { ids = listSessionIds() } catch { return }
78
+ for (const id of ids) {
79
+ try {
80
+ const raw = readAliasedRawRecord(id)
81
+ if (!raw || !raw.governed) continue
82
+ const status = (raw.status || 'active') as Lifecycle
83
+ const proposal = (raw.proposal || null) as Proposal | null
84
+ const note = raw.note || null
85
+ const fp = fpOf(status, proposal, note)
86
+ if (lastSeen.get(id) === fp) continue
87
+ if (!lastSeen.has(id)) {
88
+ const last = lastStatusEvent(id)
89
+ if (last && fpOf(last.status, last.proposal ?? null, last.note ?? null) === fp) { lastSeen.set(id, fp); continue }
90
+ }
91
+ lastSeen.set(id, fp)
92
+ append(id, { ts: new Date().toISOString(), kind: 'status', status, proposal, note })
93
+ } catch { /* one bad record must not stall the sweep */ }
94
+ }
95
+ const live = new Set(ids)
96
+ for (const k of [...lastSeen.keys()]) if (!live.has(k)) lastSeen.delete(k)
97
+ }
98
+
99
+ let watcher: FSWatcher | null = null
100
+ let debounce: ReturnType<typeof setTimeout> | null = null
101
+ let reconcile: ReturnType<typeof setInterval> | null = null
102
+
103
+ // start the recorder: one debounced fs.watch on the store (a lifecycle write lands as a session.json write)
104
+ // backstopped by a slow reconcile tick, plus an immediate first sweep. Idempotent; never throws — the
105
+ // timeline is an accessory record, and its failure must never take the server down.
106
+ export function superviseTimeline(): void {
107
+ if (!reconcile) reconcile = setInterval(scan, 60000)
108
+ if (!watcher) {
109
+ const root = sessionsRoot()
110
+ try { mkdirSync(root, { recursive: true }) } catch { /* best-effort */ }
111
+ try {
112
+ watcher = watch(root, { recursive: true }, () => {
113
+ if (debounce) return
114
+ debounce = setTimeout(() => { debounce = null; scan() }, 100)
115
+ })
116
+ } catch { watcher = null /* the reconcile tick still covers */ }
117
+ }
118
+ scan()
119
+ }
120
+
121
+ // record a CONFIRMED prompt delivery (called by sendText after the harness accepted it). `text` is the
122
+ // caller's message BEFORE any mechanism insert (the note-reply hint is transport, not conversation);
123
+ // `replyVia` marks that the hint rode along so a surface can badge it.
124
+ export function recordSent(id: string, text: string, from: string | null, replyVia?: 'note'): void {
125
+ try { if (!readAliasedRawRecord(id)?.governed) return } catch { return }
126
+ append(id, { ts: new Date().toISOString(), kind: 'sent', text, from, ...(replyVia ? { replyVia } : {}) })
127
+ }
128
+
129
+ // the read surface behind GET /api/sessions/:id/timeline: the last `limit` events, oldest first, each
130
+ // status event carrying its composed display word. null = no such session (the route 404s).
131
+ // Adjacent status lines with identical (status, proposal, note) fold into their first: TWO serve processes
132
+ // observing one store (a throwaway worktree/eval serve beside the live one) each keep their own lastSeen,
133
+ // so a single record move can append twice — cross-process write locking isn't worth buying, so the log
134
+ // stays best-effort append-only and the read is where duplicates die, same stance as the board.
135
+ export function readTimeline(id: string, limit = 500): { events: TimelineEvent[] } | null {
136
+ let raw: ReturnType<typeof readAliasedRawRecord>
137
+ try { raw = readAliasedRawRecord(id) } catch { return null }
138
+ if (!raw || !raw.governed) return null
139
+ const folded: TimelineEvent[] = []
140
+ for (const e of readEvents(id)) {
141
+ const prev = folded[folded.length - 1]
142
+ if (e.kind === 'status' && prev?.kind === 'status' && prev.status === e.status
143
+ && (prev.proposal ?? null) === (e.proposal ?? null) && (prev.note ?? null) === (e.note ?? null)) continue
144
+ folded.push(e)
145
+ }
146
+ const tail = folded.slice(Math.max(0, folded.length - Math.max(1, limit)))
147
+ return { events: tail.map((e) => (e.kind === 'status' ? { ...e, display: displayOf(e) } : e)) }
148
+ }
@@ -10,6 +10,7 @@ import { loadSpecs } from './specs.js'
10
10
  import { defaultHarness, defaultLauncher, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
11
11
  import { materialize } from './materialize.js'
12
12
  import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
13
+ import { recordSent } from './session-timeline.js'
13
14
  import { stripRefSigil } from './mentions.js'
14
15
 
15
16
  // @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
@@ -863,6 +864,14 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
863
864
  const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
864
865
  return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
865
866
  }
867
+ // @@@ withNoteReplyHint - the TERMINAL-FREE sender's insert, withSenderHint's sibling: a phone (or any
868
+ // no-terminal surface, [[mobile-ui]]) cannot read the pane, so the only text that ever reaches its human is
869
+ // the declaration NOTE ([[session-timeline]]). This one-line insert tells the agent exactly that, so its
870
+ // next stop carries the complete answer in `--note` instead of prose that dies in an unseen terminal.
871
+ // Appended server-side (the input route passes replyVia:'note'), so the phrase lives in ONE place and any
872
+ // surface — desktop included, later — can opt in with the same flag.
873
+ export const withNoteReplyHint = (text: string): string =>
874
+ `${text}\n\n— sent from a terminal-free client: the sender CANNOT see your terminal output. When you next stop (done/ask/park), put your COMPLETE reply to this message in the declaration's --note (e.g. spex session done --note "<full answer>") — the note is the only text that reaches them.`
866
875
  async function postJSON(path: string, body: unknown): Promise<void> {
867
876
  try {
868
877
  await fetch(`${await apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
@@ -1843,21 +1852,37 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
1843
1852
  }
1844
1853
 
1845
1854
  // @@@ sendText - PROMPT control for a session, delivered through the session's HARNESS ADAPTER
1846
- // ([[harness-adapter]]) — claude the rendezvous control socket (optimistic-after-liveness: the reply line flushes
1847
- // to a live socket), codex app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent
1855
+ // ([[harness-adapter]]) — claude the rendezvous control socket (atomic reply+repaint chunk: repaint-done proves
1856
+ // the daemon parsed it, a kicked connection resends, wall expiry on a live connection is busy-not-lost → ok),
1857
+ // codex app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent
1848
1858
  // fallback: a prompt that can't be delivered — no socket / dead agent (claude), no app-server/thread (codex) — FAILS LOUD, returning
1849
1859
  // ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
1850
1860
  // instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
1851
1861
  // harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
1852
- export async function sendText(id: string, text: string, from?: string): Promise<DispatchResult> {
1862
+ export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
1853
1863
  if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
1854
1864
  const rec = readRecord(id)
1855
1865
  if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
1856
1866
  const h = harnessById(rec.harness || defaultHarness.id)
1857
- const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, text)
1867
+ // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
1868
+ // prompt its channel confirms (claude's sessions panel), checkable only from the pane — refuse loudly with
1869
+ // the recovery named instead of reporting a false success. A missing pane (window gone, probe failure) skips
1870
+ // the guard: the delivery channel itself is the authority on whether the agent is reachable.
1871
+ if (h.deliveryBlockedBy) {
1872
+ try {
1873
+ const blocked = h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', id], TMUX_PROBE_TIMEOUT_MS))
1874
+ if (blocked) return { ok: false, error: blocked }
1875
+ } catch { /* no pane to consult — let the delivery channel decide */ }
1876
+ }
1877
+ // a terminal-free sender's dispatch carries the note-reply insert ([[session-timeline]]); appended here,
1878
+ // beside the delivery, so every input surface shares the one phrase and the timeline records the message
1879
+ // WITHOUT it (the hint is transport, not conversation).
1880
+ const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, opts.replyVia === 'note' ? withNoteReplyHint(text) : text)
1858
1881
  // record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
1859
1882
  // send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
1860
1883
  if (r.ok && from) void recordComms(id, from)
1884
+ // the durable interaction history ([[session-timeline]]): every confirmed delivery is a `sent` event.
1885
+ if (r.ok) recordSent(id, text, from ?? null, opts.replyVia)
1861
1886
  return r
1862
1887
  }
1863
1888