spexcode 0.2.4 → 0.2.5

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 (57) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/bin/spex.mjs +13 -10
  3. package/spec-cli/hooks/harness.sh +32 -6
  4. package/spec-cli/src/board.ts +2 -1
  5. package/spec-cli/src/boardCache.ts +27 -1
  6. package/spec-cli/src/boardStream.ts +5 -4
  7. package/spec-cli/src/cli.ts +82 -19
  8. package/spec-cli/src/contract-filter.ts +154 -0
  9. package/spec-cli/src/git.ts +18 -4
  10. package/spec-cli/src/guide.ts +91 -38
  11. package/spec-cli/src/harness.ts +18 -4
  12. package/spec-cli/src/help.ts +16 -7
  13. package/spec-cli/src/index.ts +9 -6
  14. package/spec-cli/src/init.ts +52 -10
  15. package/spec-cli/src/issues.ts +7 -5
  16. package/spec-cli/src/layout.ts +20 -8
  17. package/spec-cli/src/lint.ts +19 -0
  18. package/spec-cli/src/localIssues.ts +16 -4
  19. package/spec-cli/src/materialize.ts +218 -124
  20. package/spec-cli/src/ranker.ts +25 -8
  21. package/spec-cli/src/runtime-guard.ts +44 -0
  22. package/spec-cli/src/search.bench.mjs +15 -5
  23. package/spec-cli/src/sessions.ts +81 -16
  24. package/spec-cli/src/specs.ts +29 -14
  25. package/spec-cli/src/supervise.ts +2 -2
  26. package/spec-cli/src/tsx-bin.ts +6 -8
  27. package/spec-cli/src/uninstall.ts +15 -17
  28. package/spec-cli/src/worktree-sources.ts +50 -13
  29. package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
  30. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
  31. package/spec-dashboard/dist/assets/Dashboard-CMRJGfYI.js +27 -0
  32. package/spec-dashboard/dist/assets/EvalsPage-Dj2mxcfW.js +2 -0
  33. package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-BfNpeyRQ.js} +1 -1
  34. package/spec-dashboard/dist/assets/IssuesPage-DfY315kt.js +1 -0
  35. package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-BGdC0A0P.js} +1 -1
  36. package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-BOBCAR0t.js} +5 -5
  37. package/spec-dashboard/dist/assets/SessionWindow-D7YmjV0i.js +9 -0
  38. package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-BlSNmpH_.js} +1 -1
  39. package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BFdzpd_O.js} +2 -2
  40. package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
  41. package/spec-dashboard/dist/index.html +2 -2
  42. package/spec-forge/src/cli.ts +2 -2
  43. package/spec-forge/src/drivers/gitlab.ts +168 -0
  44. package/spec-forge/src/drivers.ts +80 -2
  45. package/spec-forge/src/resident.ts +10 -5
  46. package/spec-yatsu/src/cli.ts +37 -16
  47. package/spec-yatsu/src/evaltab.ts +6 -3
  48. package/spec-yatsu/src/filing.ts +13 -8
  49. package/spec-yatsu/src/freshness.ts +97 -22
  50. package/spec-yatsu/src/proof.ts +14 -3
  51. package/spec-yatsu/src/scenariofresh.ts +38 -11
  52. package/spec-yatsu/src/yatsu.ts +52 -28
  53. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
  54. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
  55. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
  56. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
  57. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +0 -1
@@ -36,10 +36,12 @@ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
36
36
  \`spex lint\` must report 0 errors; coverage warnings are your adoption TODO (files no node claims yet).
37
37
 
38
38
  Look these up on demand — the formats an agent authors, and the settings it configures:
39
- spex guide spec the spec.md format (frontmatter + body + the rules lint enforces)
40
- spex guide yatsu the yatsu.md format (scenario schema + how loss is measured and filed)
41
- spex guide config the spexcode.json / spexcode.local.json settings (launchers, dashboard icon, lint
42
- budgets, layout) — every field, and which of the two files it belongs in`
39
+ spex guide spec the spec.md format (frontmatter + body + the rules lint enforces)
40
+ spex guide yatsu the yatsu.md format (scenario schema + how loss is measured and filed)
41
+ spex guide config the spexcode.json / spexcode.local.json settings (launchers, dashboard icon, lint
42
+ budgets, layout) — every field, and which of the two files it belongs in
43
+ spex guide footprint the share-axis model — what SpexCode plants in a repo, the render policy
44
+ (committed | ignored | hidden), and every migration recipe`
43
45
 
44
46
  const SPEC = `spex guide spec — the spec.md file format
45
47
 
@@ -191,12 +193,12 @@ PORTABILITY, and picking the right one is the whole discipline:
191
193
  identity, lint budgets, launcher NAMES. "Git is the database": tracked so the
192
194
  team shares ONE configuration.
193
195
  spexcode.local.json GITIGNORED — host-specific, never committed. Absolute launcher paths, cert/secret
194
- paths, private-overlay mode. Layered OVER spexcode.json (see MERGE below); a
195
- targeted env override (SPEXCODE_CODEX_SERVER_CMD, …) still wins at its read site.
196
+ paths, a personal "render": "hidden" vote. Layered OVER spexcode.json (see MERGE
197
+ below); a targeted env override (SPEXCODE_CODEX_SERVER_CMD, …) still wins at its read site.
196
198
 
197
199
  Rule of thumb — is the value TRUE FOR THE PROJECT or TRUE FOR THIS MACHINE? A branch name, a dashboard
198
200
  icon, a lint budget, a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
