spexcode 0.2.4 → 0.2.6

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 (69) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +13 -10
  4. package/spec-cli/hooks/dispatch.sh +28 -40
  5. package/spec-cli/hooks/harness.sh +43 -7
  6. package/spec-cli/src/board.ts +2 -1
  7. package/spec-cli/src/boardCache.ts +27 -1
  8. package/spec-cli/src/boardStream.ts +5 -4
  9. package/spec-cli/src/cli.ts +93 -20
  10. package/spec-cli/src/commit-surgery.ts +81 -0
  11. package/spec-cli/src/contract-filter.ts +156 -0
  12. package/spec-cli/src/doctor.ts +11 -5
  13. package/spec-cli/src/git.ts +18 -4
  14. package/spec-cli/src/guide.ts +94 -38
  15. package/spec-cli/src/harness-select.ts +2 -2
  16. package/spec-cli/src/harness.ts +22 -8
  17. package/spec-cli/src/help.ts +28 -16
  18. package/spec-cli/src/index.ts +9 -6
  19. package/spec-cli/src/init.ts +17 -10
  20. package/spec-cli/src/issues.ts +7 -5
  21. package/spec-cli/src/layout.ts +31 -13
  22. package/spec-cli/src/lint.ts +19 -0
  23. package/spec-cli/src/localIssues.ts +16 -4
  24. package/spec-cli/src/materialize.ts +214 -144
  25. package/spec-cli/src/mentions.ts +5 -3
  26. package/spec-cli/src/plugin-harness.ts +10 -9
  27. package/spec-cli/src/ranker.ts +25 -8
  28. package/spec-cli/src/runtime-guard.ts +44 -0
  29. package/spec-cli/src/search.bench.mjs +15 -5
  30. package/spec-cli/src/sessions.ts +96 -22
  31. package/spec-cli/src/specs.ts +38 -18
  32. package/spec-cli/src/supervise.ts +2 -2
  33. package/spec-cli/src/tsx-bin.ts +6 -8
  34. package/spec-cli/src/uninstall.ts +18 -19
  35. package/spec-cli/src/worktree-sources.ts +52 -13
  36. package/spec-cli/templates/hooks/post-checkout +22 -0
  37. package/spec-cli/templates/hooks/post-merge +15 -9
  38. package/spec-cli/templates/hooks/pre-commit +10 -0
  39. package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
  40. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
  41. package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
  42. package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
  43. package/spec-dashboard/dist/assets/Dashboard-BlRRsxE7.js +27 -0
  44. package/spec-dashboard/dist/assets/EvalsPage-BzVE38-Z.js +2 -0
  45. package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-DFuLVOeu.js} +1 -1
  46. package/spec-dashboard/dist/assets/IssuesPage-CzDaazhe.js +1 -0
  47. package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-CXQrQCNp.js} +1 -1
  48. package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-D1pUBl6q.js} +8 -8
  49. package/spec-dashboard/dist/assets/SessionWindow-Y25Bwg1e.js +9 -0
  50. package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-R610Vbzd.js} +1 -1
  51. package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BO0Zuweu.js} +2 -2
  52. package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
  53. package/spec-dashboard/dist/index.html +2 -2
  54. package/spec-forge/src/cli.ts +2 -2
  55. package/spec-forge/src/drivers/gitlab.ts +168 -0
  56. package/spec-forge/src/drivers.ts +80 -2
  57. package/spec-forge/src/resident.ts +10 -5
  58. package/spec-yatsu/src/cli.ts +37 -16
  59. package/spec-yatsu/src/evaltab.ts +6 -3
  60. package/spec-yatsu/src/filing.ts +13 -8
  61. package/spec-yatsu/src/freshness.ts +97 -22
  62. package/spec-yatsu/src/proof.ts +14 -3
  63. package/spec-yatsu/src/scenariofresh.ts +38 -11
  64. package/spec-yatsu/src/yatsu.ts +52 -28
  65. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
  66. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
  67. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
  68. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
  69. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +0 -1
@@ -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 footprint model: never-tracked artifacts, exclude + content filter, anchors
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]',
@@ -312,7 +318,9 @@ ${ROUTING_NOTE}`,
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
+ Footprint needs no vote: materialized artifacts are never tracked — hidden via the per-clone .git/info/exclude, with
323
+ a tracked/mixed CLAUDE.md/AGENTS.md covered by the clean/smudge filter (see spex guide footprint).`,
316
324
  see: 'spex guide (the full setup workflow) · spex uninstall (the inverse) · spex lint (adoption TODO)',
