spexcode 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
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",
@@ -43,6 +43,16 @@ proj="${CLAUDE_PROJECT_DIR:-$PWD}"
43
43
  rt="$(cd "$proj" 2>/dev/null && hp_runtime_dir)" || rt=""
44
44
  slot="$(cd "$proj" 2>/dev/null && hp_tree_dir)" || slot=""
45
45
 
46
+ # A project transport can outlive the tree that installed it. The current tree's last successful materialize
47
+ # is the authority for whether its events are active. Before the v1 marker, an absent allowlist is the legacy
48
+ # shape; afterwards absence means this tree never successfully selected a harness and dispatch stays inert.
49
+ allowed="$slot/harnesses"
50
+ if [ -f "$allowed" ]; then
51
+ grep -Fxq "$harness" "$allowed" || exit 0
52
+ elif [ -f "$rt/harness-selection-v1" ]; then
53
+ exit 0
54
+ fi
55
+
46
56
  # --- dispatch ---------------------------------------------------------------------------------------------
47
57
  if [ -n "${SPEX_HOOK_MANIFEST:-}" ]; then
48
58
  manifest="$SPEX_HOOK_MANIFEST"
@@ -1006,10 +1006,10 @@ if (cmd === 'serve') {
1006
1006
  if (!owner) { console.error('maintenance_identity_unknown: hook dispatcher owner identity is not exact'); process.exit(2) }
1007
1007
  sessionMaintenance().finishExternalOperation(ticket, owner)
1008
1008
  } else if (sub === 'shared-runtime-spawn') {
1009
- const [cwd, logFile, pidFile, isolationFile, command] = process.argv.slice(4, 9)
1009
+ const [cwd, logFile, pidFile, receiptFile, command] = process.argv.slice(4, 9)
1010
1010
  const args = process.argv.slice(9)
1011
- if (!cwd || !logFile || !pidFile || !isolationFile || !command) {
1012
- console.error('usage: spex internal shared-runtime-spawn <cwd> <log> <pid-file> <isolation-file> <command> [args...]')
1011
+ if (!cwd || !logFile || !pidFile || !receiptFile || !command) {
1012
+ console.error('usage: spex internal shared-runtime-spawn <cwd> <log> <pid-file> <receipt-file> <command> [args...]')
1013
1013
  process.exit(2)
1014
1014
  }
1015
1015
  const { readFileSync } = await import('node:fs')
@@ -1029,7 +1029,7 @@ if (cmd === 'serve') {
1029
1029
  delete env.SPEXCODE_MAINTENANCE_SESSION_ID
1030
1030
  delete env.SPEXCODE_SESSION_ID
1031
1031
  const runtime = await runSessionOperation({ op: 'shared-spawn', sessionId, ...(delegateChannelPresent ? { delegate } : {}) }, () =>
1032
- spawnDetachedRuntime({ cwd, logFile, pidFile, isolationFile, command, args, env }))
1032
+ spawnDetachedRuntime({ cwd, logFile, pidFile, receiptFile, command, args, env }))
1033
1033
  console.log(runtime.pid)
1034
1034
  } else if (sub === 'codex-launch') {
1035
1035
  // BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
@@ -1130,10 +1130,11 @@ if (cmd === 'serve') {
1130
1130
  const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
1131
1131
  console.log(ok.ok ? `state -> ${st}${noteEcho(flag('note'))}` : ok.reason ?? noRecord())
1132
1132
  } else if (sub === 'session-fail') {
1133
- // the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
1134
- const { s, sess, mark, noRecord } = await stateKit()
1135
- const failed = mark(() => s.markError(sess))
1136
- console.log(failed.ok ? 'marked error' : failed.reason ?? noRecord())
1133
+ // StopFailure is one native source for the shared active-only turn-failure CAS. A declaration or explicit
1134
+ // stop that landed first is authoritative, just as it is for Codex notifications and headless exits.
1135
+ const { s, sess, mark } = await stateKit()
1136
+ const failed = mark(() => s.markTurnFailure(sess, 'claude turn failed'))
1137
+ console.log(failed.ok ? 'marked error' : failed.reason ?? 'noop (session is not live active)')
1137
1138
  } else if (sub === 'session-turn-fail') {
1138
1139
  // Headless adapters report an ephemeral turn's non-zero exit through this one shared CAS. A declaration
1139
1140
  // that landed before teardown wins, so a late child close can never erase an agent-authored state.
@@ -1143,7 +1144,7 @@ if (cmd === 'serve') {
1143
1144
  process.exit(2)
1144
1145
  }
1145
1146
  const { markHeadlessTurnFailure } = await import('./sessions.js')
1146
- console.log(markHeadlessTurnFailure(sessionId, harness, exitCode) ? `marked error (${harness} ${exitCode})` : 'noop (no active session record)')
1147
+ console.log(markHeadlessTurnFailure(sessionId, harness, exitCode) ? `marked error (${harness} ${exitCode})` : 'noop (session is not live active)')
1147
1148
  } else if (sub === 'session-idle') {
1148
1149
  // the Notification(idle_prompt) hook marks its session (--session from the payload) idle when claude waits
1149
1150
  // at its prompt. INFERRED, so guarded active-only: it no-ops unless the current status is exactly `active`,
@@ -1,8 +1,9 @@
1
- import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
2
2
  import { join, relative } from 'node:path'
3
3
  import { execFileSync } from 'node:child_process'
4
4
  import { git } from './git.js'
5
5
  import { writeManagedBlock, removeManagedBlock } from './harness.js'
6
+ import { encodeProject, runtimeRoot, treeSlotDir } from './layout.js'
6
7
 
7
8
  // @@@ contract-filter ([[content-filter]]) - the answer for a MIXED-CONTENT contract file: a
8
9
  // CLAUDE.md/AGENTS.md the HOST TRACKS — or has begun writing its OWN prose into — where "generate + ignore"
@@ -34,30 +35,54 @@ function commonDirOf(proj: string): string {
34
35
  const filterDir = (common: string) => join(common, 'spexcode')
35
36
  const shimPath = (common: string) => join(filterDir(common), 'contract-filter.sh')
36
37
  const blockPath = (common: string) => join(filterDir(common), 'contract-block.md')
38
+ const rootPath = (common: string) => join(filterDir(common), 'contract-filter-root')
39
+ const bindingsPath = (common: string) => join(filterDir(common), 'contract-filter-bindings')
40
+ const treeFilterDir = (proj: string) => join(treeSlotDir(proj), 'contract-filter')
37
41
  const attributesPath = (common: string) => join(common, 'info', 'attributes')
38
42
 
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:
43
+ const registeredSlots = (proj: string) => git(['-C', proj, 'worktree', 'list', '--porcelain', '-z']).split('\0')
44
+ .filter((row) => row.startsWith('worktree ')).map((row) =>
45
+ join(runtimeRoot(proj), 'trees', encodeProject(row.slice('worktree '.length))))
46
+ const legacyFilterTree = (proj: string) => registeredSlots(proj)
47
+ .some((slot) => existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, 'contract-filter-v2')))
48
+
49
+ export type ContractFilterPayload = { file: string; content: string }
50
+ export type ContractFilterBinding = { file: string; start: string; end: string; legacy?: boolean }
51
+
52
+ // The common shim both filter directions run through. Pure shell/awk (no node boot on git's hot path), it
53
+ // resolves the invoking checkout to that tree's payload before mirroring managed-block normalization:
41
54
  // 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.
55
+ // smudge: clean first (a block already in the index can never double-inject), then append the tree payload.
44
56
  // Byte-exactness holds for text ending in exactly one newline (git's own well-formed-text shape); a pristine
45
57
  // file ending in zero or 2+ newlines is normalized to one on the first round-trip and stable after.
46
58
  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.
59
+ # spexcode managed-text filter (generated by spex materialize; see [[content-filter]]).
60
+ # One common driver selects the payload belonging to the Git checkout that invoked it.
49
61
  set -u
50
- mode="\${1:?usage: contract-filter.sh smudge|clean}"
62
+ mode="\${1:?usage: contract-filter.sh smudge|clean <path>}"
63
+ path="\${2:?usage: contract-filter.sh smudge|clean <path>}"
51
64
  here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
52
- block="$here/contract-block.md"
65
+ binding="$(awk -F '\t' -v p="$path" '$1 == p { print $2 "\t" $3 "\t" $4; exit }' "$here/contract-filter-bindings" 2>/dev/null)"
66
+ [ -n "$binding" ] || { cat; exit 0; }
67
+ start="\${binding%%$'\t'*}"; rest="\${binding#*$'\t'}"
68
+ end="\${rest%%$'\t'*}"; legacy="\${rest#*$'\t'}"
69
+ top="$(git rev-parse --show-toplevel 2>/dev/null || true)"
70
+ root="$(cat "$here/contract-filter-root" 2>/dev/null || true)"
71
+ key="$(printf '%s' "$top" | sed 's#[/.]#-#g')"
72
+ manifest="$root/trees/$key/contract-filter/manifest"
73
+ payload="$(awk -F '\t' -v p="$path" '$1 == p { print $2; exit }' "$manifest" 2>/dev/null)"
74
+ # A pre-v2 tree has only the old common payload. Once that tree materializes, its marker makes a missing
75
+ # per-file payload mean identity (for example, AGENTS.md in a Claude-only tree), never global fallback.
76
+ marker="$root/trees/$key/contract-filter-v2"
77
+ if [ ! -r "$payload" ] && [ ! -f "$marker" ] && [ "$legacy" = 1 ] && [ -r "$here/contract-block.md" ]; then payload="$here/contract-block.md"; fi
53
78
  strip() {
54
- awk 'BEGIN { n = 0 }
79
+ awk -v sline="$start" -v eline="$end" 'BEGIN { n = 0 }
55
80
  { lines[n++] = $0 }
56
81
  END {
57
82
  s = -1; e = -1
58
83
  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
84
+ if (lines[i] == sline && s < 0) s = i
85
+ if (lines[i] == eline && s >= 0 && e < 0) e = i
61
86
  }
62
87
  if (s >= 0 && e >= s) {
63
88
  a = s; while (a > 0 && lines[a-1] == "") a--
@@ -66,24 +91,22 @@ strip() {
66
91
  for (i = 0; i < n; i++) if (i < a || i > b) lines[j++] = lines[i]
67
92
  n = j
68
93
  }
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
94
  for (i = 0; i < n; i++) print lines[i]
72
95
  }'
73
96
  }
74
97
  case "$mode" in
75
98
  clean) strip ;;
76
99
  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 }
