@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/manifests/documentation.yaml +88 -0
  3. package/package.json +1 -1
  4. package/src/core/buildPlan.js +11 -1
  5. package/src/core/permissionPolicy.js +1 -1
  6. package/src/core/status.js +3 -1
  7. package/src/index/buildIndex.js +244 -4
  8. package/src/index/impactContext.js +91 -11
  9. package/src/index/languageTools.js +126 -0
  10. package/src/index/paths.js +5 -1
  11. package/src/index/playbookRegistry.js +108 -9
  12. package/src/index/queryIndex.js +415 -1
  13. package/src/index/routeCatalog.js +1 -1
  14. package/src/index/taskRouting.js +25 -3
  15. package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
  16. package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
  17. package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
  18. package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
  19. package/template_project/.claude/hooks/record-execution.mjs +23 -0
  20. package/template_project/.claude/hooks/skill-router.sh +105 -7
  21. package/template_project/.claude/settings.json +21 -1
  22. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  23. package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
  24. package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
  25. package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
  26. package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
  27. package/template_project/.claude/ukit/index/query-index.mjs +100 -7
  28. package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
  29. package/template_project/.claude/ukit/index/route-task.mjs +208 -31
  30. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +455 -24
  31. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
  32. package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
  33. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
  34. package/template_project/.omp/hooks/pre/ukit-bridge.js +32 -4
  35. package/template_user/playbooks/feature-implementation.md +9 -0
  36. 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
- if [ "$CREATE_SCOPE_RUN" != 1 ] && ! printf '%s' "$INPUT" | grep -qE 'AI_HANDOFF|git[[:space:]]+push'; then
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 (isBash ? !shellWriteHit(command) : !(filePath || filePaths.length || patch.unparseable)) return '';
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 transcriptPath = typeof payload?.transcript_path === 'string' ? payload.transcript_path : '';
591
- if (!transcriptPath) return '';
592
- try { await fsp.access(transcriptPath, fs.constants.R_OK); } catch { return ''; } // missing/unreadable: silent
593
- let scan;
594
- try {
595
- const mod = await import(require('url').pathToFileURL(path.resolve(process.env.HOOK_DIR || '.', '../ukit/runtime/handoff-intent.mjs')).href);
596
- scan = await mod.transcriptHandoffIntent(transcriptPath);
597
- } catch {
598
- scan = { intent: null, known: false };
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
- if (!scan.known) {
601
- 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); this write proceeded without the create write-scope check.';
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 writeJsonFileAtomic(statePath, JSON.stringify({
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 writeJsonFileAtomic(statePath, JSON.stringify(provisionalState));
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 writeJsonFileAtomic(statePath, JSON.stringify(reusedState));
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 writeJsonFileAtomic(statePath, JSON.stringify(sharedState));
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": [