317
325
  },
318
326
  uninstall: {
@@ -320,30 +328,32 @@ shims). Additive — never overwrites your files. --preset picks the .config plu
320
328
  body: `Usage: spex uninstall [dir=cwd] [--hooks]
321
329
 
322
330
  Removes every SpexCode-GENERATED artifact (harness shims · contract blocks · trust entries ·
323
- .gitignore block · global store · plugin bundle) and never your .spec/.config data or your own
331
+ exclude/ignore blocks · global store · plugin bundle) and never your .spec/.config data or your own
324
332
  prose. Git hooks are preserved unless --hooks.`,
325
333
  see: 'spex init (re-adopt later — your .spec survives)',
326
334
  },
327
335
  materialize: {
328
- line: 'materialize re-render the harness artifacts (contract block · shims) for cwd’s project',
336
+ line: 'materialize re-materialize the harness artifacts (contract block · shims) for cwd’s project',
329
337
  body: `Usage: spex materialize
330
338
 
331
- Renders the surface:system config nodes into the managed <!-- spexcode --> block of
332
- CLAUDE.md/AGENTS.md plus the .claude/.codex shims, and prints the content hash. Run it after a
333
- toolchain update or any .config edit that the automatic dispatch gate hasn't picked up — these
334
- artifacts are generated and gitignored, so they never arrive via git.`,
335
- see: 'spex doctor (verify the render actually reaches an agent)',
339
+ Materializes the surface:system config nodes into the managed <!-- spexcode --> block of
340
+ CLAUDE.md/AGENTS.md plus the .claude/.codex shims, and prints the content hash. The materialize anchors on
341
+ git-native events only (init · this verb · session-worktree creation · the pre-commit/post-checkout/
342
+ post-merge hooks) run it by hand after a toolchain update, or in the setup step of any clone that
343
+ has no spex-planted hooks yet (CI, a cloud agent): the artifacts are generated and excluded, so they
344
+ never arrive via git.`,
345
+ see: 'spex doctor (verify the materialized artifacts actually reach an agent)',
336
346
  },
337
347
  doctor: {
338
348
  line: 'doctor diagnose whether the workflow actually reaches this agent — per-layer, per-harness',
339
349
  body: `Usage: spex doctor per-layer coverage report: preconditions · git-hook floor · contract ·
340
- hooks + handler existence · backend — for every harness materialize renders
350
+ hooks + handler existence · backend — for every harness materialize delivers to
341
351
  spex doctor contract print the composed surface:system text any agent here reads
342
352
  spex doctor conflicts detect double-delivery (loose artifacts beside the managed ones)
343
353
 
344
354
  Run it when a worker seems to be missing its contract or hooks — it names the broken layer and the
345
355
  repair, instead of you diffing materialized files by hand.`,
346
- see: 'spex materialize (re-render the artifacts doctor checks)',
356
+ see: 'spex materialize (re-materialize the artifacts doctor checks)',
347
357
  },
348
358
  serve: {
349
359
  line: 'serve run the API backend (default :8787) [--port N] [--public --password pw]',
@@ -372,10 +382,12 @@ so bind wide only on a network you trust (for the internet, use \`spex serve --p
372
382
  // ── plumbing ──────────────────────────────────────────────────────────────
373
383
  internal: {
374
384
  line: '', // deliberately not on the map
375
- body: `Usage: spex internal <trunk | codex-launch | codex-turn>
385
+ body: `Usage: spex internal <trunk | commit-surgery | refresh-footprint | codex-launch | codex-turn>
376
386
 
377
387
  Machine plumbing — called by generated hooks and launch scripts, never typed by a human or agent:
378
- trunk print the resolved source-of-truth branch (the pre-commit main-guard captures it)
388
+ trunk print the resolved source-of-truth branch (the pre-commit main-guard captures it)
389
+ commit-surgery pre-commit footprint anchor: unconditional materialize + staged-index repair
390
+ refresh-footprint quiet materialize — the post-checkout/post-merge freshness anchor
379
391
  codex-launch <sock> <cwd> [prompt…] backend-owned codex thread/start + first turn (launch script)
380
392
  codex-turn <sock> <threadId> <text…> fire a follow-up turn on an owned thread (tests/scripts)
381
393
 
@@ -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
@@ -99,13 +99,16 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
99
99
  }
100
100
  }
101
101
 
102
- // 1b. plant a starter spexcode.json (the lint/layout knob), pointing governedRoots at `src/`.
102
+ // 1b. plant a starter spexcode.json (the lint/layout knob). The success message reports the value the
103
+ // template ACTUALLY ships (read from the planted file, never restated as a string literal here — the two
104
+ // once drifted: the message claimed ["src"] while the template seeded ["."]).
103
105
  const cfgDest = join(targetDir, 'spexcode.json')
104
106
  if (existsSync(cfgDest)) {
105
107
  console.warn(`• spexcode.json already exists at ${cfgDest} — left untouched.`)
106
108
  } else {
107
109
  copyFileSync(join(TEMPLATES, 'spexcode.json'), cfgDest)
108
- console.log(`✓ planted spexcode.json — set lint.governedRoots to YOUR source dirs (starter: ["src"])`)
110
+ const roots = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
111
+ 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`)
109
112
  }