100
+ if [ ! -r "$payload" ]; then strip; exit 0; fi
101
+ strip | awk -v b="$payload" -v sline="$start" -v eline="$end" 'BEGIN { n = 0 }
79
102
  { lines[n++] = $0 }
80
103
  END {
81
- while (n > 0 && lines[n-1] == "") n-- # trim trailing blanks (writeManagedBlock parity)
104
+ while (n > 0 && lines[n-1] == "") n--
82
105
  for (i = 0; i < n; i++) print lines[i]
83
106
  if (n > 0) print ""
84
- print "<!-- spexcode:start -->"
107
+ print sline
85
108
  while ((getline l < b) > 0) print l
86
- print "<!-- spexcode:end -->"
109
+ print eline
87
110
  }' ;;
88
111
  *) echo "contract-filter.sh: unknown mode $mode" >&2; exit 1 ;;
89
112
  esac
@@ -92,25 +115,37 @@ esac
92
115
  // edge ①: the command git runs is a tolerant wrapper — the shim path is an ARGUMENT ($0), and a missing/
93
116
  // unreadable shim degrades to `cat` (identity) instead of a per-operation fatal.
94
117
  const filterCmd = (shim: string, mode: 'smudge' | 'clean') =>
95
- `sh -c 'test -r "$0" && exec bash "$0" ${mode} || exec cat' '${shim.replace(/'/g, `'\\''`)}'`
118
+ `sh -c 'test -r "$0" && exec bash "$0" ${mode} "$1" || exec cat' '${shim.replace(/'/g, `'\\''`)}' %f`
96
119
 
