spexcode 0.2.7 → 0.3.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 (97) 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/attach.ts +2 -2
  7. package/spec-cli/src/cli.ts +695 -536
  8. package/spec-cli/src/client.ts +31 -30
  9. package/spec-cli/src/contract-filter.ts +1 -1
  10. package/spec-cli/src/doctor.ts +40 -13
  11. package/spec-cli/src/gateway.ts +11 -7
  12. package/spec-cli/src/git.ts +2 -2
  13. package/spec-cli/src/{board.ts → graph.ts} +44 -14
  14. package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
  15. package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
  16. package/spec-cli/src/graphStream.ts +288 -0
  17. package/spec-cli/src/guide.ts +123 -96
  18. package/spec-cli/src/harness-select.ts +2 -2
  19. package/spec-cli/src/harness.ts +30 -14
  20. package/spec-cli/src/help.ts +289 -384
  21. package/spec-cli/src/hooks.ts +1 -1
  22. package/spec-cli/src/index.ts +130 -103
  23. package/spec-cli/src/init.ts +9 -9
  24. package/spec-cli/src/issues.ts +89 -33
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +73 -8
  27. package/spec-cli/src/localIssues.ts +42 -60
  28. package/spec-cli/src/materialize.ts +1 -1
  29. package/spec-cli/src/mentions.ts +15 -15
  30. package/spec-cli/src/migrate-table.ts +397 -0
  31. package/spec-cli/src/migrate.ts +386 -0
  32. package/spec-cli/src/ranker.ts +30 -4
  33. package/spec-cli/src/reaper.ts +117 -0
  34. package/spec-cli/src/search.bench.mjs +10 -10
  35. package/spec-cli/src/search.ts +1 -1
  36. package/spec-cli/src/sessions.ts +244 -138
  37. package/spec-cli/src/specs.ts +25 -15
  38. package/spec-cli/src/supervise.ts +2 -2
  39. package/spec-cli/src/tree.ts +5 -5
  40. package/spec-cli/src/uninstall.ts +4 -4
  41. package/spec-cli/templates/hooks/post-checkout +1 -1
  42. package/spec-cli/templates/hooks/post-merge +3 -3
  43. package/spec-cli/templates/hooks/pre-commit +9 -9
  44. package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
  45. package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
  46. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
  47. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
  48. package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
  49. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
  50. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
  51. package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
  52. package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
  53. package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
  54. package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +4 -4
  55. package/spec-cli/templates/spec/project/{.config → .plugins}/forge-link/spec.md +1 -1
  56. package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
  57. package/spec-cli/templates/spec/project/{.config → .plugins}/reproduce-before-fix/spec.md +3 -3
  58. package/spec-cli/templates/spec/project/{.config → .plugins}/spec.md +4 -4
  59. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
  60. package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
  61. package/spec-cli/templates/spec/project/spec.md +1 -1
  62. package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-C7Bzsv86.js} +9 -9
  63. package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +2 -0
  64. package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-D5iB4Ac2.js} +1 -1
  65. package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-CMFTsQhg.js} +1 -1
  66. package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-DwuTKgdP.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-CBS5_cmK.js} +3 -3
  68. package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CqAnjWfI.js} +2 -2
  69. package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BW5f0OaW.js} +1 -1
  70. package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-Cc26X4ce.css} +1 -1
  71. package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-Ce0wDyQS.js} +8 -8
  72. package/spec-dashboard/dist/index.html +2 -2
  73. package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
  74. package/{spec-yatsu → spec-eval}/src/cli.ts +164 -95
  75. package/{spec-yatsu → spec-eval}/src/evaltab.ts +24 -24
  76. package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
  77. package/{spec-yatsu → spec-eval}/src/freshness.ts +50 -24
  78. package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
  79. package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
  80. package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
  81. package/{spec-yatsu → spec-eval}/src/sidecar.ts +7 -1
  82. package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
  83. package/spec-forge/src/__fixtures__/github-forge.json +9 -9
  84. package/spec-forge/src/cli.ts +14 -13
  85. package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
  86. package/spec-cli/src/boardStream.ts +0 -179
  87. package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
  88. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
  89. package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
  90. /package/spec-cli/templates/presets/careful/{.config → .plugins}/clarify-before-code/spec.md +0 -0
  91. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
  92. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
  93. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
  94. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
  95. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
  96. /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
  97. /package/spec-cli/templates/spec/project/{.config → .plugins}/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
 