199
- PATH of a launcher wrapper, a TLS cert path, private mode are machine facts → gitignored spexcode.local.json.
201
+ PATH of a launcher wrapper, a TLS cert path, a personal render:hidden are machine facts → gitignored spexcode.local.json.
200
202
  Both files are optional; omit any field to take its default, except \`sessions.defaultLauncher\` when using
201
203
  \`spex new\` or the dashboard without an explicit launcher choice.
202
204
 
@@ -292,6 +294,13 @@ the guard (the flag is the proof of intent). Reads point anywhere.
292
294
  issues.enabled the issues-workflow on/off switch (default ON). OFF silences the post-merge nudge and
293
295
  hides the dashboard view; the CLI toggle is \`spex issues on|off\`.
294
296
 
297
+ ── FORGE (spexcode.json — which forge this repo's remote is; a project fact, so committed) ──
298
+ forge.host explicit forge host id ('github' | 'gitlab' | …) overriding the automatic derivation.
299
+ Normally OMIT it: spec-forge resolves the host from the origin remote's hostname —
300
+ github.com → github, a gitlab/self-hosted remote → gitlab — and only an ambiguous
301
+ self-hosted domain the heuristic misreads needs the override. A resolved host with no
302
+ registered driver degrades to an EMPTY forge slice (local issues still work, no error).
303
+
295
304
  ── LINT (spexcode.json — a top-level "lint" key; budgets are portable, so committed only) ──
296
305
  lint.governedRoots dirs whose source files must each be governed by a spec (coverage).
297
306
  '.' = the whole project (only git-TRACKED files). Default
@@ -314,37 +323,81 @@ Example — govern your own source dir and loosen the altitude budget:
314
323
  preset the SELECTED init preset — which cumulative .config tier \`spex init\` seeds (default
315
324
  'default'; seed-time only, read by init.ts).
316
325
  harnesses which harness targets \`spex materialize\` delivers into — native ids ("claude"|"codex") or a
317
- { "plugin": "<folder>" } bundle. Default (omitted): all native harnesses.
318
- private (spexcode.local.json ONLY) private-overlay mode when true, \`spex materialize\` leaves ZERO
319
- trace in the host's TRACKED files: managed ignore entries go to .git/info/exclude and any
320
- tracked contract file is marked skip-worktree. Trades away git-derived spec version history.
321
- A HOST decision never the committed file. See PRIVATE MODE below.
322
-
323
- ── PRIVATE MODE (default private the two delivery modes) ──
324
- DEFAULT mode (private absent/false): materialize commits .spec + spexcode.json and writes its ignore list as
325
- a managed block in the TRACKED .gitignore transparent, but every collaborator sees it. PRIVATE mode
326
- (spexcode.local.json { "private": true }): the SAME contract reaches the agent, but the ignore list goes to
327
- the per-clone .git/info/exclude and any host-tracked CLAUDE.md/AGENTS.md is skip-worktree'd — so \`git status\`
328
- stays clean and nothing enters shared history.
329
-
330
- Switch: edit spexcode.local.json ({ "private": true } on / false or remove = off), then \`spex materialize\`
331
- (the hook gate also re-runs it on the next agent turn).
332
- Reversible + idempotent: the two modes fully CANCEL OUT. default→private→default (or private→default→private)
333
- converges to the SAME on-disk state as running that mode once each mode re-asserts the inverse of
334
- the other (exclude block .gitignore block, skip-worktree set cleared). Switch order never
335
- matters; running a mode twice changes nothing.
336
-
337
- MANUAL STEP (the only one materialize can't do for you it also PRINTS this when needed):
338
- .git/info/exclude hides UNTRACKED paths only. If you adopted DEFAULT mode first, .spec + spexcode.json are
339
- already committed, so private mode can't hide them until you un-track them ONCE:
340
- git rm -r --cached .spec spexcode.json # keeps the files on disk, stops tracking them
341
- (then commit that on your branch). A private-from-the-start adoption never needs this.
342
-
343
- FOOTGUN (skip-worktree): a host-tracked CLAUDE.md/AGENTS.md is skip-worktree'd in private mode, so a later
344
- \`git pull\` that touches it can complain. Fix: flip back to DEFAULT mode (or \`git update-index
345
- --no-skip-worktree CLAUDE.md AGENTS.md\`), pull, then re-materialize.`
346
-
347
- const TOPICS: Record<string, string> = { spec: SPEC, yatsu: YATSU, config: CONFIG }
326
+ { "plugin": "<folder>" } bundle. Default (omitted): all native harnesses. PERSISTENT and
327
+ self-healing: an edit moves the hook gate's key, so the next harness event re-renders under
328
+ the new set (a deselected harness's artifacts are pruned) no manual materialize needed.
329
+ render the footprint vote: where the machine-independent RENDERS (CLAUDE.md/AGENTS.md contract
330
+ blocks, .claude/.codex skills + agents) sit relative to the shared repo. One of
331
+ "committed" | "ignored" (default) | "hidden". "committed" is a PROJECT fact → spexcode.json;
332
+ "hidden" is a HOST/person fact spexcode.local.json. The spec DATA (.spec, spexcode.json)
333
+ has NO knob always tracked. Full model + migration recipes: \`spex guide footprint\`.
334
+ private RETIRED (old private-overlay toggle)read as "render": "hidden" with a loud migration
335
+ notice; its data-untrack semantics are gone. See \`spex guide footprint\` MIGRATIONS.`
336
+
337
+ const FOOTPRINT = `spex guide footprint — the share-axis model (what SpexCode plants in a repo, and who sees it)
338
+
339
+ SpexCode claims software engineering's HEAD (the recording of intent) and TAIL (the storage of
340
+ measurement) and leaves the MIDDLE construction to the harness/agent/test framework; freshness
341
+ stitches the two ends into a closed loop. The footprint follows: the head+tail (.spec, spexcode.json,
342
+ readings) is the ASSET and lives in git like source; everything else is derived wiring or a machine fact.
343
+ ONE axis "who sees this artifact in the shared repo?" is answered per KIND, never per usage mode.
344
+
345
+ ── THE FOUR KINDS (three are fixed; one votes) ──
346
+ spec data .spec/ (incl .config/) + spexcode.json ALWAYS tracked. Git is the database; there is
347
+ deliberately NO way to say "untrack the spec" in this schema.
348
+ machine facts spexcode.local.json, the hook shims (.claude/settings.json, .codex/hooks.json), plugin
349
+ bundles NEVER tracked; always in the ignore rules, whatever those rules' home.
350
+ renders the CLAUDE.md/AGENTS.md contract blocks + rendered skills/agents — machine-independent,
351
+ derived from the spec tree. THE one voted class: the \`render\` field (see below).
352
+ run residue .worktrees/, the global store (~/.spexcode), .git/spexcode blobs never tracked;
353
+ out-of-tree, or ignore-ruled where in-tree.
354
+
355
+ ── THE VOTE: render = "committed" | "ignored" | "hidden" ──
356
+ committed renders are ordinary committed files; their entries LEAVE the ignore block (machine facts
357
+ stay ignored). The contract reaches teammates/CI who never installed SpexCode — the harness
358
+ discovers the committed file natively. Pick for a fully-adopted team. → spexcode.json.
359
+ ignored (default) renders are generated + gitignored via the managed block in the TRACKED
360
+ .gitignore — the team sees the RULE, never the products. NOTE: a contract file the host
361
+ ALREADY TRACKS cannot be ignored — it stays visibly dirty, your prompt to pick committed
362
+ or hidden.
363
+ hidden zero repo footprint: the same managed block lives in the per-clone .git/info/exclude, and a
364
+ HOST-TRACKED CLAUDE.md/AGENTS.md is covered by a per-clone git clean/smudge content filter —
365
+ the repo keeps the pristine host prose, your working tree carries prose + contract block,
366
+ and git status stays clean. Pick to dogfood on a repo you don't own. → spexcode.local.json.
367
+ The ignore rules are themselves a render artifact, so their HOME follows the same vote — one axis, no
368
+ second mechanism. TRACK ≠ PUSH: the vote never touches remotes; where commits GO is branch/remote policy.
369
+ Vote at adoption in one step: \`spex init --render <word>\` (committed/ignored → spexcode.json, hidden →
370
+ spexcode.local.json). Until an explicit vote is set, init/materialize print a one-time decision hint
371
+ whenever a host-TRACKED contract file carries the block — the "mystery M" explained at first sight.
372
+
373
+ ── GUARANTEES (the forgetting law) ──
374
+ materialize(P₂) ∘ materialize(P₁) = materialize(P₂): every render first ERASES all landing points by
375
+ SpexCode's own identity stamps, then re-asserts the current policy — so switching render values (or
376
+ harness sets) in ANY order, any number of times, converges; running one policy twice changes nothing.
377
+ \`spex uninstall\` is the empty policy plus the global store: a total backout that never touches your
378
+ .spec/.config or prose. Fresh clones and session worktrees are self-sufficient: data by checkout, renders
379
+ by re-render (init/materialize/the hook gate), the machine snapshot (spexcode.local.json) by copy.
380
+
381
+ ── THE CONTENT FILTER (hidden + a host-tracked contract file) ──
382
+ Per-clone only — git config filter.spexcode.* + .git/info/attributes + a shim under .git/spexcode/ — and
383
+ planted ONLY when the contract file is actually tracked. clean strips the sentinel block (history never
384
+ sees it); smudge re-injects it on checkout. A missing shim degrades to identity (never a git fatal). Your
385
+ own edits to the file's prose still show as real modifications; only the block is invisible to git.
386
+
387
+ ── MIGRATIONS ──
388
+ private: true (retired) → replace with { "render": "hidden" } in spexcode.local.json. materialize
389
+ reads the old flag as hidden and prints this recipe until you migrate.
390
+ legacy untracked spec the retired mode also untracked .spec + spexcode.json. Track them once:
391
+ (untrack-private) git add .spec spexcode.json # then commit on your branch
392
+ WARNING: tracking is not retroactive secrecy — history already pushed
393
+ elsewhere cannot be recalled.
394
+ hidden → committed/ignored just edit \`render\` — the hook gate re-renders on the very next harness
395
+ event (the gate key covers spexcode.json/.local), or run \`spex materialize\`
396
+ to apply immediately; the erase phase unplants the filter/exclude block in
397
+ the same pass. Narrowing \`harnesses\` self-heals the same way.
398
+ back out entirely \`spex uninstall\` (add --hooks to also remove the spexcode git hooks).`
399
+
400
+ const TOPICS: Record<string, string> = { spec: SPEC, yatsu: YATSU, config: CONFIG, footprint: FOOTPRINT }
348
401
 
349
402
  // every guide page ends by naming the OTHER help layer, so a reader never dead-ends here: guide is
350
403
  // the skill layer (workflows · formats · settings); command usage lives in help.ts's two layers.
@@ -8,6 +8,7 @@ import { promisify } from 'node:util'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { claudeSlashCommands, codexSlashCommands, type SlashCommand } from './slash-commands.js'
10
10
  import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
11
+ import { git } from './git.js'
11
12
 
12
13
  // @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
13
14
  // Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
@@ -770,9 +771,14 @@ export function removeManagedBlock(file: string, comment: readonly [string, stri
770
771
  // remove ONLY our block plus the blank lines writeManagedBlock inserted around it; do NOT normalize the
771
772
  // user's OWN whitespace elsewhere — this must leave every other byte intact so it is a faithful INVERSE of
772
773
  // writeManagedBlock's append. A global `\n{3,}→\n\n` collapse used to sit here and mutated pre-existing
773
- // blank-line runs in the user's file, which broke the private⇄default round-trip ([[private-overlay]]):
774
- // default→private→default left a spurious one-line diff on a .gitignore that had internal blank lines.
775
- const out = cur.replace(re, '\n').replace(/^\n+/, '')
774
+ // blank-line runs in the user's file, which broke the policy round-trip ([[render-policy]]): a mode flip
775
+ // and back left a spurious one-line diff on a .gitignore that had internal blank lines. The leading-newline
776
+ // strip is GUARDED the same way: it exists only for a block sitting at the TOP of the file (whose '\n'
777
+ // replacement would otherwise become a leading blank) — a host file that BEGINS with its own blank lines
778
+ // keeps them ([[content-filter]]'s invariant, same bug class as the shim's old unconditional strip).
779
+ const atTop = (re.exec(cur)?.index ?? -1) === 0
780
+ const replaced = cur.replace(re, '\n')
781
+ const out = atTop ? replaced.replace(/^\n+/, '') : replaced
776
782
  if (deleteIfEmpty && !out.trim()) { rmSync(file, { force: true }); return }
777
783
  writeFileSync(file, out)
778
784
  }
@@ -874,6 +880,11 @@ function removeCodexTrust(proj: string): void {
874
880
  writeFileSync(file, cleaned ? `${cleaned}\n` : '')
875
881
  }
876
882
 
883
+ // is this file git-tracked in proj? (guards cleanHarness's deleteIfEmpty; env-stripped git, never throws)
884
+ function isTrackedFile(proj: string, f: string): boolean {
885
+ try { git(['-C', proj, 'ls-files', '--error-unmatch', f]); return true } catch { return false }
886
+ }
887
+
877
888
  // @@@ cleanHarness - the shared clean: the inverse of materialize's per-harness write, expressed PURELY
878
889
  // through the adapter's own path methods so it can never drift from what write put there. Each step is
879
890
  // surgical, gated on a SpexCode identity stamp: the contract files carry the managed-block sentinels; the shim
@@ -882,7 +893,10 @@ function removeCodexTrust(proj: string): void {
882
893
  // blocks and our own named products — never a user's CLAUDE.md/AGENTS.md prose, a hand-made settings.json, or
883
894
  // a sibling skill/agent the user added, and NEVER any .spec data.
884
895
  function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts): void {
885
- for (const f of h.contractFiles(proj)) removeManagedBlock(f, ['<!-- ', ' -->'], true)
896
+ // deleteIfEmpty ONLY for an UNTRACKED contract file: a wholly-ours generated file goes; a HOST-TRACKED file
897
+ // that carried nothing but our block (an empty committed CLAUDE.md we folded into) is stripped back to its
898
+ // pristine emptiness but never deleted — deleting a tracked file would surface as a `D` in the host's status.
899
+ for (const f of h.contractFiles(proj)) removeManagedBlock(f, ['<!-- ', ' -->'], !isTrackedFile(proj, f))
886
900
  const shim = h.shimFile(proj)
887
901
  if (existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh')) rmSync(shim, { force: true })
888
902
  const anchor = h.worktreeHookAnchor(proj) // the linked-worktree anchor copy, same identity gate as the shim
@@ -72,11 +72,12 @@ session list. Identical to GET /api/board; needs the backend (spex serve) reacha
72
72
  see: 'spex tree (the same graph, human-readable) · spex ls (just the sessions, as a table) · spex search (find one node instead of dumping all)',
73
73
  },
74
74
  guide: {
75
- line: 'guide [topic] the manuals: setup workflow · spec/yatsu file formats · spexcode.json',
75
+ line: 'guide [topic] the manuals: setup workflow · spec/yatsu file formats · spexcode.json · footprint',
76
76
  body: `Usage: spex guide the human setup workflow (install once, adopt a repo, serve)
77
77
  spex guide spec the spec.md file format + every lint rule
78
78
  spex guide yatsu the yatsu.md scenario format + how loss is measured and filed
79
79
  spex guide config every spexcode.json / spexcode.local.json field, and which file it belongs in
80
+ spex guide footprint the share-axis model: the render vote (committed|ignored|hidden) + migrations
80
81
 
81
82
  guide is the SKILL layer — workflows and formats. Command usage lives here in help
82
83
  (\`spex help <cmd>\`); guide carries what the commands assume you know.`,
@@ -180,16 +181,21 @@ a node/<id> branch links its PR for free). Read-only — git/.spec stays the sin
180
181
  },
181
182
  // ── dispatch & manage sessions (manager loop) ─────────────────────────────
182
183
  new: {
183
- line: 'new "<prompt>" launch a worker session in its own node worktree [--node <id>] [--launcher <name>]',
184
+ line: 'new "<prompt>" launch a worker session in its own node worktree [--prompt-file <path>|-] [--node <id>] [--launcher <name>]',
184
185
  body: `Usage: spex new "<task prompt>" [--node <id>] [--launcher <name>]
186
+ spex new --prompt-file <path>|- [--node <id>] [--launcher <name>]
185
187
 
186
188
  Creates a session: node branch + worktree + a launched agent carrying your prompt (= session new).
187
189
  Give it ONLY its task — the dev-flow contract reaches it through the materialized system prompt.
190
+ --prompt-file <path> reads the prompt from a file (- = stdin), so a long multi-paragraph prompt never
191
+ fights shell quoting; it is exclusive with the inline prompt (both given = error), and an unreadable
192
+ or empty file refuses the launch.
188
193
  The launcher name selects both the agent harness and the command/auth profile (built-ins: claude, codex);
189
194
  omitting it requires sessions.defaultLauncher in spexcode.json or spexcode.local.json.
190
195
  Routes through the running backend (auth env + concurrency cap); prints the created session JSON.
191
- Then MONITOR it: background \`spex wait <id>\`, or \`spex watch\` for the whole stream.`,
192
- see: 'spex wait / spex watch (monitor) · spex review (when it proposes) · ' + SEL_NOTE.split('\n')[0],
196
+ Then MONITOR it: background \`spex wait <id>\`, or \`spex watch\` for the whole stream.
197
+ Talk to it with \`spex send <id> "<msg>"\` never raw tmux keystrokes.`,
198
+ see: 'spex wait / spex watch (monitor) · spex send (talk to it) · spex review (when it proposes) · ' + SEL_NOTE.split('\n')[0],
193
199
  },
194
200
  ls: {
195
201
  line: 'ls [SEL…] living-sessions table [--status a,b] [--json] [--api URL]',
@@ -307,12 +313,15 @@ ${ROUTING_NOTE}`,
307
313
 
308
314
  // ── install & serve (operator) ────────────────────────────────────────────
309
315
  init: {
310
- line: 'init [dir] adopt SpexCode on a repo: seed .spec + hooks + materialize [--preset name]',
311
- body: `Usage: spex init [dir=cwd] [--preset default|careful]
316
+ line: 'init [dir] adopt SpexCode on a repo: seed .spec + hooks + materialize [--preset name] [--render word]',
317
+ body: `Usage: spex init [dir=cwd] [--preset default|careful] [--render committed|ignored|hidden]
312
318
 
313
319
  Scaffolds adoption in one shot: seeds a starter .spec tree (project root + .config plugins), plants
314
320
  spexcode.json, installs the git hooks, and materializes the harness artifacts (contract block +
315
- shims). Additive — never overwrites your files. --preset picks the .config plugin tier (cumulative).`,
321
+ shims). Additive — never overwrites your files. --preset picks the .config plugin tier (cumulative).
322
+ --render casts the one-time render-policy vote at adoption (committed → spexcode.json, hidden →
323
+ spexcode.local.json; an unknown word fails loud) — without it, a host-tracked CLAUDE.md/AGENTS.md
324
+ prints a one-time decision hint instead (see spex guide footprint).`,
316
325
  see: 'spex guide (the full setup workflow) · spex uninstall (the inverse) · spex lint (adoption TODO)',
317
326
  },
318
327
  uninstall: {
@@ -7,6 +7,7 @@ import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadCon
7
7
  import { issuesEnabled, remarkOnHost, resolveRemark, retractRemark } from './localIssues.js'
8
8
  import { closeIssue, createIssue, issueStores, mergedIssues, promote, replyIssue } from './issues.js'
9
9
  import { residentForgeState, refreshForgeNow } from '../../spec-forge/src/resident.js'
10
+ import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
10
11
  import { summarize } from './mentions.js'
11
12
  import { resolveLayout, mainBranch } from './layout.js'
12
13
  import { getBoardJson } from './boardCache.js'
@@ -44,7 +45,8 @@ app.get('/health', (c) => c.text('ok'))
44
45
  // shares ONE build instead of each running its own — the poll-frequency cut (push channel) and the
45
46
  // build-coalescing cut compound. A hard timeout bounds a wedged build to a loud 503 rather than an
46
47
  // unboundedly-held connection (the wall sits well above the legitimately-several-seconds cold first build);
47
- // the underlying single-flight build keeps running and caches for the next poll.
48
+ // a merely-slow single-flight build keeps running and caches for the next poll, while a NEVER-settling one
49
+ // is bounded by [[board-cache]]'s own build watchdog, so the next poll retries a fresh build.
48
50
  const BOARD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_TIMEOUT_MS || 20000)
49
51
  app.get('/api/board', etag(), async (c) => {
50
52
  const timeout = Symbol('timeout')
@@ -160,7 +162,7 @@ app.get('/api/issues', etag(), (c) =>
160
162
  c.json({
161
163
  enabled: issuesEnabled(),
162
164
  stores: issueStores(),
163
- issues: mergedIssues({ host: 'github', state: residentForgeState() }, loadSpecsLite().map((s) => s.id)),
165
+ issues: mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, loadSpecsLite().map((s) => s.id)),
164
166
  }))
165
167
  // the WRITE surface ([[local-issues]] / [[issues-view]]) — the human reply path, STORE-ROUTED through the one
166
168
  // reply verb ([[issues]] replyIssue): a local id git-commits to the trunk store, a forge id ('github#N')
@@ -181,7 +183,7 @@ app.post('/api/issues/:id/reply', async (c) => {
181
183
  try {
182
184
  // the mention prompt's node context, from the same resident merge the GET serves
183
185
  const node = id.includes('#')
184
- ? mergedIssues({ host: 'github', state: residentForgeState() }, loadSpecsLite().map((s) => s.id)).find((i) => i.id === id)?.nodes[0] ?? null
186
+ ? mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, loadSpecsLite().map((s) => s.id)).find((i) => i.id === id)?.nodes[0] ?? null
185
187
  : null
186
188
  const r = await replyIssue(id, text, { author: 'human', node, evidence })
187
189
  if (r.store !== 'local') await refreshForgeNow()
@@ -446,9 +448,10 @@ app.post('/api/sessions/:id/rawkey', async (c) => {
446
448
  // removes the worktree. {ok:false} = no such session.
447
449
  app.post('/api/sessions/:id/exit', async (c) => c.json({ ok: await exitSession(c.req.param('id')) }))
448
450
  app.post('/api/sessions/:id/close', async (c) => c.json({ ok: await closeSession(c.req.param('id')) }))
449
- // set (or clear, with a blank) a session's display-name override; persists to the worktree's `.session` so
450
- // it survives a restart. Unknown id → 404. The worktree file is OUTSIDE the store the board watchers see,
451
- // so the route nudges the stream explicitly ([[board-stream]]) the rename shows in ~150ms, not a cold tick.
451
+ // set (or clear, with a blank) a session's display-name override; persists to the session's global record
452
+ // (`session.json`) so it survives a restart. Unknown id → 404. That record sits INSIDE the watched store, but
453
+ // the store watch is best-effort (it can fail to attach), so the route still nudges the stream explicitly
454
+ // ([[board-stream]]) — the rename shows in ~150ms deterministically, never waiting out a cold tick.
452
455
  app.post('/api/sessions/:id/rename', async (c) => {
453
456
  const body = await c.req.json().catch(() => ({}))
454
457
  const ok = await renameSession(c.req.param('id'), typeof body?.name === 'string' ? body.name : '')
@@ -1,8 +1,8 @@
1
- import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, chmodSync } from 'node:fs'
1
+ import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, chmodSync, writeFileSync } from 'node:fs'
2
2
  import { join, resolve, relative } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { execFileSync } from 'node:child_process'
5
- import { readConfig } from './layout.js'
5
+ import { readConfig, readJsonConfig } from './layout.js'
6
6
  import { resolveHarnessTargets } from './harness-select.js'
7
7
 
8
8
  // this file lives at <pkgRoot>/src/init.ts, so `..` is the package root — the same derivation the
@@ -53,9 +53,27 @@ function resolveHooksDir(dir: string): string | null {
53
53
  }
54
54
  }
55
55
 
56
- export async function specInit(targetArg: string | undefined, presetArg?: string): Promise<void> {
56
+ export async function specInit(targetArg: string | undefined, presetArg?: string, renderArg?: string): Promise<void> {
57
57
  const targetDir = resolve(targetArg ?? process.cwd())
58
58
 
59
+ // the one-step render vote (`--render <committed|ignored|hidden>`, see [[render-policy]]): validated up
60
+ // front against the same vocabulary materialize enforces (resolveRenderPolicy — an unknown word fails loud
61
+ // BEFORE anything is written), applied to the config files below, and honored by this run's materialize.
62
+ const render = renderArg?.trim()
63
+ if (renderArg !== undefined) {
64
+ if (!render) {
65
+ console.error('spex init: --render needs a value — the render axis has three words: committed | ignored | hidden (see `spex guide footprint`)')
66
+ process.exit(1)
67
+ }
68
+ try {
69
+ const { resolveRenderPolicy } = await import('./materialize.js')
70
+ resolveRenderPolicy({ render })
71
+ } catch (e) {
72
+ console.error(`spex init: ${(e as Error).message}`)
73
+ process.exit(1)
74
+ }
75
+ }
76
+
59
77
  // the preset the NEW adopter gets — `--preset <name>` wins, else an existing target spexcode.json's
60
78
  // `preset` field, else the lean `default`. Validated loudly against the chain (an unknown name would
61
79
  // otherwise seed silently). A non-default tier stacks its template package on top of the default set below.
@@ -99,13 +117,29 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
99
117
  }
100
118
  }
101
119
 
102
- // 1b. plant a starter spexcode.json (the lint/layout knob), pointing governedRoots at `src/`.
120
+ // 1b. plant a starter spexcode.json (the lint/layout knob). The success message reports the value the
121
+ // template ACTUALLY ships (read from the planted file, never restated as a string literal here — the two
122
+ // once drifted: the message claimed ["src"] while the template seeded ["."]).
103
123
  const cfgDest = join(targetDir, 'spexcode.json')
104
124
  if (existsSync(cfgDest)) {
105
125
  console.warn(`• spexcode.json already exists at ${cfgDest} — left untouched.`)
106
126
  } else {
107
127
  copyFileSync(join(TEMPLATES, 'spexcode.json'), cfgDest)
108
- console.log(`✓ planted spexcode.json — set lint.governedRoots to YOUR source dirs (starter: ["src"])`)
128
+ const roots = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
129
+ console.log(`✓ planted spexcode.json — lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded); curate explicit roots later if you want a narrower graph`)
130
+ }
131
+
132
+ // 1c. apply the --render vote where the axis says each word lives ([[render-policy]]): committed/ignored
133
+ // are project facts → the committed spexcode.json; hidden is a host/person fact → the gitignored
134
+ // spexcode.local.json. An explicit flag is an explicit instruction, so it sets the field even in a
135
+ // pre-existing config file (the ONE deliberate exception to "existing files are left untouched").
136
+ if (render) {
137
+ const home = render === 'hidden' ? 'spexcode.local.json' : 'spexcode.json'
138
+ const p = join(targetDir, home)
139
+ const cur = readJsonConfig(p) // fails loud on a malformed existing file, {} when absent
140
+ cur.render = render
141
+ writeFileSync(p, JSON.stringify(cur, null, 2) + '\n')
142
+ console.log(`✓ render policy voted: "${render}" → ${home}`)
109
143
  }
110
144
 
111
145
  // validate the harness DELIVERY TARGET set ([[harness-select]]) up front: a bad `harnesses` set (plugin +
@@ -149,21 +183,29 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
149
183
  const prevCwd = process.cwd()
150
184
  try {
151
185
  process.chdir(targetDir)
152
- const { materialize } = await import('./materialize.js')
186
+ const { materialize, renderVoteHint } = await import('./materialize.js')
153
187
  materialize(targetDir)
154
- console.log('✓ materialized harness artifacts (global hook manifest, AGENTS.md/CLAUDE.md block, .claude/.codex shims, Codex trust)')
188
+ console.log('✓ materialized harness artifacts (global hook manifest, AGENTS.md/CLAUDE.md block, harness shims, Codex trust)')
189
+ // the adoption vote hint ([[render-policy]]): a host-TRACKED contract file now carries the generated
190
+ // block and shows honestly dirty under the default policy — print the one-time decision guidance while
191
+ // the vote is open (an explicit `render`, including --render on this very run, retires it).
192
+ const hint = renderVoteHint(targetDir)
193
+ if (hint) console.log(`\n${hint}`)
155
194
  } catch (e) {
156
195
  console.warn(`• materialize skipped (${(e as Error).message}) — run \`spex materialize\` once the packages are installed.`)
