spexcode 0.2.5 → 0.2.7

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 (45) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/dispatch.sh +28 -40
  4. package/spec-cli/hooks/harness.sh +29 -19
  5. package/spec-cli/src/cli.ts +30 -12
  6. package/spec-cli/src/commit-surgery.ts +81 -0
  7. package/spec-cli/src/contract-filter.ts +16 -14
  8. package/spec-cli/src/doctor.ts +11 -5
  9. package/spec-cli/src/guide.ts +69 -59
  10. package/spec-cli/src/harness-select.ts +2 -2
  11. package/spec-cli/src/harness.ts +23 -14
  12. package/spec-cli/src/help.ts +23 -17
  13. package/spec-cli/src/init.ts +5 -40
  14. package/spec-cli/src/layout.ts +26 -20
  15. package/spec-cli/src/materialize.ts +102 -126
  16. package/spec-cli/src/mentions.ts +5 -3
  17. package/spec-cli/src/plugin-harness.ts +10 -9
  18. package/spec-cli/src/sessions.ts +56 -28
  19. package/spec-cli/src/specs.ts +9 -4
  20. package/spec-cli/src/uninstall.ts +6 -5
  21. package/spec-cli/src/worktree-sources.ts +6 -4
  22. package/spec-cli/templates/hooks/post-checkout +22 -0
  23. package/spec-cli/templates/hooks/post-merge +15 -9
  24. package/spec-cli/templates/hooks/pre-commit +10 -0
  25. package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
  26. package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
  27. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +1 -1
  28. package/spec-dashboard/dist/assets/{Dashboard-CMRJGfYI.js → Dashboard-P0B9ukSG.js} +7 -7
  29. package/spec-dashboard/dist/assets/{EvalsPage-Dj2mxcfW.js → EvalsPage-BrvAGyc4.js} +1 -1
  30. package/spec-dashboard/dist/assets/{FoldToggle-BfNpeyRQ.js → FoldToggle-BuQ0lokE.js} +1 -1
  31. package/spec-dashboard/dist/assets/{IssuesPage-DfY315kt.js → IssuesPage-H-D8aHEl.js} +1 -1
  32. package/spec-dashboard/dist/assets/{MobileApp-BGdC0A0P.js → MobileApp-oZXIeCPb.js} +1 -1
  33. package/spec-dashboard/dist/assets/{SessionInterface-BOBCAR0t.js → SessionInterface-Blr_MEdU.js} +8 -8
  34. package/spec-dashboard/dist/assets/{SessionWindow-D7YmjV0i.js → SessionWindow-LcCzBMU7.js} +5 -5
  35. package/spec-dashboard/dist/assets/{Settings-BlSNmpH_.js → Settings-_yOye-In.js} +1 -1
  36. package/spec-dashboard/dist/assets/{index-BFdzpd_O.js → index-BhIslAau.js} +2 -2
  37. package/spec-dashboard/dist/index.html +1 -1
  38. package/spec-yatsu/src/cli.ts +3 -5
  39. package/spec-yatsu/src/evaltab.ts +3 -2
  40. package/spec-yatsu/src/filing.ts +3 -4
  41. package/spec-yatsu/src/freshness.ts +1 -3
  42. package/spec-yatsu/src/proof.ts +3 -2
  43. package/spec-yatsu/src/scenariofresh.ts +27 -5
  44. package/spec-yatsu/src/sidecar.ts +8 -8
  45. package/spec-yatsu/src/evaluator.ts +0 -24
@@ -40,7 +40,7 @@ Look these up on demand — the formats an agent authors, and the settings it co
40
40
  spex guide yatsu the yatsu.md format (scenario schema + how loss is measured and filed)
