spexcode 0.2.5 → 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 (36) hide show
  1. package/README.md +1 -1
  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 +21 -11
  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 +61 -58
  10. package/spec-cli/src/harness-select.ts +2 -2
  11. package/spec-cli/src/harness.ts +5 -5
  12. package/spec-cli/src/help.ts +20 -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 +22 -13
  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-dashboard/dist/assets/{Dashboard-CMRJGfYI.js → Dashboard-BlRRsxE7.js} +7 -7
  28. package/spec-dashboard/dist/assets/{EvalsPage-Dj2mxcfW.js → EvalsPage-BzVE38-Z.js} +1 -1
  29. package/spec-dashboard/dist/assets/{FoldToggle-BfNpeyRQ.js → FoldToggle-DFuLVOeu.js} +1 -1
  30. package/spec-dashboard/dist/assets/{IssuesPage-DfY315kt.js → IssuesPage-CzDaazhe.js} +1 -1
  31. package/spec-dashboard/dist/assets/{MobileApp-BGdC0A0P.js → MobileApp-CXQrQCNp.js} +1 -1
  32. package/spec-dashboard/dist/assets/{SessionInterface-BOBCAR0t.js → SessionInterface-D1pUBl6q.js} +8 -8
  33. package/spec-dashboard/dist/assets/{SessionWindow-D7YmjV0i.js → SessionWindow-Y25Bwg1e.js} +5 -5
  34. package/spec-dashboard/dist/assets/{Settings-BlSNmpH_.js → Settings-R610Vbzd.js} +1 -1
  35. package/spec-dashboard/dist/assets/{index-BFdzpd_O.js → index-BO0Zuweu.js} +2 -2
  36. package/spec-dashboard/dist/index.html +1 -1