157
196
  } finally {
158
197
  process.chdir(prevCwd)
159
198
  }
160
199
 
161
- // 3. next steps — what the human must do to bring the instance to life.
200
+ // 3. next steps — what the human must do to bring the instance to life. The governedRoots line reads the
201
+ // LIVE value (the planted starter's, or a pre-existing config's) so it can never drift from what's on disk.
202
+ const rootsNow = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
162
203
  console.log(`
163
204
  Next steps:
164
205
  1. Edit .spec/project/spec.md to describe YOUR project, then grow child nodes beneath it.
165
- 2. Set lint.governedRoots in spexcode.json to your source dir(s) until you do, \`spex lint\`
166
- warns it is governing nothing (it ships pointing at "src").
206
+ 2. lint.governedRoots in spexcode.json (currently ${rootsNow}) names what \`spex lint\` governs —
207
+ ["."] governs the whole git-tracked tree (tests excluded); narrow it to explicit source roots
208
+ when you want a curated graph.
167
209
  3. Start the backend and open the board:
168
210
  spex serve # http://localhost:8787
169
211
  4. \`spex lint\` should report 0 errors. Coverage warnings are your adoption TODO (source files no
@@ -9,7 +9,7 @@
9
9
  // (createIssue/createComment/closeIssue — the driver stays the only network toucher; the tracer stays read-only).
10
10
  import type { ForgeIssue, ForgePR } from '../../spec-forge/src/port.js'
11
11
  import { resolveLinks } from '../../spec-forge/src/links.js'
12
- import { DEFAULT_FORGE_HOST, FORGE_DRIVERS, forgeDriverFor, forgeIssueStores } from '../../spec-forge/src/drivers.js'
12
+ import { FORGE_DRIVERS, forgeDriverFor, forgeIssueStores, resolveForgeHost } from '../../spec-forge/src/drivers.js'
13
13
  import { closeLocalIssue, loadLocalIssues, loadOne, postLocalIssue, reply, issuesEnabled, replyLocalIssue, runIssueWrite, ISSUE_WRITE_SUBS } from './localIssues.js'
14
14
  import { dispatchMentions, parseMentions, type DispatchOutcome, type LoopIn } from './mentions.js'
15
15
  import { envSessionId } from './layout.js'
@@ -191,8 +191,9 @@ export async function promote(id: string, opts: { author?: string } = {}): Promi
191
191
  const author = opts.author || envSessionId() || 'unknown'
192
192
  const t = loadOne(id)
193
193
  if (t.status !== 'open') throw new Error(`'${id}' is ${t.status} — only an open local issue promotes`)
194
- const driver = forgeDriverFor(DEFAULT_FORGE_HOST)
195
- if (!driver) throw new Error(`unknown default forge host '${DEFAULT_FORGE_HOST}'`)
194
+ const host = resolveForgeHost()
195
+ const driver = forgeDriverFor(host)
196
+ if (!driver) throw new Error(`no driver for this repo's forge host '${host}' (known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')}) — promotion needs one`)
196
197
  const body = [
197
198
  t.body,
198
199
  t.nodes.length ? `\nSpec: ${t.nodes.join(', ')}` : '',
@@ -300,8 +301,9 @@ export async function runIssues(args: string[]): Promise<number> {
300
301
  const nodeIds = loadSpecsLite().map((s) => s.id)
301
302
  let forge: ForgeSlice | null = null
302
303
  try {
303
- const driver = forgeDriverFor(DEFAULT_FORGE_HOST)
304
- if (!driver) throw new Error(`unknown default forge host '${DEFAULT_FORGE_HOST}'`)
304
+ const host = resolveForgeHost()
305
+ const driver = forgeDriverFor(host)
306
+ if (!driver) throw new Error(`no driver for this repo's forge host '${host}' (known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')})`)
305
307
  const [issues, prs] = await Promise.all([driver.listIssues(), driver.listPRs()])
306
308
  forge = { host: driver.host, state: { issues, prs } }
307
309
  } catch (e) {
@@ -10,12 +10,21 @@ type Config = {
10
10
  mainBranch?: string // source-of-truth BRANCH worktrees fork from (default: auto-detected — see mainBranch())
11
11
  branchPrefix?: string // how a branch names its node (default: "node/")
12
12
  preset?: string // the SELECTED init preset — which cumulative .config tier `spex init` seeds (default 'default'; seed-time only, no launcher gate; read by init.ts; see [[init-preset]])
13
- // PRIVATE-OVERLAY mode ([[private-overlay]]) — belongs in the gitignored spexcode.local.json, NEVER the
14
- // committed spexcode.json. When true, `spex materialize` leaves ZERO trace in the host's TRACKED files /
15
- // shared history: the managed ignore entries (incl .spec + spexcode.json, which the DEFAULT mode commits —
16
- // "git is the database") go to the per-clone `.git/info/exclude`, and any host-tracked contract file
17
- // (CLAUDE.md/AGENTS.md) the system block folds into is marked `skip-worktree` so that block never stages.
18
- // The dogfood becomes invisible to collaborators, trading away git-derived spec version history ([[source-of-truth]]).
13
+ // the RENDER POLICY ([[render-policy]]) — the ONE voted footprint knob: where the machine-independent
14
+ // RENDERS (the CLAUDE.md/AGENTS.md contract blocks, the .claude/.codex skills + agents) sit relative to the
15
+ // shared repo. Three words, validated fail-loud by materialize's resolveRenderPolicy:
16
+ // 'committed' the renders are ordinary committed files (their entries leave the ignore block) the
17
+ // contract reaches un-adopted teammates/CI through the harness's native discovery, for free;
18
+ // 'ignored' (default) the renders are generated + gitignored via the managed block in the TRACKED
19
+ // .gitignore — the team sees the rule, never the products;
20
+ // 'hidden' zero repo footprint — the ignore rules live in the per-clone .git/info/exclude, and a
21
+ // HOST-TRACKED contract file is handled by the clean/smudge content filter ([[content-filter]]).
22
+ // `committed` is a project fact → spexcode.json; `hidden` is a host/person fact → spexcode.local.json.
23
+ // The schema deliberately has NO knob for the spec DATA: `.spec` + spexcode.json are ALWAYS tracked
24
+ // ("git is the database") — the vocabulary itself makes "untrack the spec" unsayable.
25
+ render?: 'committed' | 'ignored' | 'hidden'
26
+ // DEPRECATED ([[render-policy]] compat): the retired private-overlay toggle. Read as render:'hidden' (with a
27
+ // loud, non-fatal migration notice); its old data-untrack semantics are gone — see `spex guide footprint`.
19
28
  private?: boolean
20
29
  // which harness targets `spex materialize` delivers into — native ids ('claude'|'codex') or a {plugin:"<folder>"}
21
30
  // bundle; resolved + validated by [[harness-select]] (harness-select.ts). Default (omitted): all native harnesses.
@@ -46,12 +55,15 @@ type Config = {
46
55
  issues?: {
47
56
  enabled?: boolean // the [[local-issues]] issues-workflow on/off switch (default ON). OFF silences the post-merge nudge + hides the dashboard view; flip with `spex issues on|off`. (Pre-rename `proposals.enabled` still reads — localIssues.ts issuesEnabled.)
48
57
  }
58
+ forge?: {
59
+ host?: string // explicit forge host id ('github'|'gitlab'|…) overriding the origin-remote derivation ([[forge-host]] — read by spec-forge drivers.ts resolveForgeHost, not here). A project fact → committed spexcode.json.
60
+ }
49
61
  }
50
62
  // the resolved LAYOUT convention — main/mainBranch/branchPrefix filled to defaults. `dashboard`, `sessions`,
51
- // `serve`, `harnesses`, and `preset` are frontend/runtime/policy concerns (read separately via readConfig —
63
+ // `serve`, `harnesses`, `render`, and `preset` are frontend/runtime/policy concerns (read separately via readConfig —
52
64
  // preset by init.ts at seed time, harnesses by [[harness-select]]; see api-endpoint / sessions.ts maxActive /
53
65
  // gateway.ts), NOT layout fields, so they stay out of the convention rather than forcing a default.
54
- type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'private'>>
66
+ type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'forge' | 'private' | 'render'>>
55
67
 
56
68
  export type Worktree = {
57
69
  path: string; branch: string | null; node: string | null
@@ -140,6 +140,11 @@ export async function specLint(): Promise<Finding[]> {
140
140
  out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing file: ${f}` })
