@ngockhoale/ukit 3.4.47 → 3.4.48

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 (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/manifests/documentation.yaml +22 -0
  3. package/manifests/platform.full.yaml +27 -0
  4. package/package.json +4 -1
  5. package/scripts/dev/worktree-preflight.mjs +20 -1
  6. package/src/core/diffPlan.js +38 -12
  7. package/src/core/executionContracts.js +67 -16
  8. package/src/core/metadata.js +4 -0
  9. package/src/core/runInstallPipeline.js +19 -2
  10. package/src/core/runtimeConfig.js +1 -1
  11. package/src/index/playbookRegistry.js +22 -3
  12. package/src/index/taskRouting.js +2 -2
  13. package/template_project/.claude/agents/bug-debugger.md +1 -0
  14. package/template_project/.claude/agents/code-reviewer.md +1 -0
  15. package/template_project/.claude/agents/feature-implementer.md +1 -0
  16. package/template_project/.claude/agents/handoff-planner.md +1 -0
  17. package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
  18. package/template_project/.claude/hooks/completion-gate.sh +1 -1
  19. package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
  20. package/template_project/.claude/hooks/skill-router.sh +147 -6
  21. package/template_project/.claude/settings.json +8 -8
  22. package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
  23. package/template_project/.claude/ukit/index/route-resolver.mjs +67 -15
  24. package/template_project/.claude/ukit/index/route-task.mjs +115 -37
  25. package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
  26. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
  27. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +306 -20
  28. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
  29. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  30. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
  31. package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
  32. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +79 -3
  33. package/template_project/.omp/agents/bug-debugger.md +1 -0
  34. package/template_project/.omp/agents/code-reviewer.md +1 -0
  35. package/template_project/.omp/agents/feature-implementer.md +1 -0
  36. package/template_project/.omp/agents/handoff-planner.md +1 -0
  37. package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
  38. package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
  39. package/template_project/ukit/storage/config.json +1 -1
  40. package/scripts/bench/data-foundation.mjs +0 -929
  41. package/scripts/bench/decision-runtime-baseline.mjs +0 -427
  42. package/scripts/bench/decision-runtime-variant.mjs +0 -626
  43. package/scripts/bench/goldTasks.json +0 -38
  44. package/scripts/bench/memory-ablation.mjs +0 -495
  45. package/scripts/bench/memory-baseline.mjs +0 -596
  46. package/scripts/bench/memory-bench.mjs +0 -661
  47. package/scripts/bench/memory-canary.mjs +0 -331
  48. package/scripts/bench/memory-corpus.mjs +0 -354
  49. package/scripts/bench/memory-gate.mjs +0 -389
  50. package/scripts/bench/memory-metrics.mjs +0 -179
  51. package/scripts/bench/outline-savings.mjs +0 -107
  52. package/scripts/bench/parallel-agents.mjs +0 -224
  53. package/scripts/bench/recorder-overhead.mjs +0 -204
  54. package/scripts/bench/runGold.mjs +0 -238
  55. package/scripts/bench/sqlite-spike.mjs +0 -451
  56. package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
  57. package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
  58. package/scripts/bench/v3-ceremony.mjs +0 -220
  59. package/scripts/perf/audit-perf.mjs +0 -1195
  60. package/scripts/perf/diff-perf-findings.mjs +0 -136
  61. package/scripts/perf/perf-findings.json +0 -474
  62. package/scripts/perf/perf-measure.md +0 -271
@@ -2,6 +2,7 @@
2
2
  name: bug-debugger
3
3
  description: "Debugging specialist for reproducible errors, failing tests, and unexpected behavior. Use proactively when investigation will involve noisy logs, stack traces, or a self-contained reproduce-trace-fix-verify loop. Do not use for trivial obvious fixes or broad architecture ideation."
4
4
  model: sonnet # unic-code
5
+ effort: medium
5
6
  color: red
6
7
  tools: ["Read", "Grep", "Glob", "Bash", "Edit", "TodoWrite"]
7
8
  ---
@@ -2,6 +2,7 @@
2
2
  name: code-reviewer
3
3
  description: "Independent reviewer for handoff Phase 3 and for spec/plan documents. For code (default): use after executor reports STATUS: DONE on a handoff task, MUST run with a model different from the executor (configured in .ukit/storage/config.json → handoff.reviewer.model, default unic-smart), produces a verdict: APPROVED | APPROVED-WITH-MINOR | CHANGES-REQUESTED | CRITICAL. For spec/plan documents (set REVIEW_TARGET_TYPE=spec or plan): reviews a docs/plans/*.md file for completeness/consistency/clarity/scope/YAGNI, produces Status: Approved | Issues Found."
4
4
  model: opus # unic-smart
5
+ effort: medium
5
6
  color: yellow
6
7
  tools: ["Read", "Grep", "Glob", "Bash"]
7
8
  ---
@@ -2,6 +2,7 @@
2
2
  name: feature-implementer
3
3
  description: "Implementation specialist for clear, self-contained coding tasks. Use proactively when the approach is clear and the work can be completed in a bounded pass with a concise summary. Do not use for trivial one-file tweaks or tightly coupled exploratory work."
4
4
  model: sonnet # unic-code
5
+ effort: medium
5
6
  color: green
6
7
  tools: ["Read", "Edit", "Write", "Grep", "Glob", "Bash", "TodoWrite"]
7
8
  ---
@@ -2,6 +2,7 @@
2
2
  name: handoff-planner
3
3
  description: "Handoff planning specialist for Phase 1+2. Use when creating a new handoff cycle: writes PLAN.md with full test plan, splits into TASK-xxx.md files with TDD-embedded test cases, and updates INDEX.md. Always use the strongest available model (Opus/unic-smart)."
4
4
  model: opus # unic-smart
5
+ effort: medium
5
6
  color: purple
6
7
  tools: ["Read", "Edit", "Write", "Glob", "Bash", "TodoWrite"]
7
8
  ---
@@ -2,6 +2,7 @@
2
2
  name: ukit-small-task-maintainer
3
3
  description: "Internal UKit maintenance subagent for low-risk, reversible UKit decisions. Use proactively when UKit needs to decide or perform safe cleanup such as pruning docs/TASKS.md, classifying queued work, choosing whether compact/summarization is appropriate, summarizing local docs/status, or maintaining small UKit runtime queues. Do not use for product implementation, security, release/publish, data-loss, architecture, or risky/shared code changes."
4
4
  model: haiku # unic-lite
5
+ effort: low
5
6
  color: cyan
6
7
  tools: ["Read", "Grep", "Glob", "Edit", "Write"]
7
8
  ---
@@ -85,7 +85,7 @@ fi
85
85
  # (coordinator deadline + margin); a forced kill lands on the existing crash-streak
86
86
  # path (non-zero STATUS) so the outcome is announced, never silently released.
87
87
  __ukit_coord_out="$(mktemp "${TMPDIR:-/tmp}/ukit-coord-out.XXXXXX")"
88
- printf '%s' "$INPUT" | UKIT_HARNESS=claude-code UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT" --evaluate-stop > "$__ukit_coord_out" 2>/dev/null &
88
+ printf '%s' "$INPUT" | UKIT_HARNESS="${UKIT_HARNESS:-claude-code}" UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT" --evaluate-stop > "$__ukit_coord_out" 2>/dev/null &
89
89
  __ukit_coord_pid=$!
90
90
  __ukit_coord_ms="${UKIT_HOOK_DEADLINE_MS:-3000}"
91
91
  case "$__ukit_coord_ms" in ''|*[!0-9]*) __ukit_coord_ms=3000 ;; esac
