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
@@ -0,0 +1,81 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { relative } from 'node:path'
3
+ import { materialize, stripSpexcodeBlock, GENERATED_MARK } from './materialize.js'
4
+ import { HARNESSES } from './harness.js'
5
+
6
+ // @@@ commit-surgery ([[commit-surgery]]) - the history anchor: pre-commit runs an UNCONDITIONAL materialize
7
+ // (the masks are provably fresh at the only moment history is written — the temporal invariant "materialize
8
+ // must never be stale" collapses into this one event) and then REPAIRS the staged index instead of rejecting:
9
+ // - a staged contract blob carrying our sentinel block → clean it IN PLACE (source = the STAGED BLOB, never
10
+ // the worktree: `git add -p`'s partial staging survives byte-for-byte; only the block is removed);
11
+ // - a staged generated/machine artifact that HEAD does not track → evict it (its tracked contribution is
12
+ // zero bytes by definition — an empty husk is worse than absence);
13
+ // - anything HEAD already tracks is never deleted by a hook — a legacy committed artifact heals by the
14
+ // block-strip above, converging history toward pristine without a surprise deletion commit.
15
+ // Both operations carry zero intent ambiguity (the block's content is never the user's; a wholly-ours file
16
+ // holds no user byte), so there is no question to ask and no rejection — one printed note per repair, and
17
+ // the commit proceeds. The worst full path costs the user ONE bump, and it is git's own: `git add` refused
18
+ // on an excluded path, git itself suggests -f, and this surgery makes that native escape hatch safe.
19
+ //
20
+ // GIT ENV, deliberately INVERTED from git.ts's git(): every call here PRESERVES the hook's environment —
21
+ // GIT_INDEX_FILE must be honored so the surgery reads/writes the EXACT index this commit is being built
22
+ // from (a `git commit <path>` pathspec commit and `git commit -a` both run hooks against a TEMPORARY index;
23
+ // operating on the real one would silently miss them). git.ts strips that env for repo DISCOVERY reasons;
24
+ // index surgery is the one place the env is the point.
25
+ const raw = (args: string[], input?: string): string =>
26
+ execFileSync('git', args, { input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
27
+
28
+ function inHead(p: string): boolean {
29
+ try { raw(['cat-file', '-e', `HEAD:${p}`]); return true } catch { return false }
30
+ }
31
+ function stagedBlob(p: string): string | null {
32
+ try { return raw(['show', `:${p}`]) } catch { return null }
33
+ }
34
+ function evict(p: string, why: string): void {
35
+ raw(['update-index', '--force-remove', '--', p])
36
+ console.error(`spexcode: unstaged ${p} (${why} — generated artifacts are never tracked; the file stays on disk)`)
37
+ }
38
+ function replaceBlob(p: string, content: string): void {
39
+ const stage = raw(['ls-files', '--stage', '--', p]).trim() // "100644 <sha> 0\t<p>"
40
+ const mode = stage.split(/\s/)[0] || '100644'
41
+ const sha = raw(['hash-object', '-w', '--stdin'], content).trim()
42
+ raw(['update-index', '--cacheinfo', `${mode},${sha},${p}`])
43
+ console.error(`spexcode: stripped the <!-- spexcode --> block from staged ${p} (the block is working-tree context, never history)`)
44
+ }
45
+
46
+ export function commitSurgery(proj = process.cwd()): void {
47
+ // (1) unconditional materialize — machine-fixable state (exclude entries, filter binding, kind flips)
48
+ // is repaired BEFORE the index is inspected, so the inspection below judges against fresh masks.
49
+ try { materialize(proj) } catch (e) {
50
+ console.error(`spexcode: pre-commit materialize failed (${(e as Error).message}) — footprint may be stale this commit`)
51
+ }
52
+ const staged = raw(['diff', '--cached', '--name-only', '-z']).split('\0').filter(Boolean)
53
+ if (!staged.length) return
54
+ const rel = (f: string) => relative(proj, f)
55
+ const contracts = new Set(HARNESSES.flatMap((h) => h.contractFiles(proj)).map(rel))
56
+ const machine = new Set<string>(['spexcode.local.json', '.session'])
57
+ for (const h of HARNESSES) {
58
+ machine.add(rel(h.shimFile(proj)))
59
+ const a = h.worktreeHookAnchor(proj)
60
+ if (a) machine.add(rel(a))
61
+ }
62
+ const generatedDirs = HARNESSES.flatMap((h) => [h.skillDir(proj), h.agentDir(proj)])
63
+ .filter((d): d is string => !!d).map((d) => `${rel(d)}/`)
64
+ for (const p of staged) {
65
+ if (contracts.has(p)) {
66
+ const blob = stagedBlob(p)
67
+ if (blob === null) continue
68
+ const stripped = stripSpexcodeBlock(blob)
69
+ if (stripped === blob) continue // no block staged — clean already did its job
70
+ if (!stripped.trim() && !inHead(p)) evict(p, 'wholly a spexcode materialized artifact')
71
+ else replaceBlob(p, stripped)
72
+ } else if (machine.has(p) || p.startsWith('.worktrees/')) {
73
+ if (!inHead(p)) evict(p, 'a machine-local spexcode file')
74
+ else console.error(`spexcode: ${p} is a machine-local spexcode file but HEAD already tracks it — not touching a tracked file; untrack it yourself (git rm --cached ${p})`)
75
+ } else if (generatedDirs.some((d) => p.startsWith(d))) {
76
+ if (inHead(p)) continue // historically tracked — the host's call
77
+ const blob = stagedBlob(p)
78
+ if (blob !== null && blob.includes(GENERATED_MARK)) evict(p, 'a generated skill/agent artifact')
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,156 @@
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import { execFileSync } from 'node:child_process'
4
+ import { git } from './git.js'
5
+ import { writeManagedBlock, removeManagedBlock } from './harness.js'
6
+
7
+ // @@@ contract-filter ([[content-filter]]) - the answer for a MIXED-CONTENT contract file: a
8
+ // CLAUDE.md/AGENTS.md the HOST TRACKS — or has begun writing its OWN prose into — where "generate + ignore"
9
+ // is either a no-op (git ignores only untracked paths) or would hide USER content. A git clean/smudge
10
+ // content filter keeps the two contents on their own sides of the index: the REPO stores the
11
+ // pristine host prose (clean strips our sentinel block on stage/diff), the WORKING TREE carries prose + block
12
+ // (smudge re-injects it on checkout). Everything the filter needs is PER-CLONE — `git config
13
+ // filter.spexcode.*` + a managed block in `.git/info/attributes` + two files under `<common>/spexcode/` —
14
+ // zero repo footprint. The sentinels are the load-bearing anchor: the invariant
15
+ // is clean(smudge(x)) == x (for text ending in one newline — see the shim), so `git status` stays clean.
16
+ // Planted only where mixed content EXISTS or is imminent (a tracked contract file, or an untracked one the
17
+ // user's prose entered — pre-armed so their eventual `git add` strips the block; a wholly-ours file needs
18
+ // only the exclude). materialize plants/refreshes/erases it per that live kind detection ([[residence]]
19
+ // / the forgetting law).
20
+
21
+ // the three field-sharpened edges this module owes ([[content-filter]]):
22
+ // ① the configured command points at a STABLE shim path and degrades to `cat` (identity) when the shim is
23
+ // missing — a bare missing filter command makes git spray "cannot fork" fatals on EVERY operation;
24
+ // ② a changed contract does NOT propagate by itself (git re-smudges only on checkout) — materialize's
25
+ // re-materialize writes the managed block straight into the working file (writeManagedBlock IS the re-smudge),
26
+ // and this module refreshes the block file the shim reads so future checkouts agree;
27
+ // ③ unplanting must strip the block from the WORKING FILES before the config goes away, or the block
28
+ // residue surfaces as an uncommitted modification — the caller (dematerialize) removes the managed
29
+ // blocks first and only then calls removeContractFilter.
30
+
31
+ function commonDirOf(proj: string): string {
32
+ return git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
33
+ }
34
+ const filterDir = (common: string) => join(common, 'spexcode')
35
+ const shimPath = (common: string) => join(filterDir(common), 'contract-filter.sh')
36
+ const blockPath = (common: string) => join(filterDir(common), 'contract-block.md')
37
+ const attributesPath = (common: string) => join(common, 'info', 'attributes')
38
+
39
+ // the per-clone shim both filter directions run through. Pure shell/awk (no node boot on git's hot path),
40
+ // mirroring writeManagedBlock/removeManagedBlock's normalization exactly so the two writers agree:
41
+ // clean : drop the sentinel block (+ the blank line smudge printed before it) → the pristine host prose.
42
+ // smudge: clean first (defensive — a block already in the index can never double-inject), then append one
43
+ // blank line + START + the contract-block file's content + END.
44
+ // Byte-exactness holds for text ending in exactly one newline (git's own well-formed-text shape); a pristine
45
+ // file ending in zero or 2+ newlines is normalized to one on the first round-trip and stable after.
46
+ const SHIM = `#!/usr/bin/env bash
47
+ # spexcode contract filter (generated by spex materialize; see [[content-filter]]).
48
+ # clean(smudge(x)) == x: the repo keeps the pristine host prose, the working tree carries prose + block.
49
+ set -u
50
+ mode="\${1:?usage: contract-filter.sh smudge|clean}"
51
+ here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
52
+ block="$here/contract-block.md"
53
+ strip() {
54
+ awk 'BEGIN { n = 0 }
55
+ { lines[n++] = $0 }
56
+ END {
57
+ s = -1; e = -1
58
+ for (i = 0; i < n; i++) {
59
+ if (lines[i] == "<!-- spexcode:start -->" && s < 0) s = i
60
+ if (lines[i] == "<!-- spexcode:end -->" && s >= 0 && e < 0) e = i
61
+ }
62
+ if (s >= 0 && e >= s) {
63
+ a = s; while (a > 0 && lines[a-1] == "") a--
64
+ b = e; while (b + 1 < n && lines[b+1] == "") b++
65
+ j = 0
66
+ for (i = 0; i < n; i++) if (i < a || i > b) lines[j++] = lines[i]
67
+ n = j
68
+ }
69
+ # NO leading-blank strip: dropping a..b (block + its surrounding blanks) can never CREATE a leading
70
+ # blank, and a host file that BEGINS with blank lines must keep them — clean(smudge(x)) == x.
71
+ for (i = 0; i < n; i++) print lines[i]
72
+ }'
73
+ }
74
+ case "$mode" in
75
+ clean) strip ;;
76
+ smudge)
77
+ if [ ! -r "$block" ]; then cat; exit 0; fi # no block content → identity (graceful, never fatal)
78
+ strip | awk -v b="$block" 'BEGIN { n = 0 }
79
+ { lines[n++] = $0 }
80
+ END {
81
+ while (n > 0 && lines[n-1] == "") n-- # trim trailing blanks (writeManagedBlock parity)
82
+ for (i = 0; i < n; i++) print lines[i]
83
+ if (n > 0) print ""
84
+ print "<!-- spexcode:start -->"
85
+ while ((getline l < b) > 0) print l
86
+ print "<!-- spexcode:end -->"
87
+ }' ;;
88
+ *) echo "contract-filter.sh: unknown mode $mode" >&2; exit 1 ;;
89
+ esac
90
+ `
91
+
92
+ // edge ①: the command git runs is a tolerant wrapper — the shim path is an ARGUMENT ($0), and a missing/
93
+ // unreadable shim degrades to `cat` (identity) instead of a per-operation fatal.
94
+ const filterCmd = (shim: string, mode: 'smudge' | 'clean') =>
95
+ `sh -c 'test -r "$0" && exec bash "$0" ${mode} || exec cat' '${shim.replace(/'/g, `'\\''`)}'`
96
+
97
+ // plant (or refresh) the filter for the given contract files (tracked, or untracked-with-host-content —
98
+ // pre-armed): the shim + the block content it smudges, the per-clone git config, and the attribute lines
99
+ // binding each file to the filter. Idempotent — every write is a full replace. `contract` is the assembled
100
+ // block body (guide + surface:system). settleIndexStat skips untracked entries (no index blob) by design.
101
+ export function plantContractFilter(proj: string, trackedFiles: string[], contract: string): void {
102
+ const common = commonDirOf(proj)
103
+ mkdirSync(filterDir(common), { recursive: true })
104
+ writeFileSync(blockPath(common), contract.endsWith('\n') ? contract : `${contract}\n`) // edge ②: the shim's smudge source, refreshed with the materialize
105
+ writeFileSync(shimPath(common), SHIM)
106
+ chmodSync(shimPath(common), 0o755)
107
+ git(['-C', proj, 'config', 'filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')])
108
+ git(['-C', proj, 'config', 'filter.spexcode.clean', filterCmd(shimPath(common), 'clean')])
109
+ // attribute patterns are checkout-relative, so one line serves the main checkout and every worktree.
110
+ const entries = trackedFiles.map((f) => `/${relative(proj, f)} filter=spexcode`).sort().join('\n')
111
+ mkdirSync(join(common, 'info'), { recursive: true })
112
+ writeManagedBlock(attributesPath(common), entries, ['# ', ''])
113
+ settleIndexStat(proj, trackedFiles)
114
+ }
115
+
116
+ // settle the index STAT for each file — the famous filtered-path phantom-`M`: git cannot verify a
117
+ // clean-filtered path by stat alone (worktree size ≠ blob size by design), and `git status` reports such an
118
+ // entry modified FOREVER without ever content-checking it (field-verified on git 2.43; `git diff` meanwhile
119
+ // runs the filter and shows nothing; even `update-index --really-refresh` leaves it). The block-strip side of
120
+ // a mode switch/backout leaves the same stale stat on the then-unfiltered path. `git add --renormalize`
121
+ // re-cleans the file and refreshes the cached stat — run ONLY when the (possibly filtered) worktree already
122
+ // EQUALS the index blob, so it is a pure stat refresh that can never stage a user's real unstaged edit (a
123
+ // genuine edit keeps its honest `M`). Exported for dematerialize (the unplant side); plant calls it below.
124
+ // Best-effort: an unsettled stat is cosmetic noise, never corruption.
125
+ export function settleIndexStat(proj: string, files: string[]): void {
126
+ const env = { ...process.env }
127
+ delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
128
+ for (const f of files) {
129
+ const rel = relative(proj, f)
130
+ try {
131
+ const indexBlob = git(['-C', proj, 'rev-parse', `:${rel}`]).trim()
132
+ const filtered = execFileSync('git', ['-C', proj, 'hash-object', '--path', rel, '--stdin'],
133
+ { input: readFileSync(f), env, stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim()
134
+ if (indexBlob === filtered) git(['-C', proj, 'add', '--renormalize', '--', rel])
135
+ } catch { /* best-effort */ }
136
+ }
137
+ }
138
+
139
+ // the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
140
+ // config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
141
+ // (yatsu blobs), so only OUR two files go, never the dir.
142
+ export function removeContractFilter(proj: string): void {
143
+ let common: string
144
+ try { common = commonDirOf(proj) } catch { return } // not a git repo → nothing was ever planted
145
+ removeManagedBlock(attributesPath(common), ['# ', ''], true)
146
+ for (const key of ['filter.spexcode.smudge', 'filter.spexcode.clean']) {
147
+ try { git(['-C', proj, 'config', '--unset', key]) } catch { /* not set — already clean */ }
148
+ }
149
+ rmSync(shimPath(common), { force: true })
150
+ rmSync(blockPath(common), { force: true })
151
+ }
152
+
153
+ // is the filter currently planted? (the assert-side probe tests use; cheap: one config read)
154
+ export function contractFilterPlanted(proj: string): boolean {
155
+ try { return git(['-C', proj, 'config', 'filter.spexcode.clean']).trim().length > 0 } catch { return false }
156
+ }
@@ -1,9 +1,9 @@
1
1
  // @@@ spex doctor - the DIAGNOSIS surface ([[doctor]]; command renamed from `self`, which misread as the
2
2
  // tool itself / the global install). When a user launches their OWN claude/codex with no SpexCode process
3
- // in the launch, the workflow reaches that agent only through the files materialize() renders (the manifest
3
+ // in the launch, the workflow reaches that agent only through the files materialize() writes (the manifest
4
4
  // in the global store; the in-tree contract blocks + hook shims + codex trust). Bare `spex doctor` answers
5
5
  // "is this agent actually governed, or silently running free?" — diagnosing that materialized contract per
6
- // LAYER, looping the same HARNESSES adapter materialize renders through (so claude AND codex are covered
6
+ // LAYER, looping the same HARNESSES adapter materialize delivers through (so claude AND codex are covered
7
7
  // with no hardcoded paths). It catches the SILENT failure: a shim whose handler is missing, a PATH that
8
8
  // can't resolve `spex`, a contract that never landed. Read-only today: the bare report, `contract` (print
9
9
  // the surface:system text any agent reads), `conflicts`. install/uninstall are STAGED (noteStaged).
@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'
13
13
  import { execFileSync } from 'node:child_process'
14
14
  import { homedir } from 'node:os'
15
15
  import { loadSystemConfig, loadSkillConfig } from './specs.js'
16
- import { runtimeRoot, envSessionId, readAliasedRawRecord } from './layout.js'
16
+ import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord } from './layout.js'
17
17
 
18
18
  // this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
19
19
  // materialize.ts use (never a hardcoded repo path), so the git-hook template lookup survives a relocated install.
@@ -255,14 +255,20 @@ async function doctor(): Promise<number> {
255
255
  const shim = read(h.shimFile(base))
256
256
  line(`${h.id} shim`, /dispatch\.sh/.test(shim) ? `wired (${h.shimFile(base).replace(base + '/', '')})` : 'NOT wired (no dispatch shim)')
257
257
  }
258
+ // manifest resolution mirrors dispatch.sh: this tree's materialize slot first, then the legacy global file
259
+ // (a pre-slot tree's migration-window fallback) — so the doctor reads exactly what a dispatch would.
258
260
  let manifestText = ''
259
- try { manifestText = read(join(runtimeRoot(base), 'hooks-manifest')) } catch { /* non-git / no store */ }
261
+ let manifestHome = 'tree slot'
262
+ try { manifestText = read(join(treeSlotDir(base), 'hooks-manifest')) } catch { /* non-git / no store */ }
263
+ if (!manifestText) {
264
+ try { manifestText = read(join(runtimeRoot(base), 'hooks-manifest')); manifestHome = 'legacy global file (pre-slot materialize — re-run `spex materialize`)' } catch { /* neither */ }
265
+ }
260
266
  if (!manifestText) {
261
267
  line('manifest', 'MISSING from the global store — materialize never ran (hooks fire but find no manifest)')
262
268
  } else {
263
269
  const scripts = manifestScripts(manifestText)
264
270
  const missing = scripts.filter((s) => !existsSync(join(base, s)))
265
- line('manifest', `${scripts.length} handler(s) in the global store`)
271
+ line('manifest', `${scripts.length} handler(s) in the ${manifestHome}`)
266
272
  line('handlers', missing.length === 0 ? 'all readable in the worktree' : `${missing.length} MISSING in the worktree → those hooks SILENTLY NO-OP:`)
267
273
  for (const m of missing) L.push(` ✗ ${m}`)
268
274
  }
@@ -5,11 +5,24 @@ import { join, isAbsolute, resolve } from 'node:path'
5
5
 
6
6
  const US = '\x1f', RS = '\x1e'
7
7
 
8
+ // @@@ bounded git children - a git child that never exits (wedged fs, a hijacked PATH git, a dead network
9
+ // mount) must not pin its awaiter forever: [[board-cache]]'s settle guarantee starts at this seam. Every
10
+ // shared helper passes a generous timeout (an order of magnitude above the slowest legitimate full-history
11
+ // walk) with SIGKILL — same pattern sessions.ts's tmux/ps probes already use — so a hung child dies and the
12
+ // call fails like any other git failure instead of hanging its caller's promise. The kill is warned loudly:
13
+ // gitA maps failure to '', which would otherwise hide the pathology as an innocently-empty result.
14
+ const GIT_TIMEOUT_MS = Number(process.env.SPEXCODE_GIT_TIMEOUT_MS || 120000)
15
+ function warnIfTimedOut(e: any, args: string[]): void {
16
+ if (e?.signal === 'SIGKILL') console.warn(`spec-cli: git ${args.slice(0, 6).join(' ')}… killed after ${GIT_TIMEOUT_MS}ms — child never exited`)
17
+ }
18
+
8
19
  // strip git's hook-exported env (GIT_DIR etc.) so every call discovers the repo from the filesystem.
9
20
  export function git(args: string[]): string {
10
21
  const env = { ...process.env }
11
22
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
12
- return execFileSync('git', args, { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] })
23
+ try {
24
+ return execFileSync('git', args, { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL' })
25
+ } catch (e: any) { warnIfTimedOut(e, args); throw e }
13
26
  }
14
27
 
15
28
  const pexecFile = promisify(execFile)
@@ -17,18 +30,19 @@ export async function gitA(args: string[]): Promise<string> {
17
30
  const env = { ...process.env }
18
31
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
19
32
  try {
20
- const { stdout } = await pexecFile('git', args, { encoding: 'utf8', env, maxBuffer: 1 << 24 })
33
+ const { stdout } = await pexecFile('git', args, { encoding: 'utf8', env, maxBuffer: 1 << 24, timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL' })
21
34
  return stdout
22
- } catch { return '' }
35
+ } catch (e: any) { warnIfTimedOut(e, args); return '' }
23
36
  }
24
37
 
25
38
  export async function gitTry(args: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
26
39
  const env = { ...process.env }
27
40
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
28
41
  try {
29
- const { stdout, stderr } = await pexecFile('git', args, { encoding: 'utf8', env, maxBuffer: 1 << 24 })
42
+ const { stdout, stderr } = await pexecFile('git', args, { encoding: 'utf8', env, maxBuffer: 1 << 24, timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL' })
30
43
  return { ok: true, stdout, stderr }
31
44
  } catch (e: any) {
45
+ warnIfTimedOut(e, args)
32
46
  return { ok: false, stdout: e?.stdout ?? '', stderr: e?.stderr ?? String(e?.message ?? e) }
33
47
  }
34
48
  }
@@ -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 footprint model — what SpexCode plants in a repo, and who sees it
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. 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 or a TLS cert path 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,84 @@ 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'dso \`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
+ 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
+ render RETIRED (the old three-word footprint vote) ignored with a loud notice. Materialized
331
+ artifacts are never tracked; there is exactly one residence behavior. Remove the field.
332
+ \`spex guide footprint\`.
333
+ private RETIRED (old private-overlay toggle) ignored with a loud notice, same as \`render\`; its
334
+ data-untrack semantics are gone. See \`spex guide footprint\` MIGRATIONS.`
335
+
336
+ const FOOTPRINT = `spex guide footprint — what SpexCode plants in a repo, and who sees it (no voteone behavior)
337
+
338
+ SpexCode claims software engineering's HEAD (the recording of intent) and TAIL (the storage of
339
+ measurement) and leaves the MIDDLE construction to the harness/agent/test framework; freshness
340
+ stitches the two ends into a closed loop. The footprint follows: the head+tail (.spec, spexcode.json,
341
+ readings) is the ASSET and lives in git like source; everything else is derived wiring or a machine fact.
342
+ Materialized artifacts carry no facts, so they are NEVER trackedthe old render vote is retired; there
343
+ is exactly one residence behavior, decided per KIND (and, for a contract file, by its live CONTENT).
344
+
345
+ ── THE FOUR KINDS (all fixed) ──
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 per-clone exclude.
350
+ artifacts the CLAUDE.md/AGENTS.md contract blocks + materialized skills/agents derived, NEVER
351
+ tracked; hidden via .git/info/exclude. The host's tracked .gitignore is never touched.
352
+ run residue .worktrees/, the global store (~/.spexcode), .git/spexcode blobs never tracked;
353
+ out-of-tree, or exclude-ruled where in-tree.
354
+
355
+ ── A CONTRACT FILE'S RESIDENCE IS A LIVE CONTENT FACT (re-judged at every materialize) ──
356
+ host-tracked → the clean/smudge content filter: the repo keeps the pristine host prose, your
357
+ working tree carries prose + block, status stays clean.
358
+ untracked, wholly ours→ one exclude entry. The exclude is the ignored-bit DECLARATION the rest of git
359
+ consults (checkout may overwrite, clean -fd spares, status/add -A/stash silent).
360
+ untracked, YOUR prose → the exclude entry is withdrawn (hiding user content would be data-loss shaped)
361
+ entered the file and the clean filter is pre-armed: the file shows honestly as untracked, and
362
+ IF you choose to \`git add\` it, the block is stripped automatically — tracking
363
+ is always your act; SpexCode never stages or commits anything for you.
364
+
365
+ ── THE GIT-NATIVE ANCHORS (no harness event ever triggers a materialize) ──
366
+ spex init / spex materialize / session-worktree creation — the explicit passes;
367
+ pre-commit the correctness anchor: an UNCONDITIONAL materialize (masks provably fresh at the only
368
+ moment history is written) + staged-index surgery — a staged blob carrying the sentinel
369
+ block is cleaned IN PLACE (partial staging survives; source is the staged blob), a
370
+ HEAD-untracked generated artifact is unstaged. Repairs and proceeds, never rejects.
371
+ post-checkout/post-merge freshness anchors: .spec/.config edits are git-transactional — they take
372
+ effect at the commit/checkout/merge that carries them, like any other source change.
373
+ An environment with no spex-planted hooks (CI, a cloud agent's fresh clone, a teammate who hasn't
374
+ installed) simply runs \`spex materialize\` in its setup step — there is no committed-artifact mode.
375
+ TRACK ≠ PUSH: none of this ever touches remotes; where commits GO is branch/remote policy.
376
+
377
+ ── GUARANTEES (the forgetting law) ──
378
+ materialize(P₂) ∘ materialize(P₁) = materialize(P₂): every materialize first ERASES all landing points by
379
+ SpexCode's own identity stamps, then re-asserts — legacy states (a .gitignore managed block, a committed
380
+ artifact, the retired vote's homes) are forgotten by the same pass. \`spex uninstall\` is the empty
381
+ materialize plus the global store: a total backout that never touches your .spec/.config or prose. Fresh
382
+ clones and session worktrees are self-sufficient: data by checkout, materialized artifacts by
383
+ re-materialize, the machine snapshot (spexcode.local.json) by copy.
384
+
385
+ ── THE CONTENT FILTER (mixed-content contract files) ──
386
+ Per-clone only — git config filter.spexcode.* + .git/info/attributes + a shim under .git/spexcode/ —
387
+ planted where mixed content exists or is imminent (tracked, or untracked with your prose). clean strips
388
+ the sentinel block (history never sees it); smudge re-injects it on checkout. A missing shim degrades to
389
+ identity (never a git fatal). Your own edits to the prose still show as real modifications; only the
390
+ block is invisible to git.
391
+
392
+ ── MIGRATIONS ──
393
+ render: "..." (retired) remove the field from spexcode.json / spexcode.local.json — it is ignored
394
+ with a loud notice. A previously COMMITTED artifact heals at your next commit
395
+ touching it (pre-commit strips the block from the staged blob); or run
396
+ \`spex materialize\` + commit the block's removal once.
397
+ private: true (retired) same — remove it; its data-untrack semantics are long gone.
398
+ legacy untracked spec track the sources once: git add .spec spexcode.json (commit on your branch)
399
+ WARNING: tracking is not retroactive secrecy — history already pushed
400
+ elsewhere cannot be recalled.
401
+ back out entirely \`spex uninstall\` (add --hooks to also remove the spexcode git hooks).`
402
+
403
+ const TOPICS: Record<string, string> = { spec: SPEC, yatsu: YATSU, config: CONFIG, footprint: FOOTPRINT }
348
404
 
349
405
  // every guide page ends by naming the OTHER help layer, so a reader never dead-ends here: guide is
350
406
  // the skill layer (workflows · formats · settings); command usage lives in help.ts's two layers.
@@ -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;
@@ -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
@@ -19,7 +20,7 @@ import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
19
20
  // dispatch.sh's first argument (`bash <dispatch> <id> <Event>`). dispatch.sh exports SPEXCODE_HARNESS, so a
20
21
  // hook subprocess learns its harness deterministically from the shim that wired it — never from guessing the
21
22
  // payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
22
- // (materialize renders every harness's artifacts).
23
+ // (materialize writes every harness's artifacts).
23
24
 
24
25
  export type HarnessId = 'claude' | 'codex'
25
26
  export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
@@ -158,7 +159,7 @@ export interface Harness {
158
159
  // its existing importers.
159
160
  export type DispatchResult = { ok: boolean; error?: string }
160
161
  export type HarnessDeliveryRecord = { session: string; worktreePath?: string; harnessSessionId?: string | null; runtimeDir?: string }
161
- // 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
162
163
  // skill subdirs / agent files are SpexCode's to remove (name-scoped, never a blind wipe of a dir the user may
163
164
  // also populate). materialize passes the live skill/agent node names; clean reconstructs the same paths.
164
165
  export type HarnessArtifacts = { skills: readonly string[]; agents: readonly string[] }
@@ -770,16 +771,21 @@ 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 ([[residence]]): 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
  }
779
785
 
780
786
  // the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
781
787
  // baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
782
- // the cli-needing handlers + the gate's `spex materialize`.
788
+ // the cli-needing handlers.
783
789
  function buildShim(id: HarnessId, events: readonly string[], dispatch: string, spex: string): { json: string; cmd: (e: string) => string } {
784
790
  const cmd = (e: string) => `SPEX='${spex}' bash ${dispatch} ${id} ${e}`
785
791
  const hooks: Record<string, unknown> = {}
@@ -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
@@ -1031,7 +1045,7 @@ export const codexHarness: Harness = {
1031
1045
  resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
1032
1046
  }
1033
1047
 
1034
- // every adapter — materialize iterates this to render each harness's artifacts in one pass.
1048
+ // every adapter — materialize iterates this to write each harness's artifacts in one pass.
1035
1049
  export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness]
1036
1050
 
1037
1051
  // the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.