@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.
- package/CHANGELOG.md +17 -0
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/src/core/diffPlan.js +87 -8
- package/src/core/executionContracts.js +8 -2
- package/src/core/metadata.js +5 -0
- package/src/core/ompConfigMerge.js +60 -0
- package/src/core/ompProjectConfig.js +462 -0
- package/src/core/runInstallPipeline.js +25 -1
- package/src/core/uninstall.js +28 -1
- package/src/decision/reviewVerdict.js +16 -2
- package/src/index/fixLoopEscalation.js +10 -4
- package/src/render/buildVariables.js +4 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +37 -2
- package/template_project/.claude/hooks/record-execution.mjs +9 -0
- package/template_project/.claude/hooks/skill-router.sh +67 -7
- package/template_project/.claude/ukit/index/review-verdict.mjs +34 -5
- package/template_project/.claude/ukit/index/route-resolver.mjs +19 -5
- package/template_project/.claude/ukit/index/route-task.mjs +150 -3
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -58
- package/template_project/.claude/ukit/runtime/context-budget-policy.mjs +309 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +28 -5
- package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +74 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +7 -0
- package/template_project/.omp/config.yml +30 -14
|
@@ -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,
|
|
357
|
-
* `answer` is
|
|
358
|
-
*
|
|
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
|
|
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 = ['.',
|
|
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
|
-
|
|
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
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
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
|
-
|
|
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
|