spexcode 0.2.8 → 0.4.0

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 (101) hide show
  1. package/README.md +56 -44
  2. package/package.json +3 -3
  3. package/spec-cli/bin/spex.mjs +2 -2
  4. package/spec-cli/hooks/dispatch.sh +1 -1
  5. package/spec-cli/hooks/harness.sh +26 -6
  6. package/spec-cli/src/anchors.ts +300 -0
  7. package/spec-cli/src/attach.ts +2 -2
  8. package/spec-cli/src/cli.ts +691 -536
  9. package/spec-cli/src/client.ts +31 -30
  10. package/spec-cli/src/contract-filter.ts +1 -1
  11. package/spec-cli/src/doctor.ts +40 -13
  12. package/spec-cli/src/gateway.ts +11 -7
  13. package/spec-cli/src/git.ts +23 -15
  14. package/spec-cli/src/{board.ts → graph.ts} +44 -14
  15. package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
  16. package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
  17. package/spec-cli/src/graphStream.ts +288 -0
  18. package/spec-cli/src/guide.ts +142 -98
  19. package/spec-cli/src/harness-select.ts +2 -2
  20. package/spec-cli/src/harness.ts +30 -14
  21. package/spec-cli/src/help.ts +300 -385
  22. package/spec-cli/src/hooks.ts +1 -1
  23. package/spec-cli/src/index.ts +144 -103
  24. package/spec-cli/src/init.ts +14 -13
  25. package/spec-cli/src/issues.ts +89 -33
  26. package/spec-cli/src/layout.ts +5 -5
  27. package/spec-cli/src/lint.ts +128 -30
  28. package/spec-cli/src/localIssues.ts +61 -60
  29. package/spec-cli/src/materialize.ts +1 -1
  30. package/spec-cli/src/mentions.ts +15 -15
  31. package/spec-cli/src/migrate-table.ts +398 -0
  32. package/spec-cli/src/migrate.ts +386 -0
  33. package/spec-cli/src/ranker.ts +30 -4
  34. package/spec-cli/src/reaper.ts +117 -0
  35. package/spec-cli/src/search.bench.mjs +12 -12
  36. package/spec-cli/src/search.ts +1 -1
  37. package/spec-cli/src/sessions.ts +244 -138
  38. package/spec-cli/src/specs.ts +40 -25
  39. package/spec-cli/src/supervise.ts +2 -2
  40. package/spec-cli/src/tree.ts +5 -5
  41. package/spec-cli/src/uninstall.ts +4 -4
  42. package/spec-cli/templates/hooks/post-checkout +1 -1
  43. package/spec-cli/templates/hooks/post-merge +3 -3
  44. package/spec-cli/templates/hooks/pre-commit +9 -9
  45. package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
  46. package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
  47. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
  48. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
  49. package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
  50. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
  51. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
  52. package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
  53. package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
  54. package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
  55. package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
  56. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
  58. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
  59. package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
  60. package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
  61. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
  62. package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
  63. package/spec-cli/templates/spec/project/spec.md +1 -1
  64. package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
  65. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
  66. package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
  67. package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
  68. package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
  69. package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
  70. package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
  71. package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
  74. package/spec-dashboard/dist/index.html +2 -2
  75. package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
  76. package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
  77. package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
  78. package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
  79. package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
  80. package/spec-eval/src/humanok.ts +43 -0
  81. package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
  82. package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
  83. package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
  84. package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
  85. package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
  86. package/spec-forge/src/__fixtures__/github-forge.json +9 -9
  87. package/spec-forge/src/cli.ts +14 -13
  88. package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
  89. package/spec-cli/src/boardStream.ts +0 -179
  90. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
  91. package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
  92. package/spec-cli/templates/spec/project/.config/spec.md +0 -15
  93. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
  94. package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
  95. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
  96. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
  97. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
  98. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
  99. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
  100. /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
  101. /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
@@ -3,7 +3,7 @@
3
3
  // never written into it. One thread = one PLAIN markdown file at <main>/.spec/.issues/<id>.md; there is
4
4
  // deliberately no content-kind taxonomy (a change suggestion, an annotation, a Q&A are the same mechanism —
5
5
  // the prose says what it is). Others reply/discuss like an async chatroom; a supervisor drains it via
