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 +1 -1
- package/spec-cli/hooks/dispatch.sh +10 -0
- package/spec-cli/src/cli.ts +10 -9
- package/spec-cli/src/contract-filter.ts +73 -29
- package/spec-cli/src/guide.ts +2 -1
- package/spec-cli/src/harness.ts +426 -120
- package/spec-cli/src/host-resources.ts +20 -28
- package/spec-cli/src/index.ts +2 -1
- package/spec-cli/src/init.ts +26 -7
- package/spec-cli/src/layout.ts +3 -11
- package/spec-cli/src/materialize.ts +118 -49
- package/spec-cli/src/process-identity.ts +144 -19
- package/spec-cli/src/runtime-ownership.ts +5 -16
- package/spec-cli/src/sessions.ts +123 -17
- package/spec-cli/templates/hooks/pre-commit +3 -9
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.5.
|
|
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"
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -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,
|
|
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 || !
|
|
1012
|
-
console.error('usage: spex internal shared-runtime-spawn <cwd> <log> <pid-file> <
|
|
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,
|
|
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
|
-
//
|
|
1134
|
-
|
|
1135
|
-
const
|
|
1136
|
-
|
|
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 (
|
|
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
|
-
|
|
40
|
-
|
|
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 (
|
|
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
|
|
48
|
-
#
|
|
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
|
-
|
|
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] ==
|
|
60
|
-
if (lines[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 "$
|
|
78
|
-
strip | awk -v b="$
|
|
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--
|
|
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
|
|
107
|
+
print sline
|
|
85
108
|
while ((getline l < b) > 0) print l
|
|
86
|
-
print
|
|
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,
|
|
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
|
-
|
|
110
|
-
const
|
|
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,
|
|
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
|
-
|
|
150
|
-
|
|
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)
|
package/spec-cli/src/guide.ts
CHANGED
|
@@ -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.
|
|
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" }
|