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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/hooks/dispatch.sh +28 -40
- package/spec-cli/hooks/harness.sh +29 -19
- package/spec-cli/src/cli.ts +21 -11
- package/spec-cli/src/commit-surgery.ts +81 -0
- package/spec-cli/src/contract-filter.ts +16 -14
- package/spec-cli/src/doctor.ts +11 -5
- package/spec-cli/src/guide.ts +61 -58
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +5 -5
- package/spec-cli/src/help.ts +20 -17
- package/spec-cli/src/init.ts +5 -40
- package/spec-cli/src/layout.ts +26 -20
- package/spec-cli/src/materialize.ts +102 -126
- package/spec-cli/src/mentions.ts +5 -3
- package/spec-cli/src/plugin-harness.ts +10 -9
- package/spec-cli/src/sessions.ts +22 -13
- package/spec-cli/src/specs.ts +9 -4
- package/spec-cli/src/uninstall.ts +6 -5
- package/spec-cli/src/worktree-sources.ts +6 -4
- 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/distill/digest.mjs +136 -0
- package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
- package/spec-dashboard/dist/assets/{Dashboard-CMRJGfYI.js → Dashboard-BlRRsxE7.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Dj2mxcfW.js → EvalsPage-BzVE38-Z.js} +1 -1
- package/spec-dashboard/dist/assets/{FoldToggle-BfNpeyRQ.js → FoldToggle-DFuLVOeu.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-DfY315kt.js → IssuesPage-CzDaazhe.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-BGdC0A0P.js → MobileApp-CXQrQCNp.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-BOBCAR0t.js → SessionInterface-D1pUBl6q.js} +8 -8
- package/spec-dashboard/dist/assets/{SessionWindow-D7YmjV0i.js → SessionWindow-Y25Bwg1e.js} +5 -5
- package/spec-dashboard/dist/assets/{Settings-BlSNmpH_.js → Settings-R610Vbzd.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BFdzpd_O.js → index-BO0Zuweu.js} +2 -2
- 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,
|
|
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.
|
|
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.
|
|
7
|
-
# in order
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
|
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
|
|
34
|
-
# NOT the worktree — so
|
|
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
|
-
# ---
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
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).
|
|
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
|
|
103
|
-
# function of (config content,
|
|
104
|
-
# never self-heals its stale contract/shims/manifest until someone happens to edit .config (the field
|
|
105
|
-
# a toolchain update does NOT self-heal). A source checkout answers with the git TREE hash of the
|
|
106
|
-
# (moves exactly when the toolchain's content moves, not on every repo commit); an npm install
|
|
107
|
-
# answers with the package.json hash (npm bumps the version). env-stripped git — a git hook's
|
|
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
|
-
|
|
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
|
|
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
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
# (
|
|
122
|
-
#
|
|
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
|
-
{
|
|
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
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -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', '--
|
|
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>]
|
|
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')
|
|
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 -
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
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
|
|
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
|
|
8
|
-
// CLAUDE.md/AGENTS.md the HOST
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
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
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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-
|
|
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
|
|
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
|
|
97
|
-
// smudges, the per-clone git config, and the attribute lines
|
|
98
|
-
// every write is a full replace. `contract` is the assembled
|
|
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
|
|
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')])
|
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
|
}
|