package/README.md CHANGED
@@ -74,7 +74,7 @@ Requires Node ≥ 22 and git. This part is plain tooling — no AI involved yet.
74
74
  ```sh
75
75
  npm i -g spexcode # installs the `spex` command
76
76
  cd your-repo
77
- spex init # seeds .spec/, installs git hooks, renders the agent contract
77
+ spex init # seeds .spec/, installs git hooks, materializes the agent contract
78
78
  spex serve # API backend on :8787
79
79
  spex dashboard # board UI on :5173, proxying to the backend
80
80
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -3,22 +3,21 @@
3
3
  # / .codex/hooks.json, written by each [[harness-adapter]]) binds one line per event to
4
4
  # `dispatch.sh <harness> <Event>` — the harness id is BAKED IN by the adapter that wrote the shim, so this is
5
5
  # the deterministic harness DETECTOR for the shell side: we export SPEXCODE_HARNESS (read by harness.sh, the
6
- # adapter's shell mirror, which the hook handlers source) without ever sniffing the payload shape. Two jobs,
7
- # in order:
8
- # (1) GATE a cheap pure-shell content hash of the config roots (~10ms, every event). If it moved since the
9
- # last render, re-run `spex materialize` (the ~0.85s node step) to bring manifest + contract (AGENTS.md/
10
- # CLAUDE.md block) + shims + Codex trust back in lockstep with the EDITABLE .config. Content-based, so it
11
- # catches bash/sed/user/other-agent/git edits alike (a tool-payload path would miss them). Serialized by
12
- # a lock with a re-check inside, so concurrent sessions never race the write (§ atomicity).
13
- # (2) DISPATCH — run every handler bound to <Event> from the persistent manifest, in order, feeding each the
14
- # ORIGINAL stdin. Reproduces the native parallel multi-hook contract DETERMINISTICALLY: all handlers run
15
- # (side effects preserved), their stdout (decision/additionalContext) is concatenated through, and a
16
- # block:true handler that exits 2 makes the dispatch exit 2 with that handler's stderr — the one signal
17
- # the harness propagates. Pure bash, no node boot on the hot path (node runs only inside the gate, only
18
- # on actual change). cwd = the project/worktree. $SPEX (abs tsx+cli) is inherited from the shim env.
6
+ # adapter's shell mirror, which the hook handlers source) without ever sniffing the payload shape. ONE job:
7
+ # DISPATCH — run every handler bound to <Event> from the persistent manifest, in order, feeding each the
8
+ # ORIGINAL stdin. Reproduces the native parallel multi-hook contract DETERMINISTICALLY: all handlers run
9
+ # (side effects preserved), their stdout (decision/additionalContext) is concatenated through, and a
10
+ # block:true handler that exits 2 makes the dispatch exit 2 with that handler's stderr the one signal
11
+ # the harness propagates. Pure bash, no node boot on the hot path. cwd = the project/worktree. $SPEX (abs
12
+ # tsx+cli) is inherited from the shim env.
13
+ #
14
+ # The old (1) GATE an auto-materialize when the config content-hash moved — is RETIRED ([[commit-surgery]]):
15
+ # a harness event is never a materialize trigger; the materialize anchors are git-native only (spex verbs,
16
+ # session-worktree creation, and the pre-commit/post-checkout/post-merge hooks). .config edits are
17
+ # git-transactional: they take effect at the commit/checkout/merge that carries them, like any other source.
19
18
  set -u
20
19
  # args: `<harness> <Event>`. A harness id as $1 (claude|codex|plugin) is consumed; otherwise we keep $1 as the
21
- # event and default the harness to claude — so a stale shim still rendered as `dispatch.sh <Event>` keeps working.
20
+ # event and default the harness to claude — so a stale shim still written as `dispatch.sh <Event>` keeps working.
22
21
  # `plugin` is the bundle form ([[plugin-harness]]): it parses payloads as the claude family in harness.sh (z-code/
23
22
  # Claude share Claude's tool names + file_path), so it joins the claude branch there via the default case.
24
23
  harness=claude
@@ -30,35 +29,24 @@ export SPEXCODE_HARNESS="$harness"
30
29
  export SPEXCODE_HARNESS_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness.sh"
31
30
  . "$SPEXCODE_HARNESS_LIB"
32
31
  proj="${CLAUDE_PROJECT_DIR:-$PWD}"
33
- # the manifest + content-hash + gate lock live in the GLOBAL per-project store (mirrors layout.runtimeRoot),
34
- # NOT the worktree — so the worktree carries zero SpexCode-rendered runtime. Empty if git can't resolve.
32
+ # the manifest lives in THIS tree's materialize slot of the GLOBAL per-project store (mirrors layout.treeSlotDir),
33
+ # NOT the worktree — and per tree, so a dispatch can only read the manifest of the tree it fires in
34
+ # ([[hook-dispatch]]). Slot key = this cwd's rev-parse --show-toplevel through hp_tree_dir. Empty if git
35
+ # can't resolve.
35
36
  rt="$(cd "$proj" 2>/dev/null && hp_runtime_dir)" || rt=""
37
+ slot="$(cd "$proj" 2>/dev/null && hp_tree_dir)" || slot=""
36
38
 
37
- # --- (1) gate -------------------------------------------------------------------------------------------
38
- # the config fingerprint is hp_config_hash (harness.sh, already sourced) — the SAME function materialize.ts
39
- # shells to, so the gate and the renderer never disagree on "changed".
40
- cur="$( (cd "$proj" 2>/dev/null && hp_config_hash) )"
41
- if [ -n "$rt" ] && [ -n "$cur" ] && [ "$cur" != "$(cat "$rt/content-hash" 2>/dev/null || true)" ]; then
42
- mkdir -p "$rt" 2>/dev/null
43
- # POSIX-portable mutex: mkdir is atomic on every POSIX fs, so it serializes the render with NO dependency on
44
- # util-linux `flock` (absent on macOS — where it silently no-op'd, letting concurrent sessions race the write).
45
- # Spin on `mkdir` with a bounded wait; after ~10s (200 * 0.05s, well above the ~0.85s materialize) treat the
46
- # dir as orphaned by a dead entrant and clear it, so a stale lock can never deadlock a launch. Held only across
47
- # the re-check + render, released right after.
48
- lockd="$rt/.materialize.lock.d"
49
- _lk=0
50
- until mkdir "$lockd" 2>/dev/null; do
51
- _lk=$((_lk+1)); [ "$_lk" -ge 200 ] && { rm -rf "$lockd" 2>/dev/null; _lk=0; }
52
- sleep 0.05
53
- done
54
- if [ "$cur" != "$(cat "$rt/content-hash" 2>/dev/null || true)" ]; then # re-check: a sibling dispatch may have just rendered
55
- ( cd "$proj" && ${SPEX:-spex} materialize >/dev/null 2>&1 )
56
- fi
57
- rmdir "$lockd" 2>/dev/null
39
+ # --- dispatch ---------------------------------------------------------------------------------------------
40
+ if [ -n "${SPEX_HOOK_MANIFEST:-}" ]; then
41
+ manifest="$SPEX_HOOK_MANIFEST"
42
+ else
43
+ # migration window: a tree last materialized by a pre-slot toolchain has no slot until its next git-native
44
+ # anchor — fall back to the legacy global manifest (its exact pre-migration behavior) so no hook (the
45
+ # Stop gate included) silently no-ops. The legacy file is never written again; the next anchor plants the
46
+ # slot and this branch goes dead.
47
+ manifest="$slot/hooks-manifest"
48
+ [ -f "$manifest" ] || manifest="$rt/hooks-manifest"
58
49
  fi
59
-
60
- # --- (2) dispatch ---------------------------------------------------------------------------------------
61
- manifest="${SPEX_HOOK_MANIFEST:-$rt/hooks-manifest}"
62
50
  [ -f "$manifest" ] || exit 0 # no manifest yet (materialize never ran) → nothing to dispatch
63
51
  input="$(cat 2>/dev/null || true)" # capture stdin ONCE; each handler gets its own copy
64
52
  err="/tmp/.spex-hook-$$.err" # per-dispatch (pid-unique) stderr capture; no cross-session race
@@ -70,7 +70,7 @@ hp_session_id() {
70
70
 
71
71
  # the per-PROJECT GLOBAL runtime dir (mirrors spec-cli/src/layout.ts `runtimeRoot`): <store>/projects/<enc>,
72
72
  # keyed by the project (dirname of the ABSOLUTE git-common-dir, so the answer is identical from main or any
73
- # worktree). This is where the materialized hook manifest + content-hash + gate lock live NOT the worktree.
73
+ # worktree). The per-session dirs and the per-tree materialize slots (hp_tree_dir) live under it.
74
74
  # Echoes the dir; returns non-zero (echoing nothing) when git can't resolve, so a caller can `|| exit 0`.
75
75
  hp_runtime_dir() {
76
76
  local gcd
@@ -79,6 +79,19 @@ hp_runtime_dir() {
79
79
  printf '%s/projects/%s' "${SPEXCODE_HOME:-$HOME/.spexcode}" "$(printf '%s' "$(dirname "$gcd")" | sed 's#[/.]#-#g')"
80
80
  }
81
81
 
82
+ # the per-WORKTREE materialize slot (mirrors layout.ts `treeSlotDir`): <runtime>/trees/<enc(worktree-toplevel)> —
83
+ # where THIS tree's materialized hook manifest + content-hash marker live. Keyed by the cwd's own
84
+ # `rev-parse --show-toplevel` through the same enc transform, so a dispatch can only ever read the manifest
85
+ # of the tree it fires in ([[hook-dispatch]] — the old single global file let the last-materialized tree's
86
+ # hook set reach every other tree's sessions). Echoes the dir; returns non-zero when git can't resolve.
87
+ hp_tree_dir() {
88
+ local rd top
89
+ rd=$(hp_runtime_dir) || return 1
90
+ top=$(git rev-parse --show-toplevel 2>/dev/null)
91
+ [ -n "$top" ] || return 1
92
+ printf '%s/trees/%s' "$rd" "$(printf '%s' "$top" | sed 's#[/.]#-#g')"
93
+ }
94
+
82
95
  # the per-session GLOBAL store dir for a session id — <runtime>/sessions/<id> (sibling of the per-project
83
96
  # runtime above). Echoes the dir; returns non-zero (echoing nothing) when git can't resolve.
84
97
  # ALIAS resolution: a codex hook fires from the shared per-PROJECT app-server process, whose env may carry a
@@ -99,35 +112,32 @@ hp_store_dir() {
99
112
  printf '%s' "$direct"
100
113
  }
101
114
 
102
- # the RENDERER's own version fingerprint — the toolchain side of the gate key. The rendered artifacts are a
103
- # function of (config content, renderer), so a TOOLCHAIN update must move the key too, or an updated deploy
104
- # never self-heals its stale contract/shims/manifest until someone happens to edit .config (the field lesson:
105
- # a toolchain update does NOT self-heal). A source checkout answers with the git TREE hash of the package dir
106
- # (moves exactly when the toolchain's content moves, not on every repo commit); an npm install (no .git)
107
- # answers with the package.json hash (npm bumps the version). env-stripped git — a git hook's exported
108
- # GIT_DIR must not misdirect repo discovery (same rule as git.ts's git()).
115
+ # the TOOLCHAIN's own version fingerprint — the toolchain side of the content key. The materialized artifacts
116
+ # are a function of (config content, toolchain), so a TOOLCHAIN update must move the key too, or an updated
117
+ # deploy never self-heals its stale contract/shims/manifest until someone happens to edit .config (the field
118
+ # lesson: a toolchain update does NOT self-heal). A source checkout answers with the git TREE hash of the
119
+ # package dir (moves exactly when the toolchain's content moves, not on every repo commit); an npm install
120
+ # (no .git) answers with the package.json hash (npm bumps the version). env-stripped git — a git hook's
121
+ # exported GIT_DIR must not misdirect repo discovery (same rule as git.ts's git()).
109
122
  SPEXCODE_HP_PKG="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)"
110
- hp_renderer_version() {
123
+ hp_toolchain_version() {
111
124
  ( cd "$SPEXCODE_HP_PKG" 2>/dev/null && env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse 'HEAD:./' 2>/dev/null ) \
112
125
  || sha256sum "$SPEXCODE_HP_PKG/package.json" 2>/dev/null | cut -d' ' -f1 \
113
126
  || echo unversioned
114
127
  }
115
128
 
116
- # the deterministic content fingerprint of EVERYTHING the render is a function of: the EDITABLE config
129
+ # the deterministic content fingerprint of EVERYTHING the materialize is a function of: the EDITABLE config
117
130
  # roots (.config + config md/sh), the PERSISTED POLICY files (the MAIN checkout's spexcode.json +
118
- # spexcode.local.json — the `harnesses` set and `render` vote materialize reads via readConfig(mainCheckout)),
119
- # and the renderer version above the gate's "did anything the render depends on move?" signal. The policy
120
- # files are in the key so a harness-selection or render-policy edit SELF-HEALS on the very next hook event
121
- # (before this, narrowing `harnesses` left the deselected harness's stale artifacts in place until an
122
- # unrelated .config edit happened to fire the gate — [[harness-select]]). Run with cwd = the project. ONE
123
- # definition: the dispatch.sh gate sources this and materialize.ts shells to it, so the gate and the renderer
124
- # can NEVER disagree on what "changed" means (the two used to inline this pipeline verbatim, each commenting
125
- # the other "MUST match"). env-stripped git, same rule as hp_renderer_version.
131
+ # spexcode.local.json — the `harnesses` set materialize reads via readConfig(mainCheckout)), and the
132
+ # toolchain version above. Since the dispatch-gate retired ([[commit-surgery]] materialize anchors on
133
+ # git-native events only), this is a FRESHNESS STAMP materialize records after each pass, a diagnostic
134
+ # (is the last materialize current?) rather than a trigger. Run with cwd = the project. ONE definition:
135
+ # materialize.ts shells to it. env-stripped git, same rule as hp_toolchain_version.
126
136
  hp_config_hash() {
127
137
  local gcd
128
138
  gcd=$(env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
129
139
  || gcd=$(realpath "$(env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null)
130
- { hp_renderer_version
140
+ { hp_toolchain_version
131
141
  find .spec/*/.config .spec/*/config \( -name '*.md' -o -name '*.sh' \) -type f -print0 2>/dev/null \
132
142
  | sort -z | xargs -0 cat 2>/dev/null
133
143
  [ -n "$gcd" ] && cat "$(dirname "$gcd")/spexcode.json" "$(dirname "$gcd")/spexcode.local.json" 2>/dev/null
@@ -52,7 +52,7 @@ function flushExit(code = 0): Promise<never> {
52
52
  }
53
53
  const has = (name: string) => process.argv.includes(`--${name}`)
54
54
  // bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
55
- const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--render', '--limit', '--session', '--depth'])
55
+ const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth'])
56
56
  function positionals(from: number): string[] {
57
57
  const out: string[] = []
58
58
  for (let i = from; i < process.argv.length; i++) {
@@ -279,9 +279,9 @@ if (cmd === 'serve') {
279
279
  }
280
280
  } else if (cmd === 'init') {
281
281
  // scaffold a repo to adopt SpexCode: copy the shipped DATA templates (seed spec tree + git hooks)
282
- // into <targetDir> (default cwd). spex init [targetDir] [--preset <tier>] [--render <word>]
282
+ // into <targetDir> (default cwd). spex init [targetDir] [--preset <tier>]
283
283
  const { specInit } = await import('./init.js')
284
- await specInit(positionals(3)[0], flag('preset'), flag('render'))
284
+ await specInit(positionals(3)[0], flag('preset'))
285
285
  } else if (cmd === 'uninstall') {
286
286
  // the surgical inverse of init: remove every SpexCode-generated artifact (harness shims/contract/trust, the
287
287
  // .gitignore block, the global store, any plugin bundle) — NEVER the user's .spec/.config data or their own
@@ -401,19 +401,16 @@ if (cmd === 'serve') {
401
401
  const run = cmd === 'remark' ? m.runRemark : cmd === 'resolve' ? m.runResolve : m.runRetract
402
402
  await flushExit(await run(process.argv.slice(3)))
403
403
  } else if (cmd === 'materialize') {
404
- // @@@ materialize - the pay-per-change render: surface nodes → manifest + AGENTS.md/CLAUDE.md block +
405
- // shims + Codex trust, for cwd's project. The cheap shell gate (dispatch.sh) invokes it only on change.
406
- // The adoption vote hint ([[render-policy]]) prints only here and on init — the human surfaces — never
407
- // from the silent gate/bootstrap renders of the same function.
408
- const { materialize, renderVoteHint } = await import('./materialize.js')
404
+ // @@@ materialize - surface nodes → manifest + AGENTS.md/CLAUDE.md block + shims + Codex
405
+ // trust, for cwd's project. Anchored on git-native events only ([[commit-surgery]]): this verb, init,
406
+ // session-worktree creation, and the planted pre-commit/post-checkout/post-merge hooks.
407
+ const { materialize } = await import('./materialize.js')
409
408
  console.log(`materialized — content-hash ${materialize()}`)
410
- const hint = renderVoteHint()
411
- if (hint) console.log(`\n${hint}`)
412
409
  } else if (cmd === 'doctor') {
413
410
  // @@@ doctor - the diagnosis surface ([[doctor]], né `self` — renamed: "self" read as the tool itself /
414
411
  // the global install, while the report is about THIS agent's wiring): does the materialized workflow
415
412
  // actually reach this agent? Bare `doctor` reports per-layer coverage (preconditions · git-hook floor ·
416
- // contract · hooks+handler-existence · backend) over the same HARNESSES materialize renders through;
413
+ // contract · hooks+handler-existence · backend) over the same HARNESSES materialize delivers through;
417
414
  // `contract` prints the surface:system text; `conflicts` just the double-delivery check. Thin route.
418
415
  const { runDoctor } = await import('./doctor.js')
419
416
  await flushExit(await runDoctor(process.argv.slice(3)))
@@ -760,6 +757,19 @@ if (cmd === 'serve') {
760
757
  const sid = process.env.SPEXCODE_SESSION_ID
761
758
  if (sid) markHarnessSessionId(sid, r.threadId)
762
759
  console.log(r.threadId)
760
+ } else if (sub === 'commit-surgery') {
761
+ // the pre-commit footprint anchor ([[commit-surgery]]): unconditional materialize + staged-index repair
762
+ // (strip our sentinel block from staged blobs, unstage HEAD-untracked generated artifacts). Called only
763
+ // by the planted pre-commit hook; repairs and proceeds, never rejects. Exit non-zero only on an internal
764
+ // error — the hook treats that as advisory (warn + continue), CI lint remains the real gate.
765
+ const { commitSurgery } = await import('./commit-surgery.js')
766
+ commitSurgery()
767
+ } else if (sub === 'refresh-footprint') {
768
+ // the post-checkout / post-merge freshness anchor ([[commit-surgery]]): a quiet materialize after a git
769
+ // state transition (the only events that can move the materialize's inputs — .spec/.config arrive by commit,
770
+ // merge, or checkout). Best-effort and silent on success; hooks call it fire-and-forget.
771
+ const { materialize } = await import('./materialize.js')
772
+ try { materialize() } catch (e) { console.error(`spexcode: footprint refresh failed (${(e as Error).message})`); process.exit(1) }
763
773
  } else if (sub === 'codex-turn') {
764
774
  // fire a follow-up turn on an OWNED thread over the per-project socket (the delivery channel, exposed for
765
775
  // tests / scripts). steer-vs-start is chosen live from the thread read.
@@ -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
+ }
@@ -4,24 +4,25 @@ import { execFileSync } from 'node:child_process'
4
4
  import { git } from './git.js'
5
5
  import { writeManagedBlock, removeManagedBlock } from './harness.js'
6
6
 
7
- // @@@ contract-filter ([[content-filter]]) - the render=hidden answer for a MIXED-CONTENT contract file: a
8
- // CLAUDE.md/AGENTS.md the HOST ALREADY TRACKS, where "generate + ignore" is a no-op (git ignores only
9
- // untracked paths) and the folded-in block would ride the tracked file into every teammate's diff. A git
10
- // clean/smudge content filter keeps the two contents on their own sides of the index: the REPO stores the
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
11
  // pristine host prose (clean strips our sentinel block on stage/diff), the WORKING TREE carries prose + block
12
12
  // (smudge re-injects it on checkout). Everything the filter needs is PER-CLONE — `git config
