@ngockhoale/ukit 3.4.28 → 3.4.30
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 +23 -0
- package/manifests/documentation.yaml +88 -0
- package/package.json +1 -1
- package/src/core/buildPlan.js +11 -1
- package/src/core/permissionPolicy.js +1 -1
- package/src/core/status.js +3 -1
- package/src/index/buildIndex.js +244 -4
- package/src/index/impactContext.js +91 -11
- package/src/index/languageTools.js +126 -0
- package/src/index/paths.js +5 -1
- package/src/index/playbookRegistry.js +108 -9
- package/src/index/queryIndex.js +415 -1
- package/src/index/routeCatalog.js +1 -1
- package/src/index/taskRouting.js +25 -3
- package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
- package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
- package/template_project/.claude/hooks/record-execution.mjs +23 -0
- package/template_project/.claude/hooks/skill-router.sh +105 -7
- package/template_project/.claude/settings.json +21 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
- package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
- package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
- package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
- package/template_project/.claude/ukit/index/query-index.mjs +100 -7
- package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
- package/template_project/.claude/ukit/index/route-task.mjs +208 -31
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +455 -24
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
- package/template_project/.omp/hooks/pre/ukit-bridge.js +32 -4
- package/template_user/playbooks/feature-implementation.md +9 -0
- package/template_user/playbooks/migration.md +4 -0
|
@@ -91,7 +91,7 @@ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
|
91
91
|
# transcript_path is read from the FIRST unescaped key occurrence: a tool_input string
|
|
92
92
|
# can only carry the text with escaped quotes, so it cannot spoof this lookup.
|
|
93
93
|
CREATE_SCOPE_RUN=0
|
|
94
|
-
if printf '%s' "$INPUT" | grep -qE '"tool_name"[[:space:]]*:[[:space:]]*"(Edit|Write|MultiEdit|NotebookEdit|Bash)"'; then
|
|
94
|
+
if printf '%s' "$INPUT" | grep -qE '"tool_name"[[:space:]]*:[[:space:]]*"(Edit|Write|MultiEdit|NotebookEdit|Bash|Agent|Task)"'; then
|
|
95
95
|
__ukit_tp="$(printf '%s' "$INPUT" | grep -oE '"transcript_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n 1 | sed -E 's/^"transcript_path"[[:space:]]*:[[:space:]]*"//; s/"$//')"
|
|
96
96
|
case "$__ukit_tp" in
|
|
97
97
|
'') ;;
|
|
@@ -106,6 +106,8 @@ if printf '%s' "$INPUT" | grep -qE '"tool_name"[[:space:]]*:[[:space:]]*"(Edit|W
|
|
|
106
106
|
CREATE_SCOPE_RUN=1
|
|
107
107
|
elif grep -q -a -F -m 1 handoff-create "$__ukit_tp" 2>/dev/null; then
|
|
108
108
|
CREATE_SCOPE_RUN=1
|
|
109
|
+
elif head -c 8192 "$__ukit_tp" 2>/dev/null | grep -q -a -F '"parentSession"'; then
|
|
110
|
+
CREATE_SCOPE_RUN=1 # omp delegated session: authority is inherited from the parent transcript
|
|
109
111
|
fi
|
|
110
112
|
;;
|
|
111
113
|
esac
|
|
@@ -113,11 +115,38 @@ if printf '%s' "$INPUT" | grep -qE '"tool_name"[[:space:]]*:[[:space:]]*"(Edit|W
|
|
|
113
115
|
;;
|
|
114
116
|
esac
|
|
115
117
|
fi
|
|
116
|
-
|
|
118
|
+
# Phase-marker fallback (session-keyed, written by the node scope path on a conclusive intent
|
|
119
|
+
# scan with phase=create). Consulted ONLY when the transcript cannot answer: no/unreadable
|
|
120
|
+
# transcript, or a subagent payload (agent_id/agent_type) whose sidechain lacks the command
|
|
121
|
+
# tag. A readable parent transcript with no `handoff-create` stays a conclusive "not create".
|
|
122
|
+
if [ "$CREATE_SCOPE_RUN" != 1 ] && printf '%s' "$INPUT" | grep -qE '"tool_name"[[:space:]]*:[[:space:]]*"(Edit|Write|MultiEdit|NotebookEdit|Bash|Agent|Task)"'; then
|
|
123
|
+
__ukit_sid="$(printf '%s' "$INPUT" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[A-Za-z0-9_-]+"' | head -n 1 | sed -E 's/^"session_id"[[:space:]]*:[[:space:]]*"//; s/"$//')"
|
|
124
|
+
__ukit_marker="$PROJECT_ROOT/.ukit/storage/cache/handoff-phase/$__ukit_sid.json"
|
|
125
|
+
if [ -n "$__ukit_sid" ] && [ -f "$__ukit_marker" ] && grep -q '"phase":"create"' "$__ukit_marker" 2>/dev/null; then
|
|
126
|
+
if [ -z "${__ukit_tp:-}" ] || [ ! -r "${__ukit_tp:-/nonexistent}" ] \
|
|
127
|
+
|| printf '%s' "$INPUT" | grep -qE '"(agent_id|agent_type)"[[:space:]]*:'; then
|
|
128
|
+
CREATE_SCOPE_RUN=1
|
|
129
|
+
fi
|
|
130
|
+
fi
|
|
131
|
+
fi
|
|
132
|
+
# Task-claim pre-filter: an executor delegation naming a TASK id needs the claim check.
|
|
133
|
+
TASK_CLAIM_RUN=0
|
|
134
|
+
if printf '%s' "$INPUT" | grep -qE '"tool_name"[[:space:]]*:[[:space:]]*"(Agent|Task)"' \
|
|
135
|
+
&& printf '%s' "$INPUT" | grep -qE 'TASK-([Cc][0-9]+-)?[0-9]+'; then
|
|
136
|
+
TASK_CLAIM_RUN=1
|
|
137
|
+
fi
|
|
138
|
+
# Main-agent ownership pre-filter: a source Edit/Write only needs the node gate while a
|
|
139
|
+
# handoff-fullstack run sits in an implement phase (cheap RUN.md grep, no node spawn otherwise).
|
|
140
|
+
MAIN_OWNER_RUN=0
|
|
141
|
+
if printf '%s' "$INPUT" | grep -qE '"tool_name"[[:space:]]*:[[:space:]]*"(Edit|Write|MultiEdit)"' \
|
|
142
|
+
&& grep -qE '^Phase:[[:space:]]*I[1-4]\b' "$PROJECT_ROOT/docs/AI_HANDOFF/RUN.md" 2>/dev/null; then
|
|
143
|
+
MAIN_OWNER_RUN=1
|
|
144
|
+
fi
|
|
145
|
+
if [ "$CREATE_SCOPE_RUN" != 1 ] && [ "$TASK_CLAIM_RUN" != 1 ] && [ "$MAIN_OWNER_RUN" != 1 ] && ! printf '%s' "$INPUT" | grep -qE 'AI_HANDOFF|git[[:space:]]+push'; then
|
|
117
146
|
exit 0
|
|
118
147
|
fi
|
|
119
148
|
|
|
120
|
-
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" CREATE_SCOPE_RUN="$CREATE_SCOPE_RUN" HOOK_DIR="$SCRIPT_DIR" node <<'NODE'
|
|
149
|
+
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" CREATE_SCOPE_RUN="$CREATE_SCOPE_RUN" TASK_CLAIM_RUN="$TASK_CLAIM_RUN" HOOK_DIR="$SCRIPT_DIR" node <<'NODE'
|
|
121
150
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
122
151
|
// SPEC §8: a timed-out gate silently passes work it never evaluated — announce the
|
|
123
152
|
// degrade (fail-open posture kept: still exit 0).
|
|
@@ -562,24 +591,107 @@ async function createScopeVerdict({ toolName: tool, filePath, filePaths, command
|
|
|
562
591
|
return { block: false, cls: 'allow' };
|
|
563
592
|
}
|
|
564
593
|
|
|
594
|
+
// Subagent types a planning-only session may delegate to: read-only / planning lanes. Anything
|
|
595
|
+
// that carries Edit/Write/Bash for product work (feature-implementer, bug-debugger,
|
|
596
|
+
// general-purpose, claude, or an unknown/missing type) is an implementation hand-off.
|
|
597
|
+
const CREATE_AGENT_ALLOW = new Set([
|
|
598
|
+
'handoff-planner', 'code-reviewer', 'ukit-small-task-maintainer', 'ukit-vision-analyst',
|
|
599
|
+
'explore', 'plan', 'claude-code-guide', 'statusline-setup',
|
|
600
|
+
// omp read-only lanes (real omp `task` tool: input.tasks[].agent)
|
|
601
|
+
'scout', 'reviewer', 'security-reviewer',
|
|
602
|
+
]);
|
|
603
|
+
// Every delegated agent type in the payload: Claude Agent/Task (`subagent_type`) and the omp
|
|
604
|
+
// `task` tool (`tasks[].agent`). A missing type is the host's DEFAULT agent, which can write —
|
|
605
|
+
// it is reported as '' and therefore denied.
|
|
606
|
+
function delegatedAgentTypes(inp) {
|
|
607
|
+
const types = [];
|
|
608
|
+
if (Array.isArray(inp?.tasks) && inp.tasks.length) {
|
|
609
|
+
for (const t of inp.tasks) types.push(String(t?.agent || t?.subagent_type || '').toLowerCase());
|
|
610
|
+
} else {
|
|
611
|
+
types.push(String(inp?.subagent_type || inp?.agent || inp?.agent_type || '').toLowerCase());
|
|
612
|
+
}
|
|
613
|
+
return types;
|
|
614
|
+
}
|
|
615
|
+
const PHASE_MARKER_TTL_MS = 12 * 60 * 60 * 1000;
|
|
616
|
+
const phaseMarkerPath = (sidOverride) => {
|
|
617
|
+
const sid = String(sidOverride ?? payload?.session_id ?? '');
|
|
618
|
+
return /^[A-Za-z0-9_-]+$/.test(sid) ? path.join(projectRoot, '.ukit/storage/cache/handoff-phase', `${sid}.json`) : '';
|
|
619
|
+
};
|
|
620
|
+
async function writePhaseMarker(phase) {
|
|
621
|
+
const file = phaseMarkerPath();
|
|
622
|
+
if (!file) return;
|
|
623
|
+
try {
|
|
624
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
625
|
+
await fsp.writeFile(file, JSON.stringify({ phase, ts: Date.now() }) + '\n', 'utf8');
|
|
626
|
+
} catch {}
|
|
627
|
+
}
|
|
628
|
+
async function readPhaseMarker(sidOverride) {
|
|
629
|
+
const file = phaseMarkerPath(sidOverride);
|
|
630
|
+
if (!file) return null;
|
|
631
|
+
try {
|
|
632
|
+
const m = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
633
|
+
return m && typeof m.phase === 'string' && Date.now() - Number(m.ts) < PHASE_MARKER_TTL_MS ? m.phase : null;
|
|
634
|
+
} catch { return null; }
|
|
635
|
+
}
|
|
636
|
+
// Bounded deny-audit row (provider, tool, phase, reason class) — deterministic, no LLM.
|
|
637
|
+
async function auditCreateDecision(row) {
|
|
638
|
+
try {
|
|
639
|
+
const dir = path.join(projectRoot, '.ukit/storage/cache');
|
|
640
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
641
|
+
const file = path.join(dir, 'handoff-phase-audit.jsonl');
|
|
642
|
+
let prior = '';
|
|
643
|
+
try { prior = await fsp.readFile(file, 'utf8'); } catch {}
|
|
644
|
+
const lines = prior.split('\n').filter(Boolean).slice(-199);
|
|
645
|
+
lines.push(JSON.stringify({ ts: new Date().toISOString(), provider: process.env.UKIT_HARNESS || 'claude-code', tool: toolName, ...row }));
|
|
646
|
+
await fsp.writeFile(file, lines.join('\n') + '\n', 'utf8');
|
|
647
|
+
} catch {}
|
|
648
|
+
}
|
|
649
|
+
|
|
565
650
|
// Returns a degrade message to emit on a clean (exit 0) finish, or ''.
|
|
651
|
+
// Phase source 1: the transcript (newest structured command wins). Memoized; a conclusive
|
|
652
|
+
// scan refreshes the session marker so later subagent/oversized-transcript calls inherit it.
|
|
653
|
+
let scanMemo = null;
|
|
654
|
+
async function scanTranscript() {
|
|
655
|
+
if (scanMemo) return scanMemo;
|
|
656
|
+
let scan = { intent: null, known: false };
|
|
657
|
+
const transcriptPath = typeof payload?.transcript_path === 'string' ? payload.transcript_path : '';
|
|
658
|
+
let readable = false;
|
|
659
|
+
if (transcriptPath) {
|
|
660
|
+
try { await fsp.access(transcriptPath, fs.constants.R_OK); readable = true; } catch {}
|
|
661
|
+
}
|
|
662
|
+
if (readable) {
|
|
663
|
+
try {
|
|
664
|
+
const mod = await import(require('url').pathToFileURL(path.resolve(process.env.HOOK_DIR || '.', '../ukit/runtime/handoff-intent.mjs')).href);
|
|
665
|
+
scan = await (mod.transcriptHandoffIntentInherited || mod.transcriptHandoffIntent)(transcriptPath);
|
|
666
|
+
} catch {
|
|
667
|
+
scan = { intent: null, known: false };
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (scan.known && scan.intent) await writePhaseMarker(scan.intent);
|
|
671
|
+
scanMemo = { scan, readable };
|
|
672
|
+
return scanMemo;
|
|
673
|
+
}
|
|
566
674
|
async function createScopeGate() {
|
|
567
|
-
const SCOPE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash'];
|
|
675
|
+
const SCOPE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash', 'Agent', 'Task'];
|
|
568
676
|
if (process.env.CREATE_SCOPE_RUN !== '1' || !SCOPE_TOOLS.includes(toolName)) return '';
|
|
569
677
|
try {
|
|
570
678
|
const cfg = JSON.parse(await fsp.readFile(path.join(projectRoot, '.ukit/storage/config.json'), 'utf8'));
|
|
571
679
|
if (cfg?.handoff?.createScopeGuard === false) return '';
|
|
572
680
|
} catch {}
|
|
573
681
|
|
|
682
|
+
await scanTranscript(); // refresh the phase marker even for in-scope calls
|
|
574
683
|
const isBash = toolName === 'Bash';
|
|
684
|
+
const isAgent = toolName === 'Agent' || toolName === 'Task';
|
|
575
685
|
const command = isBash ? String(input.command || '') : '';
|
|
576
686
|
const filePath = String(input.file_path || input.notebook_path || input.path || '');
|
|
577
|
-
const patch = isBash ? { paths: [], unparseable: false } : patchTargets(input);
|
|
687
|
+
const patch = (isBash || isAgent) ? { paths: [], unparseable: false } : patchTargets(input);
|
|
578
688
|
const filePaths = [...(Array.isArray(input.paths) ? input.paths.filter((p) => typeof p === 'string') : []), ...patch.paths];
|
|
579
|
-
if (
|
|
689
|
+
if (isAgent) {
|
|
690
|
+
// fall through: every delegation is checked against the allow-list below
|
|
691
|
+
} else if (isBash ? !shellWriteHit(command) : !(filePath || filePaths.length || patch.unparseable)) return '';
|
|
580
692
|
|
|
581
|
-
// Only deny/unknown classes (or a write-ish command) pay for the transcript scan.
|
|
582
|
-
if (!isBash) {
|
|
693
|
+
// Only deny/unknown classes (or a write-ish command, or a delegation) pay for the transcript scan.
|
|
694
|
+
if (!isBash && !isAgent) {
|
|
583
695
|
let denied = patch.unparseable;
|
|
584
696
|
for (const target of [filePath, ...filePaths]) {
|
|
585
697
|
if (target && (await classifyTarget(target, projectRoot)).block) { denied = true; break; }
|
|
@@ -587,21 +699,51 @@ async function createScopeGate() {
|
|
|
587
699
|
if (!denied) return '';
|
|
588
700
|
}
|
|
589
701
|
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
702
|
+
const { scan, readable } = await scanTranscript();
|
|
703
|
+
let intent = null;
|
|
704
|
+
let source = 'transcript';
|
|
705
|
+
if (scan.known && scan.intent) {
|
|
706
|
+
intent = scan.intent;
|
|
707
|
+
} else {
|
|
708
|
+
// Phase source 2: the session marker — for payloads whose transcript cannot answer
|
|
709
|
+
// (subagent sidechain without the command tag, oversized/timed-out/missing transcript).
|
|
710
|
+
const subagentPayload = Boolean(payload?.agent_id || payload?.agent_type);
|
|
711
|
+
if (!scan.known || subagentPayload || !readable) {
|
|
712
|
+
const marked = await readPhaseMarker();
|
|
713
|
+
if (marked) { intent = marked; source = 'marker'; }
|
|
714
|
+
}
|
|
715
|
+
// Declared-but-unreadable lineage (omp child whose parent transcript is gone): verified
|
|
716
|
+
// identity only — the PARENT's own session marker (keyed by the id in its transcript file
|
|
717
|
+
// name). No marker => unknown lineage, which stays advisory exactly as before; a marker
|
|
718
|
+
// for the parent is never borrowed from an unrelated session.
|
|
719
|
+
if (!intent && scan.unresolvedParent) {
|
|
720
|
+
const stem = path.basename(String(scan.unresolvedParent)).replace(/\.jsonl?$/i, '');
|
|
721
|
+
for (const cand of new Set([stem, ...stem.split('_')])) {
|
|
722
|
+
const marked = cand ? await readPhaseMarker(cand) : null;
|
|
723
|
+
if (marked) { intent = marked; source = 'parent-marker'; break; }
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (intent !== 'create') {
|
|
728
|
+
if (!scan.known && !intent) {
|
|
729
|
+
return 'UKit handoff create-scope guard: the session-intent scan was inconclusive (transcript larger than the 32 MiB scan window, scan timed out, or intent module unavailable) and no session phase marker exists; this call proceeded without the create write-scope check.';
|
|
730
|
+
}
|
|
731
|
+
return '';
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (isAgent) {
|
|
735
|
+
const type = delegatedAgentTypes(input).find((t) => !CREATE_AGENT_ALLOW.has(t));
|
|
736
|
+
if (type !== undefined) {
|
|
737
|
+
await auditCreateDecision({ phase: intent, source, decision: 'deny', cls: 'agent-delegation', agent: type || 'unspecified' });
|
|
738
|
+
block(`handoff-create is planning-only: delegating to '${type || 'general-purpose'}' (an implementation-capable agent) is not allowed here. Allowed: ${[...CREATE_AGENT_ALLOW].join(', ')}. To implement, run /ukit:handoff-implement or /ukit:handoff-fullstack.`);
|
|
739
|
+
}
|
|
740
|
+
return '';
|
|
599
741
|
}
|
|
600
|
-
|
|
601
|
-
|
|
742
|
+
const verdict = await createScopeVerdict({ toolName, filePath, filePaths, command, projectDir: projectRoot, intent, patchUnparseable: patch.unparseable });
|
|
743
|
+
if (verdict.block) {
|
|
744
|
+
await auditCreateDecision({ phase: intent, source, decision: 'deny', cls: verdict.cls });
|
|
745
|
+
block(verdict.reason);
|
|
602
746
|
}
|
|
603
|
-
const verdict = await createScopeVerdict({ toolName, filePath, filePaths, command, projectDir: projectRoot, intent: scan.intent, patchUnparseable: patch.unparseable });
|
|
604
|
-
if (verdict.block) block(verdict.reason);
|
|
605
747
|
return '';
|
|
606
748
|
}
|
|
607
749
|
|
|
@@ -616,6 +758,78 @@ async function createScopeGate() {
|
|
|
616
758
|
}
|
|
617
759
|
}
|
|
618
760
|
|
|
761
|
+
// Per-task execution claim (goal loop): no two executor agents run the same INDEX task, and a
|
|
762
|
+
// finished task is never re-dispatched. Released by record-execution.mjs on the tool result;
|
|
763
|
+
// an abandoned claim expires by TTL (stale takeover). Only lock/IO trouble degrades open —
|
|
764
|
+
// an active foreign claim or a completed task is a hard, explained deny.
|
|
765
|
+
async function taskClaimGate() {
|
|
766
|
+
if (process.env.TASK_CLAIM_RUN !== '1' || (toolName !== 'Agent' && toolName !== 'Task')) return;
|
|
767
|
+
try {
|
|
768
|
+
const cfg = JSON.parse(await fsp.readFile(path.join(projectRoot, '.ukit/storage/config.json'), 'utf8'));
|
|
769
|
+
if (cfg?.handoff?.taskClaims === false) return;
|
|
770
|
+
} catch {}
|
|
771
|
+
let mod;
|
|
772
|
+
try {
|
|
773
|
+
mod = await import(require('url').pathToFileURL(path.resolve(process.env.HOOK_DIR || '.', '../ukit/runtime/resumable-run.mjs')).href);
|
|
774
|
+
} catch { return; }
|
|
775
|
+
const indexText = await readTextSafe(path.join(projectRoot, 'docs/AI_HANDOFF/INDEX.md'));
|
|
776
|
+
if (!indexText) return; // no handoff cycle here
|
|
777
|
+
const rowFor = (id) => indexText.split('\n').find((line) => new RegExp(`^\\|\\s*${id}\\s*\\|`, 'i').test(line));
|
|
778
|
+
const targets = mod.delegatedClaimTargets(input, { isKnownTask: (id) => Boolean(rowFor(id)) });
|
|
779
|
+
const owner = mod.delegatedClaimOwner(payload);
|
|
780
|
+
if (!targets.length || !owner) return;
|
|
781
|
+
for (const { taskId } of targets) {
|
|
782
|
+
const row = rowFor(taskId);
|
|
783
|
+
if (!row) continue; // not a task of this cycle
|
|
784
|
+
const status = (row.split('|')[5] || '').trim().toLowerCase();
|
|
785
|
+
if (status === 'done' || status === 'cancelled' || status === 'cancelled_superseded' || status === 'archived') {
|
|
786
|
+
block(`${taskId} is already ${status} in docs/AI_HANDOFF/INDEX.md — it will not be dispatched again. Pick the next open task or reopen it explicitly in INDEX.md first.`);
|
|
787
|
+
}
|
|
788
|
+
const claim = await mod.acquireTaskClaim(projectRoot, taskId, { owner, sessionId: payload?.session_id || null, ttlMs: mod.DELEGATED_CLAIM_TTL_MS });
|
|
789
|
+
if (!claim.ok && claim.reason === 'active-claim') {
|
|
790
|
+
block(`${taskId} is already being executed by another agent (active claim). Wait for it to finish, or pick another task; a stale claim expires on its own.`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
await taskClaimGate();
|
|
795
|
+
|
|
796
|
+
// Main-agent task ownership. handoff-fullstack contracts implementation to claim-bearing
|
|
797
|
+
// executor subagents ("Do NOT implement the tasks yourself in the current session"). The
|
|
798
|
+
// delegation path is claim-gated above; this closes the direct path: while a run is in an
|
|
799
|
+
// implement phase (I1-I4), the MAIN Claude Code session (no agent_id/agent_type in the payload)
|
|
800
|
+
// may not Edit/Write product files unless it holds an active `main:<session>` claim from the
|
|
801
|
+
// SAME claim registry (`resumable-run.mjs claim <root> TASK-xxx --owner main:<session>`).
|
|
802
|
+
// Docs/runtime/tooling paths and files outside this checkout (per-task worktrees) are exempt.
|
|
803
|
+
// Provider limit: only Claude Code payloads prove main-vs-subagent identity, so omp/Codex
|
|
804
|
+
// keep the delegation-only claim gate (no false denies against their subagent edits).
|
|
805
|
+
async function mainAgentOwnershipGate() {
|
|
806
|
+
if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) return;
|
|
807
|
+
if (payload?.agent_id || payload?.agent_type) return;
|
|
808
|
+
if (String(process.env.UKIT_HARNESS || 'claude-code') !== 'claude-code') return;
|
|
809
|
+
try {
|
|
810
|
+
const cfg = JSON.parse(await fsp.readFile(path.join(projectRoot, '.ukit/storage/config.json'), 'utf8'));
|
|
811
|
+
if (cfg?.handoff?.taskClaims === false || cfg?.handoff?.mainAgentExecution === 'allow') return;
|
|
812
|
+
} catch {}
|
|
813
|
+
const filePath = String(input.file_path || input.path || '');
|
|
814
|
+
if (!filePath) return;
|
|
815
|
+
const rel = path.relative(projectRoot, filePath).replace(/\\/g, '/');
|
|
816
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return;
|
|
817
|
+
if (/^(docs|\.ukit|\.claude|\.omp|\.codex|\.cache|node_modules)(\/|$)/.test(rel)) return;
|
|
818
|
+
const run = await readTextSafe(path.join(projectRoot, 'docs/AI_HANDOFF/RUN.md'));
|
|
819
|
+
const phase = (run.match(/^Phase:\s*(\S+)/im) || [])[1] || '';
|
|
820
|
+
if (!/^I[1-4]\b/i.test(phase) || !/^Command:\s*handoff-fullstack/im.test(run)) return;
|
|
821
|
+
const sid = String(payload?.session_id || '');
|
|
822
|
+
try {
|
|
823
|
+
const claims = JSON.parse(await fsp.readFile(path.join(projectRoot, '.ukit/storage/runs/claims.json'), 'utf8'))?.claims || {};
|
|
824
|
+
const now = Date.now();
|
|
825
|
+
for (const claim of Object.values(claims)) {
|
|
826
|
+
if (sid && String(claim?.owner || '') === `main:${sid}` && now - Number(claim.heartbeat) <= Number(claim.ttlMs)) return;
|
|
827
|
+
}
|
|
828
|
+
} catch {}
|
|
829
|
+
block(`${rel}: the main session may not implement handoff tasks itself while the run is in ${phase}. Dispatch a feature-implementer agent (claim-bearing), or — only when no subagent is available — take a claim first: node .claude/ukit/runtime/resumable-run.mjs claim . TASK-xxx --owner main:${sid || '<session>'}`);
|
|
830
|
+
}
|
|
831
|
+
await mainAgentOwnershipGate();
|
|
832
|
+
|
|
619
833
|
if (toolName === 'Write' || toolName === 'Edit') {
|
|
620
834
|
const filePath = String(input.file_path || '');
|
|
621
835
|
if (!filePath) process.exit(0);
|
|
@@ -42,12 +42,35 @@ function parsePayload(rawInput) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Goal-loop claim release: the delegation guard acquired a per-task claim on PreToolUse; the
|
|
46
|
+
// tool result (success OR failure) hands the task back so it is retryable/recoverable.
|
|
47
|
+
// Owner-checked inside releaseTaskClaim; any error is swallowed (TTL expiry is the backstop).
|
|
48
|
+
async function releaseDelegatedClaims(payload, projectRoot) {
|
|
49
|
+
try {
|
|
50
|
+
const mod = await import(pathToFileURL(path.join(MODULE_DIR, '..', 'ukit', 'runtime', 'resumable-run.mjs')).href);
|
|
51
|
+
// Lease renewal: any tool event of the owning session proves its delegated agent is alive.
|
|
52
|
+
if (typeof mod.renewSessionClaims === 'function' && payload.session_id) {
|
|
53
|
+
await mod.renewSessionClaims(projectRoot, String(payload.session_id));
|
|
54
|
+
}
|
|
55
|
+
if (payload.tool_name !== 'Agent' && payload.tool_name !== 'Task') return;
|
|
56
|
+
const owner = mod.delegatedClaimOwner(payload);
|
|
57
|
+
if (!owner) return;
|
|
58
|
+
for (const { taskId } of mod.delegatedClaimTargets(payload.tool_input)) {
|
|
59
|
+
await mod.releaseTaskClaim(projectRoot, taskId, { owner });
|
|
60
|
+
}
|
|
61
|
+
} catch { /* advisory */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
// Shared advisory body for both modes. `rawInput` that fails to parse is a
|
|
46
65
|
// silent pass — identical to the .sh piping garbage into `node … --record`,
|
|
47
66
|
// which JSON.parse-falls back to `{}` and early-returns on the missing
|
|
48
67
|
// tool_name.
|
|
49
68
|
async function record({ rawInput, projectRoot, env, signal, deadlineMs }) {
|
|
50
69
|
const payload = parsePayload(rawInput) ?? {};
|
|
70
|
+
await releaseDelegatedClaims(payload, projectRoot);
|
|
71
|
+
// A failed delegation never reaches PostToolUse; the claim is already handed back above and
|
|
72
|
+
// there is no successful receipt to record.
|
|
73
|
+
if (payload.hook_event_name === 'PostToolUseFailure') return { code: 0, stdout: '', stderr: '' };
|
|
51
74
|
const ledgerPath = env.UKIT_EXECUTION_LEDGER_PATH || DEFAULT_LEDGER_PATH;
|
|
52
75
|
let ledger;
|
|
53
76
|
try {
|
|
@@ -1132,6 +1132,35 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
1132
1132
|
}
|
|
1133
1133
|
}
|
|
1134
1134
|
|
|
1135
|
+
// Reuse/reference/clone/SQL asks name an EXISTING source (function, approved page, DDL
|
|
1136
|
+
// column). Live runs showed agents guessing without it (OMP: "use checknull from Core" ->
|
|
1137
|
+
// `import … from 'Core'`, an invented module). Emit one bounded line per detected signal
|
|
1138
|
+
// naming the deterministic resolver. Advisory only — never blocks, never throws.
|
|
1139
|
+
async function emitSourceResolutionAdvisory({ promptText, commandText, skip }) {
|
|
1140
|
+
try {
|
|
1141
|
+
if (skip) return;
|
|
1142
|
+
const registry = await loadPlaybookRegistry();
|
|
1143
|
+
if (!registry || typeof registry.deriveRouteOrchestrationSignals !== 'function') return;
|
|
1144
|
+
const sig = registry.deriveRouteOrchestrationSignals({ promptText, commandText });
|
|
1145
|
+
const text = String(promptText || '');
|
|
1146
|
+
const advice = [];
|
|
1147
|
+
if (sig.reuseFirst) {
|
|
1148
|
+
const named = text.match(/\buse\s+([A-Za-z_][\w.]*)\s+from\b/i)?.[1];
|
|
1149
|
+
advice.push(`Reuse-first: the ask names an existing function${named ? ` ("${named}")` : ''} — resolve the REAL definition with \`node .claude/ukit/index/query-index.mjs --symbol ${named || '<name>'}\`, read its signature/semantics, import it; never invent a module or re-implement it. A needs-confirmation candidate is a lookup result, not a substitution: state which symbol you chose.`);
|
|
1150
|
+
}
|
|
1151
|
+
if (sig.referenceTarget || sig.cloneIntent) {
|
|
1152
|
+
advice.push('Reference/clone: locate the named source page/component with `node .claude/ukit/index/resolve-context.mjs "<ask>"`, read it and its API/loader dependencies first, reuse the existing component, change only target-specific parts, and leave the reference untouched.');
|
|
1153
|
+
}
|
|
1154
|
+
if (sig.sqlIntent) {
|
|
1155
|
+
const ids = [...new Set(text.match(/\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b/g) || [])].slice(0, 3);
|
|
1156
|
+
advice.push(`SQL: resolve the identifier in the real DDL${ids.length ? ` (${ids.map((i) => `\`query-index.mjs --symbol ${i}\``).join(', ')})` : ''}; an exact alias match is not proof of a source column — verify table/schema, never invent a column, keep the requested output alias distinct from the source column, then check dependent objects.`);
|
|
1157
|
+
}
|
|
1158
|
+
for (const line of advice) {
|
|
1159
|
+
process.stdout.write(`[ukit-skill-router] ${line}\n`);
|
|
1160
|
+
}
|
|
1161
|
+
} catch {}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1135
1164
|
async function readProjectPackage(projectRoot) {
|
|
1136
1165
|
return readJson(path.join(projectRoot, 'package.json'), null);
|
|
1137
1166
|
}
|
|
@@ -1990,6 +2019,38 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
1990
2019
|
return questionSignal || consultShape;
|
|
1991
2020
|
}
|
|
1992
2021
|
|
|
2022
|
+
// Greeting fast-path (parity with route-task.mjs isPureGreetingPrompt): a
|
|
2023
|
+
// bare "hi" / "xin chào" / "thanks" (≤6 tokens, no command, no target) is an
|
|
2024
|
+
// informational turn — no mutating lane, no task debt. Any non-greeting
|
|
2025
|
+
// word ("hi, fix the login bug") disqualifies the prompt.
|
|
2026
|
+
function isPureGreetingPrompt({ promptText = '', commandText = '', targetFile = null } = {}) {
|
|
2027
|
+
const raw = String(promptText ?? '').trim();
|
|
2028
|
+
if (!raw || String(commandText ?? '').trim() || targetFile) return false;
|
|
2029
|
+
if (raw.length > 64) return false;
|
|
2030
|
+
const tokens = raw.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\u0111/g, 'd')
|
|
2031
|
+
.replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(Boolean);
|
|
2032
|
+
if (tokens.length === 0 || tokens.length > 6) return false;
|
|
2033
|
+
const core = new Set([
|
|
2034
|
+
'hi', 'hello', 'hey', 'yo', 'chao', 'xin', 'good',
|
|
2035
|
+
'morning', 'afternoon', 'evening',
|
|
2036
|
+
'thanks', 'thank', 'cam', 'on', 'bye', 'tam', 'biet',
|
|
2037
|
+
]);
|
|
2038
|
+
const filler = new Set([
|
|
2039
|
+
'you', 'u', 'there', 'guys', 'everyone', 'all',
|
|
2040
|
+
'ban', 'cac', 'minh', 'moi', 'nguoi', 'nhe', 'nha', 'nhieu',
|
|
2041
|
+
'ok', 'okay', 'again', 'still',
|
|
2042
|
+
]);
|
|
2043
|
+
let coreHit = false;
|
|
2044
|
+
for (const token of tokens) {
|
|
2045
|
+
if (core.has(token)) {
|
|
2046
|
+
coreHit = true;
|
|
2047
|
+
} else if (!filler.has(token)) {
|
|
2048
|
+
return false; // any non-greeting word means this is a task wearing a greeting
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
return coreHit;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
1993
2054
|
// TASK-C85-019: an explicit no-change/advisory assertion overrides the signal
|
|
1994
2055
|
// scores. A report or advisory question carrying fix vocabulary as SUBJECT
|
|
1995
2056
|
// MATTER ("không tạo thay đổi", "giữ nguyên mã", "do not change anything",
|
|
@@ -2053,6 +2114,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2053
2114
|
// — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
|
|
2054
2115
|
// order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
|
|
2055
2116
|
// TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
|
|
2117
|
+
const MUT_VERBS = 'implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh';
|
|
2056
2118
|
const TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
2057
2119
|
// C89-008: WAIT_TAIL ends a waiting-clause strip before any coordinating
|
|
2058
2120
|
// conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything
|
|
@@ -2061,7 +2123,12 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2061
2123
|
const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
2062
2124
|
const residue = trimmed
|
|
2063
2125
|
.replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
|
|
2126
|
+
// "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
|
|
2127
|
+
.replace(/\b(?:tao|lap|create|make|write|draft)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap)\b/g, ' ')
|
|
2064
2128
|
.replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
|
|
2129
|
+
// A negated LIST ("do not implement, fix, or refactor"; "khong implement, sua, refactor hay chinh") negates every
|
|
2130
|
+
// verb in the list: the TAIL strip below stops at the first comma, which would leave the later verbs as orders.
|
|
2131
|
+
.replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])(?:\\s*(?:,|,?\\s+(?:or|and|nor|hay|hoac|va))\\s*(?:(?:${MUT_VERBS})(?![A-Za-z0-9_])|(?:${MUT_VERBS})\\s))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
|
|
2065
2132
|
.replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
|
|
2066
2133
|
.replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
|
|
2067
2134
|
.replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
|
|
@@ -2081,7 +2148,6 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2081
2148
|
.replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
|
|
2082
2149
|
.replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
|
|
2083
2150
|
.replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
|
|
2084
|
-
.replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
|
|
2085
2151
|
.replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
|
|
2086
2152
|
.replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
|
|
2087
2153
|
.replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
|
|
@@ -2111,6 +2177,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2111
2177
|
// — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
|
|
2112
2178
|
// order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
|
|
2113
2179
|
// TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
|
|
2180
|
+
const MUT_VERBS = 'implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh';
|
|
2114
2181
|
const TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
2115
2182
|
// C89-008: WAIT_TAIL ends a waiting-clause strip before a coordinating
|
|
2116
2183
|
// conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything
|
|
@@ -2118,10 +2185,15 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2118
2185
|
const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
2119
2186
|
const residue = trimmed
|
|
2120
2187
|
.replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
|
|
2188
|
+
// "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
|
|
2189
|
+
.replace(/\b(?:tao|lap|create|make|write|draft)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap)\b/g, ' ')
|
|
2121
2190
|
// Descriptive state, not an order: "đã/vừa [được] + verb" ("hooks vừa được cài đặt") reports
|
|
2122
2191
|
// something already done. Only the verb is stripped, so a later imperative still survives.
|
|
2123
2192
|
.replace(/(?<![a-z0-9_])(?:da|vua)\s+(?:duoc\s+)?(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![a-z0-9_])/g, ' ')
|
|
2124
2193
|
.replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
|
|
2194
|
+
// A negated LIST ("do not implement, fix, or refactor"; "khong implement, sua, refactor hay chinh") negates every
|
|
2195
|
+
// verb in the list: the TAIL strip below stops at the first comma, which would leave the later verbs as orders.
|
|
2196
|
+
.replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])(?:\\s*(?:,|,?\\s+(?:or|and|nor|hay|hoac|va))\\s*(?:(?:${MUT_VERBS})(?![A-Za-z0-9_])|(?:${MUT_VERBS})\\s))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
|
|
2125
2197
|
.replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
|
|
2126
2198
|
.replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
|
|
2127
2199
|
.replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
|
|
@@ -2143,7 +2215,6 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2143
2215
|
.replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
|
|
2144
2216
|
.replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
|
|
2145
2217
|
.replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
|
|
2146
|
-
.replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
|
|
2147
2218
|
.replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
|
|
2148
2219
|
.replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
|
|
2149
2220
|
.replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
|
|
@@ -2239,6 +2310,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2239
2310
|
/\b(?:chi|just|only)\s+(?:len|lap|vach|lam)\b[^.,;\n]{0,40}\b(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap|blueprint)\b/.test(trimmed)
|
|
2240
2311
|
|| /\b(?:len|lap|vach|de\s+xuat|draft)\s+(?:ke\s+hoach|plan|phuong\s+an|lo\s+trinh|roadmap)\b/.test(trimmed)
|
|
2241
2312
|
|| /\b(?:plan|planning)\s+(?:only|first|mode)\b/.test(trimmed)
|
|
2313
|
+
|| /\b(?:chi|just|only)\s+(?:tao|create|make|write|draft)\s+(?:a\s+|an\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|roadmap)\b/.test(trimmed)
|
|
2242
2314
|
|| /\b(?:ke\s+hoach|plan|phuong\s+an|lo\s+trinh|roadmap)\b[^.,;\n]{0,40}\b(?:khong|chua|khong\s+can)\s+(?:implement|code|thuc\s+hien|thuc\s+thi|sua|lam|build|viet)\b/.test(trimmed)
|
|
2243
2315
|
|| (/\b(?:khong|chua|dung)\s+(?:implement|code|thuc\s+hien|thuc\s+thi|sua|lam|viet\s+code)\b/.test(trimmed)
|
|
2244
2316
|
&& /\b(?:plan|ke\s+hoach|phuong\s+an|roadmap)\b/.test(trimmed));
|
|
@@ -3509,6 +3581,26 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
3509
3581
|
const sessionId = sessionCandidate === undefined || sessionCandidate === null
|
|
3510
3582
|
? null
|
|
3511
3583
|
: String(sessionCandidate).trim() || null;
|
|
3584
|
+
|
|
3585
|
+
// Per-session sidecar next to the shared single-slot state: a concurrent session routing
|
|
3586
|
+
// after this one overwrites the shared file, and this session's Stop would then see a
|
|
3587
|
+
// foreign owner and report its route as lost. Best-effort — the shared write is authoritative.
|
|
3588
|
+
async function persistRouteState(text) {
|
|
3589
|
+
await writeJsonFileAtomic(statePath, text);
|
|
3590
|
+
if (!sessionId) return;
|
|
3591
|
+
try {
|
|
3592
|
+
const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'route-state');
|
|
3593
|
+
await writeJsonFileAtomic(path.join(dir, `${String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'default'}.json`), text);
|
|
3594
|
+
if (Math.random() < 0.05) {
|
|
3595
|
+
const cutoff = Date.now() - 14 * 24 * 3600 * 1000;
|
|
3596
|
+
for (const name of await fsp.readdir(dir)) {
|
|
3597
|
+
const file = path.join(dir, name);
|
|
3598
|
+
const st = await fsp.stat(file).catch(() => null);
|
|
3599
|
+
if (st && st.mtimeMs < cutoff) await fsp.rm(file, { force: true });
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
} catch {}
|
|
3603
|
+
}
|
|
3512
3604
|
const storedPrevious = await readJson(statePath, {});
|
|
3513
3605
|
// Route state is project-local, while hook ledgers are session-local. Do not let a
|
|
3514
3606
|
// differently-bound snapshot become the contract for a new session.
|
|
@@ -3584,6 +3676,9 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
3584
3676
|
const previousTs = Number(previous?.ts || 0);
|
|
3585
3677
|
const informationalNoTargetPrompt = (isInformationalPrompt({ promptText, commandText })
|
|
3586
3678
|
|| hasAdvisoryOnlyAssertion({ promptText, commandText })) && !filePath;
|
|
3679
|
+
// Greeting fast-path: a pure greeting gets the same read-only stamp as an
|
|
3680
|
+
// informational question — never a mutating lane, never task debt.
|
|
3681
|
+
const greetingFastPath = !filePath && isPureGreetingPrompt({ promptText, commandText });
|
|
3587
3682
|
// A delivery-only prompt ("push this to git", "đẩy bộ này lên git") starts a new,
|
|
3588
3683
|
// mutation-free action and typically matches no skill. It must not inherit the
|
|
3589
3684
|
// previous request's completion contract — carrying find-cause/map-impact debt
|
|
@@ -3629,7 +3724,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
3629
3724
|
targetFile: filePath,
|
|
3630
3725
|
contextIntent: '',
|
|
3631
3726
|
taskType: '',
|
|
3632
|
-
...((informationalNoTargetPrompt || deliveryOnlyNoRoute) ? { executionMode: 'informational' } : {}),
|
|
3727
|
+
...((informationalNoTargetPrompt || deliveryOnlyNoRoute || greetingFastPath) ? { executionMode: 'informational' } : {}),
|
|
3633
3728
|
// Read-only/carried contexts: stamp whether THIS prompt ordered a
|
|
3634
3729
|
// mutation so the Stop gate can release a misrouted write-debt instead
|
|
3635
3730
|
// of looping "make an Edit" on an answer-only question.
|
|
@@ -3659,12 +3754,14 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
3659
3754
|
process.exit(0);
|
|
3660
3755
|
}
|
|
3661
3756
|
|
|
3757
|
+
await emitSourceResolutionAdvisory({ promptText, commandText, skip: informationalNoTargetPrompt || greetingFastPath || deliveryOnlyNoRoute });
|
|
3758
|
+
|
|
3662
3759
|
await ensureDir(statePath);
|
|
3663
3760
|
// A no-route prompt (short nudge, informational question) must not wipe the last full
|
|
3664
3761
|
// route: completion-gate.sh reads routeSummary/requestKey from this state to decide
|
|
3665
3762
|
// whether a Stop is premature. Downgrading to a route-less state here silently disarms
|
|
3666
3763
|
// the gate mid-task, so carry the previous route forward until a real route replaces it.
|
|
3667
|
-
await
|
|
3764
|
+
await persistRouteState(JSON.stringify({
|
|
3668
3765
|
...(sessionId ? { sessionId } : {}),
|
|
3669
3766
|
fingerprint,
|
|
3670
3767
|
ts: now,
|
|
@@ -3809,7 +3906,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
3809
3906
|
};
|
|
3810
3907
|
provisionalState.fingerprint = buildRouteStateFingerprint(provisionalState);
|
|
3811
3908
|
await ensureDir(statePath);
|
|
3812
|
-
await
|
|
3909
|
+
await persistRouteState(JSON.stringify(provisionalState));
|
|
3813
3910
|
|
|
3814
3911
|
let cacheUtils = null;
|
|
3815
3912
|
if (await pathExists(cacheUtilsPath)) {
|
|
@@ -3882,7 +3979,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
3882
3979
|
}
|
|
3883
3980
|
|
|
3884
3981
|
await ensureDir(statePath);
|
|
3885
|
-
await
|
|
3982
|
+
await persistRouteState(JSON.stringify(reusedState));
|
|
3886
3983
|
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
3887
3984
|
routingContext: reusedState.routingContext || {},
|
|
3888
3985
|
routeSummary: rescuedRouteSummary || {},
|
|
@@ -4007,7 +4104,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
4007
4104
|
...(helpers ? { helpers } : {}),
|
|
4008
4105
|
routeSummary: compactRouteSummary(routeSummary),
|
|
4009
4106
|
};
|
|
4010
|
-
await
|
|
4107
|
+
await persistRouteState(JSON.stringify(sharedState));
|
|
4011
4108
|
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
4012
4109
|
routingContext,
|
|
4013
4110
|
routeSummary,
|
|
@@ -4032,6 +4129,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
4032
4129
|
|
|
4033
4130
|
process.stdout.write(`[ukit-skill-router] Read skills: ${getActiveSkillPaths(selected).join(', ')}\n`);
|
|
4034
4131
|
process.stdout.write(`[ukit-skill-router] Route: ${formatDisplayRouteSummary(routeSummary, routingContext)}\n`);
|
|
4132
|
+
await emitSourceResolutionAdvisory({ promptText, commandText, skip: false });
|
|
4035
4133
|
const nextDisplay = formatDisplayNextAction(routeSummary);
|
|
4036
4134
|
if (nextDisplay) {
|
|
4037
4135
|
process.stdout.write(`[ukit-skill-router] Next: ${nextDisplay}\n`);
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"Bash(rm -rf ..:*)",
|
|
50
50
|
"Bash(rm -rf .:*)",
|
|
51
51
|
"Bash(dd if=/dev/:*)",
|
|
52
|
-
"Bash(*> /dev/sda
|
|
52
|
+
"Bash(*> /dev/sda*)",
|
|
53
53
|
"Bash(mkfs:*)",
|
|
54
54
|
"Bash(:(){ :|:& };::*)",
|
|
55
55
|
"Bash(gh pr create:*)",
|
|
@@ -79,6 +79,16 @@
|
|
|
79
79
|
}
|
|
80
80
|
]
|
|
81
81
|
},
|
|
82
|
+
{
|
|
83
|
+
"matcher": "MultiEdit|NotebookEdit|Agent|Task",
|
|
84
|
+
"hooks": [
|
|
85
|
+
{
|
|
86
|
+
"type": "command",
|
|
87
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8",
|
|
88
|
+
"timeout": 18
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
},
|
|
82
92
|
{
|
|
83
93
|
"matcher": "Bash",
|
|
84
94
|
"hooks": [
|
|
@@ -111,6 +121,16 @@
|
|
|
111
121
|
}
|
|
112
122
|
]
|
|
113
123
|
},
|
|
124
|
+
{
|
|
125
|
+
"matcher": "MultiEdit|NotebookEdit|Agent|Task",
|
|
126
|
+
"hooks": [
|
|
127
|
+
{
|
|
128
|
+
"type": "command",
|
|
129
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.mjs\":8",
|
|
130
|
+
"timeout": 18
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
},
|
|
114
134
|
{
|
|
115
135
|
"matcher": "Bash",
|
|
116
136
|
"hooks": [
|