@ngockhoale/ukit 3.4.47 → 3.4.49

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 +15 -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 +79 -18
  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 +159 -8
  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 +79 -17
  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 +331 -21
  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
@@ -114,6 +114,20 @@ function hasSqlIntent(text) {
114
114
  // The SQL ask touches shape/data rather than a read-only query — routes the
115
115
  // migration row instead of a feature build.
116
116
  const SQL_SCHEMA_RE = /(?<![A-Za-z0-9_])(?:ddl|alter|drop|(?:create|alter|drop|truncate)\s+table|(?:add|drop|rename|change|modify|update|thêm|xóa|xoá|sửa|sua|đổi|doi)\s+(?:a\s+|the\s+|new\s+|một\s+)*(?:column|cột|cot)(?!\s+of\b)|kiểu\s+dữ\s+liệu|kieu\s+du\s+lieu|data\s?type|not\s+null|constraint|migration|migrate)(?![A-Za-z0-9_])/i;
117
+ // Explicit delegation order: the user themself asks for a worker/subagent to implement
118
+ // ("Delegate the implementation to a worker agent", "have a subagent implement it"). That is an
119
+ // opt-in to the orchestrated path (Tier 3 orchestrates, Tier 2 implements, integration verified by
120
+ // the orchestrator), so it lifts the small-feature fast path into feature-implementation. Live omp
121
+ // run: this exact order stayed on small-feature (explicit-local-target fast path, subagents
122
+ // suppressed) and the Tier 3 main implemented nothing of the delegation. Bare mentions of the
123
+ // words ("worker thread", "the delegate pattern") do not match; negated orders ("don't delegate")
124
+ // veto.
125
+ const DELEGATION_ORDER_RE = /\bdelegat(?:e|ing)\b[^.\n]{0,80}?\bto\s+(?:an?\s+|the\s+|your\s+)?(?:[\w-]+\s+){0,2}?(?:worker|sub-?agents?|agents?|implementer)\b|\b(?:use|spawn|dispatch|launch|assign|have|let)\s+(?:an?\s+|the\s+|your\s+)?(?:[\w-]+\s+)?(?:worker|sub-?agents?|implementer)(?:\s+agent)?\s+(?:to|for)\b|(?<![A-Za-z0-9_])giao\s+(?:việc\s+)?cho\s+(?:worker|sub-?agent|agent)/i;
126
+ const DELEGATION_NEGATED_RE = /\b(?:don['’]?t|do\s+not|never|without|avoid|no\s+need\s+to)\s+(?:[\w-]+\s+){0,2}?(?:delegat\w*|(?:use|spawn|dispatch)\s+(?:an?\s+|any\s+)?(?:worker|sub-?agents?))|\bno\s+(?:worker|sub-?agents?|delegation)\b|\b(?:inline|yourself)\s+(?:only|not)\b/i;
127
+ export function hasDelegationOrder(text) {
128
+ const t = String(text ?? '');
129
+ return DELEGATION_ORDER_RE.test(t) && !DELEGATION_NEGATED_RE.test(t);
130
+ }
117
131
  // Reuse-first ask: "use the existing/core function", "dùng hàm có sẵn",
118
132
  // "tái sử dụng" — search for the function before writing a new one.
119
133
  const REUSE_FIRST_RE = /(?:\buse\s+(?:the\s+)?(?:existing|core|shared|same|current)\b)|(?:\buse\s+[\w.]+\s+from\s+(?:the\s+)?(?:core|shared|utils?|common|lib)\b)|\b(?:existing|core|shared)\s+(?:function|helper|util(?:ity|ities)?|service|component)\b|\breuse\b|(?<![A-Za-z0-9_])(?:tái\s+sử\s+dụng|tai\s+su\s+dung)|(?<![A-Za-z0-9_])(?:dùng|dung|dựa|dua)\s+(?:vào\s+|vao\s+)?(?:hàm|ham)(?:\s+(?:có\s+sẵn|co\s+san|sẵn\s+có|san\s+co|đã\s+có|da\s+co|cũ|cu))?(?![A-Za-z0-9_])/i;
@@ -174,7 +188,7 @@ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
174
188
  // contract signals) escalates to feature-implementation. Reference-driven,
175
189
  // clone, reuse-first and SQL-schema asks take the same escape: they name a
176
190
  // source of truth or a data contract the small path must not gloss over.
177
- discriminator: (s) => !s.escalated && !s.contractSignal && !s.multiSurface && !s.sqlSchemaChange && !s.referenceTarget && !s.cloneIntent && !s.reuseFirst && s.changeVerbOrCreate,
191
+ discriminator: (s) => !s.escalated && !s.contractSignal && !s.multiSurface && !s.sqlSchemaChange && !s.referenceTarget && !s.cloneIntent && !s.reuseFirst && !s.delegationOrdered && s.changeVerbOrCreate,
178
192
  fastPath: 'small-feature',
179
193
  },
180
194
  {
@@ -195,7 +209,7 @@ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
195
209
  // migration row instead of capturing the build lane.
196
210
  discriminator: (s) => (
197
211
  (s.contractSignal || s.multiSurface || (s.escalated && !s.sqlSchemaChange) || s.wiredShipAsk)
198
- || ((s.referenceTarget || s.cloneIntent || s.reuseFirst) && s.defectIdx === -1)
212
+ || ((s.referenceTarget || s.cloneIntent || s.reuseFirst || s.delegationOrdered) && s.defectIdx === -1)
199
213
  ) && !s.disposable && !s.releaseAsk,
200
214
  },
201
215
  {
@@ -291,7 +305,9 @@ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
291
305
  lanes: ['local-fix', 'review-release'],
292
306
  triggers: [/\bverify\b|\bcheck\b|\bconfirm\b|\bprove\b|\bvalidate\b|\bassert\b/i],
293
307
  // the ask IS a check — a check verb leads, or no defect verb exists at all.
294
- discriminator: (s) => s.checkFirst || s.defectIdx === -1,
308
+ // A build/create verb that leads the ask makes the check its closing step ("implement X … then run the
309
+ // tests and confirm"), not the ask itself — the build row owns it instead of tying into ambiguity.
310
+ discriminator: (s) => (s.checkFirst || s.defectIdx === -1) && !s.buildLeadsCheck,
295
311
  },
296
312
  {
297
313
  workGroup: 'skill-evaluation',
@@ -409,6 +425,7 @@ async function buildRouteSignals({
409
425
  const explanationAsk = explainIdx !== -1 && (firstVerbIdx === -1 || explainIdx < firstVerbIdx);
410
426
  const changeRequested = !consultLead && firstVerbIdx !== -1 && (explainIdx === -1 || firstVerbIdx < explainIdx);
411
427
  const checkFirst = checkIdx !== -1 && (defectIdx === -1 || checkIdx < defectIdx);
428
+ const buildLeadsCheck = changeRequested && checkIdx !== -1 && firstVerbIdx !== -1 && firstVerbIdx < checkIdx;
412
429
  // C85-019: a QUESTION about a defect is not a defect order. The bug-fix floor
413
430
  // demands repro+root-cause receipts — demanding them on a "did it get fixed
414
431
  // yet?" turn forces edits nobody asked for (user-reported lock). Question
@@ -440,12 +457,14 @@ async function buildRouteSignals({
440
457
  changeRequested,
441
458
  questionAsk,
442
459
  checkFirst,
460
+ buildLeadsCheck,
443
461
  changeVerbOrCreate: changeIdx !== -1 || createIdx !== -1,
444
462
  referenceTarget,
445
463
  cloneIntent,
446
464
  sqlIntent,
447
465
  sqlSchemaChange: sqlIntent && SQL_SCHEMA_RE.test(signalText),
448
466
  reuseFirst,
467
+ delegationOrdered: hasDelegationOrder(signalText) && changeRequested,
449
468
  contractSignal: CONTRACT_SIGNAL_RE.test(signalText) || /\boauth\b/i.test(signalText),
450
469
  multiSurface: MULTI_SURFACE_RE.test(signalText),
451
470
  escalated: Array.isArray(escalationTriggers) && escalationTriggers.length > 0,
@@ -895,7 +895,7 @@ function deriveExecutionScores({
895
895
  const buildSignal = /\b(build|create|add|implement|feature)\b/.test(raw);
896
896
  const debugSignal = intentMode === 'debug-specific' || /\b(root cause|debug|triage|flaky|investigate|why)\b/.test(raw);
897
897
  const reviewSignal = intentMode === 'review-specific' || /\b(review|audit|verify|release readiness)\b/.test(raw);
898
- const failureSignal = /\b(failing|failed|broken|error|crash|timeout|undefined|exception|eacces|trace)\b/.test(raw);
898
+ const failureSignal = /\b(failing|failed|broken|error|crash|timeout|undefined|exception|eacces|trace)\b/.test(raw.replace(/\b(?:throw|raise|emit|reject|return|log|show|surface)(?:s|es|ing)?\s+(?:an?\s+|the\s+)?(?:[a-z-]+\s+){0,2}[a-z]*(?:error|exception)s?\b/g, ' '));
899
899
  const impactSignal = /\b(map impact|impact|blast radius|all affected|across (?:runtime|templates|helpers|adapters|mirrors)|affected adapters|affected mirrors|inspect impact)\b/.test(raw);
900
900
  const boundedEditSignal = /\b(one[- ]line|small|tiny|single|local|guard|log line|loading message|label)\b/.test(raw);
901
901
  const implementSignal = /\b(implement|apply|update|modify|add|create|ship|deliver|fix)\b/.test(raw)
@@ -1813,7 +1813,7 @@ function deriveIntentMode({ promptText = '', commandText = '', targetFile = null
1813
1813
  }
1814
1814
 
1815
1815
  if (concreteTask) {
1816
- if (/\b(bug|debug|error|crash|broken|failing|stack trace|triage|fix|timeout)\b/.test(lower)) {
1816
+ if (/\b(bug|debug|error|crash|broken|failing|stack trace|triage|fix|timeout)\b/.test(lower.replace(/\b(?:throw|raise|emit|reject|return|log|show|surface)(?:s|es|ing)?\s+(?:an?\s+|the\s+)?(?:[a-z-]+\s+){0,2}[a-z]*(?:error|exception)s?\b/g, ' '))) {
1817
1817
  return 'debug-specific';
1818
1818
  }
1819
1819
  if (/\b(review|audit|diff|pr feedback|code review|kiem tra|soat)\b/.test(raw)) {
@@ -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
+ }