@ngockhoale/ukit 2.6.10 → 2.7.0

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 (44) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/manifests/documentation.yaml +24 -2
  3. package/manifests/instructionRules.yaml +62 -0
  4. package/manifests/platform.full.yaml +11 -0
  5. package/package.json +1 -1
  6. package/scripts/perf/audit-perf.mjs +920 -0
  7. package/src/cli/commands/doctor.js +23 -4
  8. package/src/cli/commands/feedback.js +97 -0
  9. package/src/cli/commands/memory.js +250 -1
  10. package/src/cli/commands/metrics.js +109 -1
  11. package/src/cli/index.js +7 -0
  12. package/src/core/codeintel/retriever.js +65 -0
  13. package/src/core/diffPlan.js +8 -0
  14. package/src/core/memory/store.js +7 -2
  15. package/src/core/ompConfigMerge.js +222 -0
  16. package/src/core/runInstallPipeline.js +11 -0
  17. package/src/core/runtimeConfig.js +64 -0
  18. package/src/core/unattendedDoctor.js +227 -0
  19. package/src/diagnostics/failurePatterns.js +1 -34
  20. package/src/diagnostics/feedbackEvents.js +196 -0
  21. package/src/diagnostics/laneStats.js +111 -0
  22. package/src/diagnostics/ledgerFiles.js +47 -0
  23. package/src/diagnostics/skillAccuracy.js +158 -0
  24. package/src/learning/patternProposals.js +151 -0
  25. package/src/learning/tuning.js +213 -0
  26. package/templates/.claude/hooks/block-dangerous.sh +76 -9
  27. package/templates/.claude/hooks/context-hardcap-gate.sh +26 -8
  28. package/templates/.claude/hooks/project-important.sh +70 -9
  29. package/templates/.claude/hooks/protect-files.sh +24 -7
  30. package/templates/.claude/hooks/sensitive-data-guard.sh +57 -5
  31. package/templates/.claude/hooks/session-episode.sh +84 -0
  32. package/templates/.claude/settings.json +29 -113
  33. package/templates/.claude/ukit/index/route-task.mjs +6 -0
  34. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +217 -10
  35. package/templates/.claude/ukit/runtime/hook-input.sh +119 -0
  36. package/templates/.omp/config.yml +32 -4
  37. package/templates/.omp/hooks/pre/ukit-bridge.js +12 -1
  38. package/templates/AGENTS.md +22 -10
  39. package/templates/CLAUDE.md +22 -10
  40. package/templates/adapter-presets/opencode/opencode.template.json +1 -1
  41. package/templates/docs/UKIT_INTERNALS.md +17 -0
  42. package/templates/instructions/core.md +22 -10
  43. package/templates/instructions/layout.yaml +12 -12
  44. package/templates/ukit/storage/config.json +20 -0
@@ -14,7 +14,9 @@ import { appendTelemetryRow, TELEMETRY_VERSION } from './hook-telemetry.mjs';
14
14
  // disagree. Kept as a per-run resolution (not module constants) so an operator's
15
15
  // env change is honored without a reimport.
16
16
  import {
17
+ resolveChainBaseBudgetMs,
17
18
  resolveChainBudgetMs,
19
+ resolveChainCeilingMs,
18
20
  resolveChainChildBudgetMs,
19
21
  } from './hook-chain-budget.mjs';
20
22
 