6
- // `spex issues` (reading is the port's job, issues.ts — this module owns only the store + its write verbs).
6
+ // `spex issue ls` (reading is the port's job, issues.ts — this module owns only the store + its write verbs).
7
7
  // Because a thread file is NOT named spec.md, the spec walk never nodes it and isSpecMd ignores it —
8
8
  // invisible to lint / drift / deriveStatus / board with ZERO exemption. The store lives on the TRUNK, not
9
9
  // per-branch: reads and writes target the main checkout and commit STRAIGHT to it (--no-verify, provably
@@ -28,22 +28,12 @@ const LEGACY_STORE_REL = '.spec/.forum'
28
28
  // truth is `spexcode.json`'s `issues.enabled` (the same settings file that carries every other toggle),
29
29
  // read via readConfig so a machine-local `spexcode.local.json` can override it. OFF silences the post-merge
30
30
  // nudge (and, in the dashboard, hides the issues view); the raw write verbs stay usable, since running one
31
- // is explicit consent. `spex issues on|off` flips the flag on diskeffective immediately, no commit
32
- // needed, because readConfig reads the working tree. The dashboard toggle is a thin wrapper over this same
33
- // switch. (The key was historically `proposals.enabled`; a pre-rename value still reads, and the next
34
- // toggle write rewrites it under `issues` the same self-heal-on-touch discipline as the store-dir rename.)
35
- export const issuesEnabled = (): boolean => {
36
- const cfg = readConfig(mainCheckout())
37
- return cfg.issues?.enabled ?? (cfg as { proposals?: { enabled?: boolean } }).proposals?.enabled ?? true
38
- }
39
-
40
- function setEnabled(on: boolean): void {
41
- const f = join(mainCheckout(), 'spexcode.json')
42
- const cfg = existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : {}
43
- cfg.issues = { ...(cfg.issues || {}), enabled: on }
44
- delete cfg.proposals // the pre-rename key, superseded by `issues` on this write
45
- writeFileSync(f, JSON.stringify(cfg, null, 2) + '\n')
46
- }
31
+ // is explicit consent. There is deliberately NO CLI toggle verb (v0.3.0"no `spex config set`" is this
32
+ // project's standing rule): flip the key by editing the JSON, effective immediately because readConfig
33
+ // reads the working tree. A legacy pre-rename `proposals.enabled` key is NOT read (no fallback fail
34
+ // toward the default); `spex doctor` reports it so an old settings file gets repaired, not silently obeyed.
35
+ export const issuesEnabled = (): boolean =>
36
+ readConfig(mainCheckout()).issues?.enabled ?? true
47
37
 
48
38
  const list = (v: string | undefined): string[] =>
49
39
  v ? v.split(',').map((s) => s.trim()).filter(Boolean) : []
@@ -79,7 +69,7 @@ function requirePrimaryStore(action: string): void {
79
69
  }
80
70
 
81
71
  // the local issue store dir — a fixed path directly under the trunk's .spec (name-independent, unlike the
82
- // .config system which nests under the named root node), OR the disposable override. Every read and write goes here.
72
+ // .plugins system which nests under the named root node), OR the disposable override. Every read and write goes here.
83
73
  const localStoreDir = (): string => overrideStoreDir() ?? join(mainCheckout(), LOCAL_STORE_REL)
84
74
  // the author's signature: the effective governed session id (envSessionId handles the claude/codex split).
85
75
  const currentSession = (): string => envSessionId() || 'unknown'