@@ -368,13 +358,13 @@ export async function replyLocalIssue(id: string, body: string, author: string,
368
358
  // every filer is offline/absent, the NODE's governing session, so an unresolved remark still REACHES an agent
369
359
  // who can act on it. A broken/absent worktree sidecar falls through silently — one bad worktree never fails
370
360
  // 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).
361
+ // `spex remark resolve`). Non-eval threads pay nothing (no eval/specs/sessions import).
372
362
  const EVAL_CONCERN_RE = /^eval: (.+?) · (.+)$/ // node first (never contains ' · '), then the scenario (may)
373
363
  async function threadOriginators(thread: Issue): Promise<(string | null)[]> {
374
364
  const m = EVAL_CONCERN_RE.exec(thread.concern)
375
365
  if (!m) return [thread.by]
376
366
  const node = m[1].trim(), scenario = m[2].trim()
377
- const { evalReadingFiler } = await import('../../spec-yatsu/src/filing.js')
367
+ const { evalReadingFiler } = await import('../../spec-eval/src/filing.js')
378
368
  const chain: (string | null)[] = [evalReadingFiler(node, scenario)]
379
369
  try {
380
370
  const { listSessions } = await import('./sessions.js')
@@ -453,7 +443,7 @@ function resolveRemarkHost(host: { issue?: string; node?: string; scenario?: str
453
443
  return loadOne(host.issue).id // throws loudly if the issue doesn't exist
454
444
  }
455
445
 
456
- // author a remark on a host — the ONE write both the CLI (`spex remark`) and the server call. Stamps the
446
+ // author a remark on a host — the ONE write both the CLI (`spex remark add`) and the server call. Stamps the
457
447
  // codeSha it was authored against (the worktree HEAD by default — R2). Returns the `<thread-id>#<rid>` ref.
458
448
  export async function remarkOnHost(
459
449
  host: { issue?: string; node?: string; scenario?: string },
@@ -471,7 +461,7 @@ export async function remarkOnHost(
471
461
  // a remark ref is `<thread-id>#<rid>`; the thread id (a store slug) never contains '#', so split on the last.
472
462
  function parseRemarkRef(ref: string): { id: string; rid: string } {
473
463
  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)`)
464
+ 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
465
  return { id: ref.slice(0, i), rid: ref.slice(i + 1) }
476
466
  }
477
467
 
@@ -525,15 +515,15 @@ export function nudge(node: string): string {
525
515
  '',
526
516
  '1. CLOSE what you finished. An issue whose work just landed is closed, not',
527
517
  ' 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>',
518
+ ' lie: spex issue ls --store local then spex issue close <id>',
529
519
  '',
530
520
  '2. RECORD only what OUTLIVES this task — a concern you are NOT acting on now:',
531
521
  ' an off-mainline smell / awkward boundary / wish, or a trivial-but-must-not-',
532
522
  " forget to-do that doesn't earn a spec node. NOT a bug tracker (that is the",
533
523
  ' spec graph + the forge), NOT your assigned task or a fix you are about to',
534
524
  ' 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',
525
+ ' spex issue ls # read first — reply if already raised',
526
+ ' spex issue open "<concern>" [--node <id>] # else open one',
537
527
  'A supervisor drains the store later. (Advisory — skip if nothing is owed.)',
538
528
  '───────────────────────────────────────────────────────────────────',
539
529
  ].join('\n')
@@ -553,7 +543,7 @@ export function closeoutNudge(sessionId: string | null | undefined): string {
553
543
  t.status === 'open' && !EVAL_CONCERN_RE.test(t.concern) &&
554
544
  (t.by === sessionId || t.replies.some((r) => r.by === sessionId)))
555
545
  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.`
546
+ 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
547
  }
558
548
 
559
549
  // ───────────────────────── CLI ─────────────────────────
@@ -582,24 +572,16 @@ function readBody(args: string[]): string | undefined {
582
572
  const repeated = (args: string[], name: string): string[] =>
583
573
  args.flatMap((a, i) => (a === `--${name}` ? [args[i + 1]] : [])).filter(Boolean) as string[]
584
574
 
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,
575
+ // the local-issue WRITE verbs of the issue drawer (`spex issue <verb>`): open "<concern>" [--store local|<host>] [--node id…]
576
+ // [--evidence hash…] [--body -|text], and the id-based reply. Store is a property of the issue,
589
577
  // never a second command — open and reply route by it (issues.ts createIssue/replyIssue).
590
578
  export async function runIssueWrite(args: string[]): Promise<number> {
591
579
  const sub = args[0]
592
580
  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
581
  if (sub === 'reply') {
600
582
  const id = bare(args.slice(1))[0]
601
583
  const body = readBody(args)
602
- if (!id || !body) { console.error('usage: spex issues reply <issue-id> --body -|<text> [--evidence <hash>…]'); return 2 }
584
+ if (!id || !body) { console.error('usage: spex issue reply <issue-id> --body -|<text> [--evidence <hash>…]'); return 2 }
603
585
  // the ONE store-routed reply verb ([[issues]]): a forge id posts a real comment through the driver,
604
586
  // a local id commits to the store — the same command either way (dynamic import: no static cycle).
605
587
  const r = await (await import('./issues.js')).replyIssue(id, body, { evidence: repeated(args, 'evidence') })
@@ -610,19 +592,13 @@ export async function runIssueWrite(args: string[]): Promise<number> {
610
592
  if (s) console.log(` ${s}`)
611
593
  return 0
612
594
  }
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
595
  // `open`: start a new issue — STORE-ROUTED through the one creation port ([[issues]] createIssue, the
620
596
  // same routine POST /api/issues runs): default local commits to the trunk store; `--store <host>`
621
597
  // creates the real forge issue through that store's driver (no promote round-trip when the concern is
622
598
  // born forge-visible). The concern is the bare positional(s) after the sub.
623
599
  const concern = sub === 'open' ? bare(args.slice(1)).join(' ').trim() : ''
624
600
  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')
601
+ 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
602
  return 2
627
603
  }
628
604
  const r = await (await import('./issues.js')).createIssue(concern, {
@@ -633,23 +609,25 @@ export async function runIssueWrite(args: string[]): Promise<number> {
633
609
  })
634
610
  const re = r.nodes.length ? ` (re: ${r.nodes.join(', ')})` : ''
635
611
  console.log(r.store === 'local'
636
- ? `opened '${r.id}'${re} — committed to the local issue store; read it with \`spex issues\``
612
+ ? `opened '${r.id}'${re} — committed to the local issue store; read it with \`spex issue ls\``
637
613
  : `opened '${r.id}' on ${r.store}${re} — ${r.url}`)
638
614
  const s = summarize(r.outcomes)
639
615
  if (s) console.log(` ${s}`)
640
616
  return 0
641
617
  } catch (e) {
642
- console.error(`spex issues: ${e instanceof Error ? e.message : e}`)
618
+ console.error(`spex issue: ${e instanceof Error ? e.message : e}`)
643
619
  return 1
644
620
  }
645
621
  }
646
622
 
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'])
623
+ // the first positionals runIssueWrite handles — the issue drawer routes these to it. Exported so the
624
+ // router and the runner can never drift. (`nudge` is not here: it is machine plumbing, called only by the
625
+ // post-merge hook as `spex internal nudge`; the on|off|status toggle verbs died in v0.3.0 — the switch is
626
+ // the `issues.enabled` settings key.)
627
+ export const ISSUE_WRITE_SUBS = new Set(['open', 'reply'])
650
628
 
651
629
  // ── 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>…]`
630
+ // `spex remark add <issue-id | <node> --scenario <name>> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]`
653
631
  // host = a local issue id, OR a <node> with --scenario <name>. Records targetCodeSha (default: worktree HEAD).
654
632
  export async function runRemark(args: string[]): Promise<number> {
655
633
  try {
@@ -657,40 +635,44 @@ export async function runRemark(args: string[]): Promise<number> {
657
635
  const positional = bare(args)[0]
658
636
  const body = readBody(args)
659
637
  if (!positional || !body) {
660
- console.error('usage: spex remark <issue-id | node --scenario name> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]')
638
+ console.error('usage: spex remark add <issue-id | node --scenario name> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]')
661
639
  return 2
662
640
  }
641
+ // THE FLAG DECIDES THE PARSE ([[cli-surface]] §1): `--scenario` present ⇒ the positional is a NODE id
642
+ // (the remark pins to that node's scenario track); absent ⇒ it is an ISSUE id. Never type-sniffed —
643
+ // a node id and an issue id are both bare slugs, so any "looks like" guess would misroute; the flag
644
+ // is the one unambiguous discriminator, and a wrong host fails loud downstream (unknown issue/node).
663
645
  const host = scenario ? { node: positional, scenario } : { issue: positional }
664
646
  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\``)
647
+ console.log(`remark ${r.ref} (against ${r.codeSha.slice(0, 7) || 'HEAD'}) — read it with \`spex issue ls --all\``)
666
648
  const s = summarize(r.outcomes, r.loopIn)
667
649
  if (s) console.log(` ${s}`)
668
650
  return 0
669
651
  } catch (e) {
670
- console.error(`spex remark: ${e instanceof Error ? e.message : e}`)
652
+ console.error(`spex remark add: ${e instanceof Error ? e.message : e}`)
671
653
  return 1
672
654
  }
673
655
  }
674
656
 
675
- // `spex resolve <remark-ref>` — flip resolved=true (agent-only, never the author, monotonic — see resolveRemark).
657
+ // `spex remark resolve <remark-ref>` — flip resolved=true (agent-only, never the author, monotonic — see resolveRemark).
676
658
  export async function runResolve(args: string[]): Promise<number> {
677
659
  const ref = bare(args)[0]
678
- if (!ref) { console.error('usage: spex resolve <remark-ref> (the <thread-id>#<rid> `spex remark` printed)'); return 2 }
660
+ if (!ref) { console.error('usage: spex remark resolve <remark-ref> (the <thread-id>#<rid> `spex remark add` printed)'); return 2 }
679
661
  try {
680
662
  const by = currentSession()
681
663
  resolveRemark(ref, by)
682
664
  console.log(`resolved remark ${ref} — by ${by}`)
683
665
  return 0
684
- } catch (e) { console.error(`spex resolve: ${e instanceof Error ? e.message : e}`); return 1 }
666
+ } catch (e) { console.error(`spex remark resolve: ${e instanceof Error ? e.message : e}`); return 1 }
685
667
  }
686
668
 
687
- // `spex retract <remark-ref>` — the author withdraws their OWN remark, removing it (author-only — see retractRemark).
669
+ // `spex remark retract <remark-ref>` — the author withdraws their OWN remark, removing it (author-only — see retractRemark).
688
670
  export async function runRetract(args: string[]): Promise<number> {
689
671
  const ref = bare(args)[0]
690
- if (!ref) { console.error('usage: spex retract <remark-ref>'); return 2 }
672
+ if (!ref) { console.error('usage: spex remark retract <remark-ref>'); return 2 }
691
673
  try {
692
674
  retractRemark(ref, currentSession())
693
675
  console.log(`retracted remark ${ref}`)
694
676
  return 0
695
- } catch (e) { console.error(`spex retract: ${e instanceof Error ? e.message : e}`); return 1 }
677
+ } catch (e) { console.error(`spex remark retract: ${e instanceof Error ? e.message : e}`); return 1 }
696
678
  }
@@ -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