@@ -28,6 +30,41 @@ const FAIL_CLOSED_SCRIPTS = new Set([
28
30
 
29
31
  const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
30
32
 
33
+ // TASK-234 review fix (critical): stdin staging must be bounded. The old
34
+ // `fs.readFileSync(0)` blocked until the producer closed the pipe — a stalled
35
+ // producer held the runner until the settings timeout SIGKILLed it, so every
36
+ // fail-closed gate in the chain silently failed OPEN (no verdict emitted).
37
+ // Bound the read: at most MAX_STDIN_BYTES and at most STDIN_STAGE_MS, then
38
+ // mark the payload degraded so children see the same truncated-input contract
39
+ // hook-input.sh gives them (UKIT_INPUT_TRUNCATED=1 → fail-closed gates deny).
40
+ const MAX_STDIN_BYTES = 2 * 1024 * 1024;
41
+ const STDIN_STAGE_MS = Number(process.env.UKIT_HOOK_STDIN_STAGE_MS || 2000);
42
+
43
+ async function readStdinBounded() {
44
+ return new Promise((resolve) => {
45
+ const chunks = [];
46
+ let bytes = 0;
47
+ let settled = false;
48
+ const finish = (truncated) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ clearTimeout(timer);
52
+ process.stdin.removeAllListeners();
53
+ process.stdin.unref?.();
54
+ resolve({ text: Buffer.concat(chunks).toString('utf8'), truncated });
55
+ };
56
+ const timer = setTimeout(() => finish(true), STDIN_STAGE_MS);
57
+ process.stdin.on('data', (chunk) => {
58
+ chunks.push(chunk);
59
+ bytes += chunk.length;
60
+ if (bytes > MAX_STDIN_BYTES) finish(true);
61
+ });
62
+ process.stdin.on('end', () => finish(false));
63
+ process.stdin.on('error', () => finish(true));
64
+ process.stdin.resume();
65
+ });
66
+ }
67
+
31
68
  // TASK-018 failure taxonomy (chain level; distinct from hook-process.mjs's
32
69
  // process-level kinds). Overflow, timeout, signal, and exit-code failures are
33
70
  // distinct values so downstream consumers never have to guess:
@@ -58,8 +95,24 @@ function recordTiming(projectRoot, payload, timing) {
58
95
  // appendTelemetryRow carries the same posture (and the per-session cap).
59
96
  appendTelemetryRow(projectRoot, payload?.session_id, timing);
60
97
  }
98
+ // TASK-234: a script arg may carry a per-script timeout suffix `<path>:<seconds>`
99
+ // so consolidated settings.json chains keep each hook's original settings.json
100
+ // `timeout` instead of sharing one child budget. The suffix is stripped before
101
+ // the path is used; absent → resolveChainChildBudgetMs() fallback, unchanged.
102
+ // A `:0` or negative suffix is rejected (falls back) — zero would mean "no
103
+ // budget", which silently disables the deadline; that is never a valid hook
104
+ // contract.
105
+ function parseScriptArg(arg) {
106
+ const match = /^(.*):(\d+(?:\.\d+)?)$/.exec(arg || '');
107
+ if (!match) return { scriptPath: arg, timeoutMs: null };
108
+ const seconds = Number(match[2]);
109
+ if (!Number.isFinite(seconds) || seconds <= 0) return { scriptPath: arg, timeoutMs: null };
110
+ return { scriptPath: match[1], timeoutMs: Math.round(seconds * 1000) };
111
+ }
61
112
 
62
- async function run(payloadText, scriptPaths) {
113
+ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
114
+ const parsedArgs = scriptArgs.map(parseScriptArg);
115
+ const scriptPaths = parsedArgs.map((a) => a.scriptPath);
63
116
  const payload = JSON.parse(payloadText || '{}');
64
117
  const firstScript = scriptPaths[0] || '';
65
118
  const projectRoot = firstScript
@@ -71,13 +124,41 @@ async function run(payloadText, scriptPaths) {
71
124
  // (UserPromptSubmit now carries 4 hooks). The floor keeps short chains at 10s,
72
125
  // and TASK-018's explicit ceiling stops the per-chain growth from running away.
73
126
  // Resolved per run (not at import) so an env change takes effect immediately.
74
- const childBudgetMs = resolveChainChildBudgetMs();
75
- const totalBudgetMs = resolveChainBudgetMs(scriptPaths.length);
127
+ const defaultChildBudgetMs = resolveChainChildBudgetMs();
128
+ const childBudgetsMs = parsedArgs.map((a) => a.timeoutMs ?? defaultChildBudgetMs);
129
+ // TASK-234: with per-script budgets the total must cover the SUM of declared
130
+ // budgets (not count × shared fallback) or a 6-hook Edit chain would be capped
131
+ // below its own scripts' combined timeouts. When EVERY script declares a
132
+ // budget the declared sum IS the inner budget — the count×fallback floor
133
+ // would otherwise exceed the registered outer timeout (PostToolUse Edit|Write:
134
+ // 3×12s=36s inner vs 34s outer → host kills the runner mid-chain). Mixed
135
+ // chains keep the floor so undeclared scripts still get the fallback.
136
+ const declaredTotalMs = childBudgetsMs.reduce((sum, ms) => sum + ms, 0);
137
+ const allDeclared = parsedArgs.every((a) => a.timeoutMs != null);
138
+ // TASK-234 review fix (important): the ceiling bounds FALLBACK-driven growth,
139
+ // never the declared contract — a 52s ceiling clamping a 63s declared sum
140
+ // starves the last fail-closed gate (context-hardcap-gate) and turns a legal
141
+ // edit into a block. Only EXPLICIT `:N` budgets raise the ceiling floor —
142
+ // fallback-filled budgets must not, or bare bridge chains would bypass the
143
+ // ceiling entirely (ompHookBridge TASK-018 pins that bound).
144
+ const explicitDeclaredMs = parsedArgs.reduce((sum, a) => sum + (a.timeoutMs ?? 0), 0);
145
+ const ceilingMs = Math.max(
146
+ resolveChainCeilingMs(),
147
+ resolveChainBaseBudgetMs(),
148
+ explicitDeclaredMs,
149
+ );
150
+ const totalBudgetMs = Math.min(
151
+ allDeclared
152
+ ? declaredTotalMs
153
+ : Math.max(resolveChainBudgetMs(scriptPaths.length), declaredTotalMs),
154
+ ceilingMs,
155
+ );
76
156
  const deadline = startedAt + totalBudgetMs;
77
157
  const results = [];
78
158
  let budgetExhausted = false;
79
159
 
80
- for (const scriptPath of scriptPaths) {
160
+ for (let scriptIndex = 0; scriptIndex < scriptPaths.length; scriptIndex++) {
161
+ const scriptPath = scriptPaths[scriptIndex];
81
162
  const scriptName = path.basename(scriptPath);
82
163
  const remainingMs = deadline - Date.now();
83
164
  if (remainingMs <= 0) {
@@ -97,9 +178,9 @@ async function run(payloadText, scriptPaths) {
97
178
  const childStartedAt = Date.now();
98
179
  const result = await runHookProcess({
99
180
  command: scriptPath,
181
+ deadlineMs: Math.min(childBudgetsMs[scriptIndex], remainingMs),
100
182
  args: [],
101
183
  input: payloadText,
102
- deadlineMs: Math.min(childBudgetMs, remainingMs),
103
184
  maxBuffer: MAX_BUFFER_BYTES,
104
185
  cwd: projectRoot,
105
186
  // TASK-223 (HK-401): mark chain-spawned children so their structured
@@ -109,7 +190,10 @@ async function run(payloadText, scriptPaths) {
109
190
  env: {
110
191
  ...process.env,
111
192
  CLAUDE_PROJECT_DIR: projectRoot,
112
- UKIT_HOOK_CHAIN_RUNNER: '1',
193
+ // TASK-234: the marker selects the omp structured-decision contract
194
+ // (ask + exit 2). Under --emit-verdict the runner replays the direct
195
+ // Claude contract (deny + exit 0), so children must NOT see it.
196
+ ...(chainMarker ? { UKIT_HOOK_CHAIN_RUNNER: '1' } : {}),
113
197
  },
114
198
  });
115
199
  const failureKind = chainFailureKind(result);
@@ -139,6 +223,15 @@ async function run(payloadText, scriptPaths) {
139
223
  }
140
224
  }
141
225
 
226
+ // TASK-234 review fix (critical): a mid-chain break (killed advisory, budget
227
+ // exhausted, or a fail-closed non-zero) leaves later scripts unrun. When any
228
+ // of those unrun scripts is fail-closed, the chain must fail CLOSED — the old
229
+ // per-script path ran every hook independently, so a timed-out advisory never
230
+ // skipped a gate. `skippedFailClosed` carries that signal to the verdict.
231
+ const skippedFailClosed = scriptPaths
232
+ .slice(results.length)
233
+ .some((p) => FAIL_CLOSED_SCRIPTS.has(path.basename(p)));
234
+
142
235
  const elapsedMs = Date.now() - startedAt;
143
236
  // TASK-019: versioned rows shared with direct hooks. `outcome` reuses this
144
237
  // runner's own failure taxonomy — the aggregate of the worst child result —
@@ -165,23 +258,137 @@ async function run(payloadText, scriptPaths) {
165
258
  })),
166
259
  });
167
260
 
168
- return { results, elapsedMs, budgetMs: totalBudgetMs, budgetExhausted };
261
+ return { results, elapsedMs, budgetMs: totalBudgetMs, budgetExhausted, skippedFailClosed };
169
262
  }
170
263
 
171
264
  try {
172
- const [, , payloadArg = '{}', ...scriptPaths] = process.argv;
265
+ let argv = process.argv.slice(2);
266
+ // TASK-234: `--emit-verdict` adapts the runner for Claude Code settings.json
267
+ // hooks, where the command's own stdout/exit-code IS the verdict — not the
268
+ // JSON aggregate the omp bridge parses. In this mode the runner replays the
269
+ // last executed script's stdout verbatim and maps the chain outcome onto the
270
+ // single-command contract: exit 0 normally; exit 2 + the child's stderr when
271
+ // a child blocked (code 2) or a FAIL_CLOSED script could not produce a
272
+ // verdict (killed/error/budget-exhausted). Non-fail-closed transport failures
273
+ // stay fail-open (exit 0), matching today's per-script behavior where a
274
+ // timed-out advisory hook never blocks the call.
275
+ const emitVerdict = argv[0] === '--emit-verdict';
276
+ if (emitVerdict) argv = argv.slice(1);
277
+ const [payloadArg = '{}', ...scriptPaths] = argv;
173
278
  // The bridge passes the payload as a temp file (`@path`) when it can: argv is capped
174
279
  // (~256KB per arg on macOS) and PostToolUse Bash payloads embed whole tool outputs.
175
280
  // A leading '@' cannot occur in raw JSON, so the two forms are unambiguous.
281
+ // '-' reads the payload from stdin — the form Claude Code hook commands use.
176
282
  let payloadText = payloadArg;
177
- if (payloadArg.startsWith('@')) {
283
+ let stdinTruncated = false;
284
+ if (payloadArg === '-') {
285
+ const staged = await readStdinBounded();
286
+ payloadText = staged.text;
287
+ stdinTruncated = staged.truncated;
288
+ } else if (payloadArg.startsWith('@')) {
178
289
  try {
179
290
  payloadText = fs.readFileSync(payloadArg.slice(1), 'utf8');
180
291
  } catch {
181
292
  payloadText = '{}';
182
293
  }
183
294
  }
184
- process.stdout.write(JSON.stringify(await run(payloadText, scriptPaths)));
295
+ // A truncated stdin means the payload was never fully read — the same
296
+ // degraded contract hook-input.sh enforces (uninspected input must never be
297
+ // treated as a clean scan). Children stage their own stdin and cannot see
298
+ // the runner's truncation, so the runner owns the degraded verdict: when the
299
+ // chain carries a fail-closed gate, emit deny now instead of letting gates
300
+ // pass on a payload they never fully received.
301
+ if (stdinTruncated) {
302
+ const hasFailClosed = scriptPaths.some((p) =>
303
+ FAIL_CLOSED_SCRIPTS.has(path.basename(String(p).replace(/:\d+(?:\.\d+)?$/, ''))));
304
+ if (hasFailClosed) {
305
+ const deny = JSON.stringify({
306
+ hookSpecificOutput: {
307
+ hookEventName: 'PreToolUse',
308
+ permissionDecision: 'deny',
309
+ permissionDecisionReason:
310
+ 'UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so the fail-closed gate chain cannot prove it safe.',
311
+ },
312
+ });
313
+ if (emitVerdict) {
314
+ process.stdout.write(deny);
315
+ process.exitCode = 0;
316
+ } else {
317
+ process.stdout.write(JSON.stringify({
318
+ results: [],
319
+ wrapperError: 'stdin staging truncated — fail-closed chain refused',
320
+ stdinTruncated: true,
321
+ }));
322
+ process.exitCode = 2;
323
+ }
324
+ process.exit(emitVerdict ? 0 : 2);
325
+ }
326
+ }
327
+ const chain = await run(payloadText, scriptPaths, { chainMarker: !emitVerdict });
328
+ if (!emitVerdict) {
329
+ process.stdout.write(JSON.stringify(chain));
330
+ } else {
331
+ // TASK-234: a script that emits a hookSpecificOutput decision JSON owns the
332
+ // verdict even when it exits 0 (the direct Claude contract: deny + exit 0
333
+ // still blocks). The first decision wins, matching per-script semantics
334
+ // where each hook's output is its own verdict and a deny short-circuits.
335
+ const decisionResult = chain.results.find((r) =>
336
+ typeof r.stdout === 'string' && r.stdout.includes('"hookSpecificOutput"'));
337
+ const last = decisionResult ?? chain.results[chain.results.length - 1];
338
+
339
+ // TASK-234 review fix (critical): context stdout must be REPLAYED, not
340
+ // dropped. SessionStart/UserPromptSubmit hooks emit plain-text context
341
+ // (PROJECT_IMPORTANT mandate, skill-router guidance) — replaying only the
342
+ // decision owner's or last script's stdout loses every earlier emission.
343
+ // Concatenate every script's non-decision stdout in order, then append the
344
+ // decision JSON last so the verdict still parses.
345
+ const contextStdout = chain.results
346
+ .filter((r) => r !== decisionResult && r !== last && typeof r.stdout === 'string' && r.stdout.length > 0)
347
+ .map((r) => r.stdout)
348
+ .join('');
349
+
350
+ // TASK-234 review fix (critical): a mid-chain break that skipped a
351
+ // fail-closed gate must fail CLOSED — the old per-script path ran every
352
+ // hook independently, so a killed advisory never skipped a gate.
353
+ if (chain.skippedFailClosed) {
354
+ if (contextStdout) process.stdout.write(contextStdout);
355
+ const skipped = scriptPaths
356
+ .slice(chain.results.length)
357
+ .map((p) => path.basename(String(p).replace(/:\d+(?:\.\d+)?$/, '')))
358
+ .filter((name) => FAIL_CLOSED_SCRIPTS.has(name))
359
+ .join(', ');
360
+ process.stderr.write(`UKit hook chain broke before fail-closed gate(s) ran: ${skipped}\n`);
361
+ process.exitCode = 2;
362
+ } else if (!last) {
363
+ // No script ran at all (empty chain or budget spent before the first
364
+ // child). With fail-closed scripts declared in the chain this must not
365
+ // fail open.
366
+ const hasFailClosed = scriptPaths.some((p) => FAIL_CLOSED_SCRIPTS.has(path.basename(String(p).replace(/:\d+(?:\.\d+)?$/, ''))));
367
+ if (hasFailClosed) {
368
+ process.stderr.write('UKit hook chain produced no verdict — fail-closed gate did not run\n');
369
+ process.exitCode = 2;
370
+ }
371
+ } else {
372
+ const lastIsFailClosed = FAIL_CLOSED_SCRIPTS.has(last.scriptName);
373
+ const lastFailedToVerdict = last.killed || last.failureKind === 'error' || last.failureKind === 'budget-exhausted';
374
+ if (last.code === 2) {
375
+ if (contextStdout) process.stdout.write(contextStdout);
376
+ if (last.stdout) process.stdout.write(last.stdout);
377
+ if (last.stderr) process.stderr.write(last.stderr);
378
+ process.exitCode = 2;
379
+ } else if (lastIsFailClosed && lastFailedToVerdict) {
380
+ if (contextStdout) process.stdout.write(contextStdout);
381
+ if (last.stderr) process.stderr.write(last.stderr);
382
+ else process.stderr.write(`UKit fail-closed hook ${last.scriptName} could not produce a verdict (${last.failureKind})\n`);
383
+ process.exitCode = 2;
384
+ } else {
385
+ if (contextStdout) process.stdout.write(contextStdout);
386
+ if (last.stdout) process.stdout.write(last.stdout);
387
+ if (last.stderr) process.stderr.write(last.stderr);
388
+ process.exitCode = last.code === 0 ? 0 : (lastIsFailClosed ? 2 : 0);
389
+ }
390
+ }
391
+ }
185
392
  } catch (error) {
186
393
  process.stdout.write(JSON.stringify({
187
394
  results: [],
@@ -169,6 +169,125 @@ ukit_emit_input_degraded() {
169
169
  exit 0
170
170
  }
171
171
 
172
+ # TASK-003 (salvage verdicts from truncated payloads): when the staged payload
173
+ # is flagged truncated/stalled, a gated hook may still recover a DECISION-RELEVANT
174
+ # field if that field's value is provably COMPLETE — the closing quote AND a
175
+ # following `,`/`}` boundary must appear before the cut. Anything else (cut
176
+ # inside the string, EOF right after the quote, malformed prefix, missing key)
177
+ # is unrecoverable: callers map that to `deny`, never `ask` (bypassPermissions
178
+ # auto-approves `ask` on the direct host = gates silently skipped).
179
+ #
180
+ # Contract:
181
+ # ukit_salvage_tool_field <json-file> <dotted-field>
182
+ # stdout: the decoded string value (single line may contain \n escapes
183
+ # decoded — the value itself is written raw)
184
+ # exit 0 — field recovered AND proven complete
185
+ # exit 3 — unrecoverable / incomplete / malformed / non-string value
186
+ # Budget: UKIT_SALVAGE_BUDGET_MS (default 3000) bounds the node process; the
187
+ # file is already capped by staging (≤2MiB), so no unbounded reads.
188
+ # Single string fields only — this is a salvage step, not a JSON repairer.
189
+ ukit_salvage_tool_field() {
190
+ UKIT_SALVAGE_FILE="$1" UKIT_SALVAGE_FIELD="$2" \
191
+ UKIT_SALVAGE_BUDGET_MS="${UKIT_SALVAGE_BUDGET_MS:-3000}" node <<'UKIT_SALVAGE_NODE'
192
+ const budget = Number.parseInt(process.env.UKIT_SALVAGE_BUDGET_MS || '3000', 10) || 3000;
193
+ setTimeout(() => process.exit(3), budget).unref();
194
+ const fs = require('fs');
195
+ const fail = () => process.exit(3);
196
+ let data;
197
+ try {
198
+ data = fs.readFileSync(process.env.UKIT_SALVAGE_FILE || '', 'utf8');
199
+ } catch {
200
+ fail();
201
+ }
202
+ const dotted = String(process.env.UKIT_SALVAGE_FIELD || '').split('.').filter(Boolean);
203
+ if (!data || data[0] !== '{' || dotted.length === 0 || dotted.length > 4) fail();
204
+
205
+ const isWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
206
+ const skipWs = (s, i) => { while (i < s.length && isWs(s[i])) i += 1; return i; };
207
+ // End index of the string literal starting at `start` (which must be `"`), or -1
208
+ // when the string is cut before its closing quote.
209
+ const scanStringEnd = (s, start) => {
210
+ for (let i = start + 1; i < s.length; i += 1) {
211
+ const c = s[i];
212
+ if (c === '\\') { i += 1; continue; }
213
+ if (c === '"') return i;
214
+ }
215
+ return -1;
216
+ };
217
+ // Closing brace matching the `{` at `open` (string-aware), or -1 if unclosed.
218
+ const matchBrace = (s, open) => {
219
+ let depth = 0;
220
+ for (let i = open; i < s.length; i += 1) {
221
+ const c = s[i];
222
+ if (c === '"') {
223
+ const end = scanStringEnd(s, i);
224
+ if (end === -1) return -1;
225
+ i = end;
226
+ continue;
227
+ }
228
+ if (c === '{') depth += 1;
229
+ else if (c === '}') {
230
+ depth -= 1;
231
+ if (depth === 0) return i;
232
+ }
233
+ }
234
+ return -1;
235
+ };
236
+ // Find `"key"` used as an object key inside region [lo, hi); returns the index
237
+ // of its `:` or -1. A bare `"key"` inside a string value cannot produce this
238
+ // shape (its quotes are escaped), and non-key uses lack the `:` — both are
239
+ // skipped by scanning forward.
240
+ const findKey = (s, key, lo, hi) => {
241
+ const needle = `"${key}"`;
242
+ let pos = s.indexOf(needle, lo);
243
+ while (pos !== -1 && pos < hi) {
244
+ const colon = skipWs(s, pos + needle.length);
245
+ if (colon < hi && colon < s.length && s[colon] === ':') {
246
+ const prev = pos - 1;
247
+ const pc = prev >= 0 ? s[prev] : '';
248
+ if (prev < 0 || pc === '{' || pc === ',' || isWs(pc)) return colon;
249
+ }
250
+ pos = s.indexOf(needle, pos + 1);
251
+ }
252
+ return -1;
253
+ };
254
+
255
+ let regionLo = 0;
256
+ let regionHi = data.length;
257
+ for (let k = 0; k < dotted.length; k += 1) {
258
+ const colon = findKey(data, dotted[k], regionLo, regionHi);
259
+ if (colon === -1) fail();
260
+ const vstart = skipWs(data, colon + 1);
261
+ if (vstart >= data.length) fail();
262
+ const last = k === dotted.length - 1;
263
+ const c = data[vstart];
264
+ if (!last) {
265
+ if (c !== '{') fail();
266
+ const close = matchBrace(data, vstart);
267
+ // An unclosed parent object still bounds the search to what arrived; the
268
+ // leaf's own boundary proof below decides completeness.
269
+ regionLo = vstart + 1;
270
+ regionHi = close === -1 ? data.length : close;
271
+ continue;
272
+ }
273
+ if (c !== '"') fail(); // string fields only
274
+ const end = scanStringEnd(data, vstart);
275
+ if (end === -1) fail(); // cut inside the value — never trust a partial field
276
+ const after = skipWs(data, end + 1);
277
+ if (after >= data.length) fail(); // closed quote but no boundary proof — err closed
278
+ const boundary = data[after];
279
+ if (boundary !== ',' && boundary !== '}') fail();
280
+ try {
281
+ process.stdout.write(JSON.parse(data.slice(vstart, end + 1)));
282
+ } catch {
283
+ fail();
284
+ }
285
+ process.exit(0);
286
+ }
287
+ fail();
288
+ UKIT_SALVAGE_NODE
289
+ }
290
+
172
291
  ukit_cleanup_hook_input() {
173
292
  # TASK-019: emit the telemetry finish marker while the staged payload file
174
293
  # still exists (its mtime is the envelope start). Strictly advisory — the
@@ -15,10 +15,37 @@ modelRoles:
15
15
  vision: unic-vision
16
16
 
17
17
  tools:
18
- approvalMode: write
18
+ # TASK-009 / SPEC FR-003: the unattended approval surface is pinned HERE —
19
+ # `approvalMode: yolo` plus all ten `tools.approval.*: allow`. The product
20
+ # source of truth is `orchestration.permissionMode: unattended`
21
+ # (.ukit/storage/config.json, written by TASK-008); this map is its omp-side
22
+ # rendering so installed projects run prompt-free out of the box.
23
+ # This SUPERSEDES the TASK-006/C34 omission decision (approvalMode left absent
24
+ # so the end user's global/runtime mode won): an interactive global would
25
+ # silently downgrade a shipped install to prompting, contradicting the
26
+ # unattended contract — the project-level pin is now required, not optional.
27
+ # `approvalMode: yolo` VERIFIED against omp v17.4.2 (2026-08-22) — same
28
+ # docContracts allowlist citation as compaction.thresholdTokens below.
29
+ # Safety under yolo is unchanged: the bash.patterns deny set below, the
30
+ # TASK-004 hook-bridge deny chain, and this approval map still gate dangerous
31
+ # ops — `allow` removes interactive prompts, not the deny checks.
32
+ # Residual risk: yolo + all-allow IS dangerously autonomous and is a
33
+ # deliberate, user-chosen posture (unattended mode). `eval` stays `allow`
34
+ # (was `prompt` pre-TASK-001): bash.patterns never covers the eval tool, but
35
+ # the TASK-004 bridge maps `eval` -> `Bash`, so every eval call is gated by
36
+ # the same block-dangerous chain as a bash command.
37
+ approvalMode: yolo
19
38
  approval:
20
39
  bash: allow
21
- eval: prompt
40
+ eval: allow
41
+ task: allow
42
+ read: allow
43
+ grep: allow
44
+ write: allow
45
+ edit: allow
46
+ lsp: allow
47
+ browser: allow
48
+ computer: allow
22
49
 
23
50
  bash:
24
51
  # Translated from templates/.claude/hooks/block-dangerous.sh's DANGEROUS_PATTERNS array.
@@ -27,8 +54,9 @@ bash:
27
54
  # catches it. `allow` only ever matches an ENTIRE, non-compound command, so the trailing "*"
28
55
  # allow is NOT a universal escape hatch: any compound command (`&&`, `;`, `|`) not itself caught
29
56
  # by a `deny`/`prompt` entry falls through to `tools.approvalMode` instead of being auto-allowed.
30
- # `bash.patterns` does not cover the `eval` tool at all — that is why `tools.approval.eval` above
31
- # is `prompt`, and why TASK-004's bridge separately maps `eval` -> `Bash`.
57
+ # `bash.patterns` does not cover the `eval` tool at all — that is why TASK-004's bridge
58
+ # separately maps `eval` -> `Bash`, and why `tools.approval.eval: allow` above does NOT
59
+ # weaken eval safety: eval stays gated by the bridge's Bash chain (TASK-001).
32
60
  patterns:
33
61
  - match: "rm -rf /*"
34
62
  approval: deny
@@ -294,10 +294,18 @@ function translateExecResult(scriptName, execResult) {
294
294
  if (code === 2) {
295
295
  const structured = parseStructuredDecision(stdout);
296
296
  if (structured?.permissionDecision === 'ask') {
297
+ const askBase = structured.reason || stderr || `${scriptName} requires a human decision`;
298
+ // omp has no native `ask` — hook() converts this to a block. A bare
299
+ // "defers to a human" reason dead-ended under YOLO, so the reason must
300
+ // name the cause class + the recovery action and the chain must surface
301
+ // a display:true note (done in runScriptChain) so a deliberate block is
302
+ // distinguishable from a stall.
297
303
  return {
298
304
  block: false,
299
305
  ask: true,
300
- reason: structured.reason || stderr || `${scriptName} requires a human decision`,
306
+ reason: `${askBase} [UKit ask→block: omp has no native "ask", so this gate decision was surfaced as a block. `
307
+ + 'Recovery: if the cause was an oversized or truncated payload, shrink the payload or re-send it; '
308
+ + 'if the gate is asking for a decision, decide explicitly — answer the question and re-send the call.]',
301
309
  stdout,
302
310
  stderr,
303
311
  };
@@ -628,6 +636,9 @@ export async function runScriptChain(
628
636
  return { block: true, reason: verdict.reason, context, invoked };
629
637
  }
630
638
  if (verdict.ask) {
639
+ // The block must be user-visible, not just model-visible — a deliberate
640
+ // ask→block has to be distinguishable from a silent stall.
641
+ sendContext(pi, [`[UKit] Gate asks for a human decision — the call was blocked: ${verdict.reason}`], 'nextTurn', { display: true });
631
642
  return { block: false, ask: true, reason: verdict.reason, context, invoked };
632
643
  }
633
644
  }
@@ -26,6 +26,14 @@
26
26
  <!-- RULE: EXEC-04 -->
27
27
  - **Every stop says why — no silent idle.** Turns ending on a user-only action open with `WAITING ON YOU: <command/action>` plus a one-shot wakeup (~20-30 min) when available — an ended turn cannot observe external changes, so without it idle looks identical to a stall. Report any error verbatim the same turn.
28
28
 
29
+ ## Stall & Wait Reporting
30
+ <!-- RULE: STALL-01 -->
31
+ - Announce long waits as they happen: `Waiting for API response / tool result — will keep retrying; check your network/API provider if this persists`. Surface any API/tool error **verbatim** in the same turn, and name network/API as the suspect on long stalls — **never blame UKit**. This guidance makes the model's side of a wait visible; it cannot detect a stall inside the host's own request loop.
32
+ <!-- RULE: STALL-02 -->
33
+ - Every stop still says why (Execution Contract EXEC-04) — the stall rules above extend it, they do not replace it.
34
+ <!-- RULE: STALL-03 -->
35
+ - **Conditional workaround only:** fall back to one tool call per assistant message ONLY when malformed/concatenated tool-input errors are observed or the harness version is known-affected — otherwise keep using legitimate parallel calls; serializing healthy hosts contradicts batching guidance.
36
+
29
37
  ## Long-Run Continuity
30
38
  <!-- RULES: LONG-01 LONG-02 -->
31
39
  - Near token-cap: **LAND one thing** end-to-end (edit + verify, ≤3 tool calls), **DEFER** the rest into `docs/STATUS.md` or bounded `docs/AI_HANDOFF/` tasks, **DELEGATE** broad work to subagents. Only then compact.
@@ -69,8 +77,8 @@ For any task needing code context:
69
77
 
70
78
  ## UKit v{{ukit.version}} Shared Runtime
71
79
  - Runtime state lives in `.ukit/storage/`; `.ukit/storage/config.json` holds runtime toggles (compact, token pipeline, router, memory, validation, Safe Patch).
72
- - Reuse `.ukit/storage/memory/` + `ukit memory recall "<current task>"` before asking users to restate decisions; inspect via `ukit status` / `ukit memory export`.
73
- - Route memory: `.claude/ukit/skill-router-state.json` — reuse compact `previous-context`/`recent-output` first. Cache state: `.ukit/storage/cache/output-history.json`, tee/. Missing/corrupt runtime or old `ukit/` root → rerun `ukit install`. Detail: `docs/UKIT_INTERNALS.md`.
80
+ - Reuse `.ukit/storage/memory/` + `ukit memory recall "<current task>"` before asking users to restate decisions; `ukit memory learn` surfaces pending proposals, `ukit memory promote` writes approved rules to MEMORY.md, `ukit memory episode` records session episodes; inspect via `ukit status` / `ukit memory export`.
81
+ - Route memory: `.claude/ukit/skill-router-state.json` — reuse compact `previous-context`/`recent-output` first. Cache state: `.ukit/storage/cache/output-history.json`, `retriever-lanes.jsonl`, `tee/`. Lifecycle hooks all degrade to exit 0 (SessionEnd `session-episode.sh` auto-writes episodes; full map: `docs/UKIT_INTERNALS.md`). Missing/corrupt runtime or old `ukit/` root → rerun `ukit install`. Detail: `docs/UKIT_INTERNALS.md`.
74
82
 
75
83
  ## Prompt Caching
76
84
  <!-- RULES: CTX-01 CTX-02 CTX-03 CTX-04 CTX-05 CTX-06 CTX-07 CTX-08 CTX-09 CTX-10 -->
@@ -84,9 +92,7 @@ For any task needing code context:
84
92
 
85
93
  ## Handoff Quality Gate — OPT-IN
86
94
  <!-- RULE: HAND-01 -->
87
- CHỈ kích hoạt khi task đi qua `docs/AI_HANDOFF/` (user nói "execute task TASK-xxx" hoặc target là `docs/AI_HANDOFF/tasks/*.md`). Daily prompt → KHÔNG đụng, flow cũ giữ nguyên.
88
-
89
- Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+Plan → Create Tasks → Implement+Test → Review+Test) + state machine + self-report model. Config: `.ukit/storage/config.json` → `handoff.*`.
95
+ CHỈ kích hoạt khi task đi qua `docs/AI_HANDOFF/` (user nói "execute task TASK-xxx" hoặc target là `docs/AI_HANDOFF/tasks/*.md`). Daily prompt → KHÔNG đụng, flow cũ giữ nguyên. Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+Plan → Create Tasks → Implement+Test → Review+Test) + state machine + self-report model. Config: `.ukit/storage/config.json` → `handoff.*`.
90
96
 
91
97
  ## Context + Verification Budget
92
98
  <!-- RULE: BUDGET-01 -->
@@ -99,14 +105,10 @@ Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+P
99
105
  - `docs/STATUS.md` captures compact current state — not source truth, never replaces source/index-first investigation.
100
106
  - "What next?"/"continue" → `next-step` with a freshness cue; after meaningful work → `update-status`. `docs/TASKS.md` is the local AI task queue — prefer `Ready for AI`. Detail: `docs/UKIT_INTERNALS.md`.
101
107
 
102
- ## Small-Task Maintainer (internal)
108
+ ## Subagent Lanes (internal)
103
109
  <!-- RULE: SUBAG-02 -->
104
110
  - The `ukit-small-task-maintainer` subagent (`subagents.smallTaskModel`, default `unic-lite`) handles safe/reversible UKit chores as a sidecar lane — never block or slow the user task; risky work hands back to the main model. Detail: `docs/UKIT_INTERNALS.md`.
105
-
106
- ## Post-Edit Sidecar Review (internal)
107
111
  - When routed state's `routeSummary.line` carries `review=code-reviewer(diff)`, launch `code-reviewer` in background (`smart` tier) **only after** write + verification evidence; findings advisory — never block the reported completion. Detail: `docs/UKIT_INTERNALS.md`.
108
-
109
- ## Selective Subagent Policy (internal only)
110
112
  <!-- RULE: SUBAG-01 -->
111
113
  - Direct execution is default for trivial/simple work; delegate only on meaningful context shrink or parallel gains (noisy side lanes, 3+ independent failures, batch plans). Never ask end users to name agents or remember agent commands.
112
114
 
@@ -114,6 +116,16 @@ Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+P
114
116
  <!-- RULE: AUTO-01 -->
115
117
  - `autonomy.level` in `.ukit/storage/config.json` controls how much UKit acts without asking: `conservative` (ask more), `balanced` (default), `free-run` (auto-run more), `vibecode` (one prompt to a finished result; the gate stops only on completion evidence, a genuine blocker, or a dangerous-command decision). End users should not need to change it.
116
118
 
119
+ ## Unattended Completion Loop
120
+ <!-- RULE: UNATTENDED-01 -->
121
+ - Under `orchestration.permissionMode: unattended` there is no human in the loop — the loop is UNDERSTAND → PLAN → IMPLEMENT → VERIFY → FIX → REVERIFY → COMPLETE. A turn ends only on COMPLETE evidence, a real external blocker, or a deny decision.
122
+ <!-- RULE: UNATTENDED-02 -->
123
+ - `maxAttemptsPerFailure: 5` per failing verification, `maxRecoveryStrategies: 3` distinct fix strategies before reassessing — retry the same edit, fix the cause differently, then reduce scope/approach; do not loop one strategy.
124
+ <!-- RULE: UNATTENDED-03 -->
125
+ - BLOCKED only on true external blockers: missing credentials, dead service, unreachable network/API. Test failures, lint errors, review findings, and ambiguity are NOT blockers — they feed FIX.
126
+ <!-- RULE: UNATTENDED-04 -->
127
+ - A deny (hook/gate/permission) never becomes a question: pick the next safer alternative that satisfies the intent and record the substitution. Never emit "do you want me to…" — decide and proceed.
128
+
117
129
  ## 3-Tier Model Routing
118
130
  <!-- RULES: TIER-01 TIER-02 -->
119
131
  **Internal orchestration only — end users still just use natural language. No new commands.**
@@ -26,6 +26,14 @@
26
26
  <!-- RULE: EXEC-04 -->
27
27
  - **Every stop says why — no silent idle.** Turns ending on a user-only action open with `WAITING ON YOU: <command/action>` plus a one-shot wakeup (~20-30 min) when available — an ended turn cannot observe external changes, so without it idle looks identical to a stall. Report any error verbatim the same turn.
28
28
 
29
+ ## Stall & Wait Reporting
30
+ <!-- RULE: STALL-01 -->
31
+ - Announce long waits as they happen: `Waiting for API response / tool result — will keep retrying; check your network/API provider if this persists`. Surface any API/tool error **verbatim** in the same turn, and name network/API as the suspect on long stalls — **never blame UKit**. This guidance makes the model's side of a wait visible; it cannot detect a stall inside the host's own request loop.
32
+ <!-- RULE: STALL-02 -->
33
+ - Every stop still says why (Execution Contract EXEC-04) — the stall rules above extend it, they do not replace it.
34
+ <!-- RULE: STALL-03 -->
35
+ - **Conditional workaround only:** fall back to one tool call per assistant message ONLY when malformed/concatenated tool-input errors are observed or the harness version is known-affected — otherwise keep using legitimate parallel calls; serializing healthy hosts contradicts batching guidance.
36
+
29
37
  ## Long-Run Continuity
30
38
  <!-- RULES: LONG-01 LONG-02 -->
31
39
  - Near token-cap: **LAND one thing** end-to-end (edit + verify, ≤3 tool calls), **DEFER** the rest into `docs/STATUS.md` or bounded `docs/AI_HANDOFF/` tasks, **DELEGATE** broad work to subagents. Only then compact.
@@ -69,8 +77,8 @@ For any task needing code context:
69
77
 
70
78
  ## UKit v{{ukit.version}} Shared Runtime
71
79
  - Runtime state lives in `.ukit/storage/`; `.ukit/storage/config.json` holds runtime toggles (compact, token pipeline, router, memory, validation, Safe Patch).
72
- - Reuse `.ukit/storage/memory/` + `ukit memory recall "<current task>"` before asking users to restate decisions; inspect via `ukit status` / `ukit memory export`.
73
- - Route memory: `.claude/ukit/skill-router-state.json` — reuse compact `previous-context`/`recent-output` first. Cache state: `.ukit/storage/cache/output-history.json`, tee/. Missing/corrupt runtime or old `ukit/` root → rerun `ukit install`. Detail: `docs/UKIT_INTERNALS.md`.
80
+ - Reuse `.ukit/storage/memory/` + `ukit memory recall "<current task>"` before asking users to restate decisions; `ukit memory learn` surfaces pending proposals, `ukit memory promote` writes approved rules to MEMORY.md, `ukit memory episode` records session episodes; inspect via `ukit status` / `ukit memory export`.
81
+ - Route memory: `.claude/ukit/skill-router-state.json` — reuse compact `previous-context`/`recent-output` first. Cache state: `.ukit/storage/cache/output-history.json`, `retriever-lanes.jsonl`, `tee/`. Lifecycle hooks all degrade to exit 0 (SessionEnd `session-episode.sh` auto-writes episodes; full map: `docs/UKIT_INTERNALS.md`). Missing/corrupt runtime or old `ukit/` root → rerun `ukit install`. Detail: `docs/UKIT_INTERNALS.md`.
74
82
 
75
83
  ## Prompt Caching
76
84
  <!-- RULES: CTX-01 CTX-02 CTX-03 CTX-04 CTX-05 CTX-06 CTX-07 CTX-08 CTX-09 CTX-10 -->
@@ -84,9 +92,7 @@ For any task needing code context:
84
92
 
85
93
  ## Handoff Quality Gate — OPT-IN
86
94
  <!-- RULE: HAND-01 -->
87
- CHỈ kích hoạt khi task đi qua `docs/AI_HANDOFF/` (user nói "execute task TASK-xxx" hoặc target là `docs/AI_HANDOFF/tasks/*.md`). Daily prompt → KHÔNG đụng, flow cũ giữ nguyên.
88
-
89
- Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+Plan → Create Tasks → Implement+Test → Review+Test) + state machine + self-report model. Config: `.ukit/storage/config.json` → `handoff.*`.
95
+ CHỈ kích hoạt khi task đi qua `docs/AI_HANDOFF/` (user nói "execute task TASK-xxx" hoặc target là `docs/AI_HANDOFF/tasks/*.md`). Daily prompt → KHÔNG đụng, flow cũ giữ nguyên. Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+Plan → Create Tasks → Implement+Test → Review+Test) + state machine + self-report model. Config: `.ukit/storage/config.json` → `handoff.*`.
90
96
 
91
97
  ## Context + Verification Budget
92
98
  <!-- RULE: BUDGET-01 -->
@@ -99,14 +105,10 @@ Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+P
99
105
  - `docs/STATUS.md` captures compact current state — not source truth, never replaces source/index-first investigation.
100
106
  - "What next?"/"continue" → `next-step` with a freshness cue; after meaningful work → `update-status`. `docs/TASKS.md` is the local AI task queue — prefer `Ready for AI`. Detail: `docs/UKIT_INTERNALS.md`.
101
107
 
102
- ## Small-Task Maintainer (internal)
108
+ ## Subagent Lanes (internal)
103
109
  <!-- RULE: SUBAG-02 -->
104
110
  - The `ukit-small-task-maintainer` subagent (`subagents.smallTaskModel`, default `unic-lite`) handles safe/reversible UKit chores as a sidecar lane — never block or slow the user task; risky work hands back to the main model. Detail: `docs/UKIT_INTERNALS.md`.
105
-
106
- ## Post-Edit Sidecar Review (internal)
107
111
  - When routed state's `routeSummary.line` carries `review=code-reviewer(diff)`, launch `code-reviewer` in background (`smart` tier) **only after** write + verification evidence; findings advisory — never block the reported completion. Detail: `docs/UKIT_INTERNALS.md`.
108
-
109
- ## Selective Subagent Policy (internal only)
110
112
  <!-- RULE: SUBAG-01 -->
111
113
  - Direct execution is default for trivial/simple work; delegate only on meaningful context shrink or parallel gains (noisy side lanes, 3+ independent failures, batch plans). Never ask end users to name agents or remember agent commands.
112
114
 
@@ -114,6 +116,16 @@ Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+P
114
116
  <!-- RULE: AUTO-01 -->
115
117
  - `autonomy.level` in `.ukit/storage/config.json` controls how much UKit acts without asking: `conservative` (ask more), `balanced` (default), `free-run` (auto-run more), `vibecode` (one prompt to a finished result; the gate stops only on completion evidence, a genuine blocker, or a dangerous-command decision). End users should not need to change it.
116
118
 
119
+ ## Unattended Completion Loop
120
+ <!-- RULE: UNATTENDED-01 -->
121
+ - Under `orchestration.permissionMode: unattended` there is no human in the loop — the loop is UNDERSTAND → PLAN → IMPLEMENT → VERIFY → FIX → REVERIFY → COMPLETE. A turn ends only on COMPLETE evidence, a real external blocker, or a deny decision.
122
+ <!-- RULE: UNATTENDED-02 -->
123
+ - `maxAttemptsPerFailure: 5` per failing verification, `maxRecoveryStrategies: 3` distinct fix strategies before reassessing — retry the same edit, fix the cause differently, then reduce scope/approach; do not loop one strategy.
124
+ <!-- RULE: UNATTENDED-03 -->
125
+ - BLOCKED only on true external blockers: missing credentials, dead service, unreachable network/API. Test failures, lint errors, review findings, and ambiguity are NOT blockers — they feed FIX.
126
+ <!-- RULE: UNATTENDED-04 -->
127
+ - A deny (hook/gate/permission) never becomes a question: pick the next safer alternative that satisfies the intent and record the substitution. Never emit "do you want me to…" — decide and proceed.
128
+
117
129
  ## 3-Tier Model Routing
118
130
  <!-- RULES: TIER-01 TIER-02 -->
119
131
  **Internal orchestration only — end users still just use natural language. No new commands.**