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.
Files changed (58) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +13 -10
  4. package/spec-cli/hooks/harness.sh +32 -6
  5. package/spec-cli/src/board.ts +2 -1
  6. package/spec-cli/src/boardCache.ts +27 -1
  7. package/spec-cli/src/boardStream.ts +5 -4
  8. package/spec-cli/src/cli.ts +82 -19
  9. package/spec-cli/src/contract-filter.ts +154 -0
  10. package/spec-cli/src/git.ts +18 -4
  11. package/spec-cli/src/guide.ts +91 -38
  12. package/spec-cli/src/harness.ts +18 -4
  13. package/spec-cli/src/help.ts +16 -7
  14. package/spec-cli/src/index.ts +9 -6
  15. package/spec-cli/src/init.ts +52 -10
  16. package/spec-cli/src/issues.ts +7 -5
  17. package/spec-cli/src/layout.ts +20 -8
  18. package/spec-cli/src/lint.ts +19 -0
  19. package/spec-cli/src/localIssues.ts +16 -4
  20. package/spec-cli/src/materialize.ts +218 -124
  21. package/spec-cli/src/ranker.ts +25 -8
  22. package/spec-cli/src/runtime-guard.ts +44 -0
  23. package/spec-cli/src/search.bench.mjs +15 -5
  24. package/spec-cli/src/sessions.ts +81 -12
  25. package/spec-cli/src/specs.ts +29 -14
  26. package/spec-cli/src/supervise.ts +2 -2
  27. package/spec-cli/src/tsx-bin.ts +6 -8
  28. package/spec-cli/src/uninstall.ts +15 -17
  29. package/spec-cli/src/worktree-sources.ts +56 -0
  30. package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
  31. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
  32. package/spec-dashboard/dist/assets/Dashboard-CMRJGfYI.js +27 -0
  33. package/spec-dashboard/dist/assets/EvalsPage-Dj2mxcfW.js +2 -0
  34. package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-BfNpeyRQ.js} +1 -1
  35. package/spec-dashboard/dist/assets/IssuesPage-DfY315kt.js +1 -0
  36. package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-BGdC0A0P.js} +1 -1
  37. package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-BOBCAR0t.js} +5 -5
  38. package/spec-dashboard/dist/assets/SessionWindow-D7YmjV0i.js +9 -0
  39. package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-BlSNmpH_.js} +1 -1
  40. package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BFdzpd_O.js} +2 -2
  41. package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-forge/src/cli.ts +2 -2
  44. package/spec-forge/src/drivers/gitlab.ts +168 -0
  45. package/spec-forge/src/drivers.ts +80 -2
  46. package/spec-forge/src/resident.ts +10 -5
  47. package/spec-yatsu/src/cli.ts +37 -16
  48. package/spec-yatsu/src/evaltab.ts +6 -3
  49. package/spec-yatsu/src/filing.ts +13 -8
  50. package/spec-yatsu/src/freshness.ts +97 -22
  51. package/spec-yatsu/src/proof.ts +14 -3
  52. package/spec-yatsu/src/scenariofresh.ts +38 -11
  53. package/spec-yatsu/src/yatsu.ts +52 -28
  54. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
  55. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
  56. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
  57. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
  58. 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. (All screenshots below are this very repo on its own board.)
17
+ implementation from drifting apart.
18
18
 
19
- [![This repo's spec tree, grown from its own git history](docs/spec-tree-hero.gif)](https://spexcode.net/assets/spec-tree-growth.mp4)
19
+ [![Watch: this repo's spec tree grow from its git history](docs/spec-tree-poster.jpg)](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",
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",
@@ -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
- // tsx lives in spec-cli/node_modules in the dev monorepo, but npm may hoist it above the installed
39
- // `spexcode` package in a real consumer project. Try local candidates first, then let Node resolve upward
40
- // from spec-cli so project-local and global installs work the same way.
41
- const tsxCandidates = [join(pkg, 'node_modules', '.bin', 'tsx'), join(pkg, '..', 'node_modules', '.bin', 'tsx')]
42
- function resolveTsx() {
43
- const local = tsxCandidates.find(existsSync)
44
- if (local) return local
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
- return tsxCandidates[0]
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 tsx = resolveTsx()
53
- spawn(tsx, [cli, ...process.argv.slice(2)], { stdio: 'inherit' })
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 deterministic content fingerprint of the EDITABLE config roots (.config + config md/sh) the gate's
103
- # "did the .config move?" signal. Run with cwd = the project. ONE definition: the dispatch.sh gate sources this
104
- # and materialize.ts shells to it, so the gate and the renderer can NEVER disagree on what "changed" means (the
105
- # two used to inline this pipeline verbatim, each commenting the other "MUST match").
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
- find .spec/*/.config .spec/*/config \( -name '*.md' -o -name '*.sh' \) -type f -print0 2>/dev/null \
108
- | sort -z | xargs -0 cat 2>/dev/null | sha256sum | cut -d' ' -f1
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).
@@ -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: 'github', state: residentForgeState() }, nodes.map((n) => n.id)))
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 buildBoard()
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 the one mutation the watchers structurally cannot see: /rename writes the worktree's `.session`
93
- // (the name's ONE home, outside the watched store — [[session-rename]]). The route calls this instead of
94
- // double-writing the name into the store: a notification problem gets a notification solution, never a
95
- // second data home. Same debounced funnel as every other source.
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) ----
@@ -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
- const { materialize } = await import('./materialize.js')
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 prompt = flag('prompt') ?? positionals(3)[0] ?? ''
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
- // A nudge riding the echo, never a gate: the declaration has already landed.
533
- const noteEcho = (note?: string) => (note && note.length > s.NOTE_BOARD_LIMIT)
534
- ? `\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 ${(sess || s.ownSessionId() || '<your-session>').slice(0, 8)} / spex ls --json.`
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'))}` : 'no session record (unknown --session / no CLAUDE_CODE_SESSION_ID, or bad status)')
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}` : 'no session record')
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'))}` : 'no session record')
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' : 'no session record')
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'))}` : 'no session record')
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
+ }
@@ -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
- return execFileSync('git', args, { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] })
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
  }