@ngockhoale/ukit 3.4.31 → 3.4.33

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.
@@ -296,67 +296,12 @@ export function buildStatePacket(decision, context) {
296
296
  return packet;
297
297
  }
298
298
 
299
- // ---------------------------------------------------------------------------
300
- // Adapter spawn — one bounded single-question batch to unic-decision.mjs.
301
- // Never throws: every failure resolves to a typed 'unavailable' result.
302
- // ---------------------------------------------------------------------------
303
-
304
- function runAdapterBatch({ spec, context, rootDir, config, batchId }) {
305
- const cliPath = process.env.UKIT_DECISION_CLI_PATH || UNIC_DECISION_CLI_PATH;
306
- const timeoutMs = Math.min(
307
- (isFiniteNumber(config?.decisionPlane?.timeoutMs)
308
- ? config.decisionPlane.timeoutMs
309
- : ADAPTER_TIMEOUT_CAP_MS) + 1000,
310
- ADAPTER_TIMEOUT_CAP_MS + 1000,
311
- );
312
- const batch = {
313
- batchId,
314
- boundary: 'sidecar',
315
- deadlineMs: Math.max(500, timeoutMs - 1000),
316
- statePacket: buildStatePacket(spec.decision, context),
317
- questions: [spec.question],
318
- };
319
- let result = null;
320
- try {
321
- const spawned = spawnSync(
322
- process.execPath,
323
- [cliPath, '--root', rootDir],
324
- {
325
- cwd: rootDir,
326
- input: JSON.stringify(batch),
327
- encoding: 'utf8',
328
- timeout: timeoutMs,
329
- },
330
- );
331
- if (spawned && !spawned.error && spawned.status === 0 && spawned.stdout) {
332
- result = JSON.parse(spawned.stdout);
333
- } else {
334
- result = { status: 'unavailable', fallbackCode: 'adapter-spawn-failed' };
335
- }
336
- } catch {
337
- result = { status: 'unavailable', fallbackCode: 'adapter-spawn-failed' };
338
- }
339
- if (!result || typeof result !== 'object') {
340
- result = { status: 'unavailable', fallbackCode: 'malformed-response' };
341
- }
342
- return result;
343
- }
344
-
345
- function validModelAnswer(result, decisionKey) {
346
- const answers = Array.isArray(result?.answers) ? result.answers : [];
347
- const answer = answers.find(
348
- (a) => a?.decisionKey === decisionKey && a.validationStatus === 'valid',
349
- );
350
- return typeof answer?.value === 'string' ? answer.value : null;
351
- }
352
-
353
299
  /**
354
300
  * Resolve one sidecar decision. Returns a typed result; never throws.
355
301
  * {decision, decisionKey, stage, answer, deterministic, source,
356
- * outcomeClass, modelAnswer?, agreement?, fallbackCode}
357
- * `answer` is the authoritative value: the deterministic rule at 'off' and
358
- * 'shadow', the model's valid answer at 'canary'/'default', and the
359
- * deterministic rule again on any adapter failure (outcomeClass 'unavailable').
302
+ * outcomeClass, fallbackPolicy}
303
+ * `answer` is always the deterministic rule at every stage: no model call is
304
+ * made, so there is no model answer, agreement or adapter fallback to report.
360
305
  */
