@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.48 - 2026-10-19
6
+
7
+ **Completion evidence closure and reproducible multi-host installation.**
8
+ - Recognize standalone `git diff --check` as native Git verification rather than a dry-run mutation. Failed/masked mutations, piped verification, foreign-task evidence and release remote/tag divergence still block.
9
+ - Preserve task ownership through host-consumed coordination notices and continuations; bound Stop diagnostics without accepting incomplete work. Explicit no-publication lists and quoted/report-only references do not authorize a release.
10
+ - Integrate verified OMP child-session/model-ownership safeguards, delegation runtime, installer defaults migration, runtime package dependencies and reproducible scaffolds.
11
+ - Verified locally on macOS/Node 22. Claude/Codex/OMP asset installation is covered; OMP Full Playbook live correctness and trusted worker/dispatch attribution remain UNVERIFIED. No paid OMP smoke or cross-platform live PASS is claimed.
12
+
5
13
  ## 3.4.47 - 2026-10-18
6
14
 
7
15
  **Supervisor subagent usage attribution and rendered-instruction parity checks.**
@@ -517,6 +517,28 @@ entries:
517
517
  validation: [none]
518
518
  archive_policy: immutable
519
519
  notes: 2026-10-10 orchestration regression test inventory
520
+ - id: docs-analysis-completion-task-ownership
521
+ path: docs/analysis/COMPLETION-TASK-OWNERSHIP.md
522
+ class: archive
523
+ audience: [maintainer]
524
+ owner: product
525
+ source_of_truth: docs/analysis/COMPLETION-TASK-OWNERSHIP.md
526
+ merge_strategy: none
527
+ load_policy: never
528
+ validation: [none]
529
+ archive_policy: immutable
530
+ notes: 2026-10-19 task boundary reproduction and bounded Stop verification
531
+ - id: docs-analysis-omp-runtime-closure
532
+ path: docs/analysis/OMP-RUNTIME-CLOSURE.md
533
+ class: archive
534
+ audience: [maintainer]
535
+ owner: product
536
+ source_of_truth: docs/analysis/OMP-RUNTIME-CLOSURE.md
537
+ merge_strategy: none
538
+ load_policy: never
539
+ validation: [none]
540
+ archive_policy: immutable
541
+ notes: 2026-10-19 captured OMP lifecycle closure and offline verification boundaries
520
542
  - id: docs-analysis-orchestration-final-verification
521
543
  path: docs/analysis/ORCHESTRATION-FINAL-VERIFICATION.md
522
544
  class: archive
@@ -1222,6 +1222,7 @@ items:
1222
1222
  - hook-auto-allow-bash
1223
1223
  - hook-block-dangerous
1224
1224
  - hook-handoff-model-guard
1225
+ - hook-delegation-gate-module
1225
1226
  - hook-auto-prune-bash
1226
1227
  - hook-project-important
1227
1228
  # Env block is post-merged by applyGatewayResilienceEnv (TASK-011) to add managed
@@ -1422,6 +1423,20 @@ items:
1422
1423
  packs:
1423
1424
  - core
1424
1425
 
1426
+ # P0 delegation enforcement: in-proc PreToolUse chain step (Edit|Write, MultiEdit, Bash). Claude Code
1427
+ # only (agent_id main/worker identity); reads route state + ledger from ukit-runtime-scripts.
1428
+ - id: hook-delegation-gate-module
1429
+ type: hook
1430
+ sourceTemplate: .claude/hooks/delegation-gate.mjs
1431
+ targetPath: .claude/hooks/delegation-gate.mjs
1432
+ requires:
1433
+ - ukit-runtime-scripts
1434
+ mergeStrategy: overwrite_with_backup
1435
+ variables: []
1436
+ enabledByDefault: true
1437
+ packs:
1438
+ - core
1439
+
1425
1440
  - id: hook-handoff-model-guard
1426
1441
  type: hook
1427
1442
  sourceTemplate: .claude/hooks/handoff-model-guard.sh
@@ -1442,6 +1457,7 @@ items:
1442
1457
  - ukit-runtime-scripts
1443
1458
  - ukit-index-route-resolver-script
1444
1459
  - ukit-index-tier-selection-script
1460
+ - ukit-index-tier-lane-script
1445
1461
  mergeStrategy: overwrite_with_backup
1446
1462
  variables: []
1447
1463
  enabledByDefault: true
@@ -1865,6 +1881,16 @@ items:
1865
1881
  packs:
1866
1882
  - core
1867
1883
 
1884
+ - id: ukit-index-tier-lane-script
1885
+ type: config
1886
+ sourceTemplate: .claude/ukit/index/tier-lane.mjs
1887
+ targetPath: .claude/ukit/index/tier-lane.mjs
1888
+ mergeStrategy: overwrite_with_backup
1889
+ variables: []
1890
+ enabledByDefault: true
1891
+ packs:
1892
+ - core
1893
+
1868
1894
  - id: ukit-index-session-history-extractor-script
1869
1895
  type: config
1870
1896
  sourceTemplate: .claude/ukit/index/session-history-extractor.mjs
@@ -2012,6 +2038,7 @@ items:
2012
2038
  - ukit-index-session-history-extractor-script
2013
2039
  - ukit-index-fix-loop-escalation-script
2014
2040
  - ukit-index-tier-selection-script
2041
+ - ukit-index-tier-lane-script
2015
2042
  mergeStrategy: overwrite_with_backup
2016
2043
  variables: []
2017
2044
  enabledByDefault: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.47",
3
+ "version": "3.4.48",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,6 +36,9 @@
36
36
  "!scripts/audit/c100/usage-provenance.mjs",
37
37
  "!scripts/audit/decision-coverage.mjs",
38
38
  "!scripts/audit/subagent-cost/**",
39
+ "!scripts/bench/**",
40
+ "scripts/bench/decision-runtime-metrics.mjs",
41
+ "!scripts/perf/**",
39
42
  "template_project/",
