@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
|
@@ -99,7 +99,54 @@ export async function checkStaleSpec({ projectRoot = process.cwd(), payload = {}
|
|
|
99
99
|
const toolName = getToolName(payload);
|
|
100
100
|
const filePath = getFilePath(payload);
|
|
101
101
|
if (!filePath) return { status: 'skipped', reason: 'no-file' };
|
|
102
|
-
|
|
102
|
+
// C97 (FR-001): classify target containment at the checker boundary. The
|
|
103
|
+
// shared resolver THROWS "Path escapes project root" on containment misses
|
|
104
|
+
// and its contract must not change, so classify lexically here — before any
|
|
105
|
+
// fs probe of the target. An absolute path strictly outside the project is
|
|
106
|
+
// out of scope for this project-only check: scoped skip, NOT permission to
|
|
107
|
+
// write (other gates keep ownership, and the target is never read). Relative
|
|
108
|
+
// escapes and root-as-file get a structured block instead of an unhandled
|
|
109
|
+
// crash that killed the whole fail-closed chain. Lexical only — symlink
|
|
110
|
+
// expansion must never widen authority.
|
|
111
|
+
// Fix1 (review round 1): containment is SEGMENT-aware. String prefix
|
|
112
|
+
// matching (`startsWith('..')`) also caught internal dot-prefixed names
|
|
113
|
+
// whose first path segment starts with `..` (e.g. a project-root-level
|
|
114
|
+
// `..internal.mjs`), scoping real in-project files out of the check. An
|
|
115
|
+
// escape is a `..` PATH SEGMENT — the whole relative path or its first
|
|
116
|
+
// segment — never a leading-dot filename.
|
|
117
|
+
const root = path.resolve(projectRoot || process.cwd());
|
|
118
|
+
const rawPath = String(filePath).trim();
|
|
119
|
+
const candidate = path.isAbsolute(rawPath) ? path.resolve(rawPath) : path.resolve(root, rawPath);
|
|
120
|
+
const candidateRelative = path.relative(root, candidate);
|
|
121
|
+
const escapesRoot = candidateRelative === '..'
|
|
122
|
+
|| candidateRelative.startsWith(`..${path.sep}`)
|
|
123
|
+
|| path.isAbsolute(candidateRelative);
|
|
124
|
+
if (!candidateRelative || escapesRoot) {
|
|
125
|
+
if (!path.isAbsolute(rawPath) || !candidateRelative) {
|
|
126
|
+
return {
|
|
127
|
+
status: 'blocked',
|
|
128
|
+
code: 'invalid-project-target',
|
|
129
|
+
message: `BLOCKED: target path '${filePath}' resolves outside the project root (invalid-project-target) — relative traversal and the project root itself are refused. Choose a path inside the project.`,
|
|
130
|
+
file: rawPath,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return { status: 'skipped', reason: 'outside-project', file: rawPath };
|
|
134
|
+
}
|
|
135
|
+
// The unchanged strict resolver still refuses first-segment dot-names
|
|
136
|
+
// (its own `startsWith('..')` contract). Keep that refusal — it is the
|
|
137
|
+
// resolver's safety contract — but surface it as the structured block
|
|
138
|
+
// instead of the unhandled-crash class this checker used to die from.
|
|
139
|
+
let resolved;
|
|
140
|
+
try {
|
|
141
|
+
resolved = resolveProjectFile(projectRoot, filePath);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return {
|
|
144
|
+
status: 'blocked',
|
|
145
|
+
code: 'invalid-project-target',
|
|
146
|
+
message: `BLOCKED: project resolver refused target '${filePath}' (invalid-project-target): ${error?.message || error}. Use a normal in-project path whose segments do not start with '..'.`,
|
|
147
|
+
file: rawPath,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
103
150
|
const exists = await withTransientFsRetry(
|
|
104
151
|
() => probeExists(resolved.absolute),
|
|
105
152
|
{ deadlineMs: TRANSIENT_READ_DEADLINE_MS },
|
|
@@ -27,21 +27,35 @@
|
|
|
27
27
|
// `intent` is the MOST RECENT qualifying intent, `known=false` when the scan
|
|
28
28
|
// could not complete (missing/unreadable path, budget overrun) — callers keep
|
|
29
29
|
// their fail-closed posture on `known=false`.
|
|
30
|
+
//
|
|
31
|
+
// Intent values stay the historical pair 'create' | 'fullstack': an exact
|
|
32
|
+
// `handoff-implement` invocation CLAIMS the run (the fullstack command says so
|
|
33
|
+
// itself), so it maps onto the existing 'fullstack' intent instead of adding a
|
|
34
|
+
// new enum every consumer would have to learn (TASK-C97-004).
|
|
30
35
|
|
|
31
36
|
import fs from 'node:fs/promises';
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
// The `(?![\w-])` closer is the exact token boundary: a command name followed by
|
|
39
|
+
// MORE name characters or a hyphen (`handoff-fullstack-custom`) is a different,
|
|
40
|
+
// unsupported command — `\b` would still match there and let the suffix spoof
|
|
41
|
+
// the intent.
|
|
42
|
+
export const HANDOFF_FULLSTACK_TAG = /<command-name>\/?(?:ukit:)?handoff-fullstack(?![\w-])/;
|
|
43
|
+
export const HANDOFF_CREATE_TAG = /<command-name>\/?(?:ukit:)?handoff-create(?![\w-])/;
|
|
35
44
|
export const HANDOFF_RESUME_BANNER = 'UKIT HANDOFF RESUME — an unfinished handoff-fullstack run';
|
|
36
45
|
|
|
46
|
+
// Sweeper for "which command tag came LAST in this entry": finds every exact
|
|
47
|
+
// command-name tag occurrence with its position so the newest one wins.
|
|
48
|
+
const COMMAND_TAG_SWEEP = /<command-name>\/?(?:ukit:)?handoff-(create|fullstack|implement)(?![\w-])/g;
|
|
49
|
+
|
|
37
50
|
// omp and Codex do NOT wrap a slash command in `<command-name>` tags the way
|
|
38
51
|
// Claude Code's user turn does. Their native evidence is the command *expansion*:
|
|
39
52
|
// omp replaces `/ukit:handoff-fullstack` with the command body, whose FIRST
|
|
40
53
|
// non-empty line is the H1 `# /ukit:handoff-fullstack — …`; Codex records the same
|
|
41
54
|
// expanded body as a `response_item`/`message` with `input_text` blocks. Matching
|
|
42
55
|
// the FIRST line only (not any heading in the body) keeps prose references to the
|
|
43
|
-
// command inside the document from being mistaken for an invocation.
|
|
44
|
-
|
|
56
|
+
// command inside the document from being mistaken for an invocation. The same
|
|
57
|
+
// exact token boundary applies: `-custom` suffixed headings are not commands.
|
|
58
|
+
const NATIVE_COMMAND_HEADING = /^\s{0,3}#{1,3}\s+\/?(?:ukit:)?(handoff-create|handoff-fullstack|handoff-implement)(?![\w-])/;
|
|
45
59
|
|
|
46
60
|
// The unique customType the omp bridge stamps on host-injected UKit hook context
|
|
47
61
|
// (`sendContext` → `hookContextMessage`). A resume banner only counts as intent
|
|
@@ -62,6 +76,19 @@ function firstNonEmptyLine(text) {
|
|
|
62
76
|
return '';
|
|
63
77
|
}
|
|
64
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Map an exact command token (full `handoff-implement` or the captured suffix
|
|
81
|
+
* `implement`) onto the two intent values. `handoff-implement` CLAIMS the run,
|
|
82
|
+
* so it maps onto the existing 'fullstack' intent.
|
|
83
|
+
* @returns {'create' | 'fullstack' | null}
|
|
84
|
+
*/
|
|
85
|
+
function commandTokenIntent(token) {
|
|
86
|
+
const t = String(token).toLowerCase().replace(/^handoff-/, '');
|
|
87
|
+
if (t === 'create') return 'create';
|
|
88
|
+
if (t === 'fullstack' || t === 'implement') return 'fullstack';
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
65
92
|
/**
|
|
66
93
|
* Does this text open with a native (omp/Codex) command-expansion heading?
|
|
67
94
|
* Only the FIRST non-empty line is inspected, so an H1 that appears later in the
|
|
@@ -72,7 +99,7 @@ function firstNonEmptyLine(text) {
|
|
|
72
99
|
function nativeCommandIntent(text) {
|
|
73
100
|
const m = firstNonEmptyLine(text).match(NATIVE_COMMAND_HEADING);
|
|
74
101
|
if (!m) return null;
|
|
75
|
-
return m[1]
|
|
102
|
+
return commandTokenIntent(m[1]);
|
|
76
103
|
}
|
|
77
104
|
|
|
78
105
|
// Bounds for the tail scan. Recency is what matters, so the reader walks the
|
|
@@ -109,51 +136,61 @@ function splitUserContent(content) {
|
|
|
109
136
|
return { text, blocks, hasToolOutput };
|
|
110
137
|
}
|
|
111
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Classify one user text block. Structured evidence only: exact Claude Code
|
|
141
|
+
* `<command-name>` tags (the LAST one in the block wins) or a native
|
|
142
|
+
* command-expansion heading on the block's first line.
|
|
143
|
+
* @returns {'fullstack' | 'create' | null}
|
|
144
|
+
*/
|
|
145
|
+
function classifyUserText(text) {
|
|
146
|
+
const tags = [...String(text ?? '').matchAll(COMMAND_TAG_SWEEP)];
|
|
147
|
+
if (tags.length > 0) return commandTokenIntent(tags[tags.length - 1][1]);
|
|
148
|
+
return nativeCommandIntent(text);
|
|
149
|
+
}
|
|
150
|
+
|
|
112
151
|
/**
|
|
113
152
|
* Classify a user turn (Claude Code `type:'user'`, omp `type:'message'` role user,
|
|
114
|
-
* Codex `type:'response_item'` payload message role user).
|
|
115
|
-
*
|
|
116
|
-
*
|
|
153
|
+
* Codex `type:'response_item'` payload message role user). The LATEST qualifying
|
|
154
|
+
* evidence inside the entry wins — a later tag/block supersedes an earlier one,
|
|
155
|
+
* while trailing non-qualifying content never erases it. Tool-output blocks
|
|
156
|
+
* (`tool_result` round-trips) are not user words and never carry intent, so a
|
|
157
|
+
* turn that is purely tool output has no user text to match.
|
|
117
158
|
* @returns {'fullstack' | 'create' | null}
|
|
118
159
|
*/
|
|
119
160
|
function classifyUserTurn(content) {
|
|
120
|
-
const {
|
|
121
|
-
|
|
122
|
-
if (HANDOFF_CREATE_TAG.test(text)) return 'create';
|
|
123
|
-
if (HANDOFF_FULLSTACK_TAG.test(text)) return 'fullstack';
|
|
124
|
-
// Native (omp / Codex) command expansion — the FIRST line of a user text block.
|
|
125
|
-
// A user turn that is purely tool output has no user text to match, so a
|
|
126
|
-
// `tool_result` carrying the marker string can never register as intent.
|
|
161
|
+
const { blocks } = splitUserContent(content);
|
|
162
|
+
let latest = null;
|
|
127
163
|
for (const block of blocks) {
|
|
128
|
-
const intent =
|
|
129
|
-
if (intent)
|
|
164
|
+
const intent = classifyUserText(block);
|
|
165
|
+
if (intent) latest = intent;
|
|
130
166
|
}
|
|
131
|
-
|
|
132
|
-
return null;
|
|
167
|
+
return latest;
|
|
133
168
|
}
|
|
134
169
|
|
|
135
170
|
/**
|
|
136
171
|
* Classify assistant tool-call blocks. Handles both Claude Code (`tool_use`, name
|
|
137
|
-
* `Skill`, input.skill) and omp (`toolCall`, name/arguments.skill).
|
|
172
|
+
* `Skill`, input.skill) and omp (`toolCall`, name/arguments.skill). The LAST
|
|
173
|
+
* qualifying block wins; unrelated tool blocks never erase it.
|
|
138
174
|
*/
|
|
139
175
|
function classifyAssistantBlocks(blocks) {
|
|
140
176
|
if (!Array.isArray(blocks)) return null;
|
|
177
|
+
let latest = null;
|
|
141
178
|
for (const b of blocks) {
|
|
142
179
|
if (!b || typeof b !== 'object') continue;
|
|
143
180
|
if (b.type === 'tool_use' && b.name === 'Skill') {
|
|
144
181
|
const skill = String(b.input?.skill ?? '');
|
|
145
|
-
|
|
146
|
-
if (
|
|
182
|
+
const m = /^(?:ukit:)?handoff-(create|fullstack|implement)$/.exec(skill);
|
|
183
|
+
if (m) latest = commandTokenIntent(m[1]);
|
|
147
184
|
continue;
|
|
148
185
|
}
|
|
149
186
|
if (b.type === 'toolCall') {
|
|
150
187
|
const name = String(b.name ?? '');
|
|
151
188
|
const skill = String(b.arguments?.skill ?? '');
|
|
152
|
-
|
|
153
|
-
if (
|
|
189
|
+
const m = /^(?:ukit:)?handoff-(create|fullstack|implement)$/i.exec(name) || /^(?:ukit:)?handoff-(create|fullstack|implement)$/.exec(skill);
|
|
190
|
+
if (m) latest = commandTokenIntent(m[1]);
|
|
154
191
|
}
|
|
155
192
|
}
|
|
156
|
-
return
|
|
193
|
+
return latest;
|
|
157
194
|
}
|
|
158
195
|
|
|
159
196
|
/**
|
|
@@ -170,7 +207,9 @@ function classifyAssistantBlocks(blocks) {
|
|
|
170
207
|
* - Codex: `{ type:'response_item', payload:{ type:'message', role:'user', content:[input_text…] } }`
|
|
171
208
|
*
|
|
172
209
|
* Anything else — including a format we do not recognise — returns null (never a
|
|
173
|
-
* guessed intent), so the caller's fail-closed posture is preserved.
|
|
210
|
+
* guessed intent), so the caller's fail-closed posture is preserved. Within one
|
|
211
|
+
* entry the LATEST qualifying evidence wins; `-custom` suffixed command names and
|
|
212
|
+
* tool-result / unknown-namespace content are never intent.
|
|
174
213
|
* @returns {'fullstack' | 'create' | null}
|
|
175
214
|
*/
|
|
176
215
|
export function classifyHandoffEntry(entry) {
|
|
@@ -263,11 +302,30 @@ export async function transcriptHandoffIntent(transcriptPath, {
|
|
|
263
302
|
const line = lines[i];
|
|
264
303
|
if (!line) continue;
|
|
265
304
|
// Cheap prefilter — skip JSON.parse for lines that cannot be evidence.
|
|
266
|
-
|
|
305
|
+
// `handoff-implement` is included: an implement invocation after a create
|
|
306
|
+
// must be seen as the NEWEST intent, not lost to the older create line.
|
|
307
|
+
if (!line.includes('handoff-create') && !line.includes('handoff-fullstack') && !line.includes('handoff-implement') && !line.includes('HANDOFF RESUME')) {
|
|
267
308
|
continue;
|
|
268
309
|
}
|
|
269
|
-
|
|
310
|
+
// This is the NEWEST line carrying intent evidence. A host append can be
|
|
311
|
+
// cut mid-record (killed turn, torn write), leaving this line as unparseable
|
|
312
|
+
// JSON. Skipping it like any other non-qualifying line would fall through to
|
|
313
|
+
// an OLDER — possibly OPPOSITE — intent and misreport the session's current
|
|
314
|
+
// ownership (a torn `handoff-implement` after a create released a real
|
|
315
|
+
// fullstack run). An incomplete newest evidence-bearing record is therefore
|
|
316
|
+
// UNDETERMINED, not "no intent": return unknown so fail-closed callers keep
|
|
317
|
+
// their blocking/conservative posture (MODULE02-SPEC FR-M2-002).
|
|
318
|
+
let entry;
|
|
319
|
+
try {
|
|
320
|
+
entry = JSON.parse(line);
|
|
321
|
+
} catch {
|
|
322
|
+
return { intent: null, known: false };
|
|
323
|
+
}
|
|
324
|
+
const intent = classifyHandoffEntry(entry);
|
|
270
325
|
if (intent) return { intent, known: true };
|
|
326
|
+
// Complete record that carries no recognised intent (tool output, a foreign
|
|
327
|
+
// envelope, a malformed-but-whole unrelated line): not evidence — keep
|
|
328
|
+
// walking older lines.
|
|
271
329
|
}
|
|
272
330
|
// Nothing found. Only a scan of the WHOLE transcript proves "no handoff
|
|
273
331
|
// intent"; a tail window that missed it (the invoking turn usually appears
|
|
@@ -6,7 +6,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
6
6
|
// TASK-016: children run through the process-tree runner instead of bare
|
|
7
7
|
// spawnSync — a timed-out child is TERM→KILLed as a whole POSIX process group,
|
|
8
8
|
// so a bash wrapper can no longer orphan the Node grandchildren it spawned.
|
|
9
|
-
import { runHookProcess } from './hook-process.mjs';
|
|
9
|
+
import { runHookProcess, resolveHookCommand, SPAWN_UNAVAILABLE_CODES } from './hook-process.mjs';
|
|
10
10
|
// Repeated identical blocks (a model respawning an agent into the same refusal) get a
|
|
11
11
|
// "stop and report verbatim" directive appended. Advisory + fail-open; see block-breaker.mjs.
|
|
12
12
|
// Loaded dynamically so a partially-updated runtime (module missing) degrades to "no advice"
|
|
@@ -43,6 +43,23 @@ const FAIL_CLOSED_SCRIPTS = new Set([
|
|
|
43
43
|
|
|
44
44
|
const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
45
45
|
|
|
46
|
+
// C97 (SPEC §FR-002): bound for replaying a triggering step's already-captured
|
|
47
|
+
// stderr diagnostic in the skippedFailClosed verdict. Large enough for real
|
|
48
|
+
// causes, small enough that a flooded capture cannot drown the skipped-gate
|
|
49
|
+
// notice. The excerpt is cut at a UTF-8 code-point boundary so the emitted
|
|
50
|
+
// diagnostic is always valid UTF-8.
|
|
51
|
+
const CAUSE_EXCERPT_MAX_BYTES = 4096;
|
|
52
|
+
|
|
53
|
+
function boundedUtf8Excerpt(text, maxBytes = CAUSE_EXCERPT_MAX_BYTES) {
|
|
54
|
+
const buf = Buffer.from(String(text ?? ''), 'utf8');
|
|
55
|
+
if (buf.length <= maxBytes) return { text: buf.toString('utf8'), truncated: false };
|
|
56
|
+
let end = maxBytes;
|
|
57
|
+
// Back off over UTF-8 continuation bytes (0b10xxxxxx) so the cut never
|
|
58
|
+
// splits a code point.
|
|
59
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80) end--;
|
|
60
|
+
return { text: buf.subarray(0, end).toString('utf8'), truncated: true };
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
// TASK-234 review fix (critical): stdin staging must be bounded. The old
|
|
47
64
|
// `fs.readFileSync(0)` blocked until the producer closed the pipe — a stalled
|
|
48
65
|
// producer held the runner until the settings timeout SIGKILLed it, so every
|
|
@@ -88,6 +105,7 @@ async function readStdinBounded() {
|
|
|
88
105
|
// 'signal' — the child died from a signal this runner did not send
|
|
89
106
|
// 'error' — the child never spawned (ENOENT and friends)
|
|
90
107
|
// 'budget-exhausted' — the script never ran: the chain total budget was spent
|
|
108
|
+
// 'spawn-unavailable' — win32 bash launcher not found or could not start the .sh script (no process ran); warned, never blocks
|
|
91
109
|
function chainFailureKind(processResult) {
|
|
92
110
|
switch (processResult?.failureKind) {
|
|
93
111
|
case 'overflow':
|
|
@@ -169,6 +187,18 @@ function parseScriptArg(arg) {
|
|
|
169
187
|
// 'timeout'. The deadline is enforced by Promise.race + AbortSignal; the
|
|
170
188
|
// rejection race keeps running in the background (in-proc code cannot be
|
|
171
189
|
// SIGKILLed) but its late return value is discarded.
|
|
190
|
+
// FR-004: warning for a step whose win32 bash launcher never started. Single
|
|
191
|
+
// Windows fixText — this classification is unreachable off win32 (`viaBash`).
|
|
192
|
+
const SPAWN_UNAVAILABLE_FIX =
|
|
193
|
+
'Fix: install Git for Windows (https://git-scm.com/download/win) or set UKIT_BASH_BIN to the full path of bash.exe.';
|
|
194
|
+
|
|
195
|
+
function spawnUnavailableWarning(scriptName, cause) {
|
|
196
|
+
const gateText = FAIL_CLOSED_SCRIPTS.has(scriptName)
|
|
197
|
+
? 'fail-closed gate NOT enforced'
|
|
198
|
+
: 'advisory hook skipped';
|
|
199
|
+
return `UKit WARNING: hook ${scriptName} could not be launched (${cause}) and was SKIPPED — ${gateText}. ${SPAWN_UNAVAILABLE_FIX}\n`;
|
|
200
|
+
}
|
|
201
|
+
|
|
172
202
|
async function runModuleStep({ scriptPath, payload, payloadText, projectRoot, env, deadlineMs }) {
|
|
173
203
|
const controller = new AbortController();
|
|
174
204
|
let timer;
|
|
@@ -308,6 +338,13 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
308
338
|
// TASK-001: `.mjs` args run in-proc via runHook(ctx); `.sh` stays a child
|
|
309
339
|
// process. Both paths share deadline/budget, results[] shape, and break
|
|
310
340
|
// conditions.
|
|
341
|
+
// FR-003/FR-004: non-.mjs steps launch through the resolver (absolute Git
|
|
342
|
+
// Bash on win32 for `.sh`, the script itself everywhere else). `viaBash`
|
|
343
|
+
// is the ONLY thing that can turn a launch failure into spawn-unavailable.
|
|
344
|
+
const launch = scriptPath.endsWith('.mjs')
|
|
345
|
+
? { command: scriptPath, args: [], viaBash: false }
|
|
346
|
+
: resolveHookCommand(scriptPath);
|
|
347
|
+
const { viaBash } = launch;
|
|
311
348
|
const result = scriptPath.endsWith('.mjs')
|
|
312
349
|
? await runModuleStep({
|
|
313
350
|
scriptPath,
|
|
@@ -317,10 +354,13 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
317
354
|
env: stepEnv,
|
|
318
355
|
deadlineMs: stepDeadlineMs,
|
|
319
356
|
})
|
|
320
|
-
:
|
|
321
|
-
|
|
357
|
+
: launch.command === null
|
|
358
|
+
// No bash found on win32: nothing is spawned (never a bare `bash`).
|
|
359
|
+
? { code: 1, stdout: '', stderr: '', failureKind: 'error', spawnFailed: true, errorCode: null }
|
|
360
|
+
: await runHookProcess({
|
|
361
|
+
command: launch.command,
|
|
322
362
|
deadlineMs: stepDeadlineMs,
|
|
323
|
-
args:
|
|
363
|
+
args: launch.args,
|
|
324
364
|
input: payloadText,
|
|
325
365
|
maxBuffer: MAX_BUFFER_BYTES,
|
|
326
366
|
cwd: projectRoot,
|
|
@@ -331,6 +371,38 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
331
371
|
// decision JSON.
|
|
332
372
|
env: stepEnv,
|
|
333
373
|
});
|
|
374
|
+
const launchFailed = viaBash && (
|
|
375
|
+
launch.command === null
|
|
376
|
+
|| (result.spawnFailed === true && SPAWN_UNAVAILABLE_CODES.has(result.errorCode))
|
|
377
|
+
);
|
|
378
|
+
if (launchFailed && scriptName !== 'block-dangerous.sh' && fs.existsSync(scriptPath)) {
|
|
379
|
+
const stepElapsedMs = Date.now() - childStartedAt;
|
|
380
|
+
results.push({
|
|
381
|
+
scriptName,
|
|
382
|
+
code: 0,
|
|
383
|
+
stdout: '',
|
|
384
|
+
stderr: spawnUnavailableWarning(scriptName, launch.command === null ? 'bash.exe not found' : result.errorCode),
|
|
385
|
+
failureKind: 'spawn-unavailable',
|
|
386
|
+
killed: false,
|
|
387
|
+
elapsedMs: stepElapsedMs,
|
|
388
|
+
});
|
|
389
|
+
recordHookTiming({
|
|
390
|
+
projectRoot,
|
|
391
|
+
sessionId: payload?.session_id,
|
|
392
|
+
event: payload?.hook_event_name || null,
|
|
393
|
+
tool: payload?.tool_name || null,
|
|
394
|
+
toolUseId: payload?.tool_use_id || null,
|
|
395
|
+
hook: scriptName,
|
|
396
|
+
elapsedMs: stepElapsedMs,
|
|
397
|
+
outcome: 'spawn-unavailable',
|
|
398
|
+
});
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (launch.command === null) {
|
|
402
|
+
// Resolver found no bash but the step does not qualify for the degrade
|
|
403
|
+
// (script missing / block-dangerous.sh): fail closed exactly like a spawn error.
|
|
404
|
+
result.stderr = `UKit hook ${scriptName} could not be launched: bash.exe not found\n`;
|
|
405
|
+
}
|
|
334
406
|
const failureKind = chainFailureKind(result);
|
|
335
407
|
// FR-001: on infra failure (error/timeout — no module verdict) the result
|
|
336
408
|
// code is 2 when the step is fail-closed, else 1.
|
|
@@ -609,18 +681,34 @@ try {
|
|
|
609
681
|
.filter((r) => r !== decisionResult && r !== last && typeof r.stdout === 'string' && r.stdout.length > 0)
|
|
610
682
|
.map((r) => r.stdout)
|
|
611
683
|
.join('');
|
|
684
|
+
// FR-005: spawn-unavailable steps never block, but must stay visible. Their
|
|
685
|
+
// warnings go to stderr exactly ONCE (here) and, where stdout is a clean
|
|
686
|
+
// JSON document the runner owns, into `systemMessage`.
|
|
687
|
+
const warnings = chain.results
|
|
688
|
+
.filter((r) => r.failureKind === 'spawn-unavailable')
|
|
689
|
+
.map((r) => r.stderr)
|
|
690
|
+
.join('');
|
|
691
|
+
if (warnings) process.stderr.write(warnings);
|
|
612
692
|
if (decisionOwnsVerdict) {
|
|
613
693
|
let verdictOut = decisionResult.stdout;
|
|
614
694
|
try {
|
|
615
695
|
const doc = JSON.parse(verdictOut);
|
|
616
696
|
const hso = doc?.hookSpecificOutput;
|
|
697
|
+
let changed = false;
|
|
617
698
|
if (hso?.permissionDecision === 'deny') {
|
|
618
699
|
const advice = breakerAdvice(hso.permissionDecisionReason);
|
|
619
700
|
if (advice) {
|
|
620
701
|
hso.permissionDecisionReason = `${hso.permissionDecisionReason}\n${advice}`;
|
|
621
|
-
|
|
702
|
+
changed = true;
|
|
622
703
|
}
|
|
623
704
|
}
|
|
705
|
+
if (warnings && doc && typeof doc === 'object' && !Array.isArray(doc)) {
|
|
706
|
+
doc.systemMessage = typeof doc.systemMessage === 'string' && doc.systemMessage
|
|
707
|
+
? `${doc.systemMessage}\n${warnings.trimEnd()}`
|
|
708
|
+
: warnings.trimEnd();
|
|
709
|
+
changed = true;
|
|
710
|
+
}
|
|
711
|
+
if (changed) verdictOut = JSON.stringify(doc);
|
|
624
712
|
} catch { /* unparseable verdict: replay it untouched */ }
|
|
625
713
|
process.stdout.write(verdictOut);
|
|
626
714
|
if (decisionResult.stderr) process.stderr.write(decisionResult.stderr);
|
|
@@ -629,14 +717,57 @@ try {
|
|
|
629
717
|
// TASK-234 review fix (critical): a mid-chain break that skipped a
|
|
630
718
|
// fail-closed gate must fail CLOSED — the old per-script path ran every
|
|
631
719
|
// hook independently, so a killed advisory never skipped a gate.
|
|
720
|
+
// C97 (SPEC §FR-002): the old emission named the unrun gates but dropped
|
|
721
|
+
// the triggering step's already-captured diagnostic entirely (the
|
|
722
|
+
// sibling fail-closed branch below DOES replay `last.stderr`), so an
|
|
723
|
+
// exit-1 gate's cause never reached the operator. Provenance now names
|
|
724
|
+
// the triggering result's step + failureKind — an actual child as the
|
|
725
|
+
// attempted step; the synthetic budget-exhausted result (elapsedMs 0,
|
|
726
|
+
// killed true: the script never launched) explicitly as unattempted —
|
|
727
|
+
// and replays the step's captured stderr ONCE, bounded. An
|
|
728
|
+
// output-overflow capture is truncated mid-stream and may embed anything
|
|
729
|
+
// (payloads, secrets), so it never leaves this process; the generic
|
|
730
|
+
// stdout message carries no raw stderr.
|
|
632
731
|
if (contextStdout) process.stdout.write(contextStdout);
|
|
732
|
+
const broke = chain.results[chain.results.length - 1] ?? null;
|
|
633
733
|
const skipped = scriptPaths
|
|
634
734
|
.slice(chain.results.length)
|
|
635
735
|
.map((p) => failClosedName(p))
|
|
636
736
|
.filter((name) => FAIL_CLOSED_SCRIPTS.has(name))
|
|
637
737
|
.join(', ');
|
|
738
|
+
const unattempted = broke?.failureKind === 'budget-exhausted';
|
|
739
|
+
const causeLabel = !broke
|
|
740
|
+
? 'unknown step'
|
|
741
|
+
: unattempted
|
|
742
|
+
? `${broke.scriptName} (${broke.failureKind}, unattempted — budget exhausted before launch)`
|
|
743
|
+
: `${broke.scriptName} (${broke.failureKind}, attempted step)`;
|
|
744
|
+
const provenanceMsg = `UKit hook chain broke at ${causeLabel}.\n`;
|
|
638
745
|
const brokeMsg = `UKit hook chain broke before fail-closed gate(s) ran: ${skipped}\n`;
|
|
746
|
+
process.stderr.write(provenanceMsg);
|
|
747
|
+
const causeText = broke
|
|
748
|
+
&& broke.failureKind !== 'output-overflow'
|
|
749
|
+
&& typeof broke.stderr === 'string'
|
|
750
|
+
? broke.stderr
|
|
751
|
+
: '';
|
|
752
|
+
if (causeText.trim()) {
|
|
753
|
+
const excerpt = boundedUtf8Excerpt(causeText);
|
|
754
|
+
process.stderr.write(
|
|
755
|
+
excerpt.truncated
|
|
756
|
+
? `${excerpt.text}\n[ukit] cause excerpt truncated at ${CAUSE_EXCERPT_MAX_BYTES} UTF-8 bytes\n`
|
|
757
|
+
: (excerpt.text.endsWith('\n') ? excerpt.text : `${excerpt.text}\n`),
|
|
758
|
+
);
|
|
759
|
+
}
|
|
639
760
|
process.stderr.write(brokeMsg + breakerAdvice(brokeMsg));
|
|
761
|
+
if (!contextStdout) {
|
|
762
|
+
// Otherwise-empty stdout: emit exactly ONE parseable JSON document so
|
|
763
|
+
// the host still gets a single valid verdict — the generic failure
|
|
764
|
+
// (step/kind/skipped gates), never the raw cause. Appending it after
|
|
765
|
+
// prior stdout would create an invalid two-document stream, so prior
|
|
766
|
+
// stdout suppresses it entirely.
|
|
767
|
+
process.stdout.write(JSON.stringify({
|
|
768
|
+
systemMessage: `${provenanceMsg.trimEnd()} ${brokeMsg.trimEnd()}`,
|
|
769
|
+
}));
|
|
770
|
+
}
|
|
640
771
|
process.exitCode = 2;
|
|
641
772
|
} else if (!last) {
|
|
642
773
|
// No script ran at all (empty chain or budget spent before the first
|
|
@@ -669,8 +800,14 @@ try {
|
|
|
669
800
|
} else {
|
|
670
801
|
if (contextStdout) process.stdout.write(contextStdout);
|
|
671
802
|
if (last.stdout) process.stdout.write(last.stdout);
|
|
672
|
-
|
|
803
|
+
// A spawn-unavailable `last` carries only the warning already written above.
|
|
804
|
+
if (last.stderr && last.failureKind !== 'spawn-unavailable') process.stderr.write(last.stderr);
|
|
673
805
|
process.exitCode = last.code === 0 ? 0 : (lastIsFailClosed ? 2 : 0);
|
|
806
|
+
// FR-005(c): exiting 0 with an otherwise empty stdout — surface the warning
|
|
807
|
+
// to the host as a systemMessage. Plain context stdout is left untouched.
|
|
808
|
+
if (warnings && process.exitCode === 0 && !contextStdout && !last.stdout) {
|
|
809
|
+
process.stdout.write(JSON.stringify({ systemMessage: warnings.trimEnd() }));
|
|
810
|
+
}
|
|
674
811
|
}
|
|
675
812
|
}
|
|
676
813
|
}
|