13
13
  // filter.spexcode.*` + a managed block in `.git/info/attributes` + two files under `<common>/spexcode/` —
14
- // zero repo footprint, exactly the hidden contract. The sentinels are the load-bearing anchor: the invariant
14
+ // zero repo footprint. The sentinels are the load-bearing anchor: the invariant
15
15
  // is clean(smudge(x)) == x (for text ending in one newline — see the shim), so `git status` stays clean.
16
- // Planted ONLY for a file the host tracks (the weakest sufficient tool: an untracked contract file is wholly
17
- // ours and plain generate+ignore already hides it no filter). materialize plants/refreshes it under
18
- // render=hidden and erases it under every other policy ([[render-policy]] / the forgetting law).
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).
19
20
 
20
21
  // the three field-sharpened edges this module owes ([[content-filter]]):
21
22
  // ① the configured command points at a STABLE shim path and degrades to `cat` (identity) when the shim is
22
23
  // missing — a bare missing filter command makes git spray "cannot fork" fatals on EVERY operation;
23
24
  // ② a changed contract does NOT propagate by itself (git re-smudges only on checkout) — materialize's
24
- // re-render writes the managed block straight into the working file (writeManagedBlock IS the re-smudge),
25
+ // re-materialize writes the managed block straight into the working file (writeManagedBlock IS the re-smudge),
25
26
  // and this module refreshes the block file the shim reads so future checkouts agree;
