@ngockhoale/ukit 3.4.47 → 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/manifests/documentation.yaml +22 -0
  3. package/manifests/platform.full.yaml +27 -0
  4. package/package.json +4 -1
  5. package/scripts/dev/worktree-preflight.mjs +20 -1
  6. package/src/core/diffPlan.js +38 -12
  7. package/src/core/executionContracts.js +67 -16
  8. package/src/core/metadata.js +4 -0
  9. package/src/core/runInstallPipeline.js +19 -2
  10. package/src/core/runtimeConfig.js +1 -1
  11. package/src/index/playbookRegistry.js +22 -3
  12. package/src/index/taskRouting.js +2 -2
  13. package/template_project/.claude/agents/bug-debugger.md +1 -0
  14. package/template_project/.claude/agents/code-reviewer.md +1 -0
  15. package/template_project/.claude/agents/feature-implementer.md +1 -0
  16. package/template_project/.claude/agents/handoff-planner.md +1 -0
  17. package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
  18. package/template_project/.claude/hooks/completion-gate.sh +1 -1
  19. package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
  20. package/template_project/.claude/hooks/skill-router.sh +147 -6
  21. package/template_project/.claude/settings.json +8 -8
  22. package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
  23. package/template_project/.claude/ukit/index/route-resolver.mjs +67 -15
  24. package/template_project/.claude/ukit/index/route-task.mjs +115 -37
  25. package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
  26. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
  27. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +306 -20
  28. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
  29. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  30. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
  31. package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
  32. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +79 -3
  33. package/template_project/.omp/agents/bug-debugger.md +1 -0
  34. package/template_project/.omp/agents/code-reviewer.md +1 -0
  35. package/template_project/.omp/agents/feature-implementer.md +1 -0
  36. package/template_project/.omp/agents/handoff-planner.md +1 -0
  37. package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
  38. package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
  39. package/template_project/ukit/storage/config.json +1 -1
  40. package/scripts/bench/data-foundation.mjs +0 -929
  41. package/scripts/bench/decision-runtime-baseline.mjs +0 -427
  42. package/scripts/bench/decision-runtime-variant.mjs +0 -626
  43. package/scripts/bench/goldTasks.json +0 -38
  44. package/scripts/bench/memory-ablation.mjs +0 -495
  45. package/scripts/bench/memory-baseline.mjs +0 -596
  46. package/scripts/bench/memory-bench.mjs +0 -661
  47. package/scripts/bench/memory-canary.mjs +0 -331
  48. package/scripts/bench/memory-corpus.mjs +0 -354
  49. package/scripts/bench/memory-gate.mjs +0 -389
  50. package/scripts/bench/memory-metrics.mjs +0 -179
  51. package/scripts/bench/outline-savings.mjs +0 -107
  52. package/scripts/bench/parallel-agents.mjs +0 -224
  53. package/scripts/bench/recorder-overhead.mjs +0 -204
  54. package/scripts/bench/runGold.mjs +0 -238
  55. package/scripts/bench/sqlite-spike.mjs +0 -451
  56. package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
  57. package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
  58. package/scripts/bench/v3-ceremony.mjs +0 -220
  59. package/scripts/perf/audit-perf.mjs +0 -1195
  60. package/scripts/perf/diff-perf-findings.mjs +0 -136
  61. package/scripts/perf/perf-findings.json +0 -474
  62. 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}` : '')
@@ -2001,6 +2076,7 @@ export async function runStopCoordinator({
2001
2076
  evidenceIds: [],
2002
2077
  cost: { latencyMs: null, rounds: null },
2003
2078
  reviewActions: [],
2079
+ ...(playbookTelemetry ? { playbook: playbookTelemetry } : {}),
2004
2080
  },
2005
2081
  projectRoot,
2006
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 `env` option is dropped (verified against the bundled omp exec). The only
35
- // channel that reaches the chain runner AND its in-proc .mjs steps is the host
36
- // environment, so the bridge stamps the harness here: record-execution.mjs
37
- // reads env.UKIT_HARNESS (default 'claude-code') when journaling receipts.
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. Only the create-scope guard applies: it denies
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
  }
@@ -155,7 +155,7 @@
155
155
  },
156
156
  "orchestration": {
157
157
  "enabled": true,
158
- "orchestratorModel": "claude-sonnet-5",
158
+ "orchestratorModel": "claude-opus-5",
159
159
  "permissionMode": "unattended",
160
160
  "allowUnsafeEval": false,
161
161
  "contracts": {