141
141
  owners.set(f, [...(owners.get(f) ?? []), s.id])
142
142
  }
143
+ // one-govern: a node is source of truth for at most ONE file, so drift/yatsu/ack have a single
144
+ // unambiguous subject (see [[governed-related]]). >1 is a defect — pick the true subject, demote the
145
+ // rest to related. ERROR (the node-side twin of too-many-owners' file-side bound). 0 is fine.
146
+ if (s.code.length > 1)
147
+ out.push({ level: 'error', rule: 'one-govern', spec: s.id, msg: `'${s.id}' governs ${s.code.length} files [${s.code.join(', ')}] — a node is source of truth for at most ONE. Keep the true subject in code:, move the rest to related:` })
143
148
  }
144
149
  // a file is COVERED if any node GOVERNS (code:) or merely REFERENCES (related:) it; integrity covers both.
145
150
  // `related:` is the coverage net: govern is a sharp ideally-one-file pointer, so most files are reached by
@@ -217,6 +222,20 @@ export async function specLint(): Promise<Finding[]> {
217
222
  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` })
218
223
  }
219
224
 
225
+ // related drift: the SOFT tier ([[governed-related]]). A referenced file moved ahead of the node's
226
+ // version — a nudge that a dependency shifted. Same ancestry basis as govern drift, but WARN-only,
227
+ // never reaching the commit gate (driftGate reads govern) or yatsu. It is COMMON (shared substrate and
228
+ // faces change often without re-versioning every referrer), so per-file it is a wall; like
229
+ // too-many-owners it collapses to ONE summary line, with the per-file detail riding the board
230
+ // (relatedDriftFiles). It stays a soft edge, never a per-file interruption.
231
+ const rd = specs.flatMap((s) => s.relatedDriftFiles.map((d) => ({ id: s.id, behind: d.behind })))
232
+ if (rd.length) {
233
+ const byNode = new Map<string, number>()
234
+ for (const d of rd) byNode.set(d.id, (byNode.get(d.id) ?? 0) + 1)
235
+ const worst = [...byNode].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, n]) => `${id}(${n})`).join(', ')
236
+ out.push({ level: 'warn', rule: 'related-drift', msg: `${rd.length} related file(s) across ${byNode.size} node(s) drifted ahead of their spec (SOFT — a dependency shifted, worth a glance; never blocks, no ack, no yatsu). Most: ${worst}` })
237
+ }
238
+
220
239
  return out
221
240
  }
222
241
 
@@ -362,16 +362,28 @@ export async function replyLocalIssue(id: string, body: string, author: string,
362
362
  // The FALLBACK CHAIN of candidates a reply loops in ([[mentions]] loop-in / [[remark-substrate]] R3's dispatch
363
363
  // clause), tried in order until one is online. A plain thread's only candidate is its author (`by`). An
364
364
  // EVAL-COMMENT thread (concern `eval: <node> · <scenario>`, the eval-remark track) chains: the agent who FILED
365
- // the reading the remark judges FIRST, then when that filer is offline/absent the NODE's governing session,
366
- // so an unresolved remark still REACHES an agent who can act on it. This is notification only; it resolves
367
- // nothing (R3: resolve is a deliberate `spex resolve`). Non-eval threads pay nothing (no yatsu/specs import).
365
+ // the reading the remark judges FIRST — resolved from the TRUNK sidecar, then from each LIVE session's
366
+ // WORKTREE (an in-flight reading, filed on an unmerged branch, is invisible to the trunk exactly the
367
+ // review-time case, when the filer sits online awaiting review and the remark must reach them) — then, when
368
+ // every filer is offline/absent, the NODE's governing session, so an unresolved remark still REACHES an agent
369
+ // who can act on it. A broken/absent worktree sidecar falls through silently — one bad worktree never fails
370
+ // 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).
368
372
  const EVAL_CONCERN_RE = /^eval: (.+?) · (.+)$/ // node first (never contains ' · '), then the scenario (may)
369
373
  async function threadOriginators(thread: Issue): Promise<(string | null)[]> {
370
374
  const m = EVAL_CONCERN_RE.exec(thread.concern)
371
375
  if (!m) return [thread.by]
372
376
  const node = m[1].trim(), scenario = m[2].trim()
373
377
  const { evalReadingFiler } = await import('../../spec-yatsu/src/filing.js')
374
- return [evalReadingFiler(node, scenario), await nodeGoverningSession(node)]
378
+ const chain: (string | null)[] = [evalReadingFiler(node, scenario)]
379
+ try {
380
+ const { listSessions } = await import('./sessions.js')
381
+ for (const s of await listSessions()) {
382
+ try { if (s.path) chain.push(evalReadingFiler(node, scenario, s.path)) } catch { /* one unreadable worktree → next link */ }
383
+ }
384
+ } catch { /* sessions unavailable (bare store, no tmux) → trunk-only chain, as before */ }
385
+ chain.push(await nodeGoverningSession(node))
386
+ return chain
375
387
  }
376
388
 
377
389
  // A node's governing session — the `session` its spec resolves to (the Session: trailer of its latest version,