361
306
  export async function runSidecarDecision({ decision, context = {}, rootDir, homeDir, config } = {}) {
362
307
  // Own-property lookup (W3-SD1): `SIDECAR_DECISIONS[name]` would also resolve
@@ -0,0 +1,309 @@
1
+ // Context budget + adaptive decomposition policy — PURE decision functions, no fs,
2
+ // no LLM call. Advisory only: nothing here truncates context or blocks a tool call.
3
+ //
4
+ // Soft budgets are planning targets per role. The 100K figure is a COST BOUNDARY
5
+ // that only applies when pricing metadata says the model has a tier above it;
6
+ // with no metadata the boundary is `unknown` and is never assumed to be a cliff.
7
+ import crypto from 'node:crypto';
8
+ import { estimateTokenCount } from './token-utils.mjs';
9
+
10
+ export const COST_BOUNDARY_TOKENS = 100_000;
11
+
12
+ export const ROLE_SOFT_BUDGETS = Object.freeze({
13
+ 'simple-subagent': 40_000,
14
+ subagent: 60_000,
15
+ 'implement-subagent': 80_000,
16
+ 'review-subagent': 80_000,
17
+ orchestrator: 90_000,
18
+ });
19
+
20
+ export const DECOMPOSITION_LIMITS = Object.freeze({ maxDepth: 2, maxAttempts: 2, minSavingsRatio: 0.15 });
21
+
22
+ const num = (v) => (Number.isFinite(v) && v >= 0 ? v : null);
23
+
24
+ /**
25
+ * Uncached (fresh) vs cached input are separate: cache reads are not counted
26
+ * against the boundary's fresh-input pressure, but the prompt window still is.
27
+ */
28
+ export function summarizeUsage({ inputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 } = {}) {
29
+ const input = num(inputTokens) ?? 0;
30
+ const read = num(cacheReadTokens) ?? 0;
31
+ const write = num(cacheWriteTokens) ?? 0;
32
+ return { promptTokens: input + read + write, uncachedTokens: input + write, cachedTokens: read };
33
+ }
34
+
35
+ /** Soft-budget status for a role. Never a hard limit. */
36
+ export function evaluateContextBudget({ role = 'orchestrator', promptTokens, pricing = null } = {}) {
37
+ const tokens = num(promptTokens);
38
+ const softBudget = ROLE_SOFT_BUDGETS[role] ?? ROLE_SOFT_BUDGETS.subagent;
39
+ if (tokens === null) return { status: 'unknown', softBudget, boundary: 'unknown', hardLimit: false };
40
+ const cliff = num(pricing?.longContextThresholdTokens);
41
+ const boundary = cliff === null ? 'unknown' : cliff;
42
+ const overBoundary = cliff !== null && tokens > cliff;
43
+ let status = 'ok';
44
+ if (overBoundary) status = 'over-cost-boundary';
45
+ else if (tokens > softBudget) status = 'over-soft-budget';
46
+ else if (tokens > softBudget * 0.8) status = 'near-soft-budget';
47
+ return { status, softBudget, boundary, overBoundary, hardLimit: false };
48
+ }
49
+
50
+ function fingerprintOf(parentId, subtasks) {
51
+ const body = subtasks.map((s) => [s.id, [...(s.files ?? [])].sort(), [...(s.deps ?? [])].sort()]);
52
+ return crypto.createHash('sha256').update(JSON.stringify([parentId, body])).digest('hex').slice(0, 16);
53
+ }
54
+
55
+ /**
56
+ * Decide whether an at-risk task should be split. Inputs are plain data from the
57
+ * existing plan/ledger; `state` carries {depth, attempts, seenFingerprints}.
58
+ * Returns {decompose, reason, fingerprint?, costs?}; fallback is always the orchestrator.
59
+ */
60
+ export function shouldDecompose({
61
+ parentId = 'task',
62
+ estimatedTokens,
63
+ pricing = null,
64
+ subtasks = [],
65
+ coupled = false,
66
+ overheadPerSubtask = 4_000,
67
+ integrationTokens = 6_000,
68
+ reworkRisk = 0.1,
69
+ state = {},
70
+ } = {}) {
71
+ const limits = DECOMPOSITION_LIMITS;
72
+ const est = num(estimatedTokens);
73
+ if (est === null) return { decompose: false, reason: 'unknown-size' };
74
+ const cliff = num(pricing?.longContextThresholdTokens) ?? COST_BOUNDARY_TOKENS;
75
+ if (est <= cliff) return { decompose: false, reason: 'within-boundary' };
76
+ if ((state.depth ?? 0) >= limits.maxDepth) return { decompose: false, reason: 'depth-limit' };
77
+ if ((state.attempts ?? 0) >= limits.maxAttempts) return { decompose: false, reason: 'attempt-limit' };
78
+ if (coupled) return { decompose: false, reason: 'tightly-coupled' };
79
+ if (!Array.isArray(subtasks) || subtasks.length < 2) return { decompose: false, reason: 'no-clear-boundaries' };
80
+ if (subtasks.some((s) => !s?.id || num(s.scopeTokens) === null)) {
81
+ return { decompose: false, reason: 'incomplete-subtasks' };
82
+ }
83
+ const fingerprint = fingerprintOf(parentId, subtasks);
84
+ if ((state.seenFingerprints ?? []).includes(fingerprint)) {
85
+ return { decompose: false, reason: 'unchanged-decomposition', fingerprint };
86
+ }
87
+ const biggest = Math.max(...subtasks.map((s) => s.scopeTokens));
88
+ if (biggest > cliff) return { decompose: false, reason: 'subtask-still-over-boundary', fingerprint };
89
+ const decomposed =
90
+ subtasks.reduce((sum, s) => sum + s.scopeTokens + overheadPerSubtask, 0) + integrationTokens;
91
+ // A request above the boundary may bill the WHOLE prompt at the higher tier; the
92
+ // premium is a planning weight (pricing.longContextPremium, default 1.5), not a price.
93
+ const premium = num(pricing?.longContextPremium) ?? 1.5;
94
+ const original = est * premium;
95
+ const decomposedCost = decomposed * (1 + reworkRisk);
96
+ const savings = (original - decomposedCost) / original;
97
+ const costs = { original, decomposed: decomposedCost, savingsRatio: Number(savings.toFixed(3)) };
98
+ if (savings < limits.minSavingsRatio) return { decompose: false, reason: 'not-cost-effective', fingerprint, costs };
99
+ return { decompose: true, reason: 'cost-effective-split', fingerprint, costs };
100
+ }
101
+
102
+ /** Estimate a context's tokens with the shared estimator (no LLM, no tokenizer). */
103
+ export function estimateContextTokens(parts = []) {
104
+ return parts.reduce((sum, p) => sum + estimateTokenCount(p), 0);
105
+ }
106
+
107
+ const HANDOFF_TOKENS = 6_000;
108
+ const MAX_SUBTASKS = 4;
109
+
110
+ /**
111
+ * Turn the route's resolved targets into a task graph and run shouldDecompose on it.
112
+ * Pure: file sizes arrive as `fileTokens` ({path: tokens}); no fs, no LLM. The result
113
+ * is bounded plain data for the resumable-run record. Parent acceptance criteria are
114
+ * kept whole on the plan and checked in full by the integration step; each subtask
115
+ * only receives the criteria that concern its own files/tests (see splitAcceptance), so
116
+ * splitting can never drop a requirement nor bloat every handoff. Fallback is always "main orchestrator continues".
117
+ */
118
+ export function buildDecompositionPlan({
119
+ parentId = 'task',
120
+ goal = '',
121
+ contextTokens,
122
+ primaryTargets = [],
123
+ relatedTests = [],
124
+ fileTokens = {},
125
+ acceptance = [],
126
+ coupled = false,
127
+ pricing = null,
128
+ previous = null,
129
+ } = {}) {
130
+ const state = {
131
+ depth: previous?.depth ?? 0,
132
+ attempts: previous?.attempts ?? 0,
133
+ seenFingerprints: previous?.seenFingerprints ?? [],
134
+ };
135
+ const groups = new Map();
136
+ for (const file of primaryTargets) {
137
+ const parts = String(file).split('/');
138
+ const key = parts.length > 2 ? parts.slice(0, 2).join('/') : parts.length > 1 ? parts[0] : '.';
139
+ if (!groups.has(key)) groups.set(key, []);
140
+ groups.get(key).push(file);
141
+ }
142
+ const subtasks = [...groups.entries()].slice(0, MAX_SUBTASKS).map(([key, files], i) => {
143
+ const tests = relatedTests.filter((t) => files.some((f) => t.includes(String(f).split('/').pop().replace(/\.[^.]+$/, ''))));
144
+ const scopeTokens = [...files, ...tests].reduce((s, f) => s + (num(fileTokens[f]) ?? 2_000), 0) + HANDOFF_TOKENS;
145
+ return { id: `${parentId}.${i + 1}`, key, files, tests, deps: [], scopeTokens };
146
+ });
147
+ const decision = shouldDecompose({
148
+ parentId, estimatedTokens: contextTokens, pricing, subtasks, coupled, state,
149
+ });
150
+ if (!decision.decompose) {
151
+ return { decompose: false, reason: decision.reason, fingerprint: decision.fingerprint ?? null, estimatedTokens: num(contextTokens), depth: state.depth, attempts: state.attempts, seenFingerprints: state.seenFingerprints };
152
+ }
153
+ const accept = acceptance.slice(0, 8).map((a) => String(a).slice(0, 200));
154
+ const split = splitAcceptance(accept, subtasks);
155
+ const tasks = subtasks.map((s, i) => ({
156
+ id: s.id,
157
+ goal: `${String(goal).slice(0, 200)} [scope: ${s.key}]`.slice(0, 400),
158
+ files: s.files.slice(0, 12),
159
+ tests: s.tests.slice(0, 6),
160
+ deps: s.deps,
161
+ scopeTokens: s.scopeTokens,
162
+ acceptance: split.perTask[i],
163
+ handoff: `Scoped context only (${s.files.length} file(s)); return changed files + verification result, not history.`,
164
+ }));
165
+ tasks.push({
166
+ id: `${parentId}.integrate`,
167
+ goal: 'Integrate subtask results and run parent verification',
168
+ files: [],
169
+ tests: [],
170
+ deps: tasks.map((t) => t.id),
171
+ scopeTokens: HANDOFF_TOKENS,
172
+ acceptance: accept, // integration verifies EVERY parent criterion
173
+ handoff: 'Main orchestrator merges handoffs and verifies parent acceptance.',
174
+ });
175
+ return {
176
+ decompose: true,
177
+ reason: decision.reason,
178
+ fingerprint: decision.fingerprint,
179
+ estimatedTokens: num(contextTokens),
180
+ costs: decision.costs,
181
+ depth: state.depth + 1,
182
+ attempts: state.attempts + 1,
183
+ seenFingerprints: [...state.seenFingerprints, decision.fingerprint].slice(-4),
184
+ parentAcceptance: accept,
185
+ tasks,
186
+ };
187
+ }
188
+
189
+ const basenameOf = (f) => String(f).split('/').pop().replace(/\.[^.]+$/, '').toLowerCase();
190
+
191
+ /**
192
+ * Distribute parent criteria: a criterion that names a subtask's file/dir (or its
193
+ * basename) goes to that subtask; test-ish criteria go to subtasks that own tests;
194
+ * `shared:`/`contract:` criteria go to every subtask (shared contract); anything else
195
+ * is parent-level and only checked at integration. Nothing is lost: integration holds all.
196
+ */
197
+ export function splitAcceptance(accept, subtasks) {
198
+ const perTask = subtasks.map(() => []);
199
+ for (const c of accept) {
200
+ const lc = c.toLowerCase();
201
+ const shared = /^(shared|contract):/.test(lc);
202
+ subtasks.forEach((s, i) => {
203
+ const names = [s.key, ...s.files, ...s.files.map(basenameOf)].map((n) => String(n).toLowerCase()).filter((n) => n.length > 2 && n !== '.');
204
+ const direct = names.some((n) => lc.includes(n));
205
+ const testy = /\btests?\b/.test(lc) && s.tests.length > 0;
206
+ if (shared || direct || testy) perTask[i].push(c);
207
+ });
208
+ }
209
+ return { perTask };
210
+ }
211
+
212
+ // ---- task-graph execution (pure) -------------------------------------------------
213
+ // Progress lives on the stored decomposition entry as `done`/`running` id arrays so the
214
+ // existing resumable-run record carries it across resume. Bounded: <= 5 ids each.
215
+
216
+ export const SUBTASK_MARKER_RE = /\[ukit-subtask\s+([A-Za-z0-9_-]+\.(?:\d+|integrate))\]/;
217
+
218
+ export function planProgress(plan) {
219
+ return { done: new Set(plan?.done ?? []), running: new Set(plan?.running ?? []) };
220
+ }
221
+
222
+ /** Tasks whose deps are all done and that are neither done nor running. */
223
+ export function nextDispatchable(plan) {
224
+ if (!plan?.decompose) return [];
225
+ const { done, running } = planProgress(plan);
226
+ return plan.tasks.filter((t) => !done.has(t.id) && !running.has(t.id) && t.deps.every((d) => done.has(d)));
227
+ }
228
+
229
+ /** Gate for dispatching one task id. */
230
+ export function checkDispatch(plan, taskId) {
231
+ const task = plan?.tasks?.find((t) => t.id === taskId);
232
+ if (!task) return { ok: false, reason: 'unknown-subtask' };
233
+ const { done, running } = planProgress(plan);
234
+ if (done.has(taskId)) return { ok: false, reason: 'already-done' };
235
+ if (running.has(taskId)) return { ok: false, reason: 'already-running' };
236
+ const missing = task.deps.filter((d) => !done.has(d));
237
+ if (missing.length) return { ok: false, reason: 'deps-pending', missing };
238
+ return { ok: true, task };
239
+ }
240
+
241
+ /** Pure status transition: 'running' | 'done' | 'pending' (failed → retryable). */
242
+ export function applySubtaskStatus(plan, taskId, status) {
243
+ const { done, running } = planProgress(plan);
244
+ running.delete(taskId);
245
+ if (status === 'done') done.add(taskId);
246
+ if (status === 'running') running.add(taskId);
247
+ return { ...plan, done: [...done], running: [...running] };
248
+ }
249
+
250
+ /** Carry finished work over a replan when the subtask is unchanged. */
251
+ export function carryProgress(oldPlan, newPlan) {
252
+ if (!oldPlan?.decompose || !newPlan?.decompose) return newPlan;
253
+ const same = (a) => {
254
+ const b = oldPlan.tasks.find((t) => t.id === a.id);
255
+ return b && JSON.stringify(b.files) === JSON.stringify(a.files);
256
+ };
257
+ const done = (oldPlan.done ?? []).filter((id) => newPlan.tasks.some((t) => t.id === id && same(t)));
258
+ return { ...newPlan, done };
259
+ }
260
+
261
+ /** The scoped dispatch text for one subtask: its own files/criteria only, no parent history. */
262
+ export function buildSubtaskPrompt(task) {
263
+ return [
264
+ `[ukit-subtask ${task.id}]`,
265
+ `Goal: ${task.goal}`,
266
+ task.files.length ? `Files: ${task.files.join(', ')}` : null,
267
+ task.tests.length ? `Tests: ${task.tests.join(', ')}` : null,
268
+ task.acceptance.length ? `Acceptance: ${task.acceptance.join(' | ')}` : null,
269
+ task.handoff,
270
+ ].filter(Boolean).join('\n');
271
+ }
272
+
273
+ // ---- layered context-size estimate -------------------------------------------------
274
+ // Priority: (1) actual provider usage on the newest main-chain assistant turn (Claude Code
275
+ // `message.usage`, OMP `usage`/`message.usage`) = prompt of that request, confidence HIGH;
276
+ // (2) a caller-supplied window metric (MEDIUM); (3) transcript bytes/4 = UNRELIABLE upper
277
+ // bound (LOW: JSON/tool-output overhead inflates it). LOW never triggers an automatic split.
278
+
279
+ function promptFromUsage(u) {
280
+ if (!u || typeof u !== 'object') return null;
281
+ const input = num(u.input_tokens ?? u.input) ?? 0;
282
+ const read = num(u.cache_read_input_tokens ?? u.cacheRead) ?? 0;
283
+ const write = num(u.cache_creation_input_tokens ?? u.cacheWrite) ?? 0;
284
+ const total = input + read + write;
285
+ return total > 0 ? total : null;
286
+ }
287
+
288
+ export function contextFromUsageLines(lines = []) {
289
+ for (let i = lines.length - 1; i >= 0; i--) {
290
+ let row;
291
+ try { row = JSON.parse(lines[i]); } catch { continue; }
292
+ if (!row || typeof row !== 'object' || row.isSidechain === true) continue;
293
+ const tokens = promptFromUsage(row.message?.usage ?? row.usage);
294
+ if (tokens !== null) return tokens;
295
+ }
296
+ return null;
297
+ }
298
+
299
+ export function estimateContextWindow({ tailLines = [], windowTokens = null, transcriptBytes = null } = {}) {
300
+ const used = contextFromUsageLines(tailLines);
301
+ if (used !== null) return { tokens: used, source: 'provider-usage', confidence: 'high' };
302
+ const win = num(windowTokens);
303
+ if (win !== null && win > 0) return { tokens: win, source: 'window-metric', confidence: 'medium' };
304
+ const bytes = num(transcriptBytes);
305
+ if (bytes !== null) return { tokens: Math.ceil(bytes / 4), source: 'transcript-bytes', confidence: 'low' };
306
+ return { tokens: null, source: 'unknown', confidence: 'unknown' };
307
+ }
308
+
309
+ export const MIN_AUTO_SPLIT_CONFIDENCE = new Set(['high', 'medium']);
@@ -740,10 +740,22 @@ function workspaceFingerprint(cwd) {
740
740
  }
741
741
  }