97
120
  // plant (or refresh) the filter for the given contract files (tracked, or untracked-with-host-content —
98
121
  // pre-armed): the shim + the block content it smudges, the per-clone git config, and the attribute lines
99
122
  // binding each file to the filter. Idempotent — every write is a full replace. `contract` is the assembled
100
123
  // 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 {
124
+ export function plantContractFilter(proj: string, payloads: ContractFilterPayload[], bindings: ContractFilterBinding[]): void {
102
125
  const common = commonDirOf(proj)
103
126
  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
127
  writeFileSync(shimPath(common), SHIM)
106
128
  chmodSync(shimPath(common), 0o755)
107
129
  git(['-C', proj, 'config', 'filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')])
108
130
  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')
131
+ writeFileSync(bindingsPath(common), bindings.map((b) => `${b.file}\t${b.start}\t${b.end}\t${b.legacy ? 1 : 0}`).join('\n') + '\n')
132
+ const dir = treeFilterDir(proj)
133
+ rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true })
134
+ const manifest: string[] = []
135
+ for (const [i, payload] of payloads.entries()) {
136
+ const target = join(dir, String(i))
137
+ writeFileSync(target, payload.content.endsWith('\n') ? payload.content : `${payload.content}\n`)
138
+ manifest.push(`${payload.file}\t${target}`)
139
+ }
140
+ const manifestPath = join(dir, 'manifest')
141
+ writeFileSync(manifestPath, manifest.join('\n') + (manifest.length ? '\n' : ''))
142
+ writeFileSync(rootPath(common), `${runtimeRoot(proj)}\n`)
143
+ // Attribute patterns are checkout-relative; the stable binding set is safe in the common git dir because
144
+ // the driver selects a payload from the invoking checkout's tree slot.
145
+ const entries = bindings.map((b) => `/${b.file} filter=spexcode`).sort().join('\n')
111
146
  mkdirSync(join(common, 'info'), { recursive: true })