110
113
 
111
114
  // validate the harness DELIVERY TARGET set ([[harness-select]]) up front: a bad `harnesses` set (plugin +
@@ -141,29 +144,33 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
141
144
  if (installed.length) console.log(`✓ installed git hooks (${installed.join(', ')}) → ${hooksDir}`)
142
145
  }
143
146
 
144
- // 2c. RENDER the harness-discovered artifacts so a USER-self-launched claude/codex works with zero further
147
+ // 2c. MATERIALIZE the harness-discovered artifacts so a USER-self-launched claude/codex works with zero further
145
148
  // steps: the hook manifest (in the GLOBAL per-project store, not the worktree), the AGENTS.md/CLAUDE.md
146
149
  // <spexcode> contract block (user content preserved), the .claude/.codex shims, and the Codex trust (global,
147
150
  // scoped) so codex self-launch is prompt-free. Runs with cwd = the target so the loaders read the just-seeded
148
- // .config. Idempotent — the dispatch.sh gate keeps it fresh thereafter on every .config edit.
151
+ // .config. Idempotent — the planted git hooks (pre-commit/post-checkout/post-merge) keep it fresh
152
+ // thereafter on the git-native anchors ([[commit-surgery]]); no harness event ever triggers a materialize.
149
153
  const prevCwd = process.cwd()
150
154
  try {
151
155
  process.chdir(targetDir)
152
156
  const { materialize } = await import('./materialize.js')
153
157
  materialize(targetDir)
154
- console.log('✓ materialized harness artifacts (global hook manifest, AGENTS.md/CLAUDE.md block, .claude/.codex shims, Codex trust)')
158
+ console.log('✓ materialized harness artifacts (global hook manifest, AGENTS.md/CLAUDE.md block, harness shims, Codex trust)')
155
159
  } catch (e) {
156
160
  console.warn(`• materialize skipped (${(e as Error).message}) — run \`spex materialize\` once the packages are installed.`)
157
161
  } finally {
158
162
  process.chdir(prevCwd)
159
163
  }
160
164
 
