spexcode 0.2.3 → 0.2.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/README.md +2 -2
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +13 -10
- package/spec-cli/hooks/harness.sh +32 -6
- 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 +82 -19
- package/spec-cli/src/contract-filter.ts +154 -0
- package/spec-cli/src/git.ts +18 -4
- package/spec-cli/src/guide.ts +91 -38
- package/spec-cli/src/harness.ts +18 -4
- package/spec-cli/src/help.ts +16 -7
- package/spec-cli/src/index.ts +9 -6
- package/spec-cli/src/init.ts +52 -10
- package/spec-cli/src/issues.ts +7 -5
- package/spec-cli/src/layout.ts +20 -8
- package/spec-cli/src/lint.ts +19 -0
- package/spec-cli/src/localIssues.ts +16 -4
- package/spec-cli/src/materialize.ts +218 -124
- 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 +81 -12
- package/spec-cli/src/specs.ts +29 -14
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tsx-bin.ts +6 -8
- package/spec-cli/src/uninstall.ts +15 -17
- package/spec-cli/src/worktree-sources.ts +56 -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-dashboard/dist/assets/Dashboard-CMRJGfYI.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-Dj2mxcfW.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-BfNpeyRQ.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-DfY315kt.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-BGdC0A0P.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-BOBCAR0t.js} +5 -5
- package/spec-dashboard/dist/assets/SessionWindow-D7YmjV0i.js +9 -0
- package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-BlSNmpH_.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BFdzpd_O.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
package/README.md
CHANGED
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
Spec-driven development with AI agents in the loop. SpexCode keeps a versioned tree of specs inside
|
|
15
15
|
your git repo, links every spec to the code it governs, and runs a session manager that dispatches
|
|
16
16
|
coding agents into isolated worktrees. You review and merge; the tool keeps intent and
|
|
17
|
-
implementation from drifting apart.
|
|
17
|
+
implementation from drifting apart.
|
|
18
18
|
|
|
19
|
-
[](https://spexcode.net/assets/spec-tree-growth.mp4)
|
|
20
20
|
|
|
21
21
|
<sub>▶ This repo's own spec tree, replayed from its git history — 160 spec nodes growing over three weeks. Click for the [full video](https://spexcode.net/assets/spec-tree-growth.mp4).</sub>
|
|
22
22
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.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",
|
package/spec-cli/bin/spex.mjs
CHANGED
|
@@ -35,20 +35,23 @@ if (conflicted.length) {
|
|
|
35
35
|
console.error('spex executes this TypeScript directly (no build step); resolve the merge, then retry. (exit 75)')
|
|
36
36
|
process.exit(75)
|
|
37
37
|
}
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
// @@@ cross-platform tsx resolution ([[platform-support]]) - resolve tsx's JS ENTRY (dist/cli.mjs) with
|
|
39
|
+
// Node's own resolver from spec-cli, then run it through THIS node binary (process.execPath). tsx may live
|
|
40
|
+
// in spec-cli/node_modules (dev) or be hoisted above the installed `spexcode` package (a real consumer
|
|
41
|
+
// project) — one resolver covers both without hardcoded consumer paths. We deliberately never spawn the
|
|
42
|
+
// `.bin/tsx` shim, nor a `.mjs` directly: on Windows the shim is an extensionless sh script and the `.mjs`
|
|
43
|
+
// leans on a shebang, neither of which `child_process.spawn` can execute — that is the #37 crash
|
|
44
|
+
// (`spawn …\node_modules\.bin\tsx ENOENT`) of `spex init`. `node dist/cli.mjs …` is shell-free and identical
|
|
45
|
+
// on every OS.
|
|
46
|
+
function resolveTsxCli() {
|
|
45
47
|
try {
|
|
46
48
|
const req = createRequire(join(pkg, 'package.json'))
|
|
47
49
|
return join(dirname(req.resolve('tsx/package.json')), 'dist', 'cli.mjs')
|
|
48
50
|
} catch {
|
|
49
|
-
|
|
51
|
+
console.error('spex: cannot find the `tsx` runtime this package needs — run `npm install` in the SpexCode package, then retry.')
|
|
52
|
+
process.exit(69)
|
|
50
53
|
}
|
|
51
54
|
}
|
|
52
|
-
const
|
|
53
|
-
spawn(
|
|
55
|
+
const tsxCli = resolveTsxCli()
|
|
56
|
+
spawn(process.execPath, [tsxCli, cli, ...process.argv.slice(2)], { stdio: 'inherit' })
|
|
54
57
|
.on('exit', (code) => process.exit(code ?? 0))
|
|
@@ -99,13 +99,39 @@ hp_store_dir() {
|
|
|
99
99
|
printf '%s' "$direct"
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
# the
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
102
|
+
# the RENDERER's own version fingerprint — the toolchain side of the gate key. The rendered artifacts are a
|
|
103
|
+
# function of (config content, renderer), so a TOOLCHAIN update must move the key too, or an updated deploy
|
|
104
|
+
# never self-heals its stale contract/shims/manifest until someone happens to edit .config (the field lesson:
|
|
105
|
+
# a toolchain update does NOT self-heal). A source checkout answers with the git TREE hash of the package dir
|
|
106
|
+
# (moves exactly when the toolchain's content moves, not on every repo commit); an npm install (no .git)
|
|
107
|
+
# answers with the package.json hash (npm bumps the version). env-stripped git — a git hook's exported
|
|
108
|
+
# GIT_DIR must not misdirect repo discovery (same rule as git.ts's git()).
|
|
109
|
+
SPEXCODE_HP_PKG="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)"
|
|
110
|
+
hp_renderer_version() {
|
|
111
|
+
( cd "$SPEXCODE_HP_PKG" 2>/dev/null && env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse 'HEAD:./' 2>/dev/null ) \
|
|
112
|
+
|| sha256sum "$SPEXCODE_HP_PKG/package.json" 2>/dev/null | cut -d' ' -f1 \
|
|
113
|
+
|| echo unversioned
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# the deterministic content fingerprint of EVERYTHING the render is a function of: the EDITABLE config
|
|
117
|
+
# roots (.config + config md/sh), the PERSISTED POLICY files (the MAIN checkout's spexcode.json +
|
|
118
|
+
# spexcode.local.json — the `harnesses` set and `render` vote materialize reads via readConfig(mainCheckout)),
|
|
119
|
+
# and the renderer version above — the gate's "did anything the render depends on move?" signal. The policy
|
|
120
|
+
# files are in the key so a harness-selection or render-policy edit SELF-HEALS on the very next hook event
|
|
121
|
+
# (before this, narrowing `harnesses` left the deselected harness's stale artifacts in place until an
|
|
122
|
+
# unrelated .config edit happened to fire the gate — [[harness-select]]). Run with cwd = the project. ONE
|
|
123
|
+
# definition: the dispatch.sh gate sources this and materialize.ts shells to it, so the gate and the renderer
|
|
124
|
+
# can NEVER disagree on what "changed" means (the two used to inline this pipeline verbatim, each commenting
|
|
125
|
+
# the other "MUST match"). env-stripped git, same rule as hp_renderer_version.
|
|
106
126
|
hp_config_hash() {
|
|
107
|
-
|
|
108
|
-
|
|
127
|
+
local gcd
|
|
128
|
+
gcd=$(env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
|
|
129
|
+
|| gcd=$(realpath "$(env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null)
|
|
130
|
+
{ hp_renderer_version
|
|
131
|
+
find .spec/*/.config .spec/*/config \( -name '*.md' -o -name '*.sh' \) -type f -print0 2>/dev/null \
|
|
132
|
+
| sort -z | xargs -0 cat 2>/dev/null
|
|
133
|
+
[ -n "$gcd" ] && cat "$(dirname "$gcd")/spexcode.json" "$(dirname "$gcd")/spexcode.local.json" 2>/dev/null
|
|
134
|
+
} | sha256sum | cut -d' ' -f1
|
|
109
135
|
}
|
|
110
136
|
|
|
111
137
|
# the tool a payload is about to run / just ran (harness-agnostic field name).
|
package/spec-cli/src/board.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { resolveLayout, readConfig } from './layout.js'
|
|
|
4
4
|
import { listSessions } from './sessions.js'
|
|
5
5
|
import { repoRoot, driftIndex, historyIndex } from './git.js'
|
|
6
6
|
import { residentForgeState } from '../../spec-forge/src/resident.js'
|
|
7
|
+
import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
7
8
|
import { mergedIssues } from './issues.js'
|
|
8
9
|
import { evalContext, evalTimeline } from '../../spec-yatsu/src/evaltab.js'
|
|
9
10
|
import { yatsuNodesAsync } from '../../spec-yatsu/src/yatsu.js'
|
|
@@ -109,7 +110,7 @@ export async function buildBoard() {
|
|
|
109
110
|
// empty absent a forge, so the fold then carries the local slice alone. Sorted open-first, newest first.
|
|
110
111
|
const isOpen = (i: { status: string }) => i.status === 'open'
|
|
111
112
|
const issuesByNode: Record<string, ReturnType<typeof mergedIssues>> = {}
|
|
112
|
-
for (const issue of mergedIssues({ host:
|
|
113
|
+
for (const issue of mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, nodes.map((n) => n.id)))
|
|
113
114
|
for (const nid of issue.nodes) (issuesByNode[nid] ??= []).push(issue)
|
|
114
115
|
for (const n of nodes) {
|
|
115
116
|
const issues = issuesByNode[n.id]
|
|
@@ -18,6 +18,18 @@ export type Board = Awaited<ReturnType<typeof buildBoard>>
|
|
|
18
18
|
// genuinely-degraded hot path shouts while an ordinary cold start stays quiet-ish.
|
|
19
19
|
const BUDGET_MS = Number(process.env.SPEXCODE_BOARD_BUDGET_MS || 1500)
|
|
20
20
|
|
|
21
|
+
// a build that NEVER settles is a different animal from a slow one: `inflight` clears only in the finally
|
|
22
|
+
// below, so a never-settling buildBoard() would pin the single-flight forever — every later read (even of a
|
|
23
|
+
// perfectly good cached board) short-circuits into the pinned promise before `valid` is consulted,
|
|
24
|
+
// invalidation can't help, no log ever fires, and only a restart cures it (the live wedge: hung git
|
|
25
|
+
// children → /api/board 503 forever, silently). So the build races a generous watchdog that REJECTS loudly;
|
|
26
|
+
// the rejection flows through the SAME finally → inflight clears → the next read retries fresh. Sitting at
|
|
27
|
+
// the single-flight boundary, this one wall bounds every never-settle cause — including ones with no child
|
|
28
|
+
// process at all (fs/promises under libuv threadpool starvation); git.ts's per-child timeouts merely make
|
|
29
|
+
// the common cause die sooner. Generous: well above the slowest legitimate cold build, so it only ever
|
|
30
|
+
// fires on a genuine wedge.
|
|
31
|
+
const BUILD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_BUILD_TIMEOUT_MS || 120000)
|
|
32
|
+
|
|
21
33
|
let cached: Board | null = null // last completed build; served while `valid`
|
|
22
34
|
let cachedJson: string | null = null // JSON.stringify(cached), serialized ONCE per build (see getBoardJson)
|
|
23
35
|
let valid = false
|
|
@@ -42,13 +54,27 @@ export function getBoard(): Promise<Board> {
|
|
|
42
54
|
const startGen = gen
|
|
43
55
|
const p = (async () => {
|
|
44
56
|
const t0 = Date.now()
|
|
57
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined
|
|
45
58
|
try {
|
|
46
|
-
const board = await
|
|
59
|
+
const board = await Promise.race([
|
|
60
|
+
buildBoard(),
|
|
61
|
+
// the race consumes the loser's eventual settlement, so an abandoned build that fails later
|
|
62
|
+
// can't surface as an unhandled rejection; unref'd so a pending watchdog never holds a one-shot
|
|
63
|
+
// CLI process open.
|
|
64
|
+
new Promise<never>((_, reject) => {
|
|
65
|
+
watchdog = setTimeout(() => {
|
|
66
|
+
console.warn(`spec-cli: /api/board build did not settle within ${BUILD_TIMEOUT_MS}ms — wedged build abandoned so the next read can retry`)
|
|
67
|
+
reject(new Error(`board build did not settle within ${BUILD_TIMEOUT_MS}ms`))
|
|
68
|
+
}, BUILD_TIMEOUT_MS)
|
|
69
|
+
watchdog.unref?.()
|
|
70
|
+
}),
|
|
71
|
+
])
|
|
47
72
|
cached = board
|
|
48
73
|
cachedJson = null // invalidate the memoized serialization; re-serialized lazily on first read
|
|
49
74
|
valid = gen === startGen
|
|
50
75
|
return board
|
|
51
76
|
} finally {
|
|
77
|
+
clearTimeout(watchdog)
|
|
52
78
|
const ms = Date.now() - t0
|
|
53
79
|
if (ms > BUDGET_MS) console.warn(`spec-cli: /api/board build took ${ms}ms (budget ${BUDGET_MS}ms) — hot path is slow`)
|
|
54
80
|
inflight = null
|
|
@@ -89,10 +89,11 @@ function fireChanged(): void {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
// ---- event source 0: an EXPLICIT server-side nudge ----
|
|
92
|
-
// for
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
92
|
+
// for a server-side mutation that must show instantly regardless of watcher health: /rename writes the
|
|
93
|
+
// session's global record (`session.json` — [[session-rename]]), which lives INSIDE the watched store, so
|
|
94
|
+
// source 1 normally sees the write too. The explicit route call stays because that fs watch is best-effort
|
|
95
|
+
// (it can fail to attach), and the nudge makes the sub-second rename guarantee deterministic. Same
|
|
96
|
+
// debounced funnel as every other source.
|
|
96
97
|
export const notifyBoardChanged = (): void => fireChanged()
|
|
97
98
|
|
|
98
99
|
// ---- event source 1: the session store (lifecycle status writes) ----
|
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', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth'])
|
|
55
|
+
const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--render', '--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++) {
|
|
@@ -66,7 +66,8 @@ function positionals(from: number): string[] {
|
|
|
66
66
|
// After a successful launch, nudge the caller to actually MONITOR the session — launch-then-forget is a real
|
|
67
67
|
// gap (a supervisor or human launches and then never watches, so a review/failure goes unnoticed). Goes to
|
|
68
68
|
// STDERR so the JSON on stdout (which callers parse) stays clean; keyed to whoever's calling — a supervising
|
|
69
|
-
// agent has an own-session id, a human at a terminal does not.
|
|
69
|
+
// agent has an own-session id, a human at a terminal does not. The hint also names the COMM channel
|
|
70
|
+
// (`spex send`) — field-tested gap: callers who couldn't find it reached for raw tmux keystrokes instead.
|
|
70
71
|
async function launchMonitorReminder(id: string): Promise<void> {
|
|
71
72
|
const { ownSessionId } = await import('./sessions.js')
|
|
72
73
|
const agent = ownSessionId()
|
|
@@ -78,6 +79,7 @@ async function launchMonitorReminder(id: string): Promise<void> {
|
|
|
78
79
|
} else {
|
|
79
80
|
console.error(` \`spex watch\` — the live stream of actionable session transitions (or \`spex wait ${id}\` to block on this one)`)
|
|
80
81
|
}
|
|
82
|
+
console.error(` talk to it: \`spex send ${id} "<msg>"\` — never raw tmux keystrokes`)
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
const greeted = new Set<string>()
|
|
@@ -163,6 +165,11 @@ if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
|
|
|
163
165
|
}
|
|
164
166
|
|
|
165
167
|
if (cmd === 'serve') {
|
|
168
|
+
// fail loud, not cryptic ([[platform-support]]): serve IS the entry to the session runtime, which needs a
|
|
169
|
+
// POSIX host (tmux/bash/unix-sockets). On a non-POSIX host (native Windows) point at WSL2 and exit here,
|
|
170
|
+
// before importing the supervisor spawns tsx into a downstream ENOENT.
|
|
171
|
+
const { assertSessionRuntime } = await import('./runtime-guard.js')
|
|
172
|
+
assertSessionRuntime()
|
|
166
173
|
// the supervisor owns the public port and runs index.ts as a child for zero-downtime reloads; it
|
|
167
174
|
// (not `tsx watch`) is what watches spec-cli/src, so the package `serve` script must NOT use --watch.
|
|
168
175
|
// --port is sugar over the PORT env supervise.ts reads — set BEFORE importing so it takes effect. This
|
|
@@ -197,7 +204,7 @@ if (cmd === 'serve') {
|
|
|
197
204
|
const { guideText } = await import('./guide.js')
|
|
198
205
|
const text = guideText(process.argv[3])
|
|
199
206
|
if (text === null) {
|
|
200
|
-
console.error(`spex guide: no topic '${process.argv[3]}'. Topics: spec, yatsu, config. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
|
|
207
|
+
console.error(`spex guide: no topic '${process.argv[3]}'. Topics: spec, yatsu, config, footprint. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
|
|
201
208
|
process.exit(2)
|
|
202
209
|
}
|
|
203
210
|
console.log(text)
|
|
@@ -272,9 +279,9 @@ if (cmd === 'serve') {
|
|
|
272
279
|
}
|
|
273
280
|
} else if (cmd === 'init') {
|
|
274
281
|
// scaffold a repo to adopt SpexCode: copy the shipped DATA templates (seed spec tree + git hooks)
|
|
275
|
-
// into <targetDir> (default cwd). spex init [targetDir]
|
|
282
|
+
// into <targetDir> (default cwd). spex init [targetDir] [--preset <tier>] [--render <word>]
|
|
276
283
|
const { specInit } = await import('./init.js')
|
|
277
|
-
await specInit(positionals(3)[0], flag('preset'))
|
|
284
|
+
await specInit(positionals(3)[0], flag('preset'), flag('render'))
|
|
278
285
|
} else if (cmd === 'uninstall') {
|
|
279
286
|
// the surgical inverse of init: remove every SpexCode-generated artifact (harness shims/contract/trust, the
|
|
280
287
|
// .gitignore block, the global store, any plugin bundle) — NEVER the user's .spec/.config data or their own
|
|
@@ -396,8 +403,12 @@ if (cmd === 'serve') {
|
|
|
396
403
|
} else if (cmd === 'materialize') {
|
|
397
404
|
// @@@ materialize - the pay-per-change render: surface nodes → manifest + AGENTS.md/CLAUDE.md block +
|
|
398
405
|
// shims + Codex trust, for cwd's project. The cheap shell gate (dispatch.sh) invokes it only on change.
|
|
399
|
-
|
|
406
|
+
// The adoption vote hint ([[render-policy]]) prints only here and on init — the human surfaces — never
|
|
407
|
+
// from the silent gate/bootstrap renders of the same function.
|
|
408
|
+
const { materialize, renderVoteHint } = await import('./materialize.js')
|
|
400
409
|
console.log(`materialized — content-hash ${materialize()}`)
|
|
410
|
+
const hint = renderVoteHint()
|
|
411
|
+
if (hint) console.log(`\n${hint}`)
|
|
401
412
|
} else if (cmd === 'doctor') {
|
|
402
413
|
// @@@ doctor - the diagnosis surface ([[doctor]], né `self` — renamed: "self" read as the tool itself /
|
|
403
414
|
// the global install, while the report is about THIS agent's wiring): does the materialized workflow
|
|
@@ -481,10 +492,16 @@ if (cmd === 'serve') {
|
|
|
481
492
|
intervalMs,
|
|
482
493
|
}), true) // greet=true: a stream watch greets its specific targets once; `wait` (one-shot) does not
|
|
483
494
|
} else if (cmd === 'wait') {
|
|
484
|
-
const { watchSessions } = await import('./sessions.js')
|
|
495
|
+
const { watchSessions, ownSessionId } = await import('./sessions.js')
|
|
485
496
|
const { clientListSessions } = await import('./client.js')
|
|
486
497
|
const [id] = positionals(3)
|
|
487
498
|
if (!id) { console.error('usage: spex wait <id> [--timeout SECONDS] [--interval SECONDS] [--idle]'); process.exit(2) }
|
|
499
|
+
// point-of-use turn-freeze warning ([[graph]]): a managed agent that runs this wait in the FOREGROUND
|
|
500
|
+
// freezes its whole turn until the target turns actionable — a warning that used to live only in help
|
|
501
|
+
// prose, now said where it matters. Foreground vs background is invisible from here, so the hint prints
|
|
502
|
+
// for ANY managed-agent shell (harmless in a background transcript), on stderr, and changes nothing else.
|
|
503
|
+
const own = ownSessionId()
|
|
504
|
+
if (own) console.error(`spex wait: heads-up (managed agent ${own.slice(0, 8)}) — this command BLOCKS until ${id} turns actionable; run it in the BACKGROUND or it freezes your whole turn (its exit is your wake-up). Proceeding.`)
|
|
488
505
|
const intervalMs = (Number(flag('interval')) || 2) * 1000
|
|
489
506
|
const timeoutSec = Number(flag('timeout')) || 1200
|
|
490
507
|
const r = await withWatchEdge([id], intervalMs, () => watchSessions(() => {}, {
|
|
@@ -500,12 +517,23 @@ if (cmd === 'serve') {
|
|
|
500
517
|
console.error(`spex wait: timeout — ${id} did not reach an actionable status within ${timeoutSec}s`)
|
|
501
518
|
process.exit(1)
|
|
502
519
|
} else if (cmd === 'new') {
|
|
503
|
-
// shorthand for `spex session new`: spex new "<prompt>" [--node X] (prompt = first positional or --prompt
|
|
520
|
+
// shorthand for `spex session new`: spex new "<prompt>" [--node X] (prompt = first positional or --prompt,
|
|
521
|
+
// or --prompt-file <path>|- so a long multi-paragraph prompt never fights shell quoting — [[prompt-file]]).
|
|
504
522
|
// createSession POSTs to the running backend so the launch runs in the backend's process (auth env + cap);
|
|
505
523
|
// it falls back to an in-process launch only when no backend answers.
|
|
506
524
|
const { createSession } = await import('./sessions.js')
|
|
507
525
|
if (has('harness')) { console.error('spex new: --harness was removed; use --launcher <name> (for example --launcher codex)'); process.exit(2) }
|
|
508
|
-
const
|
|
526
|
+
const promptFile = flag('prompt-file')
|
|
527
|
+
const inline = flag('prompt') ?? positionals(3)[0]
|
|
528
|
+
let prompt = inline ?? ''
|
|
529
|
+
if (promptFile !== undefined) {
|
|
530
|
+
// fail-loud exclusive: never silently pick one of two prompt sources.
|
|
531
|
+
if (inline !== undefined) { console.error('spex new: give the prompt either inline or via --prompt-file, not both'); process.exit(2) }
|
|
532
|
+
const { readFileSync } = await import('node:fs')
|
|
533
|
+
try { prompt = promptFile === '-' ? readFileSync(0, 'utf8') : readFileSync(promptFile, 'utf8') }
|
|
534
|
+
catch (e) { console.error(`spex new: --prompt-file ${promptFile}: ${e instanceof Error ? e.message : e}`); process.exit(2) }
|
|
535
|
+
if (!prompt.trim()) { console.error(`spex new: --prompt-file ${promptFile === '-' ? 'stdin' : promptFile} is empty — refusing a promptless launch`); process.exit(2) }
|
|
536
|
+
}
|
|
509
537
|
const nodeArg = flag('node')
|
|
510
538
|
const created = await createSession(nodeArg ? stripRefSigil(nodeArg) : null, prompt, flag('launcher') ?? undefined)
|
|
511
539
|
console.log(JSON.stringify(created, null, 2))
|
|
@@ -517,11 +545,34 @@ if (cmd === 'serve') {
|
|
|
517
545
|
// launch path). `c` (client.ts) backs the read/control subs that route through the backend. Lazily imported.
|
|
518
546
|
const s = await import('./sessions.js')
|
|
519
547
|
const c = await import('./client.js')
|
|
548
|
+
const l = await import('./layout.js')
|
|
549
|
+
const { existsSync, writeFileSync } = await import('node:fs')
|
|
520
550
|
const id = process.argv[4]
|
|
521
551
|
// the agent-authored state writers resolve WHICH session by id: a `--session <id>` flag (the lifecycle
|
|
522
552
|
// hooks pass it, parsed from the payload, since they no longer have a cwd `.session`) wins, else the
|
|
523
553
|
// harness env var (ownSessionId — the agent's own `spex session …` carries the harness session id).
|
|
524
554
|
const sess = flag('session')
|
|
555
|
+
// @@@ no-record diagnosis ([[state]]) - the session store resolves from the CURRENT directory (runtimeRoot
|
|
556
|
+
// ← the cwd's git common dir), so the classic declaration failure is a cd OUTSIDE the session's project —
|
|
557
|
+
// and a bare "no session record" told the author none of that (field-reported). Name the actual cause and
|
|
558
|
+
// route the fix; each branch is a distinct situation, distinguished by probing the same store the writer used.
|
|
559
|
+
const noRecord = (): string => {
|
|
560
|
+
// cwd probe FIRST: outside a git repo nothing below can resolve — not the store, and not even the env
|
|
561
|
+
// id (ownSessionId's alias walk reads the store too) — so diagnose the cwd before touching either.
|
|
562
|
+
let ids: string[] | null
|
|
563
|
+
try { ids = l.listSessionIds() } catch { ids = null }
|
|
564
|
+
if (ids === null) return `no session record — declarations resolve the session store from the CURRENT directory, and ${process.cwd()} is not inside a git repository. cd back into the session's worktree and re-declare.`
|
|
565
|
+
const wid = sess || s.ownSessionId() // safe now: the store just resolved, so the alias walk cannot throw
|
|
566
|
+
if (!wid) return 'no session record — no session id to write: this shell carries no harness session env (SPEXCODE_SESSION_ID / CLAUDE_CODE_SESSION_ID) and no --session was given. Pass --session <id> (spex ls lists ids).'
|
|
567
|
+
if (ids.length === 0) return `no session record for ${wid.slice(0, 8)} — declarations resolve the session store from the CURRENT directory, and the project at ${process.cwd()} has no sessions at all: you are in a different project's checkout. cd back into the session's worktree and re-declare.`
|
|
568
|
+
return `no session record for ${wid.slice(0, 8)} — this project's store (resolved from ${process.cwd()}) holds ${ids.length} session(s) but not this one: a wrong --session id, or you are declaring from a different project's checkout. cd back into the session's worktree and re-declare, or pass a valid --session <id> (spex ls).`
|
|
569
|
+
}
|
|
570
|
+
// a state writer from a non-repo cwd throws git's not-a-repo before it can return false — map exactly
|
|
571
|
+
// that throw to the no-record path (noRecord re-probes and names the cwd); anything else stays loud.
|
|
572
|
+
const mark = (fn: () => boolean): boolean => {
|
|
573
|
+
try { return fn() }
|
|
574
|
+
catch (e) { if (/not a git repository/i.test(String((e as any)?.stderr ?? e))) return false; throw e }
|
|
575
|
+
}
|
|
525
576
|
// appended to a done/ask/block declaration: states (not commands) that the next tool call's mark-active hook re-flips the global record to active, so a re-read won't show this.
|
|
526
577
|
const DECLARED = ' — recorded; the human sees it in the dashboard. This state lives in your session\'s global record; your next tool call flips that record back to active (the mark-active hook, by design), so it is normal for this declaration not to persist.'
|
|
527
578
|
// appended ONLY to a propose-close declaration: a worktree about to be discarded may still own ephemeral things the agent started to test this change; nudge (not gate) it to reclaim them before the worktree goes, keyed on whether the thing should outlive the task — never on who started it (a deliberately long-running service / a production build is started-by-you yet must be left alone). Project-agnostic on purpose.
|
|
@@ -529,10 +580,22 @@ if (cmd === 'serve') {
|
|
|
529
580
|
// truncation transparency ([[state]]): the board table shows only the first NOTE_BOARD_LIMIT chars of a
|
|
530
581
|
// note. When a declared note overflows that cap, the confirmation says so — length, what the board shows,
|
|
531
582
|
// where the full text is readable — so the cut is visible to the author instead of silently eaten.
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
583
|
+
// Taught ONCE per session: the first overflowing note prints the full notice and drops a sentinel beside
|
|
584
|
+
// the record; later overflows stay silent (the rule was taught — a verbatim repeat on every park/ask is
|
|
585
|
+
// noise, field-reported). A nudge riding the echo, never a gate: the declaration has already landed.
|
|
586
|
+
const noteEcho = (note?: string): string => {
|
|
587
|
+
if (!note || note.length <= s.NOTE_BOARD_LIMIT) return ''
|
|
588
|
+
const wid = sess || s.ownSessionId()
|
|
589
|
+
const rid = wid ? (l.readAliasedRawRecord(wid)?.session_id ?? wid) : null // sentinel lives in the RECORD's dir, so an aliased codex id lands on the same file
|
|
590
|
+
if (rid) {
|
|
591
|
+
const sentinel = l.sessionArtifactPath(rid, 'note-echo-taught')
|
|
592
|
+
try {
|
|
593
|
+
if (existsSync(sentinel)) return ''
|
|
594
|
+
writeFileSync(sentinel, `${new Date().toISOString()}\n`) // only reached on a successful declaration (the echo rides the success branch)
|
|
595
|
+
} catch { /* unreadable/unwritable store dir → fall through and teach again; never block the echo */ }
|
|
596
|
+
}
|
|
597
|
+
return `\nyour note is ${note.length} chars; the board table shows only the first ${s.NOTE_BOARD_LIMIT} — the full text IS recorded, and readable via spex review ${(wid || '<your-session>').slice(0, 8)} / spex ls --json. (said once — later long notes won't repeat this.)`
|
|
598
|
+
}
|
|
536
599
|
if (sub === 'reopen') {
|
|
537
600
|
// bring the agent back up (relaunch ONLY if confirmed offline, the backend owns it); demotes a working
|
|
538
601
|
// `active` to idle but leaves a standing declaration/proposal untouched (see sessions.ts reopen()). The
|
|
@@ -545,8 +608,8 @@ if (cmd === 'serve') {
|
|
|
545
608
|
} else if (sub === 'state') {
|
|
546
609
|
// the agent authors ITS OWN state: active|awaiting|parked|error [--propose] [--note] [--session]
|
|
547
610
|
const st = process.argv[4] as any
|
|
548
|
-
const ok = s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess })
|
|
549
|
-
console.log(ok ? `state -> ${st}${noteEcho(flag('note'))}` :
|
|
611
|
+
const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
|
|
612
|
+
console.log(ok ? `state -> ${st}${noteEcho(flag('note'))}` : noRecord())
|
|
550
613
|
} else if (sub === 'done') {
|
|
551
614
|
// sugar for awaiting; --propose merge|nothing|close, optional --note
|
|
552
615
|
const p = (flag('propose') as any) || 'nothing'
|
|
@@ -558,18 +621,18 @@ if (cmd === 'serve') {
|
|
|
558
621
|
try { closeNote += (await import('./localIssues.js')).closeoutNudge(sess ?? s.ownSessionId()) }
|
|
559
622
|
catch (e) { console.error(`issue closeout check failed (declaration unaffected): ${e instanceof Error ? e.message : e}`) }
|
|
560
623
|
}
|
|
561
|
-
console.log(s.markDone(p, sess, flag('note')) ? `done (${p})${DECLARED}${noteEcho(flag('note'))}${closeNote}` :
|
|
624
|
+
console.log(mark(() => s.markDone(p, sess, flag('note'))) ? `done (${p})${DECLARED}${noteEcho(flag('note'))}${closeNote}` : noRecord())
|
|
562
625
|
} else if (sub === 'park') {
|
|
563
626
|
// sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
|
|
564
|
-
console.log(s.markState('parked', { note: flag('note'), sessionId: sess }) ? `parked${DECLARED}${noteEcho(flag('note'))}` :
|
|
627
|
+
console.log(mark(() => s.markState('parked', { note: flag('note'), sessionId: sess })) ? `parked${DECLARED}${noteEcho(flag('note'))}` : noRecord())
|
|
565
628
|
} else if (sub === 'fail') {
|
|
566
629
|
// the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
|
|
567
|
-
console.log(s.markError(sess) ? 'marked error' :
|
|
630
|
+
console.log(mark(() => s.markError(sess)) ? 'marked error' : noRecord())
|
|
568
631
|
} else if (sub === 'ask') {
|
|
569
632
|
// the agent DELIBERATELY declares it is pausing to ask the human a question (like `done`/`park`, an
|
|
570
633
|
// authored state — NOT guarded active-only). The --note carries the question. Distinct from `park`
|
|
571
634
|
// (waiting on a background task, self-resumes): an asking agent resumes only when the human replies.
|
|
572
|
-
console.log(s.markState('asking', { note: flag('note'), sessionId: sess }) ? `asking${DECLARED}${noteEcho(flag('note'))}` :
|
|
635
|
+
console.log(mark(() => s.markState('asking', { note: flag('note'), sessionId: sess })) ? `asking${DECLARED}${noteEcho(flag('note'))}` : noRecord())
|
|
573
636
|
} else if (sub === 'commit-gate') {
|
|
574
637
|
// the Stop gate's deterministic commit check (from cwd = the worktree): exit 0 if the node branch is
|
|
575
638
|
// ready to declare done/merge (work committed + ahead of main), else print the reason and exit 1. Uses
|
|
@@ -0,0 +1,154 @@
|
|
|
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 render=hidden answer for a MIXED-CONTENT contract file: a
|
|
8
|
+
// CLAUDE.md/AGENTS.md the HOST ALREADY TRACKS, where "generate + ignore" is a no-op (git ignores only
|
|
9
|
+
// untracked paths) and the folded-in block would ride the tracked file into every teammate's diff. A git
|
|
10
|
+
// clean/smudge content filter keeps the two contents on their own sides of the index: the REPO stores the
|
|
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, exactly the hidden contract. 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 for a file the host tracks (the weakest sufficient tool: an untracked contract file is wholly
|
|
17
|
+
// ours and plain generate+ignore already hides it — no filter). materialize plants/refreshes it under
|
|
18
|
+
// render=hidden and erases it under every other policy ([[render-policy]] / the forgetting law).
|
|
19
|
+
|
|
20
|
+
// the three field-sharpened edges this module owes ([[content-filter]]):
|
|
21
|
+
// ① the configured command points at a STABLE shim path and degrades to `cat` (identity) when the shim is
|
|
22
|
+
// missing — a bare missing filter command makes git spray "cannot fork" fatals on EVERY operation;
|
|
23
|
+
// ② a changed contract does NOT propagate by itself (git re-smudges only on checkout) — materialize's
|
|
24
|
+
// re-render writes the managed block straight into the working file (writeManagedBlock IS the re-smudge),
|
|
25
|
+
// and this module refreshes the block file the shim reads so future checkouts agree;
|
|
26
|
+
// ③ unplanting must strip the block from the WORKING FILES before the config goes away, or the block
|
|
27
|
+
// residue surfaces as an uncommitted modification — the caller (dematerialize) removes the managed
|
|
28
|
+
// blocks first and only then calls removeContractFilter.
|
|
29
|
+
|
|
30
|
+
function commonDirOf(proj: string): string {
|
|
31
|
+
return git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
32
|
+
}
|
|
33
|
+
const filterDir = (common: string) => join(common, 'spexcode')
|
|
34
|
+
const shimPath = (common: string) => join(filterDir(common), 'contract-filter.sh')
|
|
35
|
+
const blockPath = (common: string) => join(filterDir(common), 'contract-block.md')
|
|
36
|
+
const attributesPath = (common: string) => join(common, 'info', 'attributes')
|
|
37
|
+
|
|
38
|
+
// the per-clone shim both filter directions run through. Pure shell/awk (no node boot on git's hot path),
|
|
39
|
+
// mirroring writeManagedBlock/removeManagedBlock's normalization exactly so the two writers agree:
|
|
40
|
+
// clean : drop the sentinel block (+ the blank line smudge printed before it) → the pristine host prose.
|
|
41
|
+
// smudge: clean first (defensive — a block already in the index can never double-inject), then append one
|
|
42
|
+
// blank line + START + the contract-block file's content + END.
|
|
43
|
+
// Byte-exactness holds for text ending in exactly one newline (git's own well-formed-text shape); a pristine
|
|
44
|
+
// file ending in zero or 2+ newlines is normalized to one on the first round-trip and stable after.
|
|
45
|
+
const SHIM = `#!/usr/bin/env bash
|
|
46
|
+
# spexcode contract filter (generated by spex materialize — render=hidden; see [[content-filter]]).
|
|
47
|
+
# clean(smudge(x)) == x: the repo keeps the pristine host prose, the working tree carries prose + block.
|
|
48
|
+
set -u
|
|
49
|
+
mode="\${1:?usage: contract-filter.sh smudge|clean}"
|
|
50
|
+
here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
51
|
+
block="$here/contract-block.md"
|
|
52
|
+
strip() {
|
|
53
|
+
awk 'BEGIN { n = 0 }
|
|
54
|
+
{ lines[n++] = $0 }
|
|
55
|
+
END {
|
|
56
|
+
s = -1; e = -1
|
|
57
|
+
for (i = 0; i < n; i++) {
|
|
58
|
+
if (lines[i] == "<!-- spexcode:start -->" && s < 0) s = i
|
|
59
|
+
if (lines[i] == "<!-- spexcode:end -->" && s >= 0 && e < 0) e = i
|
|
60
|
+
}
|
|
61
|
+
if (s >= 0 && e >= s) {
|
|
62
|
+
a = s; while (a > 0 && lines[a-1] == "") a--
|
|
63
|
+
b = e; while (b + 1 < n && lines[b+1] == "") b++
|
|
64
|
+
j = 0
|
|
65
|
+
for (i = 0; i < n; i++) if (i < a || i > b) lines[j++] = lines[i]
|
|
66
|
+
n = j
|
|
67
|
+
}
|
|
68
|
+
# NO leading-blank strip: dropping a..b (block + its surrounding blanks) can never CREATE a leading
|
|
69
|
+
# blank, and a host file that BEGINS with blank lines must keep them — clean(smudge(x)) == x.
|
|
70
|
+
for (i = 0; i < n; i++) print lines[i]
|
|
71
|
+
}'
|
|
72
|
+
}
|
|
73
|
+
case "$mode" in
|
|
74
|
+
clean) strip ;;
|
|
75
|
+
smudge)
|
|
76
|
+
if [ ! -r "$block" ]; then cat; exit 0; fi # no block content → identity (graceful, never fatal)
|
|
77
|
+
strip | awk -v b="$block" 'BEGIN { n = 0 }
|
|
78
|
+
{ lines[n++] = $0 }
|
|
79
|
+
END {
|
|
80
|
+
while (n > 0 && lines[n-1] == "") n-- # trim trailing blanks (writeManagedBlock parity)
|
|
81
|
+
for (i = 0; i < n; i++) print lines[i]
|
|
82
|
+
if (n > 0) print ""
|
|
83
|
+
print "<!-- spexcode:start -->"
|
|
84
|
+
while ((getline l < b) > 0) print l
|
|
85
|
+
print "<!-- spexcode:end -->"
|
|
86
|
+
}' ;;
|
|
87
|
+
*) echo "contract-filter.sh: unknown mode $mode" >&2; exit 1 ;;
|
|
88
|
+
esac
|
|
89
|
+
`
|
|
90
|
+
|
|
91
|
+
// edge ①: the command git runs is a tolerant wrapper — the shim path is an ARGUMENT ($0), and a missing/
|
|
92
|
+
// unreadable shim degrades to `cat` (identity) instead of a per-operation fatal.
|
|
93
|
+
const filterCmd = (shim: string, mode: 'smudge' | 'clean') =>
|
|
94
|
+
`sh -c 'test -r "$0" && exec bash "$0" ${mode} || exec cat' '${shim.replace(/'/g, `'\\''`)}'`
|
|
95
|
+
|
|
96
|
+
// plant (or refresh) the filter for the given TRACKED contract files: the shim + the block content it
|
|
97
|
+
// smudges, the per-clone git config, and the attribute lines binding each file to the filter. Idempotent —
|
|
98
|
+
// every write is a full replace. `contract` is the assembled block body (guide + surface:system).
|
|
99
|
+
export function plantContractFilter(proj: string, trackedFiles: string[], contract: string): void {
|
|
100
|
+
const common = commonDirOf(proj)
|
|
101
|
+
mkdirSync(filterDir(common), { recursive: true })
|
|
102
|
+
writeFileSync(blockPath(common), contract.endsWith('\n') ? contract : `${contract}\n`) // edge ②: the shim's smudge source, refreshed with the render
|
|
103
|
+
writeFileSync(shimPath(common), SHIM)
|
|
104
|
+
chmodSync(shimPath(common), 0o755)
|
|
105
|
+
git(['-C', proj, 'config', 'filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')])
|
|
106
|
+
git(['-C', proj, 'config', 'filter.spexcode.clean', filterCmd(shimPath(common), 'clean')])
|
|
107
|
+
// attribute patterns are checkout-relative, so one line serves the main checkout and every worktree.
|
|
108
|
+
const entries = trackedFiles.map((f) => `/${relative(proj, f)} filter=spexcode`).sort().join('\n')
|
|
109
|
+
mkdirSync(join(common, 'info'), { recursive: true })
|
|
110
|
+
writeManagedBlock(attributesPath(common), entries, ['# ', ''])
|
|
111
|
+
settleIndexStat(proj, trackedFiles)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// settle the index STAT for each file — the famous filtered-path phantom-`M`: git cannot verify a
|
|
115
|
+
// clean-filtered path by stat alone (worktree size ≠ blob size by design), and `git status` reports such an
|
|
116
|
+
// entry modified FOREVER without ever content-checking it (field-verified on git 2.43; `git diff` meanwhile
|
|
117
|
+
// runs the filter and shows nothing; even `update-index --really-refresh` leaves it). The block-strip side of
|
|
118
|
+
// a mode switch/backout leaves the same stale stat on the then-unfiltered path. `git add --renormalize`
|
|
119
|
+
// re-cleans the file and refreshes the cached stat — run ONLY when the (possibly filtered) worktree already
|
|
120
|
+
// EQUALS the index blob, so it is a pure stat refresh that can never stage a user's real unstaged edit (a
|
|
121
|
+
// genuine edit keeps its honest `M`). Exported for dematerialize (the unplant side); plant calls it below.
|
|
122
|
+
// Best-effort: an unsettled stat is cosmetic noise, never corruption.
|
|
123
|
+
export function settleIndexStat(proj: string, files: string[]): void {
|
|
124
|
+
const env = { ...process.env }
|
|
125
|
+
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
126
|
+
for (const f of files) {
|
|
127
|
+
const rel = relative(proj, f)
|
|
128
|
+
try {
|
|
129
|
+
const indexBlob = git(['-C', proj, 'rev-parse', `:${rel}`]).trim()
|
|
130
|
+
const filtered = execFileSync('git', ['-C', proj, 'hash-object', '--path', rel, '--stdin'],
|
|
131
|
+
{ input: readFileSync(f), env, stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim()
|
|
132
|
+
if (indexBlob === filtered) git(['-C', proj, 'add', '--renormalize', '--', rel])
|
|
133
|
+
} catch { /* best-effort */ }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
|
|
138
|
+
// config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
|
|
139
|
+
// (yatsu blobs), so only OUR two files go, never the dir.
|
|
140
|
+
export function removeContractFilter(proj: string): void {
|
|
141
|
+
let common: string
|
|
142
|
+
try { common = commonDirOf(proj) } catch { return } // not a git repo → nothing was ever planted
|
|
143
|
+
removeManagedBlock(attributesPath(common), ['# ', ''], true)
|
|
144
|
+
for (const key of ['filter.spexcode.smudge', 'filter.spexcode.clean']) {
|
|
145
|
+
try { git(['-C', proj, 'config', '--unset', key]) } catch { /* not set — already clean */ }
|
|
146
|
+
}
|
|
147
|
+
rmSync(shimPath(common), { force: true })
|
|
148
|
+
rmSync(blockPath(common), { force: true })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// is the filter currently planted? (the assert-side probe tests use; cheap: one config read)
|
|
152
|
+
export function contractFilterPlanted(proj: string): boolean {
|
|
153
|
+
try { return git(['-C', proj, 'config', 'filter.spexcode.clean']).trim().length > 0 } catch { return false }
|
|
154
|
+
}
|
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
|
}
|