112
147
  writeManagedBlock(attributesPath(common), entries, ['# ', ''])
113
- settleIndexStat(proj, trackedFiles)
148
+ settleIndexStat(proj, payloads.map((p) => join(proj, p.file)))
114
149
  }
115
150
 
116
151
  // settle the index STAT for each file — the famous filtered-path phantom-`M`: git cannot verify a
@@ -139,15 +174,24 @@ export function settleIndexStat(proj: string, files: string[]): void {
139
174
  // the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
140
175
  // config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
141
176
  // (evidence blobs), so only OUR two files go, never the dir.
142
- export function removeContractFilter(proj: string): void {
177
+ export function removeContractFilter(proj: string, files: string[] = [], final = false): void {
143
178
  let common: string
144
179
  try { common = commonDirOf(proj) } catch { return } // not a git repo → nothing was ever planted
180
+ try { rmSync(treeFilterDir(proj), { recursive: true, force: true }) } catch { /* inaccessible tree */ }
181
+ settleIndexStat(proj, files)
182
+ const anotherPayload = registeredSlots(proj).some((slot) => existsSync(join(slot, 'contract-filter', 'manifest')))
183
+ const legacyTree = legacyFilterTree(proj)
184
+ if (!legacyTree) rmSync(blockPath(common), { force: true })
185
+ if (!final && (anotherPayload || legacyTree)) return
145
186
  removeManagedBlock(attributesPath(common), ['# ', ''], true)
146
187
  for (const key of ['filter.spexcode.smudge', 'filter.spexcode.clean']) {
147
- try { git(['-C', proj, 'config', '--unset', key]) } catch { /* not set — already clean */ }
188
+ try { git(['-C', proj, 'config', '--unset-all', key]) } catch { /* not set — already clean */ }
148
189
  }
149
- rmSync(shimPath(common), { force: true })
150
- rmSync(blockPath(common), { force: true })
190
+ for (const path of [shimPath(common), blockPath(common), rootPath(common), bindingsPath(common)]) rmSync(path, { force: true })
191
+ }
192
+
193
+ export function retireLegacyContractBlock(proj: string): void {
194
+ if (!legacyFilterTree(proj)) rmSync(blockPath(commonDirOf(proj)), { force: true })
151
195
  }
152
196
 
153
197
  // is the filter currently planted? (the assert-side probe tests use; cheap: one config read)
@@ -282,7 +282,8 @@ see LAUNCHERS.
282
282
 
283
283
  ── LAYOUT (spexcode.json — portable; set only for a NON-DEFAULT repo layout) ──
284
284
  main path to the source-of-truth checkout. Default: the \`main\` worktree.
285
- mainBranch the source-of-truth BRANCH worktrees fork from. Default: auto-detected.
285
+ mainBranch the stable source-of-truth BRANCH worktrees fork from. spex init stamps the root checkout's
286
+ branch at adoption; an older omitted value uses the conventional main.
286
287
  branchPrefix how a node branch is named. Default "node/".
287
288
  Example — a repo whose trunk is \`staging\`, not \`main\`:
288
289
  { "mainBranch": "staging" }