@ngockhoale/ukit 3.4.21 → 3.4.23
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/CHANGELOG.md +27 -0
- package/manifests/documentation.yaml +11 -0
- package/manifests/platform.full.yaml +42 -0
- package/package.json +1 -1
- package/src/core/docContracts.js +60 -6
- package/src/core/hookChainDoctor.js +39 -3
- package/template_project/.claude/.gitattributes +2 -0
- package/template_project/.claude/agents/feature-implementer.md +10 -3
- package/template_project/.claude/commands/ukit/handoff-clear.md +27 -12
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +65 -32
- package/template_project/.claude/commands/ukit/handoff-implement.md +44 -23
- package/template_project/.claude/hooks/handoff-resume.sh +36 -11
- package/template_project/.claude/hooks/protect-files.sh +10 -2
- package/template_project/.claude/hooks/vision-router.sh +11 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +418 -0
- package/template_project/.claude/ukit/index/extract-image.mjs +77 -22
- package/template_project/.claude/ukit/index/land-worktree.mjs +543 -0
- package/template_project/.claude/ukit/index/stale-spec-check.mjs +48 -1
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +86 -28
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +143 -6
- package/template_project/.claude/ukit/runtime/hook-process.mjs +120 -4
- package/template_project/.claude/ukit/runtime/output-compression.mjs +46 -20
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +16 -2
- package/template_project/.omp/agents/feature-implementer.md +10 -3
- package/template_project/.omp/hooks/pre/ukit-bridge.js +12 -0
- package/template_project/docs/UKIT_INTERNALS.md +14 -2
|
@@ -18,15 +18,29 @@
|
|
|
18
18
|
// assumes group semantics.
|
|
19
19
|
//
|
|
20
20
|
// Contract (TASK-016 Interfaces):
|
|
21
|
-
// runHookProcess({command, args, input, deadlineMs, maxBuffer, ...}) ->
|
|
22
|
-
// Promise<{code, signal, stdout, stderr, failureKind, elapsedMs}>
|
|
21
|
+
// runHookProcess({command, args, input, deadlineMs, maxBuffer, spawnImpl, ...}) ->
|
|
22
|
+
// Promise<{code, signal, stdout, stderr, failureKind, elapsedMs, spawnFailed, errorCode}>
|
|
23
23
|
// failureKind: 'none' | 'deadline' | 'signal' | 'overflow' | 'error'
|
|
24
24
|
// 'deadline' — this runner killed the child because the deadline expired
|
|
25
25
|
// 'signal' — the child died from a signal this runner did not send
|
|
26
26
|
// 'overflow' — captured output exceeded maxBuffer; the tree was culled
|
|
27
27
|
// 'error' — the child never spawned (ENOENT and friends)
|
|
28
|
+
// spawnFailed: true only when the process was NEVER started — derived from child
|
|
29
|
+
// event order (an 'error' with no preceding 'spawn' event, or spawn() throwing
|
|
30
|
+
// synchronously), never from timing. An 'error' after 'spawn' is not a spawn failure.
|
|
31
|
+
// errorCode: the error's `code` (ENOENT, EINVAL, EPIPE, ...) or null.
|
|
32
|
+
// spawnImpl: injectable spawn function (default child_process.spawn) for tests.
|
|
33
|
+
//
|
|
34
|
+
// Windows .sh launching (resolveHookCommand / resolveWindowsBash):
|
|
35
|
+
// Windows cannot exec a `.sh` file directly, so on win32 a `.sh` hook is launched
|
|
36
|
+
// through an ABSOLUTE Git Bash path (never a bare `bash`, which Windows would look up
|
|
37
|
+
// in the child cwd first). Script paths are passed with forward slashes. POSIX spawns
|
|
38
|
+
// the script directly, unchanged. UKIT_HOOK_SHELL_PLATFORM overrides the platform seen
|
|
39
|
+
// by resolveHookCommand only (tests/diagnostics); UKIT_BASH_BIN overrides the bash path.
|
|
28
40
|
|
|
29
41
|
import { spawn } from 'node:child_process';
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
30
44
|
|
|
31
45
|
// POSIX only. Windows kill() targets the direct child; process groups do not
|
|
32
46
|
// exist there, so pretending otherwise would be a silent, wrong assumption.
|
|
@@ -41,6 +55,93 @@ export const KILL_GRACE_MS = 500;
|
|
|
41
55
|
// runner past its own budget.
|
|
42
56
|
const HARD_SETTLE_SLACK_MS = 2000;
|
|
43
57
|
|
|
58
|
+
// Error codes meaning "the launcher itself could not be started".
|
|
59
|
+
export const SPAWN_UNAVAILABLE_CODES = new Set([
|
|
60
|
+
'ENOENT',
|
|
61
|
+
'EINVAL',
|
|
62
|
+
'EACCES',
|
|
63
|
+
'EPERM',
|
|
64
|
+
'ENOEXEC',
|
|
65
|
+
'EFTYPE',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/** Windows path -> forward slashes, so bash's dirname/$0 logic works. */
|
|
69
|
+
export function toBashPath(p) {
|
|
70
|
+
return String(p).replace(/\\/g, '/');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const win32 = path.win32;
|
|
74
|
+
|
|
75
|
+
function safeExists(exists, candidate) {
|
|
76
|
+
try {
|
|
77
|
+
return Boolean(exists(candidate));
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Locate Git Bash on Windows. Returns an absolute path or null — never a bare
|
|
85
|
+
* or relative name (Windows resolves bare names in the child cwd first, which
|
|
86
|
+
* would let a planted bash.exe in a cloned repo run).
|
|
87
|
+
*/
|
|
88
|
+
export function resolveWindowsBash({ env = process.env, exists = fs.existsSync } = {}) {
|
|
89
|
+
const override = env.UKIT_BASH_BIN;
|
|
90
|
+
if (typeof override === 'string' && override && win32.isAbsolute(override)) return override;
|
|
91
|
+
|
|
92
|
+
const probe = (candidate) =>
|
|
93
|
+
typeof candidate === 'string' &&
|
|
94
|
+
candidate !== '' &&
|
|
95
|
+
win32.isAbsolute(candidate) &&
|
|
96
|
+
safeExists(exists, candidate);
|
|
97
|
+
|
|
98
|
+
const claude = env.CLAUDE_CODE_GIT_BASH_PATH;
|
|
99
|
+
if (probe(claude)) return claude;
|
|
100
|
+
|
|
101
|
+
const defaults = [
|
|
102
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
103
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
104
|
+
];
|
|
105
|
+
if (typeof env.LOCALAPPDATA === 'string' && env.LOCALAPPDATA) {
|
|
106
|
+
defaults.push(win32.join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'));
|
|
107
|
+
}
|
|
108
|
+
for (const candidate of defaults) {
|
|
109
|
+
if (probe(candidate)) return candidate;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const trimSlash = (p) => p.replace(/\\+$/, '').toLowerCase();
|
|
113
|
+
const system32 = trimSlash(win32.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32'));
|
|
114
|
+
const rawPath = env.PATH ?? env.Path ?? '';
|
|
115
|
+
for (const rawEntry of String(rawPath).split(';')) {
|
|
116
|
+
const entry = rawEntry.trim().replace(/^"+|"+$/g, '').trim();
|
|
117
|
+
if (!entry || !win32.isAbsolute(entry)) continue;
|
|
118
|
+
const norm = trimSlash(win32.normalize(entry));
|
|
119
|
+
if (norm === system32 || norm.endsWith('\\microsoft\\windowsapps')) continue;
|
|
120
|
+
const candidate = win32.join(entry, 'bash.exe');
|
|
121
|
+
if (safeExists(exists, candidate)) return candidate;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Decide how to launch a hook script. win32 + `.sh` -> Git Bash (command may be
|
|
128
|
+
* null when none was found); everything else spawns the script directly.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveHookCommand(
|
|
131
|
+
scriptPath,
|
|
132
|
+
{ env = process.env, platform = env.UKIT_HOOK_SHELL_PLATFORM || process.platform, exists = fs.existsSync } = {},
|
|
133
|
+
) {
|
|
134
|
+
if (typeof scriptPath !== 'string') return { command: '', args: [], viaBash: false };
|
|
135
|
+
if (platform === 'win32' && /\.sh$/i.test(scriptPath)) {
|
|
136
|
+
return {
|
|
137
|
+
command: resolveWindowsBash({ env, exists }),
|
|
138
|
+
args: [toBashPath(scriptPath)],
|
|
139
|
+
viaBash: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return { command: scriptPath, args: [], viaBash: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
44
145
|
/**
|
|
45
146
|
* Signal a process GROUP by pid (negative-pid kill). Swallows ESRCH — the
|
|
46
147
|
* child may have exited between arming the timer and this call, or the pid may
|
|
@@ -73,18 +174,20 @@ export function runHookProcess({
|
|
|
73
174
|
cwd,
|
|
74
175
|
env = process.env,
|
|
75
176
|
graceMs = KILL_GRACE_MS,
|
|
177
|
+
spawnImpl = spawn,
|
|
76
178
|
}) {
|
|
77
179
|
return new Promise((resolve) => {
|
|
78
180
|
const startedAt = Date.now();
|
|
79
181
|
let child;
|
|
80
182
|
try {
|
|
81
|
-
child =
|
|
183
|
+
child = spawnImpl(command, args, {
|
|
82
184
|
cwd,
|
|
83
185
|
env,
|
|
84
186
|
// Detached ONLY on POSIX: the child becomes a group leader so the whole
|
|
85
187
|
// tree dies together. Windows never gets a group assumption.
|
|
86
188
|
detached: GROUP_KILL_SUPPORTED,
|
|
87
189
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
190
|
+
windowsHide: true,
|
|
88
191
|
});
|
|
89
192
|
} catch (err) {
|
|
90
193
|
resolve({
|
|
@@ -94,6 +197,8 @@ export function runHookProcess({
|
|
|
94
197
|
stderr: String(err?.message || err),
|
|
95
198
|
failureKind: 'error',
|
|
96
199
|
elapsedMs: Date.now() - startedAt,
|
|
200
|
+
spawnFailed: true,
|
|
201
|
+
errorCode: err?.code ?? null,
|
|
97
202
|
});
|
|
98
203
|
return;
|
|
99
204
|
}
|
|
@@ -104,6 +209,8 @@ export function runHookProcess({
|
|
|
104
209
|
let hardSettled = false;
|
|
105
210
|
let overflowed = false;
|
|
106
211
|
let spawnError = null;
|
|
212
|
+
let spawned = false;
|
|
213
|
+
let spawnFailed = false;
|
|
107
214
|
let stdoutBuf = Buffer.alloc(0);
|
|
108
215
|
let stderrBuf = Buffer.alloc(0);
|
|
109
216
|
const stdoutRef = { value: stdoutBuf };
|
|
@@ -146,6 +253,8 @@ export function runHookProcess({
|
|
|
146
253
|
stderr: stderrRef.value.toString('utf8'),
|
|
147
254
|
failureKind,
|
|
148
255
|
elapsedMs: Date.now() - startedAt,
|
|
256
|
+
spawnFailed,
|
|
257
|
+
errorCode: spawnError?.code ?? null,
|
|
149
258
|
});
|
|
150
259
|
};
|
|
151
260
|
|
|
@@ -238,9 +347,16 @@ export function runHookProcess({
|
|
|
238
347
|
}, deadlineMs + Math.max(0, graceMs) + HARD_SETTLE_SLACK_MS);
|
|
239
348
|
}
|
|
240
349
|
|
|
350
|
+
// Event order, not timing, tells "never started" from "failed after start".
|
|
351
|
+
child.once('spawn', () => {
|
|
352
|
+
spawned = true;
|
|
353
|
+
});
|
|
241
354
|
child.on('error', (err) => {
|
|
242
355
|
// Spawn failures (ENOENT and friends) emit 'error' without 'close'.
|
|
243
|
-
if (!spawnError)
|
|
356
|
+
if (!spawnError) {
|
|
357
|
+
spawnError = err;
|
|
358
|
+
spawnFailed = !spawned;
|
|
359
|
+
}
|
|
244
360
|
finish();
|
|
245
361
|
});
|
|
246
362
|
child.on('close', () => {
|
|
@@ -1204,6 +1204,18 @@ function extractExitCode(payload) {
|
|
|
1204
1204
|
return null;
|
|
1205
1205
|
}
|
|
1206
1206
|
|
|
1207
|
+
// FR-M5-004 (TASK-C97-014): child identity is a top-level, nonempty, trimmed string
|
|
1208
|
+
// `agent_id` OR `agent_type` — the same documented subagent marker skill-router.sh
|
|
1209
|
+
// reads for its sidechain guard. Nested output text that merely contains marker
|
|
1210
|
+
// words is never authority, and missing/blank/nonstring markers fall through to the
|
|
1211
|
+
// existing main-session accounting unchanged.
|
|
1212
|
+
function isChildToolPayload(payload) {
|
|
1213
|
+
if (!payload || typeof payload !== 'object') return false;
|
|
1214
|
+
return [payload.agent_id, payload.agent_type].some(
|
|
1215
|
+
(marker) => typeof marker === 'string' && marker.trim() !== '',
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1207
1219
|
function extractText(payload, preferredKeys = []) {
|
|
1208
1220
|
const queue = [payload];
|
|
1209
1221
|
const seen = new Set();
|
|
@@ -1376,6 +1388,10 @@ async function main() {
|
|
|
1376
1388
|
|
|
1377
1389
|
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
1378
1390
|
const config = await loadRuntimeConfig(projectRoot);
|
|
1391
|
+
// FR-M5-004 (TASK-C97-014): resolved once from the parsed payload and applied at
|
|
1392
|
+
// BOTH raw-output pressure updates below. Deliberately NOT an early exit — the
|
|
1393
|
+
// compression, cache, history, tee and hard-cap paths all keep running for a child.
|
|
1394
|
+
const isChildOutput = isChildToolPayload(payload);
|
|
1379
1395
|
// Session-scoped pressure bookkeeping: output tokens must land in the calling
|
|
1380
1396
|
// session's record, never a sibling session's.
|
|
1381
1397
|
const sessionConfig = {
|
|
@@ -1448,16 +1464,21 @@ async function main() {
|
|
|
1448
1464
|
recoveryReason: cached.metadata?.recoveryReason ?? null,
|
|
1449
1465
|
truncated: Boolean(cached.metadata?.truncated),
|
|
1450
1466
|
});
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1467
|
+
// FR-M5-004: a subagent's raw output must not be charged to its parent's
|
|
1468
|
+
// compact pressure. Suppress ONLY this pressure write — cache/history/tee
|
|
1469
|
+
// writes above, the summary on stdout below, and every safety gate are untouched.
|
|
1470
|
+
if (!isChildOutput) {
|
|
1471
|
+
await updateCompactPressureFromOutput(projectRoot, {
|
|
1472
|
+
timestamp: Date.now(),
|
|
1473
|
+
command: cachedCommand,
|
|
1474
|
+
profile: cachedProfile,
|
|
1475
|
+
summary: String(cached.content),
|
|
1476
|
+
tokensBefore: cached.tokensBefore ?? 0,
|
|
1477
|
+
tokensAfter: cached.tokensAfter ?? 0,
|
|
1478
|
+
savedTokens: cached.savedTokens ?? 0,
|
|
1479
|
+
exitCode,
|
|
1480
|
+
}, sessionConfig);
|
|
1481
|
+
}
|
|
1461
1482
|
process.stdout.write(String(cached.content));
|
|
1462
1483
|
return;
|
|
1463
1484
|
}
|
|
@@ -1548,16 +1569,21 @@ async function main() {
|
|
|
1548
1569
|
savedTokens: result.savedTokens,
|
|
1549
1570
|
metadata: { command: result.command, exitCode },
|
|
1550
1571
|
});
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1572
|
+
// FR-M5-004: same suppression on the fresh path — a child's raw output never
|
|
1573
|
+
// charges the parent's compact estimate; compression, history, tee, the recovery
|
|
1574
|
+
// anchor and every downstream gate keep their existing behavior.
|
|
1575
|
+
if (!isChildOutput) {
|
|
1576
|
+
await updateCompactPressureFromOutput(projectRoot, {
|
|
1577
|
+
timestamp: Date.now(),
|
|
1578
|
+
command: result.command,
|
|
1579
|
+
profile: result.profile,
|
|
1580
|
+
summary: result.summary,
|
|
1581
|
+
tokensBefore: result.tokensBefore,
|
|
1582
|
+
tokensAfter: result.tokensAfter,
|
|
1583
|
+
savedTokens: result.savedTokens,
|
|
1584
|
+
exitCode,
|
|
1585
|
+
}, sessionConfig);
|
|
1586
|
+
}
|
|
1561
1587
|
|
|
1562
1588
|
if (config?.tokenPipeline?.promptCache) {
|
|
1563
1589
|
await writePromptCacheEntry(projectRoot, {
|
|
@@ -966,7 +966,7 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
|
|
|
966
966
|
} catch {
|
|
967
967
|
return { kind: 'none' };
|
|
968
968
|
}
|
|
969
|
-
const { goal, phase, cursor, next, exitPredicate } = parseRunCursor(text);
|
|
969
|
+
const { command, goal, phase, cursor, next, exitPredicate } = parseRunCursor(text);
|
|
970
970
|
// Shared terminal predicate — tests/handoff/cycle21 greps this exact shape across
|
|
971
971
|
// all four RUN.md consumers; keep `^(done|blocked)` as one expression.
|
|
972
972
|
const terminal = String(phase).match(/^(done|blocked)\b/i)?.[1]?.toLowerCase() || '';
|
|
@@ -980,7 +980,8 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
|
|
|
980
980
|
// must not be bounced into a stale run. Only a positive "scanned the whole
|
|
981
981
|
// transcript, latest intent is not fullstack" releases; unknown (null) keeps
|
|
982
982
|
// the block, so a real run is never released by a lost read.
|
|
983
|
-
|
|
983
|
+
const transcriptOwner = gate.scope === 'session' ? await transcriptShowsHandoffRun(transcriptPath) : true;
|
|
984
|
+
if (gate.scope === 'session' && transcriptOwner === false) {
|
|
984
985
|
return { kind: 'none' };
|
|
985
986
|
}
|
|
986
987
|
|
|
@@ -1008,6 +1009,19 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
|
|
|
1008
1009
|
return { kind: 'block', reason: lines.join('\n') };
|
|
1009
1010
|
}
|
|
1010
1011
|
|
|
1012
|
+
// Nonterminal planning cursor (TASK-C97-006, FR-M2-003): a RUN.md whose Command
|
|
1013
|
+
// is EXACTLY `handoff-create` is the planner's own cursor — it does not demand
|
|
1014
|
+
// fullstack implementation even when the session's intent is unknown (no
|
|
1015
|
+
// transcript path, unreadable, known-null). Stop releases UNLESS the latest
|
|
1016
|
+
// structured intent positively reacquired the run (`fullstack`: an explicit
|
|
1017
|
+
// handoff-implement or handoff-fullstack turn). This is the ONLY command
|
|
1018
|
+
// exception: actual handoff-fullstack/implement/review and a missing/unknown
|
|
1019
|
+
// Command keep the legacy posture, including the unknown fail-closed block. The
|
|
1020
|
+
// terminal `done` branch above is untouched.
|
|
1021
|
+
if (String(command).trim().toLowerCase() === 'handoff-create' && transcriptOwner !== true) {
|
|
1022
|
+
return { kind: 'none' };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1011
1025
|
const signature = `${phase}|${cursor}|${next}`;
|
|
1012
1026
|
const streak = await bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs });
|
|
1013
1027
|
if (streak !== null && streak > gate.maxStalledBlocks) {
|
|
@@ -138,9 +138,16 @@ mode only; daily flow does not get a Progress section.
|
|
|
138
138
|
reports `undeclared-drift`.
|
|
139
139
|
- **Milestone commit.** The commit lives on your worktree branch only (e.g.
|
|
140
140
|
`handoff/task-xxx`). Use a `-m "milestone: <name>"` message. Never commit outside your
|
|
141
|
-
worktree, never push. The orchestrator's copy-back step
|
|
142
|
-
base
|
|
143
|
-
**copy-back is
|
|
141
|
+
worktree, never push. The orchestrator's copy-back step lands the branch's final
|
|
142
|
+
base-relative snapshot (`land-worktree.mjs` diffs the working tree against the immutable
|
|
143
|
+
base), so intermediate commits on the worktree branch are free — **copy-back is
|
|
144
|
+
unaffected** by milestone commits.
|
|
145
|
+
- **Final clean milestone commit (required before PASS).** End your run with one final
|
|
146
|
+
milestone commit so the worktree's snapshot equals its committed tree — nothing staged,
|
|
147
|
+
nothing unstaged, no nonignored untracked file left behind. A Git-clean snapshot is what
|
|
148
|
+
lets a later receipt-gated cleanup remove the worktree without `--force`; a tree dirty
|
|
149
|
+
against the snapshot is retained with a named reason, never forced. Do not report PASS on
|
|
150
|
+
a dirty worktree.
|
|
144
151
|
- **Resume.** When a previous run aborted, you will be respawned. Read `## Progress`,
|
|
145
152
|
jump straight to the last entry whose `last-green:` is not `none`, and continue from
|
|
146
153
|
there. Do NOT re-plan, do NOT restart from RED, do NOT re-write tests that already
|
|
@@ -273,6 +273,18 @@ function parseStructuredDecision(stdout) {
|
|
|
273
273
|
|
|
274
274
|
function translateExecResult(scriptName, execResult) {
|
|
275
275
|
const failureKind = typeof execResult?.failureKind === 'string' ? execResult.failureKind : null;
|
|
276
|
+
// The runner could not launch a .sh hook at all (win32 without bash): a loud warning,
|
|
277
|
+
// never a block, and no captured content travels with it.
|
|
278
|
+
if (failureKind === 'spawn-unavailable') {
|
|
279
|
+
const launchStderr = typeof execResult?.stderr === 'string' ? execResult.stderr : '';
|
|
280
|
+
return {
|
|
281
|
+
block: false,
|
|
282
|
+
warning: launchStderr.trim()
|
|
283
|
+
|| `${scriptName} could not be launched and was skipped (spawn-unavailable) — install Git for Windows or set UKIT_BASH_BIN.`,
|
|
284
|
+
stdout: '',
|
|
285
|
+
stderr: launchStderr,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
276
288
|
// Infrastructure outcomes produce no verdict: no captured content travels with
|
|
277
289
|
// them (overflowed stdout is truncated mid-stream and may embed secrets).
|
|
278
290
|
const infrastructure = failureKind
|
|
@@ -280,13 +280,25 @@ Internals: `src/core/permissionPolicy.js` compiles the semantic policy (preceden
|
|
|
280
280
|
non-zero on its own), `output-overflow` (capture exceeded the 2 MiB maxBuffer and the
|
|
281
281
|
tree was culled), `timeout` (the runner killed the child at its deadline), `signal`
|
|
282
282
|
(the child died from a signal the runner did not send), `error` (the child never
|
|
283
|
-
spawned), `
|
|
284
|
-
|
|
283
|
+
spawned), `spawn-unavailable` (win32 only: no usable bash could be found or started for
|
|
284
|
+
a `.sh` script — see the launcher bullet below), `budget-exhausted` (the script never
|
|
285
|
+
ran — the chain total budget was spent first). The `--emit-verdict` contract maps them onto one exit code: `ok`/`exit-code 0`
|
|
285
286
|
and any non-fail-closed failure → exit 0 (fail OPEN); a real `exit-code 2` block, or
|
|
286
287
|
ANY failure on a fail-closed gate (`output-overflow`, `timeout`, `signal`, `error`,
|
|
287
288
|
`budget-exhausted`) → exit 2 (fail CLOSED). An overflowed capture is dropped whole
|
|
288
289
|
before it can be replayed, so a truncated stream can never masquerade as a verdict.
|
|
289
290
|
`tests/hooks/hookChainFailureSurface.test.js` pins these surfaces.
|
|
291
|
+
- **Windows bash launcher.** On win32 a `.sh` hook is not executed directly; the runner
|
|
292
|
+
launches it through Git Bash. The bash binary is the first hit of: an absolute
|
|
293
|
+
`UKIT_BASH_BIN` → `CLAUDE_CODE_GIT_BASH_PATH` → the Git for Windows default install
|
|
294
|
+
paths → absolute `PATH` entries (the System32 and WindowsApps `bash.exe`, i.e. WSL, are
|
|
295
|
+
skipped; a relative or cwd `bash.exe` is never used). The script path is passed with
|
|
296
|
+
forward slashes. When no candidate exists or the spawn fails, the entry is
|
|
297
|
+
`spawn-unavailable`: it warns instead of denying every Edit/Write/Bash. That kind is
|
|
298
|
+
win32-launcher-only — POSIX spawn errors stay `error` and fail closed, and timeouts,
|
|
299
|
+
real `exit-code 2` blocks, missing gate files and `block-dangerous.sh` stay fail closed
|
|
300
|
+
on every platform. `UKIT_HOOK_SHELL_PLATFORM` is a diagnostic override of the platform
|
|
301
|
+
the launcher assumes.
|
|
290
302
|
- **stderr is a diagnostic channel, not verdict payload.** Under `--emit-verdict` the
|
|
291
303
|
runner drops the stderr of every non-verdict script: stdout is the verdict and Claude
|
|
292
304
|
Code parses it as ONE JSON document, so interleaving arbitrary hook diagnostics there
|