742
742
 
743
- // Verification currency excludes UKit's mutable receipts/router state, not source files.
743
+ // Verification currency excludes UKit's mutable receipts/router state and the living
744
+ // bookkeeping docs (never test inputs; the project rules REQUIRE agents to update them after
745
+ // work, so counting them made every wrap-up edit invalidate a still-valid verdict). Source,
746
+ // tests and every other doc stay in the fingerprint.
747
+ const VERIFICATION_FINGERPRINT_EXCLUDES = [
748
+ ':(exclude).ukit/storage/**',
749
+ ':(exclude).claude/ukit/skill-router-state.json',
750
+ ':(exclude)docs/WORKLOG.md',
751
+ ':(exclude)docs/STATUS.md',
752
+ ':(exclude)docs/TASKS.md',
753
+ ':(exclude)docs/MEMORY.md',
754
+ ];
755
+
744
756
  function verificationDirtyFingerprint(cwd) {
745
757
  try {
746
- const paths = ['.', ':(exclude).ukit/storage/**', ':(exclude).claude/ukit/skill-router-state.json'];
758
+ const paths = ['.', ...VERIFICATION_FINGERPRINT_EXCLUDES];
747
759
  const options = { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500, maxBuffer: 16 * 1024 * 1024 };
748
760
  const diff = execFileSync('git', ['diff', 'HEAD', '--no-ext-diff', '--', ...paths], options);
749
761
  const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z', '--', ...paths], options);
@@ -824,6 +836,8 @@ function gitPatchId(diffText) {
824
836
  // SPEC §2.2 — is this verdict still valid at the current head?
825
837
  // Returns { status, current, headSha } where status is one of:
826
838
  // 'current' | 'stale' | 'stale-recoverable' | 'untracked' (no git / no sha data).
839
+ // `reason` names WHY a stale verdict is stale: 'workspace-changed' (same HEAD, tracked/untracked
840
+ // content differs from what was verified) vs 'head-moved'.
827
841
  // Cost: rev-parse plus a bounded diff/untracked snapshot for new verdicts;
828
842
  // when head moved, the existing diff/patch-id comparison also applies.
829
843
  export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
@@ -840,7 +854,7 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
840
854
  }
841
855
  const currentHead = gitOutput(['rev-parse', 'HEAD'], cwd);
842
856
  if (verdict.headSha && Object.hasOwn(verdict, 'dirtyFp') && (!verdict.dirtyFp || verificationDirtyFingerprint(cwd) !== verdict.dirtyFp)) {
843
- return { status: 'stale', current: false, headSha: currentHead };
857
+ return { status: 'stale', current: false, headSha: currentHead, reason: 'workspace-changed' };
844
858
  }
845
859
  if (!currentHead) {
846
860
  // No git repo (or git unavailable): currency cannot be proven either way —
@@ -855,7 +869,7 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
855
869
  }
856
870
  // Head moved. With no base/patch-id recorded there is nothing to compare — stale.
857
871
  if (!verdict.baseSha || !verdict.patchId) {
858
- return { status: 'stale', current: false, headSha: currentHead };
872
+ return { status: 'stale', current: false, headSha: currentHead, reason: 'head-moved' };
859
873
  }
860
874
  const diff = gitOutput(['diff', `${verdict.baseSha}..${currentHead}`], cwd);
861
875
  const patchId = gitPatchId(diff);
@@ -3073,7 +3087,10 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}, {
3073
3087
  const currency = verdictCurrent(ledger.verdict, { cwd });
3074
3088
  if (currency.current !== true) {
3075
3089
  const sha = ledger.verdict.headSha ? String(ledger.verdict.headSha).slice(0, 12) : 'unknown';
3076
- instruction = `Verification verdict is stale (head moved since ${sha}). Re-run the routed verification at current head.`;
3090
+ const cause = currency.reason === 'workspace-changed'
3091
+ ? `source/test files changed since it ran at ${sha} (HEAD unchanged or not the cause)`
3092
+ : `head moved since ${sha}`;
3093
+ instruction = `Verification verdict is stale (${cause}). Re-run the routed verification at current head.`;
3077
3094
  }
3078
3095
  }
3079
3096
  }
