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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +13 -10
- package/spec-cli/hooks/dispatch.sh +28 -40
- package/spec-cli/hooks/harness.sh +43 -7
- package/spec-cli/src/board.ts +2 -1
- package/spec-cli/src/boardCache.ts +27 -1
- package/spec-cli/src/boardStream.ts +5 -4
- package/spec-cli/src/cli.ts +93 -20
- package/spec-cli/src/commit-surgery.ts +81 -0
- package/spec-cli/src/contract-filter.ts +156 -0
- package/spec-cli/src/doctor.ts +11 -5
- package/spec-cli/src/git.ts +18 -4
- package/spec-cli/src/guide.ts +94 -38
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +22 -8
- package/spec-cli/src/help.ts +28 -16
- package/spec-cli/src/index.ts +9 -6
- package/spec-cli/src/init.ts +17 -10
- package/spec-cli/src/issues.ts +7 -5
- package/spec-cli/src/layout.ts +31 -13
- package/spec-cli/src/lint.ts +19 -0
- package/spec-cli/src/localIssues.ts +16 -4
- package/spec-cli/src/materialize.ts +214 -144
- package/spec-cli/src/mentions.ts +5 -3
- package/spec-cli/src/plugin-harness.ts +10 -9
- package/spec-cli/src/ranker.ts +25 -8
- package/spec-cli/src/runtime-guard.ts +44 -0
- package/spec-cli/src/search.bench.mjs +15 -5
- package/spec-cli/src/sessions.ts +96 -22
- package/spec-cli/src/specs.ts +38 -18
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tsx-bin.ts +6 -8
- package/spec-cli/src/uninstall.ts +18 -19
- package/spec-cli/src/worktree-sources.ts +52 -13
- package/spec-cli/templates/hooks/post-checkout +22 -0
- package/spec-cli/templates/hooks/post-merge +15 -9
- package/spec-cli/templates/hooks/pre-commit +10 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
- package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
- package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
- package/spec-dashboard/dist/assets/Dashboard-BlRRsxE7.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BzVE38-Z.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-DFuLVOeu.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CzDaazhe.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-CXQrQCNp.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-D1pUBl6q.js} +8 -8
- package/spec-dashboard/dist/assets/SessionWindow-Y25Bwg1e.js +9 -0
- package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-R610Vbzd.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BO0Zuweu.js} +2 -2
- package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-forge/src/cli.ts +2 -2
- package/spec-forge/src/drivers/gitlab.ts +168 -0
- package/spec-forge/src/drivers.ts +80 -2
- package/spec-forge/src/resident.ts +10 -5
- package/spec-yatsu/src/cli.ts +37 -16
- package/spec-yatsu/src/evaltab.ts +6 -3
- package/spec-yatsu/src/filing.ts +13 -8
- package/spec-yatsu/src/freshness.ts +97 -22
- package/spec-yatsu/src/proof.ts +14 -3
- package/spec-yatsu/src/scenariofresh.ts +38 -11
- package/spec-yatsu/src/yatsu.ts +52 -28
- package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
- package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
- package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
- 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
|
+
}
|
package/spec-cli/src/doctor.ts
CHANGED
|
@@ -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()
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/spec-cli/src/git.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/spec-cli/src/guide.ts
CHANGED
|
@@ -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
|
|
40
|
-
spex guide yatsu
|
|
41
|
-
spex guide config
|
|
42
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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 vote — one 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 tracked — the 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
|
|
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
|
|
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;
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
774
|
-
//
|
|
775
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|