40
43
  "template_user/",
41
44
  "!template_project/**/.smbdelete*",
@@ -7,6 +7,8 @@
7
7
  // - already bootstrapped → no-op
8
8
  // - missing prerequisites → run `ukit install` once, re-check
9
9
  // - not a linked worktree → refuse (the primary checkout's .claude/ is the maintainer runtime)
10
+ // Install can also create template-owned scaffolds missing from the checkout (docs/AI_HANDOFF/PLAN.md, SPEC.md);
11
+ // those are not gitignored, so they are reported as `visibleWrites` + a warning, never hidden or deleted.
10
12
  // `ukit install` writes the install flavour of root CLAUDE.md/AGENTS.md, but those files are
11
13
  // gitignored *render outputs* of template_project/instructions (docs:render). The preflight
12
14
  // re-renders them when they drift — never a tracked render target, which is only reported.
@@ -57,6 +59,15 @@ function isTracked(root, rel) {
57
59
 
58
60
  const RENDER_CLI = 'scripts/docs/render-instructions.mjs';
59
61
 
62
+ // Visible (non-ignored) paths in `git status`. `ukit install` creates template-owned scaffolds that
63
+ // are missing from the checkout (docs/AI_HANDOFF/PLAN.md, SPEC.md — mergeStrategy skip, handoff-clear
64
+ // restores them to template, never deletes them). They are not gitignored, so a HEAD that lost them
65
+ // turns the bootstrap into a visible write. Report it truthfully instead of hiding or deleting it.
66
+ function visibleStatus(root) {
67
+ const res = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
68
+ return res.status === 0 ? new Set(res.stdout.split('\n').filter(Boolean)) : null;
69
+ }
70
+
60
71
  // → rel paths whose rendered bytes differ from disk (`DRIFT <target>` lines of --check).
61
72
  export function renderDrift(root) {
62
73
  const res = spawnSync(process.execPath, [path.join(root, RENDER_CLI), '--check'], { cwd: root, encoding: 'utf8', timeout: RENDER_TIMEOUT_MS });
@@ -71,7 +82,7 @@ export function missingPrerequisites(root) {
71
82
 
72
83
  export function preflight({ root, check = false } = {}) {
73
84
  const projectRoot = fs.realpathSync(root);
74
- const result = { root: projectRoot, ok: false, bootstrapped: false, missing: [], rendered: [], warnings: [], home: null, error: null };
85
+ const result = { root: projectRoot, ok: false, bootstrapped: false, missing: [], rendered: [], visibleWrites: [], warnings: [], home: null, error: null };
75
86
 
76
87
  if (!fs.existsSync(path.join(projectRoot, 'bin/ukit'))) {
77
88
  return { ...result, exitCode: 2, error: `not a UKit checkout (no bin/ukit): ${projectRoot}` };
@@ -91,6 +102,7 @@ export function preflight({ root, check = false } = {}) {
91
102
  // worktree's ignored .cache/ so a preflight never mutates the real home or leaks from it.
92
103
  result.home = path.join(projectRoot, '.cache/worktree-preflight-home');
93
104
  fs.mkdirSync(result.home, { recursive: true });
105
+ const visibleBefore = visibleStatus(projectRoot);
94
106
  const install = spawnSync(process.execPath, [path.join(projectRoot, 'bin/ukit'), 'install'], {
95
107
  cwd: projectRoot,
96
108
  encoding: 'utf8',
@@ -98,6 +110,13 @@ export function preflight({ root, check = false } = {}) {
98
110
  timeout: 120_000,
99
111
  });
100
112
  result.bootstrapped = true;
113
+ const visibleAfter = visibleStatus(projectRoot);
114
+ if (visibleBefore && visibleAfter) {
115
+ result.visibleWrites = [...visibleAfter].filter((l) => !visibleBefore.has(l)).map((l) => l.slice(3));
116
+ if (result.visibleWrites.length > 0) {
117
+ result.warnings.push(`ukit install created non-ignored file(s) missing from this checkout: ${result.visibleWrites.join(', ')} — template-owned scaffolds; restore/commit them (not deleted)`);
118
+ }
119
+ }
101
120
  result.missing = missingPrerequisites(projectRoot);
102
121
  if (install.status !== 0 || result.missing.length > 0) {
103
122
  const tail = `${install.stdout || ''}${install.stderr || ''}`.trim().split('\n').slice(-5).join('\n');
@@ -62,6 +62,15 @@ async function checkLinkStatus(targetPath, linkTarget) {
62
62
  // Template-filled: keys the user does not have take the template value.
63
63
  // Gateway-resilience env keys are post-apply managed (applyGatewayResilienceEnv) and are
64
64
  // ignored by the drift comparison.
65
+ //
66
+ // One-time defaults migration (orchestrator tier + effort): UKit used to ship `model: "sonnet"`
67
+ // and `effortLevel: "high"`; it now ships `opus` / `medium` (main orchestrator = Tier 3, effort
68
+ // Medium unless the task is high-risk). A key is migrated ONLY when the file is UKit-tracked, the
69
+ // user's value still equals the old shipped default, and the migration has not run before
70
+ // (install.json `settingsDefaultsMigration`) — so a later deliberate choice is never touched
71
+ // again, and settings.local.json / user settings (never edited by UKit) always win at runtime.
72
+ export const SETTINGS_DEFAULTS_MIGRATION_ID = 'orchestrator-opus-medium-v1';
73
+ const LEGACY_SHIPPED_SETTINGS = { model: 'sonnet', effortLevel: 'high' };
65
74
  const UKIT_OWNED_TOP_LEVEL_KEYS = new Set(['hooks']);
66
75
  const PERMISSION_LIST_KEYS = new Set(['allow', 'deny', 'ask', 'additionalDirectories']);
67
76
  const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -94,7 +103,22 @@ function unionList(templateList, userList) {
94
103
  return out;
95
104
  }
96
105
 
97
- function mergeSettingsObjects(existing, rendered) {
106
+ // Keys whose old shipped default is still in place and should take the new template value.
107
+ function legacyDefaultKeys(existing, rendered, migrate) {
108
+ if (!migrate) return [];
109
+ return Object.keys(LEGACY_SHIPPED_SETTINGS).filter((key) => (
110
+ existing[key] === LEGACY_SHIPPED_SETTINGS[key] && key in rendered && rendered[key] !== existing[key]
111
+ ));
112
+ }
113
+
114
+ export function settingsDefaultsMigrationKeys(existingContent, renderedContent) {
115
+ const existing = parseSettingsJson(existingContent);
116
+ const rendered = parseSettingsJson(renderedContent);
117
+ return existing && rendered ? legacyDefaultKeys(existing, rendered, true) : [];
118
+ }
119
+
120
+ function mergeSettingsObjects(existing, rendered, { migrateDefaults = false } = {}) {
121
+ const migrated = new Set(legacyDefaultKeys(existing, rendered, migrateDefaults));
98
122
  const merged = {};
99
123
  for (const key of Object.keys(rendered)) {
100
124
  if (BLOCKED_KEYS.has(key)) continue;
@@ -124,7 +148,7 @@ function mergeSettingsObjects(existing, rendered) {
124
148
  // user value has the wrong shape (e.g. permissions: "x") — template shape wins
125
149
  merged[key] = templateValue;
126
150
  } else {
127
- merged[key] = userValue; // user-owned scalar/section (model, effortLevel, …)
151
+ merged[key] = migrated.has(key) ? templateValue : userValue; // user-owned scalar/section (model, effortLevel, …)
128
152
  }
129
153
  }
130
154
  return merged;
@@ -133,11 +157,11 @@ function mergeSettingsObjects(existing, rendered) {
133
157
  // Merge rendered settings.json with the user's existing file per the ownership model above.
134
158
  // Returns the merged JSON as a string, or the rendered content unchanged when either side is
135
159
  // unparseable (the caller still takes a timestamped backup before writing).
136
- export function mergeSettingsJson(existingContent, renderedContent) {
160
+ export function mergeSettingsJson(existingContent, renderedContent, options = {}) {
137
161
  const existing = parseSettingsJson(existingContent);
138
162
  const rendered = parseSettingsJson(renderedContent);
139
163
  if (!existing || !rendered) return renderedContent;
140
- return `${JSON.stringify(mergeSettingsObjects(existing, rendered), null, 2)}\n`;
164
+ return `${JSON.stringify(mergeSettingsObjects(existing, rendered, options), null, 2)}\n`;
141
165
  }
142
166
 
143
167
  function withoutGatewayEnv(settings) {
@@ -147,11 +171,11 @@ function withoutGatewayEnv(settings) {
147
171
  return { ...settings, env };
148
172
  }
149
173
 
150
- function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent) {
174
+ function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent, options = {}) {
151
175
  const existing = parseSettingsJson(existingContent);
152
176
  const rendered = parseSettingsJson(renderedContent);
153
177
  if (!existing || !rendered) return false;
154
- const merged = mergeSettingsObjects(existing, rendered);
178
+ const merged = mergeSettingsObjects(existing, rendered, options);
155
179
  return JSON.stringify(canonicalize(withoutGatewayEnv(merged)))
156
180
  === JSON.stringify(canonicalize(withoutGatewayEnv(existing)));
157
181
  }
@@ -208,7 +232,7 @@ function ukitInstalledText(mergedText, renderedText) {
208
232
  }
209
233
  }
210
234
 
211
- function resolveFileAction(entry, existingContent, ompOwnership = null) {
235
+ function resolveFileAction(entry, existingContent, ompOwnership = null, settingsMigration = null) {
212
236
  const exists = existingContent !== null;
213
237
  const binaryEntry = Buffer.isBuffer(entry.renderedContent);
214
238
 
@@ -273,9 +297,11 @@ function resolveFileAction(entry, existingContent, ompOwnership = null) {
273
297
  // User-owned permission keys (permissions.defaultMode) are excluded from the drift
274
298
  // check AND merged back into the content that gets written, so install/update can
275
299
  // never flip the user's chosen permission mode.
276
- const merged = mergeSettingsJson(existingContent, entry.renderedContent);
277
- action = settingsJsonIgnoresGatewayEnvDiff(existingContent, entry.renderedContent) ? 'unchanged' : 'update';
278
- return { ...entry, renderedContent: merged, exists, action, existingContent };
300
+ const mergeOptions = { migrateDefaults: settingsMigration?.pending === true };
301
+ const merged = mergeSettingsJson(existingContent, entry.renderedContent, mergeOptions);
302
+ action = settingsJsonIgnoresGatewayEnvDiff(existingContent, entry.renderedContent, mergeOptions) ? 'unchanged' : 'update';
303
+ const migratedKeys = mergeOptions.migrateDefaults ? settingsDefaultsMigrationKeys(existingContent, entry.renderedContent) : [];
304
+ return { ...entry, renderedContent: merged, exists, action, existingContent, ...(migratedKeys.length ? { settingsDefaultsMigrated: migratedKeys } : {}) };
279
305
  } else if (entry.id === 'runtime-config' && typeof existingContent === 'string' && typeof entry.renderedContent === 'string') {
280
306
  // U2: .ukit/storage/config.json is user-owned state seeded from the template — merge
281
307
  // by ownership instead of overwriting (see runtimeConfigMerge.js).
@@ -332,7 +358,7 @@ async function resolveSkipEntryAction(entry, ompOwnership = null) {
332
358
  return resolveFileAction(entry, read.content, ompOwnership);
333
359
  }
334
360
 
335
- export async function diffInstallPlan(plan, { ompOwnership = null } = {}) {
361
+ export async function diffInstallPlan(plan, { ompOwnership = null, settingsMigration = null } = {}) {
336
362
  // Check all entries in parallel
337
363
  const results = await Promise.all(
338
364
  plan.entries.map(async (entry) => {
@@ -352,7 +378,7 @@ export async function diffInstallPlan(plan, { ompOwnership = null } = {}) {
352
378
  if (read.status === 'unreadable') {
353
379
  return conflictResult(entry, read.error);
354
380
  }
355
- return resolveFileAction(entry, read.status === 'ok' ? read.content : null, ompOwnership);
381
+ return resolveFileAction(entry, read.status === 'ok' ? read.content : null, ompOwnership, settingsMigration);
356
382
  }),
357
383
  );
358
384
 
@@ -95,37 +95,39 @@ export const MODEL_TIER_BY_CONTRACT = {
95
95
  // no registry, no leases, no outbound capability calls.
96
96
  export const ROUTE_EFFORTS = new Set(['low', 'medium', 'high']);
97
97
 
98
- // Tier ladder for the one-band high-risk escalation; effort rides the resolved tier.
99
- const MODEL_TIER_ORDER = ['lite', 'code', 'smart'];
98
+ // Tier, effort and risk are independent decisions. Effort is Medium for every tier (Low for chores);
99
+ // High needs complexity evidence (`complexityEvidence` below), never risk or tier alone.
100
100
  const MODEL_TIER_EFFORT = {
101
101
  lite: 'low',
102
102
  code: 'medium',
103
- smart: 'high',
103
+ smart: 'medium',
104
104
  };
105
105
 
106
106
  /**
107
- * Resolves {tier, effort, advisoryOnly} for a route. A 'high-risk' floor escalates
108
- * the tier one band (lite→code→smart, capped at smart) and forces effort 'high'.
109
- * Unknown mode → {tier:null, effort:null, advisoryOnly:true}. Missing/unknown
110
- * riskFloor → 'none'. Missing hostCapabilities → advisoryOnly:true (the route text
111
- * is all the host gets when it cannot bind model and effort).
107
+ * Resolves {tier, effort, advisoryOnly} for a route. A 'high-risk' floor lifts a Tier 1 chore to the
108
+ * Tier 2 executor (lite→code) and goes no further: smart is the orchestrator / acceptance tier and is
109
+ * reached only through its own contract (review-release) or failure evidence, never through risk, so
110
+ * risk cannot turn the orchestrator into the implementation worker. Risk raises verification rigor
111
+ * (the route's riskFloor / rigor), not the tier ceiling or the effort. Effort is 'high' only with
112
+ * `complexityEvidence`. Unknown mode → {tier:null, effort:null, advisoryOnly:true}. Missing/unknown
113
+ * riskFloor → 'none'. Missing hostCapabilities → advisoryOnly:true (the route text is all the host
114
+ * gets when it cannot bind model and effort).
112
115
  */
113
116
  export function resolveModelTier({
114
117
  executionMode = null,
115
118
  riskFloor = null,
116
119
  hostCapabilities = null,
120
+ complexityEvidence = false,
117
121
  } = {}) {
118
122
  const baseTier = MODEL_TIER_BY_CONTRACT[executionMode] ?? null;
119
123
  if (!baseTier) {
120
124
  return { tier: null, effort: null, advisoryOnly: true };
121
125
  }
122
126
  const highRisk = riskFloor?.floor === 'high-risk';
123
- const tier = highRisk
124
- ? MODEL_TIER_ORDER[Math.min(MODEL_TIER_ORDER.indexOf(baseTier) + 1, MODEL_TIER_ORDER.length - 1)]
125
- : baseTier;
127
+ const tier = highRisk && baseTier === 'lite' ? 'code' : baseTier;
126
128
  return {
127
129
  tier,
128
- effort: highRisk ? 'high' : MODEL_TIER_EFFORT[tier],
130
+ effort: complexityEvidence === true ? 'high' : MODEL_TIER_EFFORT[tier],
129
131
  advisoryOnly: !(hostCapabilities?.canBindModel === true && hostCapabilities?.canBindEffort === true),
130
132
  };
131
133
  }
@@ -204,12 +206,55 @@ function neutralizeNonOrders(text) {
204
206
  .replace(/"[^"\n]*\s[^"\n]*"|`[^`\n]*\s[^`\n]*`|\u201c[^\u201d\n]*\u201d/g, ' ')
205
207
  .replace(/\b(?:neu\s+(?:phai|can|thieu|khong du|bang chung chua du)|if\s+(?:needed|necessary|required|evidence is insufficient))\b[^.;\n]*/g, ' ')
206
208
  .replace(/[^.;\n]*\b(?:nao|gi|sao|the nao|bao nhieu)\b[^.;\n]*\?/g, ' ')
207
- .replace(/\bchinh\s+xac\b|\b(?:phuong an|cach|de xuat|ban)\s+(?:sua|fix)\b/g, ' ')
209
+ .replace(/\bchinh\s+(?:xac|sach)\b|\b(?:phuong an|cach|de xuat|ban)\s+(?:sua|fix)\b|\b(?:diem|cho|muc|phan)\s+(?:phai|can|nen)\s+(?:sua|fix)\b/g, ' ')
208
210
  .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
209
211
  .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
210
212
  .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ');
211
213
  }
212
214
 
215
+ // Explicit scope declaration (intent ownership): when the request itself declares it is read-only
216
+ // ("READ-ONLY audit", "chi doc", "make no source changes") and carries no explicit order to change
217
+ // anything, verbs and operation names inside it are descriptions (a "git stash" that already ran, "a
218
+ // worklog edit", "confirmed fixes"), not orders - so they must not demand write or operation proof.
219
+ // An explicit order (imperative-initial or modal request) keeps the request MIXED/IMPLEMENTATION:
220
+ // the declaration never overrides a real instruction, and fenced/quoted/blockquoted text is never one.
221
+ // A bare verb with no object is the tail of a negated list ("no commit, release, publish"), not an order.
222
+ const READ_ONLY_DECLARATION = /\bread[\s-]*only\s+(?:audit|investigation|review|analysis|inspection|task|request|session|pass|check|diagnos\w+|verification|forensics?)\b|(?:^|\n)[\s*#>_-]*read[\s-]*only\s*[.:!—-]|\b(?:audit|investigation|review|analysis)[\s-]*only\b|[(,]\s*read[\s-]*only\s*[).]|\bchi\s+(?:doc|audit|kiem tra|phan tich|dieu tra)\b|\b(?:make|with)\s+no\s+(?:source\s+|code\s+|file\s+)?(?:changes|edits|modifications)\b/;
223
+ const ORDER_VERBS = 'implement|fix|apply|refactor|modify|edit|write|update|add|create|build|install|deploy|redeploy|restart|push|commit|rename|remove|delete|replace|change|sua|viet|chinh|them|tao|xoa|enable|disable|set|toggle|configure|migrate|upgrade|publish|release|trien khai|khoi dong lai|(?:run|execute)\\s+(?:git\\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)|(?:npm|yarn)\\s+publish)|git\\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)';
224
+ const IMPERATIVE_ORDER = new RegExp(`^(?:[-*+\\u2022>\\d.)\\s]*)(?:(?:please|hay|vui long|now|also|first|then)\\s+)*(?:${ORDER_VERBS})\\b\\s+\\S`);
225
+ const MODAL_ORDER = new RegExp(`\\b(?:please|hay|vui long|need to|needs to|must|should|want(?:\\s+you)? to|toi muon|can phai|yeu cau)\\s+(?:[\\w-]+\\s+){0,2}(?:${ORDER_VERBS})\\b`);
226
+ function hasExplicitOrder(clauses) {
227
+ return clauses.some((clause) => IMPERATIVE_ORDER.test(clause) || MODAL_ORDER.test(clause));
228
+ }
229
+ function declaredReadOnly(promptText, releasePolicy) {
230
+ if (releasePolicy) return false;
231
+ const plain = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()
232
+ .replace(/```[\s\S]*?```/g, ' ').replace(/^[ \t]*>.*$/gm, ' ');
233
+ if (!READ_ONLY_DECLARATION.test(plain)) return false;
234
+ const text = neutralizeNonOrders(plain);
235
+ const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/)
236
+ .map((clause) => clause.split(/\bwithout\b/)[0])
237
+ .filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause));
238
+ return /\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|investigation|forensic|root cause|kiem tra|phan tich|dieu tra|doc log|tim hieu)\b/.test(text)
239
+ && !hasExplicitOrder(clauses);
240
+ }
241
+
242
+ // Verification-only request. Root cause of a false `write-evidence` block on a "verify / re-run /
243
+ // confirm" prompt: the vocabulary scans counted nouns and compounds ("coordinator fix", "re-run",
244
+ // "template/install", "source change") as mutation orders. Order is structural: a request whose
245
+ // command-position verbs are verification verbs, with no explicit order (hasExplicitOrder) and no
246
+ // change-leading clause, orders no change - it owes verification evidence, not a write.
247
+ const VERIFY_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:verify|re-?run|confirm|validate|double-?check)\b/;
248
+ const CHANGE_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:make|rebuild|redo|rewrite|restore|revert|correct|clean|patch|resolve|ensure|run(?!(?=[^.]*\b(?:tests?|suite|gate|checks?|lint|typecheck|pilot|smoke|e2e|verif\w*|vitest|jest|audit)\b)))\b/;
249
+ export function isVerificationOnlyRequest(promptText) {
250
+ const plain = String(promptText ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()
251
+ .replace(/```[\s\S]*?```/g, ' ').replace(/^[ \t]*>.*$/gm, ' ');
252
+ const clauses = neutralizeNonOrders(plain).split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/)
253
+ .map((clause) => clause.split(/\bwithout\b/)[0]);
254
+ return clauses.some((clause) => VERIFY_LEAD.test(clause)) && !hasExplicitOrder(clauses)
255
+ && !clauses.some((clause) => CHANGE_LEAD.test(clause));
256
+ }
257
+
213
258
  export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
214
259
  // A named preflight and both explicit branches are required; a failed arbitrary test is not a release policy.
215
260
  const releaseText = String(promptText).normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[đĐ]/g, 'd').toLowerCase();
@@ -221,6 +266,7 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
221
266
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
222
267
  const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
223
268
  && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
269
+ if (declaredReadOnly(promptText, releasePolicy)) return { taskKind: 'READ_ONLY' };
224
270
  const sourceOrder = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
225
271
  && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
226
272
  const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
@@ -238,10 +284,15 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
238
284
  // runtime are separate domains with separate proof; a prompt naming both stays a coding
239
285
  // task rather than being half-verified. A code-change verb anywhere keeps write evidence.
240
286
  const gitOperation = actionable.some((clause) => /\bgit\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b|\bpush\s+(?:to\s+)?origin\b/.test(clause));
241
- const runtimeOperation = actionable.some((clause) => /\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\b/.test(clause)
287
+ // 'trien khai' is Vietnamese for both "deploy" and "implement": it orders a runtime operation only
288
+ // when it names a runtime target; bare it describes work ("Tier 3 truc tiep trien khai thay Tier 2").
289
+ // "restart"/"deploy" after a noun modifier ("process restart", "after redeploy") is a scenario, not an order.
290
+ const runtimeOperation = actionable.some((clause) => /(?<!\b(?:process|crash|hard|soft|graceful|after|upon|during|across|following)\s)\b(?:restart|redeploy|deploy|khoi dong lai|trien khai\s+(?:len\s+)?(?:service|server|production|prod|staging|dich vu|may chu|moi truong))\b/.test(clause)
242
291
  && !/\.[cm]?[jt]sx?\b/.test(clause) && !/\bgit\b/.test(clause));
243
292
  const nativeDomain = cronOperation ? 'scheduled-job' : gitOperation !== runtimeOperation ? (gitOperation ? 'git-integrity' : 'service-runtime') : null;
244
293
  const operation = Boolean(nativeDomain);
294
+ // A verification-only request orders no change; a concrete operation order (cancel/restart/push) stays operational.
295
+ if (!releasePolicy && !operation && isVerificationOnlyRequest(promptText)) return { taskKind: 'READ_ONLY', verificationOnly: true };
245
296
  // A config MENTION is not a config change: the same clause needs a mutation verb, and a
246
297
  // request that also changes source/code is a coding task (config is part of its tests/fixtures).
247
298
  const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
@@ -252,7 +303,7 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
252
303
  const mutationWord = /\b(?:implement|fix|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/.test(text);
253
304
  const statusReport = !mutationWord && !operation && !config
254
305
  && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua|da len chua)\b/.test(text);
255
- const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|debug|forensic|root cause|kiem tra|phan tich|dieu tra|doc log|tim hieu)\b/.test(text) || statusReport)
306
+ const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|debug|forensic|root cause|kiem tra|phan tich|dieu tra|doc log|tim hieu)\b/.test(text.replace(/\bread[\s-]*only\b/g, ' ')) || statusReport)
256
307
  && !sourceOrder && !codeChangeVerb && !operation && !config;
257
308
  const taskKind = operation ? (sourceOrder || (nativeDomain !== 'scheduled-job' && codeChangeVerb) ? 'MIXED' : 'OPERATIONAL')
258
309
  : config ? 'CONFIGURATION' : documentation ? 'DOCUMENTATION_OUTPUT' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
@@ -271,5 +322,5 @@ export function applyTaskSemantics(contract, semantic) {
271
322
  completionEvidence: semantic.taskKind === 'DOCUMENTATION_OUTPUT' ? ['report-artifact'] : operationOnly ? ['operation-verification']
272
323
  : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
273
324
  : semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
274
- : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
325
+ : semantic.taskKind === 'READ_ONLY' ? (semantic.verificationOnly || (evidence.length === 1 && evidence[0] === 'verification-evidence') ? ['verification-evidence'] : ['impact-evidence']) : evidence };
275
326
  }
@@ -69,6 +69,7 @@ export async function writeInstallMetadata({
69
69
  managedRelativePaths = null,
70
70
  retainedManagedRelativePaths = [],
71
71
  ompConfigOwnership = null,
72
+ settingsDefaultsMigration = null,
72
73
  lockStaleMs,
73
74
  lockMaxWaitMs,
74
75
  }) {
@@ -138,6 +139,9 @@ export async function writeInstallMetadata({
138
139
  // before) — lets uninstall revert only UKit's changes. Kept across runs.
139
140
  const ownership = ompConfigOwnership ?? (previousData?.tool === 'ukit' ? previousData.ompConfigOwnership : null);
140
141
  if (ownership) metadata.ompConfigOwnership = ownership;
142
+ // One-time .claude/settings.json defaults migration marker (see diffPlan.js). Kept across runs.
143
+ const migrationMarker = settingsDefaultsMigration ?? (previousData?.tool === 'ukit' ? previousData.settingsDefaultsMigration : null);
144
+ if (migrationMarker) metadata.settingsDefaultsMigration = migrationMarker;
141
145
 
142
146
  await ensureDir(path.dirname(installMetaPath));
143
147
  await writeJson(installMetaPath, metadata);
@@ -7,7 +7,7 @@ import { detectProjectContext } from '../context/detectProjectContext.js';
7
7
  import { detectProviders } from '../context/detectProviders.js';
8
8
  import { buildTemplateVariables } from '../render/buildVariables.js';
9
9
  import { buildInstallPlan } from './buildPlan.js';
10
- import { diffInstallPlan } from './diffPlan.js';
10
+ import { diffInstallPlan, SETTINGS_DEFAULTS_MIGRATION_ID } from './diffPlan.js';
11
11
  import { applyDiffResults, pruneOldBackups } from './applyPlan.js';
12
12
  import { summarizeDiff, toDiffRows } from './report.js';
13
13
  import { writeInstallMetadata, writeCliLocator, readInstallMetadata } from './metadata.js';
@@ -439,7 +439,10 @@ export async function runInstallPipeline({
439
439
  .some((e) => (typeof e === 'string' ? e : e?.p) === '.omp/config.yml');
440
440
  const ompOwnership = installMetaData?.ompConfigOwnership
441
441
  ?? (trackedOmpConfig ? { originalText: null, installedText: 'compaction:\n thresholdTokens: 350000\n' } : null);
442
- const diffResults = await diffInstallPlan(plan, { ompOwnership });
442
+ const trackedSettings = (Array.isArray(installMetaData?.files) ? installMetaData.files : [])
443
+ .some((e) => (typeof e === 'string' ? e : e?.p) === '.claude/settings.json');
444
+ const settingsMigration = { pending: trackedSettings && !installMetaData?.settingsDefaultsMigration };
445
+ const diffResults = await diffInstallPlan(plan, { ompOwnership, settingsMigration });
443
446
  const plannedSummary = summarizeDiff(diffResults);
444
447
 
445
448
  if (!dryRun) {
@@ -464,6 +467,19 @@ export async function runInstallPipeline({
464
467
  }
465
468
  }
466
469
 
470
+ // One-time settings.json defaults migration: report it explicitly (never silent) and mark it
471
+ // done once the settings entry was processed, so later user edits are never migrated again.
472
+ let settingsDefaultsMigration = null;
473
+ for (const entry of diffResults) {
474
+ if (!/(^|[\\/])\.claude[\\/]settings\.json$/.test(String(entry.targetPath))) continue;
475
+ if (!['create', 'update', 'unchanged'].includes(entry.action)) continue;
476
+ if (entry.action !== 'unchanged' && !writes.some((w) => w.targetPath === entry.targetPath)) continue;
477
+ settingsDefaultsMigration = SETTINGS_DEFAULTS_MIGRATION_ID;
478
+ if (entry.settingsDefaultsMigrated?.length) {
479
+ console.log(`[UKit] .claude/settings.json: migrated old default ${entry.settingsDefaultsMigrated.join(' + ')} to model "opus" (main orchestrator) / effortLevel "medium"; previous file backed up. To pin your own, set it in .claude/settings.local.json (UKit never edits it).`);
480
+ }
481
+ }
482
+
467
483
  // SPEC §7 step 9: surface the .omp/config.yml unattended migration result.
468
484
  for (const entry of diffResults) {
469
485
  const report = entry?.migrationReport;
@@ -546,6 +562,7 @@ export async function runInstallPipeline({
546
562
  managedRelativePaths: [...trackedManagedRelativePaths],
547
563
  retainedManagedRelativePaths,
548
564
  ompConfigOwnership,
565
+ settingsDefaultsMigration,
549
566
  });
550
567
 
551
568
  // 3.3.0: hooks spawn the CLI (episodes, self-improve) from a GUI-launched
@@ -442,7 +442,7 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
442
442
  },
443
443
  orchestration: {
444
444
  enabled: true,
445
- orchestratorModel: 'claude-sonnet-5',
445
+ orchestratorModel: 'claude-opus-5',
446
446
  permissionMode: 'unattended',
447
447
  // FR-003: eval-subprocess gate for rendered omp approval (tools.approval.eval).
448
448
  // Fail-closed: only an explicit user `true` renders `eval: allow`; absent,
@@ -114,6 +114,20 @@ function hasSqlIntent(text) {
114
114
  // The SQL ask touches shape/data rather than a read-only query — routes the
115
115
  // migration row instead of a feature build.
116
116
  const SQL_SCHEMA_RE = /(?<![A-Za-z0-9_])(?:ddl|alter|drop|(?:create|alter|drop|truncate)\s+table|(?:add|drop|rename|change|modify|update|thêm|xóa|xoá|sửa|sua|đổi|doi)\s+(?:a\s+|the\s+|new\s+|một\s+)*(?:column|cột|cot)(?!\s+of\b)|kiểu\s+dữ\s+liệu|kieu\s+du\s+lieu|data\s?type|not\s+null|constraint|migration|migrate)(?![A-Za-z0-9_])/i;
117
+ // Explicit delegation order: the user themself asks for a worker/subagent to implement
118
+ // ("Delegate the implementation to a worker agent", "have a subagent implement it"). That is an
119
+ // opt-in to the orchestrated path (Tier 3 orchestrates, Tier 2 implements, integration verified by
120
+ // the orchestrator), so it lifts the small-feature fast path into feature-implementation. Live omp
121
+ // run: this exact order stayed on small-feature (explicit-local-target fast path, subagents
122
+ // suppressed) and the Tier 3 main implemented nothing of the delegation. Bare mentions of the
123
+ // words ("worker thread", "the delegate pattern") do not match; negated orders ("don't delegate")
124
+ // veto.
125
+ const DELEGATION_ORDER_RE = /\bdelegat(?:e|ing)\b[^.\n]{0,80}?\bto\s+(?:an?\s+|the\s+|your\s+)?(?:[\w-]+\s+){0,2}?(?:worker|sub-?agents?|agents?|implementer)\b|\b(?:use|spawn|dispatch|launch|assign|have|let)\s+(?:an?\s+|the\s+|your\s+)?(?:[\w-]+\s+)?(?:worker|sub-?agents?|implementer)(?:\s+agent)?\s+(?:to|for)\b|(?<![A-Za-z0-9_])giao\s+(?:việc\s+)?cho\s+(?:worker|sub-?agent|agent)/i;
126
+ const DELEGATION_NEGATED_RE = /\b(?:don['’]?t|do\s+not|never|without|avoid|no\s+need\s+to)\s+(?:[\w-]+\s+){0,2}?(?:delegat\w*|(?:use|spawn|dispatch)\s+(?:an?\s+|any\s+)?(?:worker|sub-?agents?))|\bno\s+(?:worker|sub-?agents?|delegation)\b|\b(?:inline|yourself)\s+(?:only|not)\b/i;
127
+ export function hasDelegationOrder(text) {
128
+ const t = String(text ?? '');
129
+ return DELEGATION_ORDER_RE.test(t) && !DELEGATION_NEGATED_RE.test(t);
130
+ }
117
131
  // Reuse-first ask: "use the existing/core function", "dùng hàm có sẵn",
118
132
  // "tái sử dụng" — search for the function before writing a new one.
119
133
  const REUSE_FIRST_RE = /(?:\buse\s+(?:the\s+)?(?:existing|core|shared|same|current)\b)|(?:\buse\s+[\w.]+\s+from\s+(?:the\s+)?(?:core|shared|utils?|common|lib)\b)|\b(?:existing|core|shared)\s+(?:function|helper|util(?:ity|ities)?|service|component)\b|\breuse\b|(?<![A-Za-z0-9_])(?:tái\s+sử\s+dụng|tai\s+su\s+dung)|(?<![A-Za-z0-9_])(?:dùng|dung|dựa|dua)\s+(?:vào\s+|vao\s+)?(?:hàm|ham)(?:\s+(?:có\s+sẵn|co\s+san|sẵn\s+có|san\s+co|đã\s+có|da\s+co|cũ|cu))?(?![A-Za-z0-9_])/i;
@@ -174,7 +188,7 @@ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
174
188
  // contract signals) escalates to feature-implementation. Reference-driven,
175
189
  // clone, reuse-first and SQL-schema asks take the same escape: they name a
176
190
  // source of truth or a data contract the small path must not gloss over.
177
- discriminator: (s) => !s.escalated && !s.contractSignal && !s.multiSurface && !s.sqlSchemaChange && !s.referenceTarget && !s.cloneIntent && !s.reuseFirst && s.changeVerbOrCreate,
191
+ discriminator: (s) => !s.escalated && !s.contractSignal && !s.multiSurface && !s.sqlSchemaChange && !s.referenceTarget && !s.cloneIntent && !s.reuseFirst && !s.delegationOrdered && s.changeVerbOrCreate,
178
192
  fastPath: 'small-feature',
179
193
  },
180
194
  {
@@ -195,7 +209,7 @@ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
195
209
  // migration row instead of capturing the build lane.
196
210
  discriminator: (s) => (
197
211
  (s.contractSignal || s.multiSurface || (s.escalated && !s.sqlSchemaChange) || s.wiredShipAsk)
198
- || ((s.referenceTarget || s.cloneIntent || s.reuseFirst) && s.defectIdx === -1)
212
+ || ((s.referenceTarget || s.cloneIntent || s.reuseFirst || s.delegationOrdered) && s.defectIdx === -1)
199
213
  ) && !s.disposable && !s.releaseAsk,
200
214
  },
201
215
  {
@@ -291,7 +305,9 @@ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
291
305
  lanes: ['local-fix', 'review-release'],
292
306
  triggers: [/\bverify\b|\bcheck\b|\bconfirm\b|\bprove\b|\bvalidate\b|\bassert\b/i],
293
307
  // the ask IS a check — a check verb leads, or no defect verb exists at all.
294
- discriminator: (s) => s.checkFirst || s.defectIdx === -1,
308
+ // A build/create verb that leads the ask makes the check its closing step ("implement X … then run the
309
+ // tests and confirm"), not the ask itself — the build row owns it instead of tying into ambiguity.
310
+ discriminator: (s) => (s.checkFirst || s.defectIdx === -1) && !s.buildLeadsCheck,
295
311
  },
296
312
  {
297
313
  workGroup: 'skill-evaluation',
@@ -409,6 +425,7 @@ async function buildRouteSignals({
409
425
  const explanationAsk = explainIdx !== -1 && (firstVerbIdx === -1 || explainIdx < firstVerbIdx);
410
426
  const changeRequested = !consultLead && firstVerbIdx !== -1 && (explainIdx === -1 || firstVerbIdx < explainIdx);
411
427
  const checkFirst = checkIdx !== -1 && (defectIdx === -1 || checkIdx < defectIdx);
428
+ const buildLeadsCheck = changeRequested && checkIdx !== -1 && firstVerbIdx !== -1 && firstVerbIdx < checkIdx;
412
429
  // C85-019: a QUESTION about a defect is not a defect order. The bug-fix floor
413
430
  // demands repro+root-cause receipts — demanding them on a "did it get fixed
414
431
  // yet?" turn forces edits nobody asked for (user-reported lock). Question
@@ -440,12 +457,14 @@ async function buildRouteSignals({
440
457
  changeRequested,
441
458
  questionAsk,
442
459
  checkFirst,
460
+ buildLeadsCheck,
443
461
  changeVerbOrCreate: changeIdx !== -1 || createIdx !== -1,
444
462
  referenceTarget,
445
463
  cloneIntent,
446
464
  sqlIntent,
447
465
  sqlSchemaChange: sqlIntent && SQL_SCHEMA_RE.test(signalText),
448
466
  reuseFirst,
467
+ delegationOrdered: hasDelegationOrder(signalText) && changeRequested,
449
468
  contractSignal: CONTRACT_SIGNAL_RE.test(signalText) || /\boauth\b/i.test(signalText),
450
469
  multiSurface: MULTI_SURFACE_RE.test(signalText),
451
470
  escalated: Array.isArray(escalationTriggers) && escalationTriggers.length > 0,
@@ -895,7 +895,7 @@ function deriveExecutionScores({
895
895
  const buildSignal = /\b(build|create|add|implement|feature)\b/.test(raw);
896
896
  const debugSignal = intentMode === 'debug-specific' || /\b(root cause|debug|triage|flaky|investigate|why)\b/.test(raw);
897
897
  const reviewSignal = intentMode === 'review-specific' || /\b(review|audit|verify|release readiness)\b/.test(raw);
898
- const failureSignal = /\b(failing|failed|broken|error|crash|timeout|undefined|exception|eacces|trace)\b/.test(raw);
898
+ const failureSignal = /\b(failing|failed|broken|error|crash|timeout|undefined|exception|eacces|trace)\b/.test(raw.replace(/\b(?:throw|raise|emit|reject|return|log|show|surface)(?:s|es|ing)?\s+(?:an?\s+|the\s+)?(?:[a-z-]+\s+){0,2}[a-z]*(?:error|exception)s?\b/g, ' '));
899
899
  const impactSignal = /\b(map impact|impact|blast radius|all affected|across (?:runtime|templates|helpers|adapters|mirrors)|affected adapters|affected mirrors|inspect impact)\b/.test(raw);
900
900
  const boundedEditSignal = /\b(one[- ]line|small|tiny|single|local|guard|log line|loading message|label)\b/.test(raw);
901
901
  const implementSignal = /\b(implement|apply|update|modify|add|create|ship|deliver|fix)\b/.test(raw)
@@ -1813,7 +1813,7 @@ function deriveIntentMode({ promptText = '', commandText = '', targetFile = null
1813
1813
  }
1814
1814
 
1815
1815
  if (concreteTask) {
1816
- if (/\b(bug|debug|error|crash|broken|failing|stack trace|triage|fix|timeout)\b/.test(lower)) {
1816
+ if (/\b(bug|debug|error|crash|broken|failing|stack trace|triage|fix|timeout)\b/.test(lower.replace(/\b(?:throw|raise|emit|reject|return|log|show|surface)(?:s|es|ing)?\s+(?:an?\s+|the\s+)?(?:[a-z-]+\s+){0,2}[a-z]*(?:error|exception)s?\b/g, ' '))) {
1817
1817
  return 'debug-specific';
1818
1818
  }
1819
1819
  if (/\b(review|audit|diff|pr feedback|code review|kiem tra|soat)\b/.test(raw)) {