26
27
  // ③ unplanting must strip the block from the WORKING FILES before the config goes away, or the block
27
28
  // residue surfaces as an uncommitted modification — the caller (dematerialize) removes the managed
@@ -43,7 +44,7 @@ const attributesPath = (common: string) => join(common, 'info', 'attributes')
43
44
  // Byte-exactness holds for text ending in exactly one newline (git's own well-formed-text shape); a pristine
44
45
  // file ending in zero or 2+ newlines is normalized to one on the first round-trip and stable after.
45
46
  const SHIM = `#!/usr/bin/env bash
46
- # spexcode contract filter (generated by spex materialize — render=hidden; see [[content-filter]]).
47
+ # spexcode contract filter (generated by spex materialize; see [[content-filter]]).
47
48
  # clean(smudge(x)) == x: the repo keeps the pristine host prose, the working tree carries prose + block.
48
49
  set -u
49
50
  mode="\${1:?usage: contract-filter.sh smudge|clean}"
@@ -93,13 +94,14 @@ esac
93
94
  const filterCmd = (shim: string, mode: 'smudge' | 'clean') =>
94
95
  `sh -c 'test -r "$0" && exec bash "$0" ${mode} || exec cat' '${shim.replace(/'/g, `'\\''`)}'`
95
96
 
96
- // plant (or refresh) the filter for the given TRACKED contract files: the shim + the block content it
97
- // smudges, the per-clone git config, and the attribute lines binding each file to the filter. Idempotent —
98
- // every write is a full replace. `contract` is the assembled block body (guide + surface:system).
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.
99
101
  export function plantContractFilter(proj: string, trackedFiles: string[], contract: string): void {
100
102
  const common = commonDirOf(proj)
101
103
  mkdirSync(filterDir(common), { recursive: true })
102
- writeFileSync(blockPath(common), contract.endsWith('\n') ? contract : `${contract}\n`) // edge ②: the shim's smudge source, refreshed with the render
104
+ writeFileSync(blockPath(common), contract.endsWith('\n') ? contract : `${contract}\n`) // edge ②: the shim's smudge source, refreshed with the materialize
103
105
  writeFileSync(shimPath(common), SHIM)
104
106
  chmodSync(shimPath(common), 0o755)
105
107
  git(['-C', proj, 'config', 'filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')])
@@ -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
  }