41
41
  spex guide config the spexcode.json / spexcode.local.json settings (launchers, dashboard icon, lint
42
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
43
+ spex guide footprint the footprint model — what SpexCode plants in a repo, and who sees it
44
44
  (committed | ignored | hidden), and every migration recipe`
45
45
 
46
46
  const SPEC = `spex guide spec — the spec.md file format
@@ -176,7 +176,7 @@ reading (\`--last\` makes that explicit; repeat to peel junk back one filing at
176
176
 
177
177
  THE SCOREBOARD: readings live in yatsu.evals.ndjson beside the yatsu.md — one JSON line per measurement
178
178
  (a second git-as-database axis). Freshness is derived live from git: a reading goes STALE when a governed
179
- code file, the scenario (the yatsu.md), or the evaluator moves since it was filed.
179
+ code file or the scenario (the yatsu.md) moves since it was filed.
180
180
  spex yatsu scan [--changed] blind spots: yatsu-schema (malformed) · yatsu-drift (stale) ·
181
181
  yatsu-missing (never measured) · yatsu-uncovered (governed source, no yatsu.md) ·
182
182
  yatsu-owners (a file governed by > maxOwners scenarios — split it)
@@ -193,12 +193,12 @@ PORTABILITY, and picking the right one is the whole discipline:
193
193
  identity, lint budgets, launcher NAMES. "Git is the database": tracked so the
194
194
  team shares ONE configuration.
195
195
  spexcode.local.json GITIGNORED — host-specific, never committed. Absolute launcher paths, cert/secret
196
- paths, a personal "render": "hidden" vote. Layered OVER spexcode.json (see MERGE
196
+ paths. Layered OVER spexcode.json (see MERGE
197
197
  below); a targeted env override (SPEXCODE_CODEX_SERVER_CMD, …) still wins at its read site.
198
198
 
199
199
  Rule of thumb — is the value TRUE FOR THE PROJECT or TRUE FOR THIS MACHINE? A branch name, a dashboard
200
200
  icon, a lint budget, a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
201
- PATH of a launcher wrapper, a TLS cert path, a personal render:hidden are machine facts → gitignored spexcode.local.json.
201
+ PATH of a launcher wrapper or a TLS cert path are machine facts → gitignored spexcode.local.json.
202
202
  Both files are optional; omit any field to take its default, except \`sessions.defaultLauncher\` when using
203
203
  \`spex new\` or the dashboard without an explicit launcher choice.
204
204
 
@@ -324,77 +324,87 @@ Example — govern your own source dir and loosen the altitude budget:
324
324
  'default'; seed-time only, read by init.ts).
325
325
  harnesses which harness targets \`spex materialize\` delivers into — native ids ("claude"|"codex") or a
326
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)
327
+ git-transactional: the edit takes effect at the next git-native materialize anchor (the commit
328
+ that carries it, a checkout/merge that receives it, or a manual \`spex materialize\`) — a
329
+ deselected harness's artifacts are pruned by that pass.`
330
+
331
+ const FOOTPRINT = `spex guide footprint what SpexCode plants in a repo, and who sees it (one fixed behavior per kind)
338
332
 
339
333
  SpexCode claims software engineering's HEAD (the recording of intent) and TAIL (the storage of
340
334
  measurement) and leaves the MIDDLE — construction — to the harness/agent/test framework; freshness
341
335
  stitches the two ends into a closed loop. The footprint follows: the head+tail (.spec, spexcode.json,
342
336
  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.
337
+ Materialized artifacts carry no facts, so they are NEVER trackedthere is exactly one residence
338
+ behavior, decided per KIND (and, for a contract file, by its live CONTENT).
344
339
 
345
- ── THE FOUR KINDS (three are fixed; one votes) ──
340
+ ── THE FOUR KINDS (all fixed) ──
346
341
  spec data .spec/ (incl .config/) + spexcode.json — ALWAYS tracked. Git is the database; there is
347
342
  deliberately NO way to say "untrack the spec" in this schema.
348
343
  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).
344
+ bundles — NEVER tracked; always in the per-clone exclude.
345
+ artifacts the CLAUDE.md/AGENTS.md contract blocks + materialized skills/agents — derived, NEVER
346
+ tracked; hidden via .git/info/exclude. The host's tracked .gitignore is never touched.
352
347
  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 blockthe "mystery M" explained at first sight.
348
+ out-of-tree, or exclude-ruled where in-tree.
349
+
350
+ ── A CONTRACT FILE'S RESIDENCE IS A LIVE CONTENT FACT (re-judged at every materialize) ──
351
+ host-tracked → the clean/smudge content filter: the repo keeps the pristine host prose, your
352
+ working tree carries prose + block, status stays clean.
353
+ untracked, wholly ours→ one exclude entry. The exclude is the ignored-bit DECLARATION the rest of git
354
+ consults (checkout may overwrite, clean -fd spares, status/add -A/stash silent).
355
+ untracked, YOUR prose the exclude entry is withdrawn (hiding user content would be data-loss shaped)
356
+ entered the file and the clean filter is pre-armed: the file shows honestly as untracked, and
357
+ IF you choose to \`git add\` it, the block is stripped automatically — tracking
358
+ is always your act; SpexCode never stages or commits anything for you.
359
+
360
+ ── THE GIT-NATIVE ANCHORS (no harness event ever triggers a materialize) ──
361
+ spex init / spex materialize / session-worktree creation the explicit passes;
362
+ pre-commit the correctness anchor: an UNCONDITIONAL materialize (masks provably fresh at the only
363
+ moment history is written) + staged-index surgery a staged blob carrying the sentinel
364
+ block is cleaned IN PLACE (partial staging survives; source is the staged blob), a
365
+ HEAD-untracked generated artifact is unstaged. Repairs and proceeds, never rejects.
366
+ post-checkout/post-merge freshness anchors: .spec/.config edits are git-transactionalthey take
367
+ effect at the commit/checkout/merge that carries them, like any other source change.
368
+ An environment with no spex-planted hooks (CI, a cloud agent's fresh clone, a teammate who hasn't
369
+ installed) simply runs \`spex materialize\` in its setup step — there is no committed-artifact mode.
370
+ TRACK ≠ PUSH: none of this ever touches remotes; where commits GO is branch/remote policy.
372
371
 
373
372
  ── 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.
373
+ materialize(P₂) ∘ materialize(P₁) = materialize(P₂): every materialize first ERASES all landing points by
374
+ SpexCode's own identity stamps, then re-asserts legacy states (a .gitignore managed block, a committed
375
+ artifact) are forgotten by the same pass. \`spex uninstall\` is the empty
376
+ materialize plus the global store: a total backout that never touches your .spec/.config or prose. Fresh
377
+ clones and session worktrees are self-sufficient: data by checkout, materialized artifacts by
378
+ re-materialize, the machine snapshot (spexcode.local.json) by copy.
379
+
380
+ ── THE CONTENT FILTER (mixed-content contract files) ──
381
+ Per-clone only — git config filter.spexcode.* + .git/info/attributes + a shim under .git/spexcode/ —
382
+ planted where mixed content exists or is imminent (tracked, or untracked with your prose). clean strips
383
+ the sentinel block (history never sees it); smudge re-injects it on checkout. A missing shim degrades to
384
+ identity (never a git fatal). Your own edits to the prose still show as real modifications; only the
385
+ block is invisible to git.
386
+
387
+ ── PRIVATE LOCAL NODES (manual posture — works today) ──
388
+ Spec data is always tracked, but tracked WHERE is yours to choose: to keep a node off the shared
389
+ remote, give it a different git HOME instead of untracking it. The manual recipe:
390
+ 1. create the node dir under .spec/ as usual (spec.md and friends);
391
+ 2. add the dir's path to .git/info/exclude — per-clone, so the shared repo never sees it;
392
+ 3. inside the dir, \`git init --separate-git-dir\` pointing somewhere under the main repo's .git/
393
+ (e.g. .git/spexcode/<name>.git) — the dir then holds only a one-line .git pointer file, so the
394
+ spec loader never walks an object store;
395
+ 4. commit the node's changes through that inner repo.
396
+ The effect, honestly: filesystem-derived surfaces see the node (board, search, lint); git-derived
397
+ views are blind to it (version count, the history tab, drift), and a dispatched worker's worktree
398
+ checkout does not contain it. Those gaps are what the pending spec-local design (a first-class
399
+ private overlay root) closes — not built yet. Cautions: \`git clean -fdx\` in the outer repo deletes
400
+ the inner repo along with the dir, so off-machine backup means giving the INNER repo its own private
401
+ remote; and taking the node public later is a migration (move it into the shared tree and commit),
402
+ not a flag flip.
386
403
 
387
404
  ── 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
405
+ legacy untracked spec track the sources once: git add .spec spexcode.json (commit on your branch)
392
406
  WARNING: tracking is not retroactive secrecy — history already pushed
393
407
  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
408
  back out entirely \`spex uninstall\` (add --hooks to also remove the spexcode git hooks).`
399
409
 
400
410
  const TOPICS: Record<string, string> = { spec: SPEC, yatsu: YATSU, config: CONFIG, footprint: FOOTPRINT }
@@ -2,9 +2,9 @@ import { HARNESSES, type Harness, type HarnessId } from './harness.js'
2
2
 
3
3
  // @@@ harness-select - the DECLARATIVE choice of WHICH harness targets `spex materialize` delivers the
4
4
  // SpexCode system into. The selection is persistent config (spexcode.json's `harnesses`), NOT a one-shot
5
- // flag, because materialize is driven by a content-hash gate (re-run on every .config edit), so the intent
5
+ // flag, because materialize re-runs at every git-native anchor ([[commit-surgery]]), so the intent
6
6
  // must live where every re-materialize can read it. This module owns ONLY the vocabulary + validation; the
7
- // per-harness write/clean mechanics live on the [[harness-adapter]], the render loop on [[harness-delivery]].
7
+ // per-harness write/clean mechanics live on the [[harness-adapter]], the materialize loop on [[harness-delivery]].
8
8
 
9
9
  // a resolved DELIVERY TARGET. Either a NATIVE harness (claude/codex — its adapter writes shims/contract/trust
10
10
  // directly), or a PLUGIN bundle dropped into a host-agent-scanned folder. The plugin EMITTER is a later node;
@@ -20,7 +20,7 @@ import { git } from './git.js'
20
20
  // dispatch.sh's first argument (`bash <dispatch> <id> <Event>`). dispatch.sh exports SPEXCODE_HARNESS, so a
21
21
  // hook subprocess learns its harness deterministically from the shim that wired it — never from guessing the
22
22
  // payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
23
- // (materialize renders every harness's artifacts).
23
+ // (materialize writes every harness's artifacts).
24
24
 
25
25
  export type HarnessId = 'claude' | 'codex'
26
26
  export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
@@ -159,7 +159,7 @@ export interface Harness {
159
159
  // its existing importers.
160
160
  export type DispatchResult = { ok: boolean; error?: string }
161
161
  export type HarnessDeliveryRecord = { session: string; worktreePath?: string; harnessSessionId?: string | null; runtimeDir?: string }
162
- // the on-demand surface artifacts a materialize render wrote, by node NAME — so clean() knows EXACTLY which
162
+ // the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
163
163
  // skill subdirs / agent files are SpexCode's to remove (name-scoped, never a blind wipe of a dir the user may
164
164
  // also populate). materialize passes the live skill/agent node names; clean reconstructs the same paths.
165
165
  export type HarnessArtifacts = { skills: readonly string[]; agents: readonly string[] }
@@ -179,24 +179,33 @@ export const rvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
179
179
  // A crashed/killed claude can leave its rvSock FILE on disk (a unix-domain socket path is NOT auto-unlinked on
180
180
  // an unclean exit), so the old `existsSync(rvSock)` read a DEAD pane as `online` for as long as the stale file
181
181
  // lingered — the incident's "dead pane stuck `working` for 30+ min". The honest signal is a live LISTENER:
182
- // connect() to the socket. A real claude is accepting connects; a stale file ECONNREFUSED (instant); an
183
- // absent file → ENOENT (instant). So the common cases cost no waiting; the short timeout only bounds the
184
- // pathological file-present-but-listener-wedged case (then treated as not-live). Never throws.
185
- export function rendezvousListening(id: string, timeoutMs = 800): Promise<boolean> {
182
+ // connect() to the socket. The verdict is TRI-STATE, because only two probe results actually PROVE anything:
183
+ // 'live' — the connect completed: a real claude is accepting.
184
+ // 'dead' — ECONNREFUSED (a stale file nothing listens on) / ENOENT (no file): death PROVEN, instantly.
185
+ // 'unproven' the probe itself failed to conclude: a TIMEOUT (under load the prober's event loop fires the
186
+ // expired timer before the pending connect event — the thrashed-backend incident where every live worker
187
+ // read offline in one board answer), or EAGAIN (the listen backlog is FULL, which proves a listener is
188
+ // alive-but-busy, the opposite of dead). Collapsing these into 'dead' is how a load spike masqueraded as
189
+ // a graveyard (issue #40); the caller must render unproven death as `unknown`, never `offline`.
190
+ // The common cases cost no waiting (connect/refuse/absent are instant); the short timeout only bounds the
191
+ // wedged/thrashed path. Never throws.
192
+ export type ListenerProbe = 'live' | 'dead' | 'unproven'
193
+ const PROVEN_DEAD = new Set(['ECONNREFUSED', 'ENOENT'])
194
+ export function rendezvousListening(id: string, timeoutMs = 800): Promise<ListenerProbe> {
186
195
  return new Promise((resolve) => {
187
196
  let settled = false
188
197
  let c: ReturnType<typeof createConnection> | undefined
189
- const done = (v: boolean) => {
198
+ const done = (v: ListenerProbe) => {
190
199
  if (settled) return
191
200
  settled = true
192
201
  clearTimeout(timer)
193
202
  try { c?.destroy() } catch { /* */ }
194
203
  resolve(v)
195
204
  }
196
- const timer = setTimeout(() => done(false), timeoutMs)
197
- try { c = createConnection({ path: rvSock(id) }) } catch { return done(false) }
198
- c.on('connect', () => done(true))
199
- c.on('error', () => done(false)) // ECONNREFUSED (stale file) / ENOENT (gone) both mean no live agent
205
+ const timer = setTimeout(() => done('unproven'), timeoutMs)
206
+ try { c = createConnection({ path: rvSock(id) }) } catch { return done('unproven') }
207
+ c.on('connect', () => done('live'))
208
+ c.on('error', (e) => done(PROVEN_DEAD.has((e as NodeJS.ErrnoException).code ?? '') ? 'dead' : 'unproven'))
200
209
  })
201
210
  }
202
211
  // The app-server Unix socket MUST live on a SHORT, sun_path-safe path — NOT nested under the project runtime
@@ -771,7 +780,7 @@ export function removeManagedBlock(file: string, comment: readonly [string, stri
771
780
  // remove ONLY our block plus the blank lines writeManagedBlock inserted around it; do NOT normalize the
772
781
  // user's OWN whitespace elsewhere — this must leave every other byte intact so it is a faithful INVERSE of
773
782
  // writeManagedBlock's append. A global `\n{3,}→\n\n` collapse used to sit here and mutated pre-existing
774
- // blank-line runs in the user's file, which broke the policy round-trip ([[render-policy]]): a mode flip
783
+ // blank-line runs in the user's file, which broke the policy round-trip ([[residence]]): a mode flip
775
784
  // and back left a spurious one-line diff on a .gitignore that had internal blank lines. The leading-newline
776
785
  // strip is GUARDED the same way: it exists only for a block sitting at the TOP of the file (whose '\n'
777
786
  // replacement would otherwise become a leading blank) — a host file that BEGINS with its own blank lines
@@ -785,7 +794,7 @@ export function removeManagedBlock(file: string, comment: readonly [string, stri
785
794
 
786
795
  // the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
787
796
  // baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
788
- // the cli-needing handlers + the gate's `spex materialize`.
797
+ // the cli-needing handlers.
789
798
  function buildShim(id: HarnessId, events: readonly string[], dispatch: string, spex: string): { json: string; cmd: (e: string) => string } {
790
799
  const cmd = (e: string) => `SPEX='${spex}' bash ${dispatch} ${id} ${e}`
791
800
  const hooks: Record<string, unknown> = {}
@@ -1045,7 +1054,7 @@ export const codexHarness: Harness = {
1045
1054
  resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
1046
1055
  }
1047
1056
 
1048
- // every adapter — materialize iterates this to render each harness's artifacts in one pass.
1057
+ // every adapter — materialize iterates this to write each harness's artifacts in one pass.
1049
1058
  export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness]
1050
1059
 
1051
1060
  // the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
@@ -77,7 +77,7 @@ session list. Identical to GET /api/board; needs the backend (spex serve) reacha
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
+ spex guide footprint the footprint model: never-tracked artifacts, exclude + content filter, anchors
81
81
 
82
82
  guide is the SKILL layer — workflows and formats. Command usage lives here in help
83
83
  (\`spex help <cmd>\`); guide carries what the commands assume you know.`,
@@ -187,6 +187,9 @@ a node/<id> branch links its PR for free). Read-only — git/.spec stays the sin
187
187
 
188
188
  Creates a session: node branch + worktree + a launched agent carrying your prompt (= session new).
189
189
  Give it ONLY its task — the dev-flow contract reaches it through the materialized system prompt.
190
+ --node <id> (or the prompt's first [[id]] mention, same effect) binds the session to that node:
191
+ branch name node/<id>-<shortid>, board attribution, and one appended line with the node's spec.md
192
+ path when the node exists. With neither, the session is bound to no node.
190
193
  --prompt-file <path> reads the prompt from a file (- = stdin), so a long multi-paragraph prompt never
191
194
  fights shell quoting; it is exclusive with the inline prompt (both given = error), and an unreadable
192
195
  or empty file refuses the launch.
@@ -313,15 +316,14 @@ ${ROUTING_NOTE}`,
313
316
 
314
317
  // ── install & serve (operator) ────────────────────────────────────────────
315
318
  init: {
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]
319
+ line: 'init [dir] adopt SpexCode on a repo: seed .spec + hooks + materialize [--preset name]',
320
+ body: `Usage: spex init [dir=cwd] [--preset default|careful]
318
321
 
319
322
  Scaffolds adoption in one shot: seeds a starter .spec tree (project root + .config plugins), plants
320
323
  spexcode.json, installs the git hooks, and materializes the harness artifacts (contract block +
321
324
  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).`,
325
+ Footprint is fixed: materialized artifacts are never tracked hidden via the per-clone .git/info/exclude, with
326
+ a tracked/mixed CLAUDE.md/AGENTS.md covered by the clean/smudge filter (see spex guide footprint).`,
325
327
  see: 'spex guide (the full setup workflow) · spex uninstall (the inverse) · spex lint (adoption TODO)',
326
328
  },
327
329
  uninstall: {
@@ -329,30 +331,32 @@ prints a one-time decision hint instead (see spex guide footprint).`,
329
331
  body: `Usage: spex uninstall [dir=cwd] [--hooks]
330
332
 
331
333
  Removes every SpexCode-GENERATED artifact (harness shims · contract blocks · trust entries ·
332
- .gitignore block · global store · plugin bundle) and never your .spec/.config data or your own
334
+ exclude/ignore blocks · global store · plugin bundle) and never your .spec/.config data or your own
333
335
  prose. Git hooks are preserved unless --hooks.`,
334
336
  see: 'spex init (re-adopt later — your .spec survives)',
335
337
  },
336
338
  materialize: {
337
- line: 'materialize re-render the harness artifacts (contract block · shims) for cwd’s project',
339
+ line: 'materialize re-materialize the harness artifacts (contract block · shims) for cwd’s project',
338
340
  body: `Usage: spex materialize
339
341
 
340
- Renders the surface:system config nodes into the managed <!-- spexcode --> block of
341
- CLAUDE.md/AGENTS.md plus the .claude/.codex shims, and prints the content hash. Run it after a
342
- toolchain update or any .config edit that the automatic dispatch gate hasn't picked up — these
343
- artifacts are generated and gitignored, so they never arrive via git.`,
344
- see: 'spex doctor (verify the render actually reaches an agent)',
342
+ Materializes the surface:system config nodes into the managed <!-- spexcode --> block of
343
+ CLAUDE.md/AGENTS.md plus the .claude/.codex shims, and prints the content hash. The materialize anchors on
344
+ git-native events only (init · this verb · session-worktree creation · the pre-commit/post-checkout/
345
+ post-merge hooks) run it by hand after a toolchain update, or in the setup step of any clone that
346
+ has no spex-planted hooks yet (CI, a cloud agent): the artifacts are generated and excluded, so they
347
+ never arrive via git.`,
348
+ see: 'spex doctor (verify the materialized artifacts actually reach an agent)',
345
349
  },
346
350
  doctor: {
347
351
  line: 'doctor diagnose whether the workflow actually reaches this agent — per-layer, per-harness',
348
352
  body: `Usage: spex doctor per-layer coverage report: preconditions · git-hook floor · contract ·
349
- hooks + handler existence · backend — for every harness materialize renders
353
+ hooks + handler existence · backend — for every harness materialize delivers to
350
354
  spex doctor contract print the composed surface:system text any agent here reads
351
355
  spex doctor conflicts detect double-delivery (loose artifacts beside the managed ones)
352
356
 
353
357
  Run it when a worker seems to be missing its contract or hooks — it names the broken layer and the
354
358
  repair, instead of you diffing materialized files by hand.`,
355
- see: 'spex materialize (re-render the artifacts doctor checks)',
359
+ see: 'spex materialize (re-materialize the artifacts doctor checks)',
356
360
  },
357
361
  serve: {
358
362
  line: 'serve run the API backend (default :8787) [--port N] [--public --password pw]',
@@ -381,10 +385,12 @@ so bind wide only on a network you trust (for the internet, use \`spex serve --p
381
385
  // ── plumbing ──────────────────────────────────────────────────────────────
382
386
  internal: {
383
387
  line: '', // deliberately not on the map
384
- body: `Usage: spex internal <trunk | codex-launch | codex-turn>
388
+ body: `Usage: spex internal <trunk | commit-surgery | refresh-footprint | codex-launch | codex-turn>
385
389
 
386
390
  Machine plumbing — called by generated hooks and launch scripts, never typed by a human or agent:
387
- trunk print the resolved source-of-truth branch (the pre-commit main-guard captures it)
391
+ trunk print the resolved source-of-truth branch (the pre-commit main-guard captures it)
392
+ commit-surgery pre-commit footprint anchor: unconditional materialize + staged-index repair
393
+ refresh-footprint quiet materialize — the post-checkout/post-merge freshness anchor
388
394
  codex-launch <sock> <cwd> [prompt…] backend-owned codex thread/start + first turn (launch script)
389
395
  codex-turn <sock> <threadId> <text…> fire a follow-up turn on an owned thread (tests/scripts)
390
396
 
@@ -53,27 +53,9 @@ function resolveHooksDir(dir: string): string | null {
53
53
  }
54
54
  }
55
55
 
56
- export async function specInit(targetArg: string | undefined, presetArg?: string, renderArg?: string): Promise<void> {
56
+ export async function specInit(targetArg: string | undefined, presetArg?: 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
-
77
59
  // the preset the NEW adopter gets — `--preset <name>` wins, else an existing target spexcode.json's
78
60
  // `preset` field, else the lean `default`. Validated loudly against the chain (an unknown name would
79
61
  // otherwise seed silently). A non-default tier stacks its template package on top of the default set below.
@@ -129,19 +111,6 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
129
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`)
130
112
  }
131
113
 
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}`)
143
- }
144
-
145
114
  // validate the harness DELIVERY TARGET set ([[harness-select]]) up front: a bad `harnesses` set (plugin +
146
115
  // native, or a plugin with no folder) must fail LOUD here, not be silently swallowed by the materialize
147
116
  // try/catch below. A fresh starter spexcode.json omits the field (defaults to all natives), so this only
@@ -175,22 +144,18 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
175
144
  if (installed.length) console.log(`✓ installed git hooks (${installed.join(', ')}) → ${hooksDir}`)
176
145
  }
177
146
 
178
- // 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
179
148
  // steps: the hook manifest (in the GLOBAL per-project store, not the worktree), the AGENTS.md/CLAUDE.md
180
149
  // <spexcode> contract block (user content preserved), the .claude/.codex shims, and the Codex trust (global,
181
150
  // scoped) so codex self-launch is prompt-free. Runs with cwd = the target so the loaders read the just-seeded
182
- // .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.
183
153
  const prevCwd = process.cwd()
184
154
  try {
185
155
  process.chdir(targetDir)
186
- const { materialize, renderVoteHint } = await import('./materialize.js')
156
+ const { materialize } = await import('./materialize.js')
187
157
  materialize(targetDir)
188
158
  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}`)
194
159
  } catch (e) {
195
160
  console.warn(`• materialize skipped (${(e as Error).message}) — run \`spex materialize\` once the packages are installed.`)
196
161
  } finally {
@@ -10,21 +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
- // 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`.
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.
28
22
  private?: boolean
29
23
  // which harness targets `spex materialize` delivers into — native ids ('claude'|'codex') or a {plugin:"<folder>"}
30
24
  // bundle; resolved + validated by [[harness-select]] (harness-select.ts). Default (omitted): all native harnesses.
@@ -145,17 +139,29 @@ export function spexcodeHome(): string {
145
139
  export function encodeProject(root: string): string {
146
140
  return root.replace(/[/.]/g, '-')
147
141
  }
148
- // this project's per-PROJECT runtime tier — the materialized hook manifest + content-hash marker (and the
149
- // gate's lock) — living alongside sessions/ under the SAME global per-project dir, so NOTHING SpexCode renders
150
- // stays in the worktree (not even the manifest; the worktree holds only the harness-discovered CLAUDE.md/
151
- // AGENTS.md + shims, which must sit in-tree). proj-aware for `spex init <dir>` / materialize(proj); cwd-based
152
- // 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.
153
147
  export function runtimeRoot(proj?: string): string {
154
148
  const gcd = proj
155
149
  ? git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
156
150
  : gitCommonDir()
157
151
  return join(spexcodeHome(), 'projects', encodeProject(dirname(gcd)))
158
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
+ }
159
165
  // this project's per-session records dir, one session's dir, its structured record, and a sibling artifact —
160
166
  // all keyed by session_id under <home>/projects/<enc>/sessions/.
161
167
  export function sessionsRoot(): string { return join(runtimeRoot(), 'sessions') }