@ngockhoale/ukit 3.4.29 → 3.4.31

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 (49) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/manifests/documentation.yaml +100 -0
  3. package/package.json +1 -1
  4. package/src/core/executionContracts.js +32 -0
  5. package/src/core/permissionPolicy.js +1 -1
  6. package/src/decision/client.js +15 -0
  7. package/src/decision/protocol.js +12 -14
  8. package/src/index/buildIndex.js +244 -4
  9. package/src/index/impactContext.js +91 -11
  10. package/src/index/languageTools.js +126 -0
  11. package/src/index/paths.js +5 -1
  12. package/src/index/playbookRegistry.js +108 -9
  13. package/src/index/queryIndex.js +415 -1
  14. package/src/index/routeCatalog.js +1 -1
  15. package/src/index/routeResolver.js +2 -2
  16. package/src/index/taskRouting.js +28 -4
  17. package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
  18. package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
  19. package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
  20. package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
  21. package/template_project/.claude/hooks/record-execution.mjs +21 -0
  22. package/template_project/.claude/hooks/skill-router.sh +187 -16
  23. package/template_project/.claude/settings.json +43 -1
  24. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  25. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
  26. package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
  27. package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
  28. package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
  29. package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
  30. package/template_project/.claude/ukit/index/query-index.mjs +100 -7
  31. package/template_project/.claude/ukit/index/review-verdict.mjs +14 -4
  32. package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
  33. package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
  34. package/template_project/.claude/ukit/index/route-task.mjs +243 -38
  35. package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -59
  36. package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
  37. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
  38. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +612 -56
  39. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
  40. package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
  41. package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
  42. package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
  43. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
  44. package/template_project/.omp/hooks/pre/ukit-bridge.js +40 -5
  45. package/template_project/AGENTS.md +1 -1
  46. package/template_project/CLAUDE.md +1 -1
  47. package/template_project/instructions/core.md +1 -1
  48. package/template_user/playbooks/feature-implementation.md +9 -0
  49. 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,33 @@ 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
+ // Failed native invocations retain their real identity and never mint success evidence.
51
72
  const ledgerPath = env.UKIT_EXECUTION_LEDGER_PATH || DEFAULT_LEDGER_PATH;
52
73
  let ledger;
53
74
  try {