@ngockhoale/ukit 3.4.46 → 3.4.48
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 +13 -5
- package/manifests/documentation.yaml +22 -0
- package/manifests/platform.full.yaml +27 -0
- package/package.json +4 -1
- package/scripts/dev/capture-omp-stall.mjs +246 -0
- package/scripts/dev/worktree-preflight.mjs +20 -1
- package/scripts/docs/render-instructions.mjs +29 -5
- package/src/core/agentRuntime/supervisor.js +15 -1
- package/src/core/diffPlan.js +38 -12
- package/src/core/executionContracts.js +67 -16
- package/src/core/metadata.js +4 -0
- package/src/core/runInstallPipeline.js +19 -2
- package/src/core/runtimeConfig.js +1 -1
- package/src/diagnostics/ompStallSnapshot.js +199 -0
- package/src/index/playbookRegistry.js +22 -3
- package/src/index/taskRouting.js +2 -2
- package/template_project/.claude/agents/bug-debugger.md +1 -0
- package/template_project/.claude/agents/code-reviewer.md +1 -0
- package/template_project/.claude/agents/feature-implementer.md +1 -0
- package/template_project/.claude/agents/handoff-planner.md +1 -0
- package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
- package/template_project/.claude/hooks/completion-gate.sh +1 -1
- package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
- package/template_project/.claude/hooks/skill-router.sh +147 -6
- package/template_project/.claude/settings.json +8 -8
- package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
- package/template_project/.claude/ukit/index/route-resolver.mjs +67 -15
- package/template_project/.claude/ukit/index/route-task.mjs +115 -37
- package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +306 -20
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
- package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +80 -3
- package/template_project/.omp/agents/bug-debugger.md +1 -0
- package/template_project/.omp/agents/code-reviewer.md +1 -0
- package/template_project/.omp/agents/feature-implementer.md +1 -0
- package/template_project/.omp/agents/handoff-planner.md +1 -0
- package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
- package/template_project/ukit/storage/config.json +1 -1
- package/scripts/bench/data-foundation.mjs +0 -929
- package/scripts/bench/decision-runtime-baseline.mjs +0 -427
- package/scripts/bench/decision-runtime-variant.mjs +0 -626
- package/scripts/bench/goldTasks.json +0 -38
- package/scripts/bench/memory-ablation.mjs +0 -495
- package/scripts/bench/memory-baseline.mjs +0 -596
- package/scripts/bench/memory-bench.mjs +0 -661
- package/scripts/bench/memory-canary.mjs +0 -331
- package/scripts/bench/memory-corpus.mjs +0 -354
- package/scripts/bench/memory-gate.mjs +0 -389
- package/scripts/bench/memory-metrics.mjs +0 -179
- package/scripts/bench/outline-savings.mjs +0 -107
- package/scripts/bench/parallel-agents.mjs +0 -224
- package/scripts/bench/recorder-overhead.mjs +0 -204
- package/scripts/bench/runGold.mjs +0 -238
- package/scripts/bench/sqlite-spike.mjs +0 -451
- package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
- package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
- package/scripts/bench/v3-ceremony.mjs +0 -220
- package/scripts/perf/audit-perf.mjs +0 -1195
- package/scripts/perf/diff-perf-findings.mjs +0 -136
- package/scripts/perf/perf-findings.json +0 -474
- package/scripts/perf/perf-measure.md +0 -271
|
@@ -585,6 +585,16 @@ function loadReviewPolicyModule() {
|
|
|
585
585
|
return reviewPolicyModulePromise;
|
|
586
586
|
}
|
|
587
587
|
|
|
588
|
+
// One advisory line for a delegated feature run's acceptance verdict (null when nothing to say).
|
|
589
|
+
function playbookExecutionNote(acceptance) {
|
|
590
|
+
if (!acceptance?.applicable) return null;
|
|
591
|
+
if (!acceptance.delegated && !(acceptance.reasons || []).includes('delegation-required-not-performed')) return null;
|
|
592
|
+
const parts = [`[ukit-stop-coordinator] playbook acceptance (advisory): ${acceptance.verdict}`];
|
|
593
|
+
if (acceptance.reasons?.length) parts.push(`reasons: ${acceptance.reasons.slice(0, 4).join('; ')}`);
|
|
594
|
+
if (acceptance.unsupported?.length) parts.push(`unsupported: ${acceptance.unsupported.join(', ')}`);
|
|
595
|
+
return parts.join(' — ');
|
|
596
|
+
}
|
|
597
|
+
|
|
588
598
|
let ledgerReceiptModulePromise = null;
|
|
589
599
|
function loadLedgerReceiptModule() {
|
|
590
600
|
if (!ledgerReceiptModulePromise) {
|
|
@@ -607,7 +617,7 @@ function loadLedgerReceiptModule() {
|
|
|
607
617
|
* `completionKind` ('block'|'advisory'|'complete'|null) is the completion
|
|
608
618
|
* evaluator's verdict, used only to mark which done criteria are evidenced.
|
|
609
619
|
*/
|
|
610
|
-
export async function evaluateStopReviewPolicy({ projectRoot, completionKind = null } = {}) {
|
|
620
|
+
export async function evaluateStopReviewPolicy({ projectRoot, completionKind = null, payload = {} } = {}) {
|
|
611
621
|
try {
|
|
612
622
|
const policyModule = await loadReviewPolicyModule();
|
|
613
623
|
if (!policyModule || typeof policyModule.evaluateReviewPolicy !== 'function') return null;
|
|
@@ -647,6 +657,35 @@ export async function evaluateStopReviewPolicy({ projectRoot, completionKind = n
|
|
|
647
657
|
: 0;
|
|
648
658
|
|
|
649
659
|
const ledgerModule = await loadLedgerReceiptModule();
|
|
660
|
+
// Round 5: a feature-implementation run contributes its OWN facts (worker evidence,
|
|
661
|
+
// integration state, scope/conflict signals, retry history) as inputs to this one policy
|
|
662
|
+
// call — no second decision plane. Non-feature playbooks and non-delegated runs add nothing.
|
|
663
|
+
let featureReport = null;
|
|
664
|
+
try {
|
|
665
|
+
if (routeSummary.playbookId === 'feature-implementation' && typeof ledgerModule?.featureRunReport === 'function') {
|
|
666
|
+
const ledger = (await ledgerModule.readExecutionLedger(projectRoot, payload)) || {};
|
|
667
|
+
featureReport = ledgerModule.featureRunReport({ state, ledger, cwd: projectRoot });
|
|
668
|
+
}
|
|
669
|
+
} catch { featureReport = null; }
|
|
670
|
+
// The completion evaluator's stdout is `advisory` (not `complete`) whenever it attached the
|
|
671
|
+
// playbook acceptance note, so completionKind alone marks every route criterion 'missing' on a
|
|
672
|
+
// released feature run and pins review at run-targeted-check (acceptance NOT_PASS forever). The
|
|
673
|
+
// run's own contract-evidence facts come from the same receipt predicates the gate uses — a
|
|
674
|
+
// criterion they prove is verified; one they do not prove keeps the completionKind status.
|
|
675
|
+
const contractEvidence = featureReport?.acceptance?.facts?.contractEvidence;
|
|
676
|
+
if (contractEvidence?.known === true && Array.isArray(contractEvidence.required)) {
|
|
677
|
+
const unproven = new Set(Array.isArray(contractEvidence.missing) ? contractEvidence.missing : []);
|
|
678
|
+
for (const criterion of doneCriteria) {
|
|
679
|
+
if (criterion.status !== 'verified' && contractEvidence.required.includes(criterion.name) && !unproven.has(criterion.name)) {
|
|
680
|
+
criterion.status = 'verified';
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const extra = featureReport?.reviewInputs?.applicable ? featureReport.reviewInputs : null;
|
|
685
|
+
if (extra) {
|
|
686
|
+
const known = new Set(doneCriteria.map((c) => c.name));
|
|
687
|
+
for (const criterion of extra.doneCriteria) if (!known.has(criterion.name)) doneCriteria.push(criterion);
|
|
688
|
+
}
|
|
650
689
|
// The policy fires its calibration receipt without awaiting it; collect the promises so
|
|
651
690
|
// the receipt is on disk before this evaluator returns (a loaded host otherwise leaves
|
|
652
691
|
// the write racing the next reader).
|
|
@@ -660,10 +699,11 @@ export async function evaluateStopReviewPolicy({ projectRoot, completionKind = n
|
|
|
660
699
|
: undefined;
|
|
661
700
|
|
|
662
701
|
const decision = policyModule.evaluateReviewPolicy({
|
|
663
|
-
signals: routeSummary.suspicionSignals ?? {},
|
|
702
|
+
signals: extra ? { ...(routeSummary.suspicionSignals ?? {}), ...extra.signals } : (routeSummary.suspicionSignals ?? {}),
|
|
664
703
|
doneCriteria,
|
|
665
704
|
evidenceClasses: [],
|
|
666
|
-
fixLoopCount: Number.isFinite(historySignals.fixLoopCount) ? historySignals.fixLoopCount : 0,
|
|
705
|
+
fixLoopCount: Math.max(Number.isFinite(historySignals.fixLoopCount) ? historySignals.fixLoopCount : 0, extra?.fixLoopCount ?? 0),
|
|
706
|
+
...(extra?.escalationDepth ? { escalationDepth: extra.escalationDepth } : {}),
|
|
667
707
|
round,
|
|
668
708
|
cap: policyModule.DEFAULT_REVIEW_ROUND_CAP,
|
|
669
709
|
projectRoot,
|
|
@@ -684,6 +724,27 @@ export async function evaluateStopReviewPolicy({ projectRoot, completionKind = n
|
|
|
684
724
|
};
|
|
685
725
|
await writeJsonAtomicLocal(statePath, state);
|
|
686
726
|
|
|
727
|
+
// Round 5: persist the decision on the ledger (delegated runs only) and fold the final
|
|
728
|
+
// acceptance + dispatch hint + telemetry summary onto the returned decision. Advisory:
|
|
729
|
+
// none of this changes the completion gate's own verdict.
|
|
730
|
+
if (featureReport?.applicable) {
|
|
731
|
+
try {
|
|
732
|
+
if (featureReport.delegated) {
|
|
733
|
+
await ledgerModule.recordReviewDecision({ projectRoot, payload, decision });
|
|
734
|
+
}
|
|
735
|
+
const refreshed = (await ledgerModule.readExecutionLedger(projectRoot, payload)) || {};
|
|
736
|
+
const finalReport = ledgerModule.featureRunReport({
|
|
737
|
+
state, ledger: refreshed, cwd: projectRoot,
|
|
738
|
+
reviewDecision: { action: decision.action, namedCheck: decision.namedCheck ?? null, signalsHit: decision.signalsHit || [] },
|
|
739
|
+
});
|
|
740
|
+
decision.playbook = {
|
|
741
|
+
acceptance: finalReport.acceptance,
|
|
742
|
+
telemetry: finalReport.telemetry,
|
|
743
|
+
reviewDispatch: finalReport.reviewDispatch,
|
|
744
|
+
};
|
|
745
|
+
} catch { /* advisory */ }
|
|
746
|
+
}
|
|
747
|
+
|
|
687
748
|
return decision;
|
|
688
749
|
} catch {
|
|
689
750
|
return null; // advisory lane: never wedge the coordinator
|
|
@@ -1939,13 +2000,27 @@ export async function runStopCoordinator({
|
|
|
1939
2000
|
// the output and a review-policy receipt lands in decisions.tsv. The gate's
|
|
1940
2001
|
// own block logic is unchanged — a non-finish verdict only surfaces as a
|
|
1941
2002
|
// systemMessage note, never a block.
|
|
2003
|
+
let playbookTelemetry = null;
|
|
1942
2004
|
try {
|
|
1943
2005
|
const reviewPolicy = await evaluateStopReviewPolicy({
|
|
1944
2006
|
projectRoot,
|
|
1945
2007
|
completionKind: completion?.kind ?? null,
|
|
2008
|
+
payload,
|
|
1946
2009
|
});
|
|
1947
2010
|
if (reviewPolicy) {
|
|
1948
2011
|
merged.reviewPolicy = reviewPolicy;
|
|
2012
|
+
playbookTelemetry = reviewPolicy.playbook?.telemetry ?? null;
|
|
2013
|
+
if (reviewPolicy.playbook?.acceptance?.applicable) {
|
|
2014
|
+
merged.playbookAcceptance = reviewPolicy.playbook.acceptance;
|
|
2015
|
+
const dispatch = reviewPolicy.playbook.reviewDispatch;
|
|
2016
|
+
const notes = [
|
|
2017
|
+
playbookExecutionNote(reviewPolicy.playbook.acceptance),
|
|
2018
|
+
dispatch ? `review dispatch (advisory): Agent subagent_type=${dispatch.subagent_type} effort=${dispatch.effort} — ${dispatch.reason}. ${dispatch.brief}` : null,
|
|
2019
|
+
].filter(Boolean);
|
|
2020
|
+
if (notes.length) {
|
|
2021
|
+
merged.systemMessage = merged.systemMessage ? `${merged.systemMessage}\n${notes.join('\n')}` : notes.join('\n');
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
1949
2024
|
if (reviewPolicy.action !== 'finish') {
|
|
1950
2025
|
const note = `[ukit-stop-coordinator] review policy (advisory): ${reviewPolicy.action}`
|
|
1951
2026
|
+ (reviewPolicy.namedCheck ? ` — ${reviewPolicy.namedCheck}` : '')
|
|
@@ -1966,6 +2041,7 @@ export async function runStopCoordinator({
|
|
|
1966
2041
|
await captureUsageShadow({ projectRoot, payload });
|
|
1967
2042
|
} catch { /* advisory */ }
|
|
1968
2043
|
|
|
2044
|
+
|
|
1969
2045
|
// TASK-005 (FR-005): the coordinator's terminal verdict lands ONE
|
|
1970
2046
|
// `outcome.observed` record per coordinated stop — the merged evaluator
|
|
1971
2047
|
// decision is the session's honest outcome at this stop. Fire-and-forget:
|
|
@@ -2000,6 +2076,7 @@ export async function runStopCoordinator({
|
|
|
2000
2076
|
evidenceIds: [],
|
|
2001
2077
|
cost: { latencyMs: null, rounds: null },
|
|
2002
2078
|
reviewActions: [],
|
|
2079
|
+
...(playbookTelemetry ? { playbook: playbookTelemetry } : {}),
|
|
2003
2080
|
},
|
|
2004
2081
|
projectRoot,
|
|
2005
2082
|
payload: typeof payload?.session_id === 'string' && payload.session_id ? payload : outcomePayload,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
name: bug-debugger
|
|
3
3
|
description: "Debugging specialist for reproducible errors, failing tests, and unexpected behavior. Use proactively when investigation will involve noisy logs, stack traces, or a self-contained reproduce-trace-fix-verify loop. Do not use for trivial obvious fixes or broad architecture ideation."
|
|
4
4
|
model: "@default"
|
|
5
|
+
thinking-level: medium
|
|
5
6
|
tools: ["read","grep","glob","bash","edit","ast_edit"]
|
|
6
7
|
---
|
|
7
8
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
name: code-reviewer
|
|
3
3
|
description: "Independent reviewer for handoff Phase 3 and for spec/plan documents. For code (default): use after executor reports STATUS: DONE on a handoff task, MUST run with a model different from the executor (configured in .ukit/storage/config.json → handoff.reviewer.model, default unic-smart), produces a verdict: APPROVED | APPROVED-WITH-MINOR | CHANGES-REQUESTED | CRITICAL. For spec/plan documents (set REVIEW_TARGET_TYPE=spec or plan): reviews a docs/plans/*.md file for completeness/consistency/clarity/scope/YAGNI, produces Status: Approved | Issues Found."
|
|
4
4
|
model: "@slow"
|
|
5
|
+
thinking-level: medium
|
|
5
6
|
tools: ["read","grep","glob","bash"]
|
|
6
7
|
---
|
|
7
8
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
name: feature-implementer
|
|
3
3
|
description: "Implementation specialist for clear, self-contained coding tasks. Use proactively when the approach is clear and the work can be completed in a bounded pass with a concise summary. Do not use for trivial one-file tweaks or tightly coupled exploratory work."
|
|
4
4
|
model: "@default"
|
|
5
|
+
thinking-level: medium
|
|
5
6
|
tools: ["read","edit","write","ast_edit","grep","glob","bash"]
|
|
6
7
|
---
|
|
7
8
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
name: handoff-planner
|
|
3
3
|
description: "Handoff planning specialist for Phase 1+2. Use when creating a new handoff cycle: writes PLAN.md with full test plan, splits into TASK-xxx.md files with TDD-embedded test cases, and updates INDEX.md. Always use the strongest available model (Opus/unic-smart)."
|
|
4
4
|
model: "@slow"
|
|
5
|
+
thinking-level: medium
|
|
5
6
|
tools: ["read","edit","write","ast_edit","glob","bash"]
|
|
6
7
|
---
|
|
7
8
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
name: ukit-small-task-maintainer
|
|
3
3
|
description: "Internal UKit maintenance subagent for low-risk, reversible UKit decisions. Use proactively when UKit needs to decide or perform safe cleanup such as pruning docs/TASKS.md, classifying queued work, choosing whether compact/summarization is appropriate, summarizing local docs/status, or maintaining small UKit runtime queues. Do not use for product implementation, security, release/publish, data-loss, architecture, or risky/shared code changes."
|
|
4
4
|
model: "@smol"
|
|
5
|
+
thinking-level: low
|
|
5
6
|
tools: ["read","grep","glob","edit","write","ast_edit"]
|
|
6
7
|
blocking: false
|
|
7
8
|
---
|
|
@@ -31,10 +31,12 @@ import {
|
|
|
31
31
|
import { resolveChainExecTimeoutMs } from '../../../.claude/ukit/runtime/hook-chain-budget.mjs';
|
|
32
32
|
|
|
33
33
|
// OMP-3 (TASK-002): pi.exec forwards only {cwd, signal, timeout} to its spawn —
|
|
34
|
-
// an
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
34
|
+
// an environment option is dropped (verified against the bundled omp exec), and the host's
|
|
35
|
+
// Bun.spawn inherits the launch-time snapshot, so this runtime write does NOT reach hook
|
|
36
|
+
// children (live omp run: ledger harness 'claude-code', tier read from .claude/settings.json).
|
|
37
|
+
// It only stamps this in-proc bridge; hook children get the stamp from the `ukit_harness` payload
|
|
38
|
+
// field (buildHookPayload) that hook-chain-runner exports before any step runs. Receipts,
|
|
39
|
+
// resolveMainTier and the Claude-only delegation gate all read UKIT_HARNESS (default 'claude-code').
|
|
38
40
|
process.env.UKIT_HARNESS = 'omp';
|
|
39
41
|
|
|
40
42
|
export const HOOK_EVENT_MAP = {
|
|
@@ -45,16 +47,19 @@ export const HOOK_EVENT_MAP = {
|
|
|
45
47
|
'stale-spec-guard.sh',
|
|
46
48
|
'pre-edit-backup.sh',
|
|
47
49
|
'skill-router.sh',
|
|
50
|
+
'delegation-gate.mjs',
|
|
48
51
|
'handoff-model-guard.sh',
|
|
49
52
|
'context-hardcap-gate.sh',
|
|
50
53
|
],
|
|
51
|
-
// Delegation + multi-file mutation tools.
|
|
54
|
+
// Delegation + multi-file mutation tools. The create-scope guard applies (the delegation gate is
|
|
55
|
+
// Claude Code-only and no-ops here: omp has no verifiable worker identity): it denies
|
|
52
56
|
// implementation-capable subagents / source writes while a handoff-create session is live.
|
|
53
|
-
'MultiEdit|NotebookEdit|Agent|Task': ['handoff-model-guard.sh'],
|
|
57
|
+
'MultiEdit|NotebookEdit|Agent|Task': ['delegation-gate.mjs', 'handoff-model-guard.sh'],
|
|
54
58
|
Bash: [
|
|
55
59
|
'auto-allow-bash.sh',
|
|
56
60
|
'block-dangerous.mjs',
|
|
57
61
|
'sensitive-data-guard.mjs',
|
|
62
|
+
'delegation-gate.mjs',
|
|
58
63
|
'handoff-model-guard.sh',
|
|
59
64
|
'context-hardcap-gate.sh',
|
|
60
65
|
'verification-guard.sh',
|
|
@@ -241,6 +246,18 @@ function redactDiagnosticText(text, maxChars = 500) {
|
|
|
241
246
|
: redacted;
|
|
242
247
|
}
|
|
243
248
|
|
|
249
|
+
// Host-written session header: a subagent session carries `parentSession` (the main session file);
|
|
250
|
+
// a main session never does. This is SESSION-level descent only — it names no agent type and
|
|
251
|
+
// correlates no tool call, so it cannot attribute work to a particular task() entry.
|
|
252
|
+
export function isSubagentSession(context = {}) {
|
|
253
|
+
try {
|
|
254
|
+
const header = context?.sessionManager?.getHeader?.();
|
|
255
|
+
return typeof header?.parentSession === 'string' && header.parentSession !== '';
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
244
261
|
function runtimeMetadata(event = {}, context = {}) {
|
|
245
262
|
const sessionManager = context?.sessionManager;
|
|
246
263
|
const transcriptPath = event.transcriptPath ?? event.transcript_path ?? sessionManager?.getSessionFile?.();
|
|
@@ -255,11 +272,12 @@ function runtimeMetadata(event = {}, context = {}) {
|
|
|
255
272
|
sessionId: event.sessionId ?? event.session_id ?? sessionManager?.getSessionId?.() ?? transcriptPath,
|
|
256
273
|
cwd: context?.cwd ?? event.cwd,
|
|
257
274
|
transcriptPath,
|
|
275
|
+
subagentSession: isSubagentSession(context) ? true : undefined,
|
|
258
276
|
};
|
|
259
277
|
}
|
|
260
278
|
|
|
261
279
|
function buildHookPayload(hookEventName, fields = {}) {
|
|
262
|
-
const payload = { hook_event_name: hookEventName };
|
|
280
|
+
const payload = { hook_event_name: hookEventName, ukit_harness: 'omp' };
|
|
263
281
|
const mappings = [
|
|
264
282
|
['toolName', 'tool_name'],
|
|
265
283
|
['toolInput', 'tool_input'],
|
|
@@ -272,6 +290,7 @@ function buildHookPayload(hookEventName, fields = {}) {
|
|
|
272
290
|
['prompt', 'prompt'],
|
|
273
291
|
['transcriptPath', 'transcript_path'],
|
|
274
292
|
['source', 'source'],
|
|
293
|
+
['subagentSession', 'ukit_subagent_session'],
|
|
275
294
|
];
|
|
276
295
|
for (const [field, payloadField] of mappings) {
|
|
277
296
|
if (fields[field] !== undefined && fields[field] !== null) {
|
|
@@ -951,6 +970,14 @@ export async function runToolResult(pi, event, { projectRoot, context: extension
|
|
|
951
970
|
}
|
|
952
971
|
|
|
953
972
|
export async function runBeforeAgentStart(pi, event, { projectRoot, context: extensionContext = {} }) {
|
|
973
|
+
// Route currency: refresh the owner session's observed main model before the chain runs so
|
|
974
|
+
// resolveMainTier reads the model the host reports NOW, not the one at session creation.
|
|
975
|
+
// Observation-only (see observeMainModel): foreign/legacy records untouched, never promotes.
|
|
976
|
+
try {
|
|
977
|
+
await observeMainModel({ projectRoot, context: extensionContext, observedVia: 'before_agent_start' });
|
|
978
|
+
} catch {
|
|
979
|
+
// advisory refresh — routing continues on the previously recorded model
|
|
980
|
+
}
|
|
954
981
|
const metadata = runtimeMetadata(event, extensionContext);
|
|
955
982
|
const payload = buildHookPayload('UserPromptSubmit', {
|
|
956
983
|
prompt: event.prompt,
|
|
@@ -970,7 +997,224 @@ export async function runSessionCompacting(pi, event, { projectRoot, context: ex
|
|
|
970
997
|
return result.context.length > 0 ? { context: result.context } : undefined;
|
|
971
998
|
}
|
|
972
999
|
|
|
1000
|
+
// ── Main orchestrator (Tier 3 / `slow`) ─────────────────────────────────────────────────────
|
|
1001
|
+
// UKit-managed omp sessions orchestrate on the `slow` role while `modelRoles.default` stays the
|
|
1002
|
+
// Tier 2 worker model. The only host-native way to pick the ACTIVE model from an extension is
|
|
1003
|
+
// pi.setModel(model) (session-scoped, never persisted); it is applied once, on a FRESH session
|
|
1004
|
+
// that is still on the `default` role model — a resumed session (omp restores its recorded model),
|
|
1005
|
+
// an explicit --model, or a model the user already switched to is left untouched. What was
|
|
1006
|
+
// observed/decided is recorded for route-task (resolveMainTier) under .ukit/storage/cache.
|
|
1007
|
+
const SESSION_MAIN_MODEL_FILE = path.join('.ukit', 'storage', 'cache', 'session-main-model.json');
|
|
1008
|
+
|
|
1009
|
+
function projectModelRole(projectRoot, role) {
|
|
1010
|
+
try {
|
|
1011
|
+
const text = fs.readFileSync(path.join(projectRoot, '.omp', 'config.yml'), 'utf8');
|
|
1012
|
+
const block = text.match(/^modelRoles:[ \t]*\r?\n((?:[ \t]+.*(?:\r?\n|$))+)/m);
|
|
1013
|
+
const line = block?.[1].match(new RegExp(`^[ \\t]+${role}:[ \\t]*([^\\s#]+)`, 'm'));
|
|
1014
|
+
return line ? line[1].replace(/^["']|["']$/g, '') : null;
|
|
1015
|
+
} catch {
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function findRegistryModel(registry, value) {
|
|
1021
|
+
const wanted = String(value ?? '').replace(/:(off|minimal|low|medium|high|xhigh|max|auto)$/i, '');
|
|
1022
|
+
if (!wanted || typeof registry?.getAvailable !== 'function') return null;
|
|
1023
|
+
const matches = registry.getAvailable().filter((m) => m?.id === wanted || `${m?.provider}/${m?.id}` === wanted);
|
|
1024
|
+
return matches.length === 1 ? matches[0] : null;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function readSessionMainModel(projectRoot) {
|
|
1028
|
+
try {
|
|
1029
|
+
return JSON.parse(fs.readFileSync(path.join(projectRoot, SESSION_MAIN_MODEL_FILE), 'utf8'));
|
|
1030
|
+
} catch {
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Provenance closure for the shared single-slot record (observed real run 2026-10-18: the child's
|
|
1036
|
+
// post-promotion rewrite at 17:28:43.915 carried no writer id, so a child overwrite of the main
|
|
1037
|
+
// session's record was unattributable). Every write stamps `session` = the writer's OWN host
|
|
1038
|
+
// sessionManager id (never invented, never env-derived; the key is absent — not null — when the
|
|
1039
|
+
// host gives no id). Ownership POLICY lives with the callers: selectOrchestratorModel's skip paths
|
|
1040
|
+
// never erase another session's live promotion, and selectWorkerModel pins only for the owner.
|
|
1041
|
+
function recordSessionMainModel(projectRoot, record, { sessionId = null } = {}) {
|
|
1042
|
+
try {
|
|
1043
|
+
const file = path.join(projectRoot, SESSION_MAIN_MODEL_FILE);
|
|
1044
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1045
|
+
fs.writeFileSync(file, `${JSON.stringify({ harness: 'omp', ts: new Date().toISOString(), session: sessionId ?? undefined, ...record })}\n`);
|
|
1046
|
+
} catch {
|
|
1047
|
+
// advisory telemetry only — never fail session start
|
|
1048
|
+
}
|
|
1049
|
+
return record;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Owner match on host data only: a record without a session stamp (legacy pre-provenance write)
|
|
1053
|
+
// is owned by nobody and must not be claimed by a reader that happens to be alive.
|
|
1054
|
+
function recordOwnedBySession(record, sessionId) {
|
|
1055
|
+
return typeof sessionId === 'string' && sessionId !== ''
|
|
1056
|
+
&& typeof record?.session === 'string' && record.session !== ''
|
|
1057
|
+
&& record.session === sessionId;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
export async function selectOrchestratorModel(pi, { projectRoot, context = {} } = {}) {
|
|
1061
|
+
// A subagent session loads this same bridge and sees a fresh default-role session. Its header
|
|
1062
|
+
// carries the host-set parentSession; promoting it would run the Tier 2 worker on `slow` (observed
|
|
1063
|
+
// live: worker model_change unic-code -> unic-smart 4 ms after spawn). Skip WITHOUT recording —
|
|
1064
|
+
// the record describes the main session and selectWorkerModel reads it for later spawns.
|
|
1065
|
+
if (isSubagentSession(context)) return { action: 'skipped', reason: 'subagent-session' };
|
|
1066
|
+
const active = context.model ?? null;
|
|
1067
|
+
const activeId = active?.id ?? null;
|
|
1068
|
+
// Provenance: every record names the writer session (host sessionManager id, never env or an
|
|
1069
|
+
// invented identity) so any foreign write would be attributable; skip paths preserve a live
|
|
1070
|
+
// promotion instead of overwriting the shared single-slot record (see recordSessionMainModel).
|
|
1071
|
+
const ownSessionId = context.sessionManager?.getSessionId?.() ?? null;
|
|
1072
|
+
const record = (extra) => recordSessionMainModel(projectRoot, { model: activeId, ...extra }, { sessionId: ownSessionId });
|
|
1073
|
+
// Skip paths must not erase another main session's live promotion (the record is a single
|
|
1074
|
+
// per-project slot): preserve it and say so. ONE exception, same owner only: if THIS session's
|
|
1075
|
+
// host-reported active model no longer matches the model UKit selected for it, an explicit
|
|
1076
|
+
// model change was observed (no model_select extension event exists in omp 18.10.4 — the
|
|
1077
|
+
// active model at the next bridge observation is the only host signal). Recording it here is
|
|
1078
|
+
// what keeps resolveMainTier from reporting a stale slow role after the user switched models.
|
|
1079
|
+
// A record owned by nobody (legacy, no stamp) or by a different session is always preserved.
|
|
1080
|
+
const skip = (reason, extra = {}) => {
|
|
1081
|
+
const existing = readSessionMainModel(projectRoot);
|
|
1082
|
+
if (existing?.selected === true) {
|
|
1083
|
+
if (recordOwnedBySession(existing, ownSessionId) && activeId && activeId !== existing.model) {
|
|
1084
|
+
recordSessionMainModel(
|
|
1085
|
+
projectRoot,
|
|
1086
|
+
{ model: activeId, selected: false, reason: 'observed-active-model-changed', observedVia: reason },
|
|
1087
|
+
{ sessionId: ownSessionId },
|
|
1088
|
+
);
|
|
1089
|
+
return { action: 'skipped', reason, recordCorrected: true };
|
|
1090
|
+
}
|
|
1091
|
+
return { action: 'skipped', reason, preservedRecord: true };
|
|
1092
|
+
}
|
|
1093
|
+
record({ selected: false, reason, ...extra });
|
|
1094
|
+
return { action: 'skipped', reason };
|
|
1095
|
+
};
|
|
1096
|
+
const slow = projectModelRole(projectRoot, 'slow');
|
|
1097
|
+
const def = projectModelRole(projectRoot, 'default');
|
|
1098
|
+
if (!slow || !def || typeof pi?.setModel !== 'function' || !context.modelRegistry) return skip('unsupported');
|
|
1099
|
+
const entries = context.sessionManager?.getEntries?.();
|
|
1100
|
+
if (!Array.isArray(entries)) return skip('resume-state-unknown');
|
|
1101
|
+
if (entries.some((e) => e?.type === 'message')) return skip('resumed-session');
|
|
1102
|
+
const defaultModel = findRegistryModel(context.modelRegistry, def);
|
|
1103
|
+
if (!active || !defaultModel || activeId !== defaultModel.id || active.provider !== defaultModel.provider) return skip('active-model-not-default-role');
|
|
1104
|
+
const slowModel = findRegistryModel(context.modelRegistry, slow);
|
|
1105
|
+
if (!slowModel) return skip('slow-role-model-unavailable');
|
|
1106
|
+
if (slowModel.id === activeId && slowModel.provider === active.provider) {
|
|
1107
|
+
return skip('already-orchestrator', { role: 'slow' });
|
|
1108
|
+
}
|
|
1109
|
+
let ok = false;
|
|
1110
|
+
try {
|
|
1111
|
+
ok = (await pi.setModel(slowModel)) === true;
|
|
1112
|
+
} catch {
|
|
1113
|
+
ok = false;
|
|
1114
|
+
}
|
|
1115
|
+
if (!ok) return skip('set-model-rejected');
|
|
1116
|
+
recordSessionMainModel(projectRoot, { model: slowModel.id, selected: true, role: 'slow', reason: 'fresh-session-default-role' }, { sessionId: ownSessionId });
|
|
1117
|
+
return { action: 'selected', model: slowModel.id };
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// omp resolves a subagent whose frontmatter says `model: "@default"` as "inherit the parent's ACTIVE
|
|
1121
|
+
// model" (not as `modelRoles.default`). Once selectOrchestratorModel has moved the session to the
|
|
1122
|
+
// `slow` role, a `@default` Tier 2 coding worker would therefore run on the Tier 3 model. The host's
|
|
1123
|
+
// before_subagent_spawn event lets an extension pin the model: when UKit itself switched this session
|
|
1124
|
+
// (recorded selected:true), the agent binds `@default` and the host resolved it to that active model,
|
|
1125
|
+
// the worker is pinned back to the project's `default` role model. An explicit caller/user model, a
|
|
1126
|
+
// session UKit did not switch, and agents bound to any other role are left to the host.
|
|
1127
|
+
export function selectWorkerModel(event, { projectRoot, context = {} } = {}) {
|
|
1128
|
+
try {
|
|
1129
|
+
const agent = String(event?.agent ?? '');
|
|
1130
|
+
if (!/^[A-Za-z0-9._-]+$/.test(agent)) return undefined;
|
|
1131
|
+
const ownSessionId = context.sessionManager?.getSessionId?.() ?? null;
|
|
1132
|
+
const record = readSessionMainModel(projectRoot);
|
|
1133
|
+
if (record?.selected !== true || !record.model) return undefined;
|
|
1134
|
+
// Owner match: the pin describes the session UKit itself switched. A record written by a
|
|
1135
|
+
// DIFFERENT main session (two mains per project) or by a pre-provenance bridge (no stamp)
|
|
1136
|
+
// must never let this session inherit the pin — without this check the second main would
|
|
1137
|
+
// silently run its @default workers off the first main's promotion.
|
|
1138
|
+
if (!recordOwnedBySession(record, ownSessionId)) return undefined;
|
|
1139
|
+
// Respect explicit switches: the pin exists only while this session is still on the model
|
|
1140
|
+
// UKit itself selected. A mismatch means the owner changed models after promotion (omp has
|
|
1141
|
+
// no model_select extension event; the spawn-time active model is the host signal): correct
|
|
1142
|
+
// the same-owner record so resolveMainTier stops reporting the stale role, and leave the
|
|
1143
|
+
// spawn to host inheritance semantics — the explicit model wins.
|
|
1144
|
+
const activeNow = context.model;
|
|
1145
|
+
if (!activeNow || String(activeNow.id ?? '') !== record.model) {
|
|
1146
|
+
recordSessionMainModel(
|
|
1147
|
+
projectRoot,
|
|
1148
|
+
{ model: activeNow?.id ?? null, selected: false, reason: 'observed-active-model-changed', observedVia: 'before_subagent_spawn' },
|
|
1149
|
+
{ sessionId: ownSessionId },
|
|
1150
|
+
);
|
|
1151
|
+
return undefined;
|
|
1152
|
+
}
|
|
1153
|
+
const agentFile = fs.readFileSync(path.join(projectRoot, '.omp', 'agents', `${agent}.md`), 'utf8');
|
|
1154
|
+
const bound = agentFile.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1].match(/^model:\s*["']?([^"'#\s]+)/m)?.[1];
|
|
1155
|
+
if (bound !== '@default') return undefined;
|
|
1156
|
+
const active = context.model;
|
|
1157
|
+
const patterns = Array.isArray(event?.patterns) ? event.patterns.map((p) => String(p).replace(/:(off|minimal|low|medium|high|xhigh|max|auto)$/i, '')) : [];
|
|
1158
|
+
if (!active || patterns.length !== 1 || (patterns[0] !== active.id && patterns[0] !== `${active.provider}/${active.id}`)) return undefined;
|
|
1159
|
+
const def = projectModelRole(projectRoot, 'default');
|
|
1160
|
+
const defaultModel = def ? findRegistryModel(context.modelRegistry, def) : null;
|
|
1161
|
+
if (!defaultModel || (defaultModel.id === active.id && defaultModel.provider === active.provider)) return undefined;
|
|
1162
|
+
return { model: `${defaultModel.provider}/${defaultModel.id}`, note: 'ukit: @default coding worker pinned to modelRoles.default (session orchestrator is on slow)' };
|
|
1163
|
+
} catch {
|
|
1164
|
+
return undefined; // never block a subagent spawn on model bookkeeping
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Switch/prompt-time OBSERVATION, never initialization: session_switch (and every
|
|
1169
|
+
// before_agent_start, for route currency) may only record what the host now reports as the OWNER
|
|
1170
|
+
// session's active model. omp 18.10.4 has no model_select extension event, so the active model at
|
|
1171
|
+
// these observations is the only host signal that the user explicitly switched models after a
|
|
1172
|
+
// promotion; recording it keeps resolveMainTier from reporting a stale slow role. A foreign or
|
|
1173
|
+
// legacy (unstamped) record is never claimed and never erased, and NO promotion can happen here —
|
|
1174
|
+
// initialization (pi.setModel) stays exclusive to session_start, so a fresh session reached via a
|
|
1175
|
+
// switch can never be re-promoted behind the user's back.
|
|
1176
|
+
export async function observeMainModel({ projectRoot, context = {}, observedVia = 'observation' } = {}) {
|
|
1177
|
+
// A child session (header.parentSession) never observes, never writes, never claims the slot.
|
|
1178
|
+
if (isSubagentSession(context)) return { action: 'skipped', reason: 'subagent-session' };
|
|
1179
|
+
const active = context.model ?? null;
|
|
1180
|
+
const activeId = active?.id ?? null;
|
|
1181
|
+
const ownSessionId = context.sessionManager?.getSessionId?.() ?? null;
|
|
1182
|
+
const existing = readSessionMainModel(projectRoot);
|
|
1183
|
+
if (!existing || existing.harness !== 'omp') return { action: 'unchanged' };
|
|
1184
|
+
// Refresh ANY same-owner record whose model no longer matches the host-reported active model —
|
|
1185
|
+
// not just a live promotion. Otherwise the FIRST observed switch (smart→code) freezes the
|
|
1186
|
+
// record on code and a later switch (code→smart) would leave it stale forever. The refresh
|
|
1187
|
+
// always records selected:false: an observed model change means the recorded UKit selection is
|
|
1188
|
+
// no longer the session state, so the pin must NOT re-arm here (only session_start promotes).
|
|
1189
|
+
if (!recordOwnedBySession(existing, ownSessionId)) return { action: 'unchanged' };
|
|
1190
|
+
if (!activeId || activeId === existing.model) return { action: 'unchanged' };
|
|
1191
|
+
recordSessionMainModel(
|
|
1192
|
+
projectRoot,
|
|
1193
|
+
{ model: activeId, selected: false, reason: 'observed-active-model-changed', observedVia },
|
|
1194
|
+
{ sessionId: ownSessionId },
|
|
1195
|
+
);
|
|
1196
|
+
return { action: 'corrected', model: activeId };
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// omp emits session_start only at session CREATION; switching sessions dispatches the separate
|
|
1200
|
+
// session_switch extension event (verified against omp 18.10.4: the extension host's
|
|
1201
|
+
// handler-timeout map lists {session_start, session_switch, session_branch}). The switch lane is
|
|
1202
|
+
// observation-only (see observeMainModel) — no setModel, no context chain (exact-once, TASK-042).
|
|
1203
|
+
export async function runSessionSwitch(pi, event, { projectRoot, context: extensionContext = {} }) {
|
|
1204
|
+
try {
|
|
1205
|
+
await observeMainModel({ projectRoot, context: extensionContext, observedVia: 'session_switch' });
|
|
1206
|
+
} catch {
|
|
1207
|
+
// switch-time observation must never break the switch
|
|
1208
|
+
}
|
|
1209
|
+
return undefined;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
973
1212
|
export async function runSessionStart(pi, event, { projectRoot, context: extensionContext = {} }) {
|
|
1213
|
+
try {
|
|
1214
|
+
await selectOrchestratorModel(pi, { projectRoot, context: extensionContext });
|
|
1215
|
+
} catch {
|
|
1216
|
+
// orchestrator selection must never block session start
|
|
1217
|
+
}
|
|
974
1218
|
const metadata = runtimeMetadata(event, extensionContext);
|
|
975
1219
|
const payload = buildHookPayload('SessionStart', metadata);
|
|
976
1220
|
const result = await runScriptChain(pi, HOOK_EVENT_MAP.session_start, payload, { projectRoot });
|
|
@@ -1116,6 +1360,7 @@ export async function runSessionStop(
|
|
|
1116
1360
|
sessionId: event?.sessionId ?? event?.session_id,
|
|
1117
1361
|
cwd: extensionContext?.cwd ?? event?.cwd,
|
|
1118
1362
|
transcriptPath: event?.transcriptPath ?? event?.transcript_path ?? event?.session_file,
|
|
1363
|
+
subagentSession: isSubagentSession(extensionContext) ? true : undefined,
|
|
1119
1364
|
};
|
|
1120
1365
|
const payload = buildHookPayload('Stop', metadata);
|
|
1121
1366
|
|
|
@@ -1273,6 +1518,8 @@ export default function hook(pi) {
|
|
|
1273
1518
|
pi.on('before_agent_start', (event, context) => runBeforeAgentStart(pi, event, { projectRoot, context }));
|
|
1274
1519
|
pi.on('session.compacting', (event, context) => runSessionCompacting(pi, event, { projectRoot, context }));
|
|
1275
1520
|
pi.on('session_start', (event, context) => runSessionStart(pi, event, { projectRoot, context }));
|
|
1521
|
+
pi.on('session_switch', (event, context) => runSessionSwitch(pi, event, { projectRoot, context }));
|
|
1276
1522
|
pi.on('session_compact', (event, context) => runSessionCompact(pi, event, { projectRoot, context }));
|
|
1277
1523
|
pi.on('session_stop', (event, context) => runSessionStop(pi, event, { projectRoot, context }));
|
|
1524
|
+
pi.on('before_subagent_spawn', (event, context) => selectWorkerModel(event, { projectRoot, context }));
|
|
1278
1525
|
}
|