@@ -3264,6 +3281,12 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3264
3281
  ) {
3265
3282
  return { continue: false, notify: false, complete: true, missingEvidence: [] };
3266
3283
  }
3284
+ // A status/result lookup (READ_ONLY + statusReport) orders no change: there is no write or
3285
+ // verification debt to enforce, and exiting here must not hide a mutation contract —
3286
+ // statusReport is only ever stamped when the request carried no mutation vocabulary.
3287
+ if (routeSummary.executionContract?.taskKind === 'READ_ONLY' && routeSummary.executionContract.statusReport === true) {
3288
+ return { continue: false, notify: false, complete: true, missingEvidence: [] };
3289
+ }
3267
3290
  if (evidence.length === 0) {
3268
3291
  // A route with no completion evidence has no actionable block reason — blocking would
3269
3292
  // loop unboundedly. Release loud instead of ending indistinguishable from a stall.
@@ -0,0 +1,74 @@
1
+ // Thin IO over the stored decomposition plan: dependency-gated dispatch + completion of
2
+ // subtasks. Reuses the resumable-run record (no second store) and is called from the
3
+ // existing Agent|Task PreToolUse/PostToolUse hooks. Fail-open on IO trouble, fail-closed
4
+ // on an explicit dependency/duplicate violation.
5
+ import {
6
+ readResumableRun, writeResumableRun,
7
+ } from './resumable-run.mjs';
8
+ import { evaluateContextBudget, SUBTASK_MARKER_RE, checkDispatch, applySubtaskStatus } from './context-budget-policy.mjs';
9
+
10
+ const ROLE_BY_AGENT = { 'feature-implementer': 'implement-subagent', 'bug-debugger': 'implement-subagent', 'code-reviewer': 'review-subagent', 'handoff-planner': 'subagent', 'ukit-small-task-maintainer': 'simple-subagent', 'ukit-vision-analyst': 'simple-subagent' };
11
+
12
+ /** Advisory soft-budget check of a subtask's scoped context for the dispatched agent role. Never blocks. */
13
+ export function subtaskBudgetAdvice(task, toolInput) {
14
+ const agent = String(toolInput?.subagent_type ?? toolInput?.agent ?? '').toLowerCase();
15
+ const role = ROLE_BY_AGENT[agent] ?? 'subagent';
16
+ const verdict = evaluateContextBudget({ role, promptTokens: task?.scopeTokens });
17
+ return { role, ...verdict };
18
+ }
19
+
20
+ export function subtaskIdFromInput(toolInput) {
21
+ const input = toolInput && typeof toolInput === 'object' ? toolInput : {};
22
+ const texts = [input.prompt, input.description, input.task, ...(Array.isArray(input.tasks) ? input.tasks.flatMap((t) => [t?.task, t?.prompt, t?.description]) : [])];
23
+ for (const t of texts) {
24
+ const m = typeof t === 'string' ? SUBTASK_MARKER_RE.exec(t) : null;
25
+ if (m) return m[1];
26
+ }
27
+ return null;
28
+ }
29
+
30
+ const parentOf = (subtaskId) => subtaskId.replace(/\.(?:\d+|integrate)$/, '');
31
+
32
+ async function load(projectRoot, subtaskId) {
33
+ const taskId = parentOf(subtaskId);
34
+ const read = await readResumableRun(projectRoot, { taskId });
35
+ if (!read.record) return null;
36
+ const decisions = read.record.decisions ?? [];
37
+ const index = decisions.findIndex((d) => d && d.type === 'decomposition');
38
+ return index < 0 ? null : { record: read.record, decisions, index };
39
+ }
40
+
41
+ async function save(projectRoot, loaded, plan) {
42
+ const decisions = loaded.decisions.slice();
43
+ decisions[loaded.index] = { type: 'decomposition', ...plan };
44
+ return writeResumableRun(projectRoot, { ...loaded.record, decisions, updatedAt: Date.now() });
45
+ }
46
+
47
+ /** PreToolUse: `{ok:true}` (marks running) or `{ok:false, reason, missing?}`. */
48
+ export async function dispatchSubtask(projectRoot, subtaskId, toolInput = null) {
49
+ try {
50
+ const loaded = await load(projectRoot, subtaskId);
51
+ if (!loaded) return { ok: true, tracked: false };
52
+ const plan = loaded.decisions[loaded.index];
53
+ const verdict = checkDispatch(plan, subtaskId);
54
+ if (!verdict.ok) return verdict;
55
+ const saved = await save(projectRoot, loaded, applySubtaskStatus(plan, subtaskId, 'running'));
56
+ return { ok: true, tracked: saved.ok === true, budget: subtaskBudgetAdvice(verdict.task, toolInput) };
57
+ } catch {
58
+ return { ok: true, tracked: false };
59
+ }
60
+ }
61
+
62
+ /** PostToolUse: done on success, back to pending (retryable) on failure. */
63
+ export async function completeSubtask(projectRoot, subtaskId, { success }) {
64
+ try {
65
+ const loaded = await load(projectRoot, subtaskId);
66
+ if (!loaded) return { ok: false, reason: 'no-plan' };
67
+ const plan = loaded.decisions[loaded.index];
68
+ if (!plan.tasks?.some((t) => t.id === subtaskId)) return { ok: false, reason: 'unknown-subtask' };
69
+ const saved = await save(projectRoot, loaded, applySubtaskStatus(plan, subtaskId, success ? 'done' : 'pending'));
70
+ return { ok: saved.ok === true };
71
+ } catch {
72
+ return { ok: false, reason: 'io' };
73
+ }
74
+ }
@@ -115,6 +115,13 @@ async function buildResumableRunLines(projectRoot, state, config) {
115
115
  if (phase) lines.push(`- Resumed run phase: ${phase}`);
116
116
  const nextAction = typeof record.nextAction === 'string' ? record.nextAction.trim() : '';
117
117
  if (nextAction) lines.push(`- Resumed next action: ${nextAction}`);
118
+ const plan = (Array.isArray(record.decisions) ? record.decisions : []).find((d) => d && d.type === 'decomposition');
119
+ if (plan && Array.isArray(plan.tasks)) {
120
+ lines.push(`- Resumed decomposition plan (${plan.tasks.length} steps, parent acceptance preserved): dispatch scoped subtasks, do not replan unless sources change`);
121
+ for (const t of plan.tasks.slice(0, 6)) {
122
+ lines.push(` - ${t.id}: ${(t.files ?? []).join(', ') || 'integration'} deps=[${(t.deps ?? []).join(',')}]`);
123
+ }
124
+ }
118
125
  for (const hypothesis of (Array.isArray(record.hypotheses) ? record.hypotheses : []).slice(0, 3)) {
119
126
  const text = String(hypothesis ?? '').trim();
120
127
  if (text) lines.push(`- Resumed hypothesis: ${text}`);
@@ -124,18 +124,34 @@ bash:
124
124
  memory:
125
125
  backend: off
126
126
 
127
- # Auto-compact must trigger BEFORE context-hardcap-gate.sh starts blocking tool
128
- # calls (compact.hardCapTokens, default 500000). Rendered at install time from the
129
- # same derived number as env.CLAUDE_CODE_AUTO_COMPACT_WINDOW in
130
- # template_project/.claude/settings.json (70% of hardCapTokens), so omp and Claude Code
131
- # cannot drift apart and retuning both is a single edit to
132
- # template_project/ukit/storage/config.json.
133
- #
134
- # Key VERIFIED against omp v17.4.2 (2026-08-22): `compaction.thresholdTokens` is a
135
- # fixed token limit for context maintenance and overrides the percentage threshold
136
- # when set; omp's own default is -1 ("use compaction.thresholdPercent"). The key this
137
- # file shipped with in 2.2.1 — `compact.autoCompactWindow` — was a plan-time guess and
138
- # omp rejects it outright (`omp config get compact.autoCompactWindow` → "Unknown
139
- # setting"), so auto-compact never moved off omp's default. See PLAN.md §3 D13.
127
+ # omp-side auto-compaction, project-scoped (omp reads <project>/.omp/config.yml and it
128
+ # takes precedence over ~/.omp/agent/config.yml, which UKit never touches).
129
+ # Keys VERIFIED against omp v18.8.9 (`omp config get compaction.<key>`):
130
+ # thresholdTokens is a fixed token limit that overrides thresholdPercent when > 0
131
+ # (omp default -1).
132
+ # thresholdTokens = min(90000, Claude Code auto-compact window), so a small-context
133
+ # budget is never exceeded by the omp threshold. Starting default, not a hard limit:
134
+ # edit any value here and `ukit update` keeps it. The legacy shipped 350000 is migrated
135
+ # only when UKit's install ledger proves UKit wrote it; `ukit uninstall` reverts just
136
+ # UKit's own additions and keeps your edits. Auto-compact must still trigger BEFORE
137
+ # context-hardcap-gate blocks tool calls.
140
138
  compaction:
141
- thresholdTokens: {{compact.autoCompactWindow}}
139
+ enabled: true
140
+ thresholdTokens: {{omp.compactionThresholdTokens}}
141
+ midTurnEnabled: true
142
+ keepRecentTokens: 20000
143
+
144
+ # Per-agent native compaction thresholds (omp v18.8.9 `task.agentCompactionThresholdOverrides`,
145
+ # keyed by agent name, positive integer tokens). SOFT targets for scoped subagent context:
146
+ # omp compacts the agent's session when it grows past the value — this is NOT a request
147
+ # hard cap (one step can still jump past it). Per-key merge: any agent key you set stays;
148
+ # your other `task:` settings are never touched; `ukit uninstall` removes only these keys
149
+ # when they still carry the shipped value.
150
+ task:
151
+ agentCompactionThresholdOverrides:
152
+ feature-implementer: 80000
153
+ bug-debugger: 80000
154
+ code-reviewer: 80000
155
+ handoff-planner: 60000
156
+ ukit-small-task-maintainer: 40000
157
+ ukit-vision-analyst: 40000