@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.
Files changed (26) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/manifests/documentation.yaml +11 -0
  3. package/manifests/platform.full.yaml +42 -0
  4. package/package.json +1 -1
  5. package/src/core/docContracts.js +60 -6
  6. package/src/core/hookChainDoctor.js +39 -3
  7. package/template_project/.claude/.gitattributes +2 -0
  8. package/template_project/.claude/agents/feature-implementer.md +10 -3
  9. package/template_project/.claude/commands/ukit/handoff-clear.md +27 -12
  10. package/template_project/.claude/commands/ukit/handoff-fullstack.md +65 -32
  11. package/template_project/.claude/commands/ukit/handoff-implement.md +44 -23
  12. package/template_project/.claude/hooks/handoff-resume.sh +36 -11
  13. package/template_project/.claude/hooks/protect-files.sh +10 -2
  14. package/template_project/.claude/hooks/vision-router.sh +11 -1
  15. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +418 -0
  16. package/template_project/.claude/ukit/index/extract-image.mjs +77 -22
  17. package/template_project/.claude/ukit/index/land-worktree.mjs +543 -0
  18. package/template_project/.claude/ukit/index/stale-spec-check.mjs +48 -1
  19. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +86 -28
  20. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +143 -6
  21. package/template_project/.claude/ukit/runtime/hook-process.mjs +120 -4
  22. package/template_project/.claude/ukit/runtime/output-compression.mjs +46 -20
  23. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +16 -2
  24. package/template_project/.omp/agents/feature-implementer.md +10 -3
  25. package/template_project/.omp/hooks/pre/ukit-bridge.js +12 -0
  26. 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
- const resolved = resolveProjectFile(projectRoot, filePath);
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
- export const HANDOFF_FULLSTACK_TAG = /<command-name>\/?(?:ukit:)?handoff-fullstack\b/;
34
- export const HANDOFF_CREATE_TAG = /<command-name>\/?(?:ukit:)?handoff-create\b/;
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
- const NATIVE_COMMAND_HEADING = /^\s{0,3}#{1,3}\s+\/?(?:ukit:)?(handoff-create|handoff-fullstack)\b/;
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] === 'handoff-create' ? 'create' : 'fullstack';
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). Structured evidence only:
115
- * a Claude Code `<command-name>` tag, or a native command-expansion heading on the
116
- * first line of a user text block.
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 { text, blocks, hasToolOutput } = splitUserContent(content);
121
- // Check the planning tag first: a single turn carrying both is a create turn.
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 = nativeCommandIntent(block);
129
- if (intent) return intent;
164
+ const intent = classifyUserText(block);
165
+ if (intent) latest = intent;
130
166
  }
131
- if (hasToolOutput) return null;
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
- if (/^(?:ukit:)?handoff-create$/.test(skill)) return 'create';
146
- if (/^(?:ukit:)?handoff-fullstack$/.test(skill)) return 'fullstack';
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
- if (/^(?:ukit:)?handoff-create$/i.test(name) || /^(?:ukit:)?handoff-create$/.test(skill)) return 'create';
153
- if (/^(?:ukit:)?handoff-fullstack$/i.test(name) || /^(?:ukit:)?handoff-fullstack$/.test(skill)) return 'fullstack';
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 null;
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
- if (!line.includes('handoff-create') && !line.includes('handoff-fullstack') && !line.includes('HANDOFF RESUME')) {
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
- const intent = classifyHandoffLine(line);
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
- : await runHookProcess({
321
- command: scriptPath,
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
- verdictOut = JSON.stringify(doc);
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
- if (last.stderr) process.stderr.write(last.stderr);
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
  }