@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.
- package/CHANGELOG.md +15 -0
- package/manifests/documentation.yaml +22 -0
- package/manifests/platform.full.yaml +27 -0
- package/package.json +4 -1
- package/scripts/dev/worktree-preflight.mjs +20 -1
- package/src/core/diffPlan.js +38 -12
- package/src/core/executionContracts.js +79 -18
- package/src/core/metadata.js +4 -0
- package/src/core/runInstallPipeline.js +19 -2
- package/src/core/runtimeConfig.js +1 -1
- package/src/index/playbookRegistry.js +22 -3
- package/src/index/taskRouting.js +2 -2
- package/template_project/.claude/agents/bug-debugger.md +1 -0
- package/template_project/.claude/agents/code-reviewer.md +1 -0
- package/template_project/.claude/agents/feature-implementer.md +1 -0
- package/template_project/.claude/agents/handoff-planner.md +1 -0
- package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
- package/template_project/.claude/hooks/completion-gate.sh +1 -1
- package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
- package/template_project/.claude/hooks/skill-router.sh +159 -8
- package/template_project/.claude/settings.json +8 -8
- package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
- package/template_project/.claude/ukit/index/route-resolver.mjs +79 -17
- package/template_project/.claude/ukit/index/route-task.mjs +115 -37
- package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +331 -21
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
- package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +79 -3
- package/template_project/.omp/agents/bug-debugger.md +1 -0
- package/template_project/.omp/agents/code-reviewer.md +1 -0
- package/template_project/.omp/agents/feature-implementer.md +1 -0
- package/template_project/.omp/agents/handoff-planner.md +1 -0
- package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
- package/template_project/ukit/storage/config.json +1 -1
- package/scripts/bench/data-foundation.mjs +0 -929
- package/scripts/bench/decision-runtime-baseline.mjs +0 -427
- package/scripts/bench/decision-runtime-variant.mjs +0 -626
- package/scripts/bench/goldTasks.json +0 -38
- package/scripts/bench/memory-ablation.mjs +0 -495
- package/scripts/bench/memory-baseline.mjs +0 -596
- package/scripts/bench/memory-bench.mjs +0 -661
- package/scripts/bench/memory-canary.mjs +0 -331
- package/scripts/bench/memory-corpus.mjs +0 -354
- package/scripts/bench/memory-gate.mjs +0 -389
- package/scripts/bench/memory-metrics.mjs +0 -179
- package/scripts/bench/outline-savings.mjs +0 -107
- package/scripts/bench/parallel-agents.mjs +0 -224
- package/scripts/bench/recorder-overhead.mjs +0 -204
- package/scripts/bench/runGold.mjs +0 -238
- package/scripts/bench/sqlite-spike.mjs +0 -451
- package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
- package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
- package/scripts/bench/v3-ceremony.mjs +0 -220
- package/scripts/perf/audit-perf.mjs +0 -1195
- package/scripts/perf/diff-perf-findings.mjs +0 -136
- package/scripts/perf/perf-findings.json +0 -474
- 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
|
-
|
|
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,
|
package/src/index/taskRouting.js
CHANGED
|
@@ -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
|
+
}
|