@@ -183,7 +173,7 @@ export function loadLocalIssues(): Issue[] {
183
173
  export function loadOne(id: string): Issue {
184
174
  ensureStoreMigrated()
185
175
  const f = join(localStoreDir(), `${id}.md`)
186
- if (!existsSync(f)) throw new Error(`no local issue '${id}' (see \`spex issues --all --store local\`)`)
176
+ if (!existsSync(f)) throw new Error(`no local issue '${id}' (see \`spex issue ls --all --store local\`)`)
187
177
  return parse(id, readFileSync(f, 'utf8'))
188
178
  }
189
179
 
@@ -282,6 +272,25 @@ function writeStoreFile(p: Issue, message: string): boolean {
282
272
  return true
283
273
  }
284
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
+
285
294
  // prepare (a FRESH read-modify or a new thread) + write + commit a single store file, all under the store
286
295
  // lock so the read-modify-write is atomic. prepare() runs INSIDE the lock, so a reply/close reads the
287
296
  // current thread, never a stale copy. A pre-rename store migrates first (before the lock — ensure takes it).
@@ -368,13 +377,13 @@ export async function replyLocalIssue(id: string, body: string, author: string,
368
377
  // every filer is offline/absent, the NODE's governing session, so an unresolved remark still REACHES an agent
369
378
  // who can act on it. A broken/absent worktree sidecar falls through silently — one bad worktree never fails
370
379
  // the remark write. This is notification only; it resolves nothing (R3: resolve is a deliberate
371
- // `spex resolve`). Non-eval threads pay nothing (no yatsu/specs/sessions import).
380
+ // `spex remark resolve`). Non-eval threads pay nothing (no eval/specs/sessions import).
372
381
  const EVAL_CONCERN_RE = /^eval: (.+?) · (.+)$/ // node first (never contains ' · '), then the scenario (may)
373
382
  async function threadOriginators(thread: Issue): Promise<(string | null)[]> {
374
383
  const m = EVAL_CONCERN_RE.exec(thread.concern)
375
384
  if (!m) return [thread.by]
376
385
  const node = m[1].trim(), scenario = m[2].trim()
377
- const { evalReadingFiler } = await import('../../spec-yatsu/src/filing.js')
386
+ const { evalReadingFiler } = await import('../../spec-eval/src/filing.js')
378
387
  const chain: (string | null)[] = [evalReadingFiler(node, scenario)]
379
388
  try {
380
389
  const { listSessions } = await import('./sessions.js')
@@ -453,7 +462,7 @@ function resolveRemarkHost(host: { issue?: string; node?: string; scenario?: str
453
462
  return loadOne(host.issue).id // throws loudly if the issue doesn't exist
454
463
  }
455
464
 
456
- // author a remark on a host — the ONE write both the CLI (`spex remark`) and the server call. Stamps the
465
+ // author a remark on a host — the ONE write both the CLI (`spex remark add`) and the server call. Stamps the
457
466
  // codeSha it was authored against (the worktree HEAD by default — R2). Returns the `<thread-id>#<rid>` ref.
458
467
  export async function remarkOnHost(
459
468
  host: { issue?: string; node?: string; scenario?: string },
@@ -471,7 +480,7 @@ export async function remarkOnHost(
471
480
  // a remark ref is `<thread-id>#<rid>`; the thread id (a store slug) never contains '#', so split on the last.
472
481
  function parseRemarkRef(ref: string): { id: string; rid: string } {
473
482
  const i = ref.lastIndexOf('#')
474
- if (i <= 0 || i === ref.length - 1) throw new Error(`bad remark ref '${ref}' — expected <thread-id>#<rid> (the id \`spex remark\` printed)`)
483
+ if (i <= 0 || i === ref.length - 1) throw new Error(`bad remark ref '${ref}' — expected <thread-id>#<rid> (the id \`spex remark add\` printed)`)
475
484
  return { id: ref.slice(0, i), rid: ref.slice(i + 1) }
476
485
  }
477
486
 
@@ -525,15 +534,15 @@ export function nudge(node: string): string {
525
534
  '',
526
535
  '1. CLOSE what you finished. An issue whose work just landed is closed, not',
527
536
  ' left open — the open set is the OUTSTANDING work, so a stale open reads as a',
528
- ' lie: spex issues --store local then spex issues close <id>',
537
+ ' lie: spex issue ls --store local then spex issue close <id>',
529
538
  '',
530
539
  '2. RECORD only what OUTLIVES this task — a concern you are NOT acting on now:',
531
540
  ' an off-mainline smell / awkward boundary / wish, or a trivial-but-must-not-',
532
541
  " forget to-do that doesn't earn a spec node. NOT a bug tracker (that is the",
533
542
  ' spec graph + the forge), NOT your assigned task or a fix you are about to',
534
543
  ' make — those need no issue. Only the taste that would otherwise evaporate:',
535
- ' spex issues # read first — reply if already raised',
536
- ' spex issues open "<concern>" [--node <id>] # else open one',
544
+ ' spex issue ls # read first — reply if already raised',
545
+ ' spex issue open "<concern>" [--node <id>] # else open one',
537
546
  'A supervisor drains the store later. (Advisory — skip if nothing is owed.)',
538
547
  '───────────────────────────────────────────────────────────────────',
539
548
  ].join('\n')
@@ -553,7 +562,7 @@ export function closeoutNudge(sessionId: string | null | undefined): string {
553
562
  t.status === 'open' && !EVAL_CONCERN_RE.test(t.concern) &&
554
563
  (t.by === sessionId || t.replies.some((r) => r.by === sessionId)))
555
564
  if (!mine.length) return ''
556
- return `\n\nIssue closeout — ${mine.length} still-open local issue(s) you touched (opened or replied): ${mine.map((t) => t.id).join(', ')}. For each, close it now if its work is finished (\`spex issues close <id>\`), or reply why it should stay open past this session (\`spex issues reply <id> --body "<why>"\`). Some issues rightly outlive their session — this is a reminder to sweep, not a gate.`
565
+ return `\n\nIssue closeout — ${mine.length} still-open local issue(s) you touched (opened or replied): ${mine.map((t) => t.id).join(', ')}. For each, close it now if its work is finished (\`spex issue close <id>\`), or reply why it should stay open past this session (\`spex issue reply <id> --body "<why>"\`). Some issues rightly outlive their session — this is a reminder to sweep, not a gate.`
557
566
  }
558
567
 
559
568
  // ───────────────────────── CLI ─────────────────────────
@@ -582,24 +591,16 @@ function readBody(args: string[]): string | undefined {
582
591
  const repeated = (args: string[], name: string): string[] =>
583
592
  args.flatMap((a, i) => (a === `--${name}` ? [args[i + 1]] : [])).filter(Boolean) as string[]
584
593
 
585
- // the local-issue WRITE verbs, folded into the one issues surface (`spex issues <sub>` the read routes
586
- // here when its first positional is a write sub): open "<concern>" [--store local|<host>] [--node id…]
587
- // [--evidence hash…] [--body -|text], the id-based reply, and the feature toggle
588
- // on | off | status. `nudge` is internal (the post-merge hook's caller). Store is a property of the issue,
594
+ // the local-issue WRITE verbs of the issue drawer (`spex issue <verb>`): open "<concern>" [--store local|<host>] [--node id…]
595
+ // [--evidence hash…] [--body -|text], and the id-based reply. Store is a property of the issue,
589
596
  // never a second command — open and reply route by it (issues.ts createIssue/replyIssue).
590
597
  export async function runIssueWrite(args: string[]): Promise<number> {
591
598
  const sub = args[0]
592
599
  try {
593
- if (sub === 'on' || sub === 'off') {
594
- setEnabled(sub === 'on')
595
- console.log(`issues workflow ${sub.toUpperCase()} — spexcode.json issues.enabled = ${sub === 'on'}${sub === 'on' ? '' : ' (post-merge nudge silenced; dashboard issues view hidden)'}`)
596
- return 0
597
- }
598
- if (sub === 'status') { console.log(`issues workflow is ${issuesEnabled() ? 'ON' : 'OFF'}`); return 0 }
599
600
  if (sub === 'reply') {
600
601
  const id = bare(args.slice(1))[0]
601
602
  const body = readBody(args)
602
- if (!id || !body) { console.error('usage: spex issues reply <issue-id> --body -|<text> [--evidence <hash>…]'); return 2 }
603
+ if (!id || !body) { console.error('usage: spex issue reply <issue-id> --body -|<text> [--evidence <hash>…]'); return 2 }
603
604
  // the ONE store-routed reply verb ([[issues]]): a forge id posts a real comment through the driver,
604
605
  // a local id commits to the store — the same command either way (dynamic import: no static cycle).
605
606
  const r = await (await import('./issues.js')).replyIssue(id, body, { evidence: repeated(args, 'evidence') })
@@ -610,19 +611,13 @@ export async function runIssueWrite(args: string[]): Promise<number> {
610
611
  if (s) console.log(` ${s}`)
611
612
  return 0
612
613
  }
613
- if (sub === 'nudge') {
614
- // internal: the post-merge hook calls this to print the (toggle-aware) nudge for a merged node.
615
- const text = nudge(bare(args.slice(1))[0] || '')
616
- if (text) console.log(text)
617
- return 0
618
- }
619
614
  // `open`: start a new issue — STORE-ROUTED through the one creation port ([[issues]] createIssue, the
620
615
  // same routine POST /api/issues runs): default local commits to the trunk store; `--store <host>`
621
616
  // creates the real forge issue through that store's driver (no promote round-trip when the concern is
622
617
  // born forge-visible). The concern is the bare positional(s) after the sub.
623
618
  const concern = sub === 'open' ? bare(args.slice(1)).join(' ').trim() : ''
624
619
  if (!concern) {
625
- console.error('usage: spex issues open "<concern>" [--store local|<host>] [--node <id>…] [--evidence <hash>…] [--body -|<text>]\n spex issues reply|close|promote <issue-id> … | on|off|status')
620
+ console.error('usage: spex issue open "<concern>" [--store local|<host>] [--node <id>…] [--evidence <hash>…] [--body -|<text>]\n spex issue reply|close|promote <issue-id> …')
626
621
  return 2
627
622
  }
628
623
  const r = await (await import('./issues.js')).createIssue(concern, {
@@ -633,23 +628,25 @@ export async function runIssueWrite(args: string[]): Promise<number> {
633
628
  })
634
629
  const re = r.nodes.length ? ` (re: ${r.nodes.join(', ')})` : ''
635
630
  console.log(r.store === 'local'
636
- ? `opened '${r.id}'${re} — committed to the local issue store; read it with \`spex issues\``
631
+ ? `opened '${r.id}'${re} — committed to the local issue store; read it with \`spex issue ls\``
637
632
  : `opened '${r.id}' on ${r.store}${re} — ${r.url}`)
638
633
  const s = summarize(r.outcomes)
639
634
  if (s) console.log(` ${s}`)
640
635
  return 0
641
636
  } catch (e) {
642
- console.error(`spex issues: ${e instanceof Error ? e.message : e}`)
637
+ console.error(`spex issue: ${e instanceof Error ? e.message : e}`)
643
638
  return 1
644
639
  }
645
640
  }
646
641
 
647
- // the first positionals runIssueWrite handles — the issues command routes these to it, everything else is
648
- // the read. Exported so the router and the runner can never drift.
649
- export const ISSUE_WRITE_SUBS = new Set(['open', 'reply', 'on', 'off', 'status', 'nudge'])
642
+ // the first positionals runIssueWrite handles — the issue drawer routes these to it. Exported so the
643
+ // router and the runner can never drift. (`nudge` is not here: it is machine plumbing, called only by the
644
+ // post-merge hook as `spex internal nudge`; the on|off|status toggle verbs died in v0.3.0 — the switch is
645
+ // the `issues.enabled` settings key.)
646
+ export const ISSUE_WRITE_SUBS = new Set(['open', 'reply'])
650
647
 
651
648
  // ── remark CLI ([[remark-substrate]]) — CLI-first: the whole author→resolve→retract loop, no server needed ──
652
- // `spex remark <host> --body -|<text> [--code-sha <sha>] [--scenario <name>] [--evidence <hash>…]`
649
+ // `spex remark add <issue-id | <node> --scenario <name>> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]`
653
650
  // host = a local issue id, OR a <node> with --scenario <name>. Records targetCodeSha (default: worktree HEAD).
654
651
  export async function runRemark(args: string[]): Promise<number> {
655
652
  try {
@@ -657,40 +654,44 @@ export async function runRemark(args: string[]): Promise<number> {
657
654
  const positional = bare(args)[0]
658
655
  const body = readBody(args)
659
656
  if (!positional || !body) {
660
- console.error('usage: spex remark <issue-id | node --scenario name> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]')
657
+ console.error('usage: spex remark add <issue-id | node --scenario name> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]')
661
658
  return 2
662
659
  }
660
+ // THE FLAG DECIDES THE PARSE ([[cli-surface]] §1): `--scenario` present ⇒ the positional is a NODE id
661
+ // (the remark pins to that node's scenario track); absent ⇒ it is an ISSUE id. Never type-sniffed —
662
+ // a node id and an issue id are both bare slugs, so any "looks like" guess would misroute; the flag
663
+ // is the one unambiguous discriminator, and a wrong host fails loud downstream (unknown issue/node).
663
664
  const host = scenario ? { node: positional, scenario } : { issue: positional }
664
665
  const r = await remarkOnHost(host, body, { codeSha: fl(args, 'code-sha'), evidence: repeated(args, 'evidence') })
665
- console.log(`remark ${r.ref} (against ${r.codeSha.slice(0, 7) || 'HEAD'}) — read it with \`spex issues --all\``)
666
+ console.log(`remark ${r.ref} (against ${r.codeSha.slice(0, 7) || 'HEAD'}) — read it with \`spex issue ls --all\``)
666
667
  const s = summarize(r.outcomes, r.loopIn)
667
668
  if (s) console.log(` ${s}`)
668
669
  return 0
669
670
  } catch (e) {
670
- console.error(`spex remark: ${e instanceof Error ? e.message : e}`)
671
+ console.error(`spex remark add: ${e instanceof Error ? e.message : e}`)
671
672
  return 1
672
673
  }
673
674
  }
674
675
 
675
- // `spex resolve <remark-ref>` — flip resolved=true (agent-only, never the author, monotonic — see resolveRemark).
676
+ // `spex remark resolve <remark-ref>` — flip resolved=true (agent-only, never the author, monotonic — see resolveRemark).
676
677
  export async function runResolve(args: string[]): Promise<number> {
677
678
  const ref = bare(args)[0]
678
- if (!ref) { console.error('usage: spex resolve <remark-ref> (the <thread-id>#<rid> `spex remark` printed)'); return 2 }
679
+ if (!ref) { console.error('usage: spex remark resolve <remark-ref> (the <thread-id>#<rid> `spex remark add` printed)'); return 2 }
679
680
  try {
680
681
  const by = currentSession()
681
682
  resolveRemark(ref, by)
682
683
  console.log(`resolved remark ${ref} — by ${by}`)
683
684
  return 0
684
- } catch (e) { console.error(`spex resolve: ${e instanceof Error ? e.message : e}`); return 1 }
685
+ } catch (e) { console.error(`spex remark resolve: ${e instanceof Error ? e.message : e}`); return 1 }
685
686
  }
686
687
 
687
- // `spex retract <remark-ref>` — the author withdraws their OWN remark, removing it (author-only — see retractRemark).
688
+ // `spex remark retract <remark-ref>` — the author withdraws their OWN remark, removing it (author-only — see retractRemark).
688
689
  export async function runRetract(args: string[]): Promise<number> {
689
690
  const ref = bare(args)[0]
690
- if (!ref) { console.error('usage: spex retract <remark-ref>'); return 2 }
691
+ if (!ref) { console.error('usage: spex remark retract <remark-ref>'); return 2 }
691
692
  try {
692
693
  retractRemark(ref, currentSession())
693
694
  console.log(`retracted remark ${ref}`)
694
695
  return 0
695
- } catch (e) { console.error(`spex retract: ${e instanceof Error ? e.message : e}`); return 1 }
696
+ } catch (e) { console.error(`spex remark retract: ${e instanceof Error ? e.message : e}`); return 1 }
696
697
  }
@@ -39,7 +39,7 @@ const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
39
39
  const SPEX = join(PKG, 'bin', 'spex.mjs')
40
40
  // the manifest + content-hash marker + plugin-folder ledger land in the materialized TREE's own slot of the
41
41
  // GLOBAL per-project store (layout.treeSlotDir — trees/<enc-worktree>), NOT the worktree and NOT one shared
42
- // per-project file: each is a pure function of ONE tree's .config, and the old single slot let the last-
42
+ // per-project file: each is a pure function of ONE tree's .plugins, and the old single slot let the last-
43
43
  // materialized tree's hook set reach every other tree's dispatch ([[hook-dispatch]]). The worktree keeps
44
44
  // zero SpexCode-materialized runtime; only the harness-discovered contract files + shims (which the harness
45
45
  // must find in-tree) are written under proj below.
@@ -2,7 +2,7 @@
2
2
  // node) and `@session` (an ACTOR — a live session, or `@new` for a fresh worker). The same parser resolves
3
3
  // them in ANY input box; the resolve+dispatch live HERE (CLI-first) so the issues page, the composer, and an agent's
4
4
  // own prompt share one implementation. An `@` "just auto-sends a prompt": resolve it against the live board
5
- // sessions and dispatch via [[dispatch]]'s sendKeys / [[launch]]'s newSession — storage and delivery stay
5
+ // sessions and dispatch via [[dispatch]]'s sendText / [[launch]]'s newSession — storage and delivery stay
6
6
  // separate, and sessions.ts is imported LAZILY so a mention-free post pays nothing.
7
7
 
8
8
  // ── parse (pure) ──────────────────────────────────────────────────────────────────────────────────────
@@ -17,8 +17,8 @@ const uniq = (xs: string[]): string[] => [...new Set(xs)]
17
17
 
18
18
  // ── CLI sigil tolerance ───────────────────────────────────────────────────────────────────────────────
19
19
  // In FREE TEXT the sigils are required — they are what marks a reference apart from prose. In a CLI
20
- // ARGUMENT the whole token IS the reference, so the sigil is optional: `spex review @graph` ≡
21
- // `spex review graph`, `spex yatsu eval [[cli-surface]]` ≡ `spex yatsu eval cli-surface`. One shared strip,
20
+ // ARGUMENT the whole token IS the reference, so the sigil is optional: `spex session review @graph` ≡
21
+ // `spex session review graph`, `spex eval add [[cli-surface]]` ≡ `spex eval add cli-surface`. One shared strip,
22
22
  // applied by the session-selector matcher and every node-arg read site, so the habit a user learns in the
23
23
  // dashboard's input boxes works verbatim on the CLI — never a second grammar to learn.
24
24
  export function stripRefSigil(token: string): string {
@@ -78,8 +78,8 @@ export type DispatchOutcome = { token: string; result: 'sent' | 'spawned' | 'off
78
78
  function mentionPrompt(threadId: string, node: string | null, author: string, text: string): string {
79
79
  const re = node ? ` (re: ${node})` : ''
80
80
  return `You were @-mentioned in issue thread "${threadId}"${re} by ${author}:\n\n ${text.trim()}\n\n` +
81
- `Read the thread and act as the comment asks (often just a look): \`spex issues --all\` lists them; ` +
82
- `reply with \`spex issues reply ${threadId} --body -\`.`
81
+ `Read the thread and act as the comment asks (often just a look): \`spex issue ls --all\` lists them; ` +
82
+ `reply with \`spex issue reply ${threadId} --body -\`.`
83
83
  }
84
84
  // A non-open thread is settled work: a fresh worker spawned onto it must not re-implement what already
85
85
  // landed, so the prompt leads with the status and a verify-on-main-first instruction.
@@ -92,7 +92,7 @@ export function newWorkerPrompt(threadId: string, node: string | null, author: s
92
92
  : ''
93
93
  return `Issue thread "${threadId}"${on} @-mentioned @new (by ${author}) for a fresh look:\n\n ${text.trim()}\n\n` +
94
94
  settled +
95
- `Read the thread (\`spex issues --all\`, find ${threadId}) and act on it${node ? `; the relevant node is ${node}` : ''}.`
95
+ `Read the thread (\`spex issue ls --all\`, find ${threadId}) and act on it${node ? `; the relevant node is ${node}` : ''}.`
96
96
  }
97
97
 
98
98
  // Parse a committed issue post's text for `@` actors and deliver to each. Best-effort and LOUD: the thread is
@@ -104,7 +104,7 @@ export async function dispatchMentions(
104
104
  ): Promise<DispatchOutcome[]> {
105
105
  const { actors } = parseMentions(text)
106
106
  if (!actors.length) return []
107
- const { sendKeys, listSessions, newSession } = await import('./sessions.js')
107
+ const { sendText, listSessions, newSession } = await import('./sessions.js')
108
108
  const sessions = await listSessions()
109
109
  const resolved = resolveActors(actors, sessions as unknown as ActorSession[])
110
110
  const out: DispatchOutcome[] = []
@@ -120,7 +120,7 @@ export async function dispatchMentions(
120
120
  } catch (e) { out.push({ token: r.token, result: 'failed', detail: e instanceof Error ? e.message : String(e) }) }
121
121
  continue
122
122
  }
123
- const res = await sendKeys(r.session.id, mentionPrompt(ctx.threadId, ctx.node, ctx.author, text), 'issues')
123
+ const res = await sendText(r.session.id, mentionPrompt(ctx.threadId, ctx.node, ctx.author, text), 'issues')
124
124
  out.push(res.ok ? { token: r.token, result: 'sent', detail: r.session.id }
125
125
  : { token: r.token, result: 'offline', detail: res.error })
126
126
  }
@@ -131,9 +131,9 @@ export async function dispatchMentions(
131
131
  // A committed reply is ALSO auto-delivered as a COURTESY — never an assignment — to a FALLBACK CHAIN of
132
132
  // candidates, in order, stopping at the FIRST one that can be reached: for a remark this is the reading's
133
133
  // filer session, then the node's governing session, then nobody (it still surfaces on the board via the
134
- // teeth). This is a NOTIFICATION chain only — it resolves NOTHING (resolve stays a deliberate `spex resolve`,
134
+ // teeth). This is a NOTIFICATION chain only — it resolves NOTHING (resolve stays a deliberate `spex remark resolve`,
135
135
  // R3); it just reaches an agent who can act. It is the same delivery pipe as dispatchMentions (one
136
- // online-resolution + one sendKeys), with the same cuts that keep courtesy ≠ assignment: deliver ONLY to an
136
+ // online-resolution + one sendText), with the same cuts that keep courtesy ≠ assignment: deliver ONLY to an
137
137
  // ONLINE session (an unreachable link is skipped for the next, NEVER spawns a worker, NEVER drains — only an
138
138
  // explicit @new spawns); SKIP a candidate that is the replier (no self-notify); a candidate already reached by
139
139
  // an explicit @-target of this same text counts as delivered, so the chain STOPS (no double-delivery, no
@@ -146,14 +146,14 @@ function originatorPrompt(threadId: string, node: string | null, replier: string
146
146
  const re = node ? ` (re: ${node})` : ''
147
147
  return `A new reply landed on a thread you originated — "${threadId}"${re}, from ${replier}:\n\n ${text.trim()}\n\n` +
148
148
  `This is a courtesy heads-up (you started this thread), not an assignment. Look if it concerns you; ` +
149
- `\`spex issues --all\` lists them, reply with \`spex issues reply ${threadId} --body -\`.`
149
+ `\`spex issue ls --all\` lists them, reply with \`spex issue reply ${threadId} --body -\`.`
150
150
  }
151
151
 
152
152
  // The pure fallback decision (testable without sessions.ts): walk the ordered chain (nulls/dupes/the-replier
153
153
  // pruned) and return the FIRST link that resolves to an online session — that is who the courtesy goes to. A
154
154
  // link already reached by an explicit @-target of this same text short-circuits to `reached` (stop, no
155
155
  // double-delivery — the actor already has it); an offline/absent link falls through to the next. `none` means
156
- // the chain ran dry (nobody online). This is the whole fallback logic; delivery is a thin sendKeys around it.
156
+ // the chain ran dry (nobody online). This is the whole fallback logic; delivery is a thin sendText around it.
157
157
  export type LoopInPick =
158
158
  | { kind: 'deliver'; originator: string; session: ActorSession }
159
159
  | { kind: 'reached' }
@@ -177,7 +177,7 @@ export function pickLoopIn(
177
177
 
178
178
  // `chain` is the ordered fallback list. We deliver the courtesy to the first online link and STOP; an
179
179
  // offline/failed link falls through to the next. NOTIFICATION ONLY — this never touches a `resolved` bit
180
- // (resolve is a deliberate `spex resolve`), never spawns (only `@new` spawns).
180
+ // (resolve is a deliberate `spex remark resolve`), never spawns (only `@new` spawns).
181
181
  export async function notifyOriginator(
182
182
  chain: (string | null)[],
183
183
  replier: string,
@@ -186,10 +186,10 @@ export async function notifyOriginator(
186
186
  ): Promise<LoopIn | null> {
187
187
  const seen = new Set<string>()
188
188
  if (!chain.some((c) => c && c !== replier && !seen.has(c) && (seen.add(c), true))) return null // nothing to do → no session load
189
- const { sendKeys, listSessions } = await import('./sessions.js')
189
+ const { sendText, listSessions } = await import('./sessions.js')
190
190
  const pick = pickLoopIn(chain, replier, await listSessions() as unknown as ActorSession[], ctx.alreadyDelivered)
191
191
  if (pick.kind !== 'deliver') return null // reached via @ / nobody online → silent
192
- const res = await sendKeys(pick.session.id, originatorPrompt(ctx.threadId, ctx.node, replier, text), 'issues')
192
+ const res = await sendText(pick.session.id, originatorPrompt(ctx.threadId, ctx.node, replier, text), 'issues')
193
193
  return res.ok ? { originator: pick.originator } : null // a failed send behaves like offline: silent
194
194
  }
195
195