@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.
- package/CHANGELOG.md +29 -0
- package/manifests/documentation.yaml +100 -0
- package/package.json +1 -1
- package/src/core/executionContracts.js +32 -0
- package/src/core/permissionPolicy.js +1 -1
- package/src/decision/client.js +15 -0
- package/src/decision/protocol.js +12 -14
- 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/routeResolver.js +2 -2
- package/src/index/taskRouting.js +28 -4
- 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 +21 -0
- package/template_project/.claude/hooks/skill-router.sh +187 -16
- package/template_project/.claude/settings.json +43 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
- 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/review-verdict.mjs +14 -4
- package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
- package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
- package/template_project/.claude/ukit/index/route-task.mjs +243 -38
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -59
- package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +612 -56
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
- package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
- package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
- 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 +40 -5
- package/template_project/AGENTS.md +1 -1
- package/template_project/CLAUDE.md +1 -1
- package/template_project/instructions/core.md +1 -1
- 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,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 {
|