161
- // 3. next steps — what the human must do to bring the instance to life.
165
+ // 3. next steps — what the human must do to bring the instance to life. The governedRoots line reads the
166
+ // LIVE value (the planted starter's, or a pre-existing config's) so it can never drift from what's on disk.
167
+ const rootsNow = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
162
168
  console.log(`
163
169
  Next steps:
164
170
  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").
171
+ 2. lint.governedRoots in spexcode.json (currently ${rootsNow}) names what \`spex lint\` governs —
172
+ ["."] governs the whole git-tracked tree (tests excluded); narrow it to explicit source roots
173
+ when you want a curated graph.
167
174
  3. Start the backend and open the board:
168
175
  spex serve # http://localhost:8787
169
176
  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,15 @@ 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
+ // RETIRED ([[residence]]) — the old three-word footprint vote. Materialized artifacts carry no facts and are never
14
+ // tracked now (one residence behavior: the per-clone exclude, plus the content filter for a mixed
15
+ // contract file), so the field is IGNORED with a loud non-fatal notice (materialize's retiredAxisNotice);
16
+ // it stays in the type only so the notice can read it. The schema deliberately has NO knob for the spec
17
+ // DATA: `.spec` + spexcode.json are ALWAYS tracked ("git is the database") the vocabulary itself makes
18
+ // "untrack the spec" unsayable.
19
+ render?: string
20
+ // RETIRED (residence compat): the old private-overlay toggle — ignored with the same loud notice;
21
+ // its data-untrack semantics are long gone. See `spex guide footprint` MIGRATIONS.
19
22
  private?: boolean
20
23
  // which harness targets `spex materialize` delivers into — native ids ('claude'|'codex') or a {plugin:"<folder>"}
21
24
  // bundle; resolved + validated by [[harness-select]] (harness-select.ts). Default (omitted): all native harnesses.
@@ -46,12 +49,15 @@ type Config = {
46
49
  issues?: {
47
50
  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
51
  }
52
+ forge?: {
53
+ 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.
54
+ }
49
55
  }
50
56
  // 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 —
57
+ // `serve`, `harnesses`, `render`, and `preset` are frontend/runtime/policy concerns (read separately via readConfig —
52
58
  // preset by init.ts at seed time, harnesses by [[harness-select]]; see api-endpoint / sessions.ts maxActive /
53
59
  // 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'>>
60
+ type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'forge' | 'private' | 'render'>>
55
61
 
56
62
  export type Worktree = {
57
63
  path: string; branch: string | null; node: string | null
@@ -133,17 +139,29 @@ export function spexcodeHome(): string {
133
139
  export function encodeProject(root: string): string {
134
140
  return root.replace(/[/.]/g, '-')
135
141
  }
136
- // this project's per-PROJECT runtime tier — the materialized hook manifest + content-hash marker (and the
137
- // gate's lock) — living alongside sessions/ under the SAME global per-project dir, so NOTHING SpexCode renders
138
- // stays in the worktree (not even the manifest; the worktree holds only the harness-discovered CLAUDE.md/
139
- // AGENTS.md + shims, which must sit in-tree). proj-aware for `spex init <dir>` / materialize(proj); cwd-based
140
- // default for the hooks/board. The shell hooks mirror this as hp_runtime_dir.
142
+ // this project's per-PROJECT runtime tier — the sessions/ records AND the per-TREE materialize slots (below)
143
+ // living under the SAME global per-project dir, so NOTHING SpexCode materializes stays in the worktree (the
144
+ // worktree holds only the harness-discovered CLAUDE.md/AGENTS.md + shims, which must sit in-tree).
145
+ // proj-aware for `spex init <dir>` / materialize(proj); cwd-based default for the hooks/board. The shell
146
+ // hooks mirror this as hp_runtime_dir.
141
147
  export function runtimeRoot(proj?: string): string {
142
148
  const gcd = proj
143
149
  ? git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
144
150
  : gitCommonDir()
145
151
  return join(spexcodeHome(), 'projects', encodeProject(dirname(gcd)))
146
152
  }
153
+ // the per-WORKTREE materialize slot — <runtime>/trees/<enc(worktree-toplevel)> — holding the materialize
154
+ // products that are a pure function of ONE tree's .config (hooks-manifest, content-hash, plugin-folders).
155
+ // Slotted per tree exactly like sessions/<id> is slotted per session: the old single global file made the
156
+ // last-materialized tree win, so dispatch ran tree A's compiled hook set inside tree B's sessions
157
+ // ([[hook-dispatch]]). Key = the sessions encodeProject transform over `rev-parse --show-toplevel`, the
158
+ // SAME derivation dispatch.sh's shell mirror (hp_tree_dir) runs from its own cwd — so writer and reader
159
+ // land on the same slot from the same tree, and only from the same tree. Throws when `wt` is not a live
160
+ // git tree (fail loud); a best-effort caller (the close-time GC) wraps it.
161
+ export function treeSlotDir(wt: string): string {
162
+ const top = git(['-C', wt, 'rev-parse', '--show-toplevel']).trim()
163
+ return join(runtimeRoot(wt), 'trees', encodeProject(top || wt))
164
+ }
147
165
  // this project's per-session records dir, one session's dir, its structured record, and a sibling artifact —
148
166
  // all keyed by session_id under <home>/projects/<enc>/sessions/.
149
167
  export function sessionsRoot(): string { return join(runtimeRoot(), 'sessions') }
@@ -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,