@@ -0,0 +1,343 @@
1
+ // delegation-gate.mjs — PreToolUse chain step (in-proc `runHook(ctx)`, Claude Code).
2
+ //
3
+ // Turns the feature-implementation Playbook's "delegate the coding to Tier 2" requirement from
4
+ // advice into a host-native refusal. It fires ONLY when ALL of these hold:
5
+ // * the host is Claude Code (UKIT_HARNESS unset/'claude-code') — omp/codex worker identity is
6
+ // UNVERIFIED, so nothing is enforced there (the acceptance verdict stays UNVERIFIED);
7
+ // * the live route state is this task's `feature-implementation` run with
8
+ // `tierLane.delegationRequired === true` (Tier 3 main, Tier 2 executor);
9
+ // * the call is a source/test mutation (Edit/Write/MultiEdit/NotebookEdit, or a Bash command
10
+ // whose file write is reliably identifiable) from the MAIN thread — the host stamps
11
+ // `agent_id` on every subagent tool payload, so main vs worker is read from the host, never
12
+ // from model text.
13
+ // A main-thread mutation is refused until this task's ledger holds an evidenced native worker
14
+ // handback (the "valid delegation path"). Reads, test runs, docs/bookkeeping writes and every
15
+ // unrelated operation pass untouched.
16
+ //
17
+ // Bounded, never a bypass: each refused attempt is a task-owned `delegation-violation` receipt;
18
+ // the DELEGATION_VIOLATION_LIMIT-th one mints a visible sticky `delegation-required` blocker, so
19
+ // the Stop gate ends the run as BLOCKED. Further attempts stay refused. There is no
20
+ // "second attempt succeeds" path.
21
+ //
22
+ // Worker side: a worker's Bash heredoc/redirect/tee/sed -i/script write has no trustworthy file
23
+ // attribution (the ledger would only see an anonymous workspace delta), so it is refused with a
24
+ // pointer to the supported Edit/Write path.
25
+ //
26
+ // Boundary (documented, not hidden): Bash mutations are detected by a quote-aware scan of the
27
+ // command text. Writes made by a script FILE (`node build.js`), by variables/command
28
+ // substitution, or after a `cd` inside the command are not identifiable here. The Stop-time
29
+ // acceptance backstop still counts any anonymous workspace delta as an inline write
30
+ // (`delegation-required-not-performed`), so such work is never reported as PASS.
31
+ //
32
+ // Fail-open on any internal error (a broken advisory gate must not wedge the session). User
33
+ // overrides: `UKIT_DELEGATION_GATE=off` or `.ukit/storage/config.json` orchestration.delegationGate=false.
34
+
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+
38
+ export const hookFailClosed = false;
39
+
40
+ const FEATURE_PLAYBOOK_ID = 'feature-implementation';
41
+ const MUTATING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
42
+ const PASS = { code: 0, stdout: '', stderr: '' };
43
+
44
+ // First path segment under which a write is never "source/test" (tooling, build output, docs, scratch).
45
+ const EXEMPT_ROOTS = new Set([
46
+ '.git', '.claude', '.ukit', '.omp', '.codex', '.cache', 'node_modules', 'dist', 'build', 'coverage',
47
+ '.next', '.nuxt', '.turbo', 'tmp', 'docs',
48
+ ]);
49
+ const EXEMPT_EXT = /\.(md|mdx|txt|log|tmp|lock)$/i;
50
+
51
+ const INTERPRETERS = /^(node|nodejs|deno|bun|python|python2|python3|ruby|perl|php)$/;
52
+ const WRAPPERS = new Set(['sudo', 'env', 'command', 'time', 'nice', 'nohup', 'exec', 'builtin']);
53
+ const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash']);
54
+ // File-writing APIs of the interpreters above. `process.stdout.write` / `sys.stdout.write` are not file writes.
55
+ const WRITE_API = /\b(writeFile(?:Sync)?|appendFile(?:Sync)?|createWriteStream|copyFile(?:Sync)?|rename(?:Sync)?|writeFileSync|write_text|write_bytes|File\.write|file_put_contents)\b|\bopen\s*\([^)]*,\s*['"][wax][+b]*['"]/;
56
+
57
+ // ─── Path classification ────────────────────────────────────────────────────
58
+
59
+ /** True when `raw` names a project source/test file (i.e. a path this gate protects). */
60
+ export function isSourceTarget(raw, { projectRoot, cwd } = {}) {
61
+ let text = String(raw ?? '').trim();
62
+ if (!text) return false;
63
+ if (/[$`]/.test(text) || text.startsWith('~')) return false; // not statically resolvable
64
+ if (text.startsWith('/dev/')) return false;
65
+ const root = path.resolve(projectRoot || '.');
66
+ const abs = path.isAbsolute(text) ? path.resolve(text) : path.resolve(cwd || root, text);
67
+ const rel = path.relative(root, abs);
68
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false; // outside the project
69
+ const parts = rel.split(path.sep);
70
+ if (EXEMPT_ROOTS.has(parts[0])) return false;
71
+ if (EXEMPT_EXT.test(parts[parts.length - 1])) return false;
72
+ return true;
73
+ }
74
+
75
+ // ─── Bash scan ──────────────────────────────────────────────────────────────
76
+
77
+ // Splits `command` into heredoc-free text plus the heredoc bodies (bodies may contain `>` etc.).
78
+ function extractHeredocs(command) {
79
+ const lines = String(command).split('\n');
80
+ const kept = [];
81
+ const bodies = [];
82
+ for (let i = 0; i < lines.length; i += 1) {
83
+ const line = lines[i];
84
+ kept.push(line);
85
+ const re = /<<(-?)\s*(['"]?)([A-Za-z_][\w-]*)\2/g;
86
+ const delimiters = [];
87
+ for (let m = re.exec(line); m; m = re.exec(line)) {
88
+ if (line[m.index - 1] === '<') continue; // here-string `<<<`
89
+ delimiters.push({ strip: m[1] === '-', tag: m[3] });
90
+ }
91
+ for (const { strip, tag } of delimiters) {
92
+ const body = [];
93
+ let closed = false;
94
+ while (i + 1 < lines.length) {
95
+ i += 1;
96
+ const candidate = strip ? lines[i].replace(/^\t+/, '') : lines[i];
97
+ if (candidate.trim() === tag) { closed = true; break; }
98
+ body.push(lines[i]);
99
+ }
100
+ bodies.push(body.join('\n'));
101
+ if (!closed) break;
102
+ }
103
+ }
104
+ return { text: kept.join('\n'), bodies };
105
+ }
106
+
107
+ // Quote-aware pass: returns segments (arrays of words) and file-redirect targets.
108
+ function tokenize(text) {
109
+ const segments = [[]];
110
+ const redirects = [];
111
+ let word = null;
112
+ const pushWord = () => { if (word !== null) { segments[segments.length - 1].push(word); word = null; } };
113
+ const newSegment = () => { pushWord(); if (segments[segments.length - 1].length) segments.push([]); };
114
+ const n = text.length;
115
+ let i = 0;
116
+ const readQuoted = (quote) => {
117
+ let out = '';
118
+ i += 1;
119
+ while (i < n && text[i] !== quote) {
120
+ if (quote === '"' && text[i] === '\\' && i + 1 < n) { out += text[i + 1]; i += 2; continue; }
121
+ out += text[i]; i += 1;
122
+ }
123
+ i += 1;
124
+ return out;
125
+ };
126
+ while (i < n) {
127
+ const ch = text[i];
128
+ if (ch === "'" || ch === '"') { word = (word ?? '') + readQuoted(ch); continue; }
129
+ if (ch === '\\' && i + 1 < n) { word = (word ?? '') + text[i + 1]; i += 2; continue; }
130
+ if (ch === '#' && word === null) { while (i < n && text[i] !== '\n') i += 1; continue; }
131
+ if (/\s/.test(ch)) { if (ch === '\n') newSegment(); else pushWord(); i += 1; continue; }
132
+ if (ch === ';' || ch === '|' || ch === '(' || ch === ')' || ch === '`') { newSegment(); i += 1; if (ch === '|' && text[i] === '|') i += 1; continue; }
133
+ if (ch === '&' && text[i + 1] === '&') { newSegment(); i += 2; continue; }
134
+ if (ch === '&' && text[i + 1] !== '>') { newSegment(); i += 1; continue; }
135
+ if (ch === '&' && text[i + 1] === '>') {
136
+ pushWord(); i += 2; if (text[i] === '>') i += 1;
137
+ const target = readRedirectTarget();
138
+ if (target !== null) redirects.push(target);
139
+ continue;
140
+ }
141
+ if (ch === '>' ) {
142
+ // `2>file` : the fd digit already landed in `word`; drop it when it is a bare fd number.
143
+ if (word !== null && /^\d$/.test(word)) word = null; else pushWord();
144
+ i += 1;
145
+ if (text[i] === '(') continue; // process substitution
146
+ if (text[i] === '&') { i += 1; while (i < n && /[\d-]/.test(text[i])) i += 1; continue; } // fd dup
147
+ if (text[i] === '>' || text[i] === '|') i += 1;
148
+ const target = readRedirectTarget();
149
+ if (target !== null) redirects.push(target);
150
+ continue;
151
+ }
152
+ word = (word ?? '') + ch; i += 1;
153
+ }
154
+ pushWord();
155
+ function readRedirectTarget() {
156
+ while (i < n && (text[i] === ' ' || text[i] === '\t')) i += 1;
157
+ if (i >= n) return null;
158
+ if (text[i] === "'" || text[i] === '"') return readQuoted(text[i]);
159
+ let out = '';
160
+ while (i < n && !/[\s;|&<>()]/.test(text[i])) { out += text[i]; i += 1; }
161
+ return out || null;
162
+ }
163
+ return { segments: segments.filter((s) => s.length), redirects };
164
+ }
165
+
166
+ function isFlag(word) { return /^-/.test(word); }
167
+
168
+ function stripLeading(words) {
169
+ const out = [...words];
170
+ while (out.length) {
171
+ if (/^[A-Za-z_]\w*=/.test(out[0]) || WRAPPERS.has(out[0])) out.shift(); else break;
172
+ }
173
+ return out;
174
+ }
175
+
176
+ function scriptWrites(script, ctx) {
177
+ if (!WRITE_API.test(script)) return null;
178
+ const literals = [...script.matchAll(/['"`]([^'"`\n]{1,200})['"`]/g)].map((m) => m[1])
179
+ .filter((value) => /[\\/]/.test(value) || /\.[A-Za-z0-9]{1,6}$/.test(value));
180
+ if (literals.length === 0) return { kind: 'script-write', target: null };
181
+ const hit = literals.find((value) => isSourceTarget(value, ctx));
182
+ return hit ? { kind: 'script-write', target: hit } : null;
183
+ }
184
+
185
+ /**
186
+ * Returns the reliably identifiable source/test file mutations in a Bash command:
187
+ * [{ kind: 'redirect'|'heredoc'|'tee'|'sed-inplace'|'copy'|'patch'|'script-write', target }].
188
+ * Reads, test runs and writes to exempt paths yield []. Script FILES are not identifiable (see header).
189
+ */
190
+ export function scanBashMutations(command, ctx = {}, depth = 0) {
191
+ const raw = String(command ?? '');
192
+ if (!raw.trim()) return [];
193
+ const hits = [];
194
+ const { text, bodies } = extractHeredocs(raw);
195
+ const hasHeredoc = bodies.length > 0;
196
+ const { segments, redirects } = tokenize(text);
197
+ for (const target of redirects) {
198
+ if (isSourceTarget(target, ctx)) hits.push({ kind: hasHeredoc ? 'heredoc' : 'redirect', target });
199
+ }
200
+ for (const rawWords of segments) {
201
+ const words = stripLeading(rawWords);
202
+ if (!words.length) continue;
203
+ const cmd = path.basename(words[0]);
204
+ const args = words.slice(1);
205
+ const plain = args.filter((a) => !isFlag(a));
206
+ if (cmd === 'tee') {
207
+ for (const target of plain) if (isSourceTarget(target, ctx)) hits.push({ kind: 'tee', target });
208
+ } else if (cmd === 'sed' || cmd === 'perl' || cmd === 'ruby') {
209
+ const inPlace = args.some((a) => (cmd === 'sed' ? /^-[A-Za-z]*i|^--in-place/ : /^-[A-Za-z]*i/).test(a));
210
+ if (inPlace) {
211
+ const eIndex = args.findIndex((a) => a === '-e' || a === '-f');
212
+ const files = args.filter((a, idx) => !isFlag(a) && !(eIndex >= 0 && idx === eIndex + 1));
213
+ const candidates = cmd === 'sed' && eIndex < 0 ? files.slice(1) : files;
214
+ for (const target of candidates) if (isSourceTarget(target, ctx)) hits.push({ kind: 'sed-inplace', target });
215
+ }
216
+ } else if (cmd === 'cp' || cmd === 'mv' || cmd === 'install') {
217
+ if (plain.length >= 2 && isSourceTarget(plain[plain.length - 1], ctx)) hits.push({ kind: 'copy', target: plain[plain.length - 1] });
218
+ } else if (cmd === 'patch' || (cmd === 'git' && /^(apply|am)$/.test(plain[0] || '') && !args.includes('--check') && !args.includes('--stat'))) {
219
+ hits.push({ kind: 'patch', target: null });
220
+ } else if (cmd === 'dd') {
221
+ const of = args.find((a) => a.startsWith('of='));
222
+ if (of && isSourceTarget(of.slice(3), ctx)) hits.push({ kind: 'copy', target: of.slice(3) });
223
+ } else if (INTERPRETERS.test(cmd)) {
224
+ const inline = [];
225
+ args.forEach((a, idx) => { if (/^-(e|c|p)$/.test(a) && args[idx + 1] !== undefined) inline.push(args[idx + 1]); });
226
+ const scripts = [...inline, ...(hasHeredoc && (plain.length === 0 || args.includes('-') || args.some((a) => /^-[A-Za-z]*$/.test(a))) ? bodies : [])];
227
+ for (const script of scripts) {
228
+ const found = scriptWrites(script, ctx);
229
+ if (found) hits.push(found);
230
+ }
231
+ } else if (SHELLS.has(cmd) && depth < 2) {
232
+ const cIndex = args.indexOf('-c');
233
+ if (cIndex >= 0 && args[cIndex + 1] !== undefined) hits.push(...scanBashMutations(args[cIndex + 1], ctx, depth + 1));
234
+ } else if (cmd === 'eval' && depth < 2) {
235
+ hits.push(...scanBashMutations(args.join(' '), ctx, depth + 1));
236
+ }
237
+ }
238
+ return hits;
239
+ }
240
+
241
+ // ─── Gate ───────────────────────────────────────────────────────────────────
242
+
243
+ function readJson(file) {
244
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
245
+ }
246
+
247
+ function userDisabled(projectRoot, env) {
248
+ if (/^(off|0|false)$/i.test(String(env.UKIT_DELEGATION_GATE || ''))) return true;
249
+ const config = readJson(path.join(projectRoot, '.ukit', 'storage', 'config.json'));
250
+ return config?.orchestration?.delegationGate === false;
251
+ }
252
+
253
+ function deny(reason, env) {
254
+ const inChain = Boolean(env.UKIT_HOOK_CHAIN_RUNNER);
255
+ return {
256
+ code: inChain ? 2 : 0,
257
+ stdout: `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } })}\n`,
258
+ stderr: `BLOCKED: ${reason}\n`,
259
+ };
260
+ }
261
+
262
+ function describeTarget(mutation, projectRoot) {
263
+ if (!mutation.target) return 'a source file';
264
+ const text = String(mutation.target);
265
+ const rel = path.isAbsolute(text) && projectRoot ? path.relative(projectRoot, text) : text;
266
+ return `"${(rel && !rel.startsWith('..') ? rel : text).slice(0, 120)}"`;
267
+ }
268
+
269
+ function mainMessage(attempt, limit, taskId, mutation, projectRoot) {
270
+ if (attempt >= limit) {
271
+ return `UKit delegation gate — BLOCKED: this task's feature-implementation Playbook requires the coding to be delegated to the Tier 2 worker, but the orchestrator tried ${attempt} times to write ${describeTarget(mutation, projectRoot)} itself. This run is now recorded as BLOCKED (delegation-required-not-performed). Do not retry the write; report the blocker to the user.`;
272
+ }
273
+ return `UKit delegation gate (attempt ${attempt} of ${limit}): this task's feature-implementation Playbook requires Tier 2 coding delegation, and you are the Tier 3 orchestrator — writing ${describeTarget(mutation, projectRoot)} yourself is refused. Dispatch a native worker now with the Agent tool (subagent_type "feature-implementer", the Tier 2 coding model) and start its prompt with "[ukit-task ${taskId}]", naming the files and behaviour to implement; the worker must edit with Edit/Write. When it returns, verify the integrated result yourself with the bare test runner (for example \`npm test\`) — no pipe, tail, grep or compound command — so the runner's own exit status is the evidence. Repeating this write will end the run as BLOCKED.`;
274
+ }
275
+
276
+ function workerMessage(mutation, projectRoot) {
277
+ return `UKit delegation gate: a Bash ${mutation.kind} write to ${describeTarget(mutation, projectRoot)} has no trustworthy file attribution, so it is not accepted as worker evidence. Redo this change with the Edit or Write tool (the supported worker edit path), then run the tests with the bare runner command.`;
278
+ }
279
+
280
+ function targetsOf(toolName, input) {
281
+ if (toolName === 'Bash') return null;
282
+ const file = input.file_path ?? input.notebook_path ?? input.path;
283
+ return typeof file === 'string' && file ? file : null;
284
+ }
285
+
286
+ async function evaluate(ctx) {
287
+ const env = ctx.env || {};
288
+ const payload = ctx.payload || {};
289
+ const toolName = payload.tool_name;
290
+ if (toolName !== 'Bash' && !MUTATING_TOOLS.has(toolName)) return PASS;
291
+ if (env.UKIT_HARNESS && env.UKIT_HARNESS !== 'claude-code') return PASS;
292
+ const projectRoot = env.CLAUDE_PROJECT_DIR || ctx.projectRoot || process.cwd();
293
+ if (userDisabled(projectRoot, env)) return PASS;
294
+
295
+ const ledgerMod = await import(new URL('../ukit/runtime/execution-ledger.mjs', import.meta.url).href);
296
+ const playbook = await import(new URL('../ukit/runtime/playbook-execution.mjs', import.meta.url).href);
297
+ const state = await ledgerMod.readRouteState(projectRoot, payload);
298
+ const summary = state?.routeSummary;
299
+ if (!state?.taskInstanceId || summary?.playbookId !== FEATURE_PLAYBOOK_ID || summary.tierLane?.delegationRequired !== true) return PASS;
300
+
301
+ const input = payload.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
302
+ const scanCtx = { projectRoot, cwd: payload.cwd || projectRoot };
303
+ let mutation = null;
304
+ if (toolName === 'Bash') {
305
+ mutation = scanBashMutations(input.command, scanCtx)[0] || null;
306
+ } else {
307
+ const file = targetsOf(toolName, input);
308
+ if (file && isSourceTarget(file, scanCtx)) mutation = { kind: 'edit', target: file };
309
+ }
310
+ if (!mutation) return PASS;
311
+
312
+ if (payload.agent_id) {
313
+ // A worker: Edit/Write is the supported path; only anonymous Bash writes are refused.
314
+ return toolName === 'Bash' ? deny(workerMessage(mutation, projectRoot), env) : PASS;
315
+ }
316
+
317
+ const ledger = await ledgerMod.readExecutionLedger(projectRoot, payload);
318
+ const owned = ledger && ledger.taskInstanceId === state.taskInstanceId ? ledger : null;
319
+ const workers = playbook.assessWorkers(owned?.playbookRun || null);
320
+ if (workers.workers.some((worker) => worker.state === 'evidenced')) return PASS; // valid delegation path exists
321
+
322
+ const limit = playbook.DELEGATION_VIOLATION_LIMIT;
323
+ const prior = Number(owned?.delegationViolations?.count) || 0;
324
+ const attempt = Math.min(prior + 1, limit);
325
+ if (prior < limit) {
326
+ await ledgerMod.recordLedgerEvent({
327
+ type: 'receipt',
328
+ receipt: {
329
+ ts: Date.now(), kind: 'delegation-violation', toolName, toolUseId: payload.tool_use_id || null,
330
+ success: false, mutation: mutation.kind, target: mutation.target ? String(mutation.target).slice(0, 160) : null,
331
+ },
332
+ }, { projectRoot, payload, harness: 'claude-code', routeState: state, deadlineMs: ctx.deadlineMs });
333
+ }
334
+ return deny(mainMessage(attempt, limit, state.taskInstanceId, mutation, projectRoot), env);
335
+ }
336
+
337
+ export async function runHook(ctx) {
338
+ try {
339
+ return await evaluate(ctx || {});
340
+ } catch {
341
+ return PASS; // advisory gate: an internal fault never wedges the session
342
+ }
343
+ }