@ngockhoale/ukit 3.4.32 → 3.4.34
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 +16 -0
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/src/core/diffPlan.js +87 -8
- package/src/core/executionContracts.js +54 -8
- 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/index/taskRouting.js +9 -0
- package/src/render/buildVariables.js +4 -0
- package/template_project/.claude/hooks/block-dangerous.mjs +15 -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 +119 -10
- package/template_project/.claude/ukit/index/post-edit-verify.mjs +9 -0
- package/template_project/.claude/ukit/index/route-resolver.mjs +54 -8
- package/template_project/.claude/ukit/index/route-task.mjs +106 -1
- package/template_project/.claude/ukit/runtime/context-budget-policy.mjs +309 -0
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +67 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +47 -10
- package/template_project/.claude/ukit/runtime/operation-evidence.mjs +7 -3
- 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
|
@@ -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']);
|
|
@@ -41,3 +41,70 @@ export function cronOperationReceipt(toolName, input, response, transportSuccess
|
|
|
41
41
|
transportSuccess: transportSuccess === true, harness,
|
|
42
42
|
operation: { domain: capability.domain, role: capability.role, scope, ...state } };
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
// R2/R3: non-source operations (git integrity, service restart + HTTP check). A Bash command is
|
|
46
|
+
// proof only when it IS the checked command (not text inside echo/quotes) and its exit status
|
|
47
|
+
// is the command's own: no pipe without pipefail, no `;`/newline/`||` chain that can mask it.
|
|
48
|
+
// The goal names WHAT was ordered (verbs, services, probe path/status): a receipt for another
|
|
49
|
+
// verb, service, repo or endpoint never discharges it, and a dry run is not the operation.
|
|
50
|
+
const GIT_MUTATION = /^git\s+(?:push|commit|clean|reset|merge|rebase|stash|tag|checkout|restore|pull|cherry-pick|branch)\b/;
|
|
51
|
+
const GIT_VERB = /^git\s+(push|commit|clean|reset|merge|rebase|stash|tag|checkout|restore|pull|cherry-pick|branch)\b/;
|
|
52
|
+
const GIT_STATE = /^git\s+(?:fsck\b|diff\s+(?:--check|--exit-code|--quiet)\b|status\s+(?:--porcelain|-s|--short)\b)/;
|
|
53
|
+
const SERVICE_MUTATION = /^(?:docker(?:\s+compose)?|docker-compose)\s+(?:restart|up|down|stop|start|build)\b|^(?:sudo\s+)?systemctl\s+(?:restart|start|stop|reload)\b|^pm2\s+(?:restart|reload|start)\b|^kubectl\s+(?:rollout|apply)\b|^launchctl\s+kickstart\b|^(?:\.\/)?(?:scripts\/)?deploy[\w.-]*(?:\s|$)|^(?:npm|yarn|pnpm)\s+(?:run\s+)?deploy\b/;
|
|
54
|
+
const DRY_RUN = /(?:^|\s)(?:--dry-run|--check|--no-act|-n)(?:\s|=|$)/;
|
|
55
|
+
const CURL = /^curl\s/;
|
|
56
|
+
function requiredTargetOf(segment, required) {
|
|
57
|
+
if (!required.length) return null;
|
|
58
|
+
const tokens = segment.split(/\s+/);
|
|
59
|
+
return required.find((name) => tokens.some((tok) => tok === name || tok.endsWith(`/${name}`))) || '(other)';
|
|
60
|
+
}
|
|
61
|
+
export function bashOperationReceipt(command, response, transportSuccess, goal, { sessionId, harness } = {}) {
|
|
62
|
+
const domain = goal?.domain;
|
|
63
|
+
const scope = typeof sessionId === 'string' && sessionId.trim() ? `session:${sessionId.trim()}` : null;
|
|
64
|
+
if (!['git-integrity', 'service-runtime'].includes(domain) || typeof command !== 'string') return null;
|
|
65
|
+
const required = Array.isArray(goal.requiredTargets) ? goal.requiredTargets : [];
|
|
66
|
+
const bare = command.replace(/'[^']*'|"[^"]*"/g, '""');
|
|
67
|
+
const masked = /;|\n|\|\|/.test(bare) || (/\|/.test(bare) && !/\bpipefail\b/.test(command));
|
|
68
|
+
const real = command.split('&&').map((seg) => seg.trim());
|
|
69
|
+
const text = responseText(response);
|
|
70
|
+
const hasOutputField = typeof response === 'string' || typeof response?.stdout === 'string' || typeof response?.output === 'string';
|
|
71
|
+
let role = null; let proven = false; let target = null;
|
|
72
|
+
for (const seg of real) {
|
|
73
|
+
if (!seg || /^cd\s+\S+$/.test(seg) || DRY_RUN.test(seg.replace(/'[^']*'|"[^"]*"/g, '""'))) continue;
|
|
74
|
+
if (domain === 'git-integrity') {
|
|
75
|
+
if (GIT_STATE.test(seg)) {
|
|
76
|
+
const wanted = goal.check;
|
|
77
|
+
const kind = /\bfsck\b/.test(seg) ? 'fsck' : /\bdiff\b/.test(seg) ? 'diff-check' : 'status';
|
|
78
|
+
// `-- <pathspec>` / extra paths narrow the check; only a whole-tree status proves a clean tree.
|
|
79
|
+
const wholeTree = kind !== 'status' || !/\s--\s|\s--porcelain=\S*\s+\S|(?:--porcelain|-s|--short)\s+[^\s-]/.test(seg);
|
|
80
|
+
if (!wholeTree || (wanted && kind !== wanted)) continue;
|
|
81
|
+
role = 'state';
|
|
82
|
+
proven = kind === 'status' ? hasOutputField && text === '' : true;
|
|
83
|
+
} else if (GIT_VERB.test(seg) && role !== 'state') {
|
|
84
|
+
role = 'mutation';
|
|
85
|
+
target = seg.match(GIT_VERB)[1];
|
|
86
|
+
}
|
|
87
|
+
} else if (SERVICE_MUTATION.test(seg) && role !== 'state') {
|
|
88
|
+
role = 'mutation';
|
|
89
|
+
target = requiredTargetOf(seg, required);
|
|
90
|
+
} else if (CURL.test(seg)) {
|
|
91
|
+
const url = seg.match(/https?:\/\/\S+/)?.[0];
|
|
92
|
+
if (!url) continue;
|
|
93
|
+
let pathname = null;
|
|
94
|
+
try { pathname = new URL(url.replace(/["']/g, '')).pathname; } catch { continue; }
|
|
95
|
+
const probe = goal.probe || {};
|
|
96
|
+
if (probe.path && pathname.replace(/\/$/, '') !== probe.path.replace(/\/$/, '')) continue;
|
|
97
|
+
const code = /%\{http_code\}/.test(seg) ? text.match(/(\d{3})\s*$/)?.[1] || null : null;
|
|
98
|
+
const failFlag = /(?:^|\s)(?:-[a-zA-Z]*f[a-zA-Z]*|--fail(?:-with-body)?)(?:\s|$)/.test(seg);
|
|
99
|
+
role = 'state';
|
|
100
|
+
if (code) proven = probe.status ? code === probe.status : /^2/.test(code);
|
|
101
|
+
else proven = failFlag && (!probe.status || probe.status.startsWith('2'));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!role) return null;
|
|
105
|
+
const ok = transportSuccess === true && !masked && Boolean(scope) && (role === 'mutation' || proven);
|
|
106
|
+
return { kind: 'operation', success: ok, transportSuccess: transportSuccess === true, harness,
|
|
107
|
+
...(masked ? { exitStatusUnproven: true } : {}),
|
|
108
|
+
operation: { domain, role, scope, target: role === 'state' ? null : target,
|
|
109
|
+
...(role === 'state' ? { state: 'verified', complete: ok } : {}) } };
|
|
110
|
+
}
|
|
@@ -7,7 +7,7 @@ import fsSync from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { withAsyncLock, LOCK_MAX_SLICE_MS } from './async-lock.mjs';
|
|
10
|
-
import { cronOperationReceipt } from './cron-operation-adapter.mjs';
|
|
10
|
+
import { cronOperationReceipt, bashOperationReceipt } from './cron-operation-adapter.mjs';
|
|
11
11
|
import { operationCompletionPolicy, foldOperationEvidence, compactOperation } from './operation-evidence.mjs';
|
|
12
12
|
|
|
13
13
|
// Hook-context self-deadline (2.4.1 orphan-leak class): armed ONLY on direct
|
|
@@ -693,6 +693,15 @@ function gitOutput(args, cwd) {
|
|
|
693
693
|
// so the session root's fingerprint never moves for work done there. Only a LEADING `cd <dir>`
|
|
694
694
|
// that lands in a checkout sharing this repo's git common dir redirects the fingerprint; an
|
|
695
695
|
// unrelated repo, a missing dir or any git failure keeps the session root (fail-closed).
|
|
696
|
+
// The router stamps CODE_IMPLEMENTATION on every mutating lane, so the read-only valves must
|
|
697
|
+
// treat it like an absent kind; MIXED/OPERATIONAL/CONFIGURATION keep their own evidence.
|
|
698
|
+
// A stamped CODE_IMPLEMENTATION only opens a valve when the prompt carried no mutation order.
|
|
699
|
+
function plainCodeContract(contract, state) {
|
|
700
|
+
if (!contract?.taskKind) return true;
|
|
701
|
+
return contract.taskKind === 'CODE_IMPLEMENTATION'
|
|
702
|
+
&& (state?.routingContext?.mutationOrder === false || state?.routingContext?.planOnly === true);
|
|
703
|
+
}
|
|
704
|
+
|
|
696
705
|
function bashWorkspaceScope(command, projectRoot, shellCwd) {
|
|
697
706
|
const here = { root: projectRoot, foreign: false };
|
|
698
707
|
const match = String(command || '').match(/^\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|<>$`~]+))\s*(?:&&|;)/);
|
|
@@ -740,10 +749,22 @@ function workspaceFingerprint(cwd) {
|
|
|
740
749
|
}
|
|
741
750
|
}
|
|
742
751
|
|
|
743
|
-
// Verification currency excludes UKit's mutable receipts/router state
|
|
752
|
+
// Verification currency excludes UKit's mutable receipts/router state and the living
|
|
753
|
+
// bookkeeping docs (never test inputs; the project rules REQUIRE agents to update them after
|
|
754
|
+
// work, so counting them made every wrap-up edit invalidate a still-valid verdict). Source,
|
|
755
|
+
// tests and every other doc stay in the fingerprint.
|
|
756
|
+
const VERIFICATION_FINGERPRINT_EXCLUDES = [
|
|
757
|
+
':(exclude).ukit/storage/**',
|
|
758
|
+
':(exclude).claude/ukit/skill-router-state.json',
|
|
759
|
+
':(exclude)docs/WORKLOG.md',
|
|
760
|
+
':(exclude)docs/STATUS.md',
|
|
761
|
+
':(exclude)docs/TASKS.md',
|
|
762
|
+
':(exclude)docs/MEMORY.md',
|
|
763
|
+
];
|
|
764
|
+
|
|
744
765
|
function verificationDirtyFingerprint(cwd) {
|
|
745
766
|
try {
|
|
746
|
-
const paths = ['.',
|
|
767
|
+
const paths = ['.', ...VERIFICATION_FINGERPRINT_EXCLUDES];
|
|
747
768
|
const options = { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500, maxBuffer: 16 * 1024 * 1024 };
|
|
748
769
|
const diff = execFileSync('git', ['diff', 'HEAD', '--no-ext-diff', '--', ...paths], options);
|
|
749
770
|
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z', '--', ...paths], options);
|
|
@@ -824,6 +845,8 @@ function gitPatchId(diffText) {
|
|
|
824
845
|
// SPEC §2.2 — is this verdict still valid at the current head?
|
|
825
846
|
// Returns { status, current, headSha } where status is one of:
|
|
826
847
|
// 'current' | 'stale' | 'stale-recoverable' | 'untracked' (no git / no sha data).
|
|
848
|
+
// `reason` names WHY a stale verdict is stale: 'workspace-changed' (same HEAD, tracked/untracked
|
|
849
|
+
// content differs from what was verified) vs 'head-moved'.
|
|
827
850
|
// Cost: rev-parse plus a bounded diff/untracked snapshot for new verdicts;
|
|
828
851
|
// when head moved, the existing diff/patch-id comparison also applies.
|
|
829
852
|
export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
|
|
@@ -840,7 +863,7 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
|
|
|
840
863
|
}
|
|
841
864
|
const currentHead = gitOutput(['rev-parse', 'HEAD'], cwd);
|
|
842
865
|
if (verdict.headSha && Object.hasOwn(verdict, 'dirtyFp') && (!verdict.dirtyFp || verificationDirtyFingerprint(cwd) !== verdict.dirtyFp)) {
|
|
843
|
-
return { status: 'stale', current: false, headSha: currentHead };
|
|
866
|
+
return { status: 'stale', current: false, headSha: currentHead, reason: 'workspace-changed' };
|
|
844
867
|
}
|
|
845
868
|
if (!currentHead) {
|
|
846
869
|
// No git repo (or git unavailable): currency cannot be proven either way —
|
|
@@ -855,7 +878,7 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
|
|
|
855
878
|
}
|
|
856
879
|
// Head moved. With no base/patch-id recorded there is nothing to compare — stale.
|
|
857
880
|
if (!verdict.baseSha || !verdict.patchId) {
|
|
858
|
-
return { status: 'stale', current: false, headSha: currentHead };
|
|
881
|
+
return { status: 'stale', current: false, headSha: currentHead, reason: 'head-moved' };
|
|
859
882
|
}
|
|
860
883
|
const diff = gitOutput(['diff', `${verdict.baseSha}..${currentHead}`], cwd);
|
|
861
884
|
const patchId = gitPatchId(diff);
|
|
@@ -2778,7 +2801,12 @@ export async function recordExecutionReceipt({
|
|
|
2778
2801
|
exitCode,
|
|
2779
2802
|
};
|
|
2780
2803
|
|
|
2781
|
-
const
|
|
2804
|
+
const toolResponse = payload.tool_response ?? payload.tool_output ?? payload.tool_result;
|
|
2805
|
+
const nativeGoal = routeState?.routeSummary?.executionContract?.operationGoal;
|
|
2806
|
+
const operationReceipt = cronOperationReceipt(toolName, toolInput, toolResponse, success, { sessionId: explicitSessionId(payload), harness })
|
|
2807
|
+
|| (toolName === 'Bash' && !bashScope.foreign
|
|
2808
|
+
? bashOperationReceipt(toolInput.command, toolResponse, success, nativeGoal, { sessionId: explicitSessionId(payload), harness })
|
|
2809
|
+
: null);
|
|
2782
2810
|
if (operationReceipt) {
|
|
2783
2811
|
Object.assign(receipt, operationReceipt);
|
|
2784
2812
|
} else if (toolName === 'Read' || toolName === 'Grep' || toolName === 'Glob') {
|
|
@@ -3073,7 +3101,10 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}, {
|
|
|
3073
3101
|
const currency = verdictCurrent(ledger.verdict, { cwd });
|
|
3074
3102
|
if (currency.current !== true) {
|
|
3075
3103
|
const sha = ledger.verdict.headSha ? String(ledger.verdict.headSha).slice(0, 12) : 'unknown';
|
|
3076
|
-
|
|
3104
|
+
const cause = currency.reason === 'workspace-changed'
|
|
3105
|
+
? `source/test files changed since it ran at ${sha} (HEAD unchanged or not the cause)`
|
|
3106
|
+
: `head moved since ${sha}`;
|
|
3107
|
+
instruction = `Verification verdict is stale (${cause}). Re-run the routed verification at current head.`;
|
|
3077
3108
|
}
|
|
3078
3109
|
}
|
|
3079
3110
|
}
|
|
@@ -3264,6 +3295,12 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
|
|
|
3264
3295
|
) {
|
|
3265
3296
|
return { continue: false, notify: false, complete: true, missingEvidence: [] };
|
|
3266
3297
|
}
|
|
3298
|
+
// A status/result lookup (READ_ONLY + statusReport) orders no change: there is no write or
|
|
3299
|
+
// verification debt to enforce, and exiting here must not hide a mutation contract —
|
|
3300
|
+
// statusReport is only ever stamped when the request carried no mutation vocabulary.
|
|
3301
|
+
if (routeSummary.executionContract?.taskKind === 'READ_ONLY' && routeSummary.executionContract.statusReport === true) {
|
|
3302
|
+
return { continue: false, notify: false, complete: true, missingEvidence: [] };
|
|
3303
|
+
}
|
|
3267
3304
|
if (evidence.length === 0) {
|
|
3268
3305
|
// A route with no completion evidence has no actionable block reason — blocking would
|
|
3269
3306
|
// loop unboundedly. Release loud instead of ending indistinguishable from a stall.
|
|
@@ -3354,7 +3391,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
|
|
|
3354
3391
|
|| routeSummary.executionContract.completionRule === 'never-claim-fixed-without-write-and-verification');
|
|
3355
3392
|
if (
|
|
3356
3393
|
findCauseNoMutationContract
|
|
3357
|
-
&&
|
|
3394
|
+
&& plainCodeContract(routeSummary.executionContract, state)
|
|
3358
3395
|
&& playbookId !== BUGFIX_PLAYBOOK_ID
|
|
3359
3396
|
&& !effectiveLedger.writeAttempted
|
|
3360
3397
|
&& !effectiveLedger.verificationFailed
|
|
@@ -3377,7 +3414,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
|
|
|
3377
3414
|
// satisfied), no mutation was attempted, and no verification failed; anything less has
|
|
3378
3415
|
// concrete unfinished work and keeps recovering.
|
|
3379
3416
|
if (
|
|
3380
|
-
|
|
3417
|
+
plainCodeContract(routeSummary.executionContract, state)
|
|
3381
3418
|
&& mode === 'map-impact'
|
|
3382
3419
|
&& !missingEvidence.includes('impact-evidence')
|
|
3383
3420
|
&& effectiveLedger.writeAttempted !== true
|
|
@@ -3405,7 +3442,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
|
|
|
3405
3442
|
const readOnlyPrompt = state?.routingContext?.mutationOrder === false
|
|
3406
3443
|
|| state?.routingContext?.planOnly === true;
|
|
3407
3444
|
if (
|
|
3408
|
-
|
|
3445
|
+
plainCodeContract(routeSummary.executionContract, state)
|
|
3409
3446
|
&& readOnlyPrompt
|
|
3410
3447
|
&& !effectiveLedger.writeAttempted
|
|
3411
3448
|
&& missingEvidence.some((item) => item === 'write-evidence' || item === 'verification-evidence')
|
|
@@ -61,7 +61,11 @@ export function operationEvidenceStatus(goal, ledger = {}) {
|
|
|
61
61
|
const matches = (r) => r?.taskInstanceId === ledger.taskInstanceId && r.sessionKey === ledger.sessionKey
|
|
62
62
|
&& r.operation?.domain === goal.domain && r.operation.scope === goal.scope;
|
|
63
63
|
const attempts = entries.map((e) => e.mutation).filter(matches);
|
|
64
|
-
|
|
64
|
+
// Named verbs/services are ALL required: a receipt for another target never stands in for one.
|
|
65
|
+
const requiredTargets = Array.isArray(goal.requiredTargets) && goal.requiredTargets.length > 0 ? goal.requiredTargets : [goal.target];
|
|
66
|
+
const latestFor = (target) => attempts.filter((r) => r.operation.target === target).sort((a, b) => b.sequence - a.sequence)[0];
|
|
67
|
+
const proofs = requiredTargets.map(latestFor);
|
|
68
|
+
const attempt = proofs[0];
|
|
65
69
|
const observation = entries.map((e) => e.state).filter(matches).sort((a, b) => b.sequence - a.sequence)[0];
|
|
66
70
|
const op = observation?.operation;
|
|
67
71
|
const absent = goal.desiredState === 'absent' && Array.isArray(op?.presentTargets)
|
|
@@ -74,8 +78,8 @@ export function operationEvidenceStatus(goal, ledger = {}) {
|
|
|
74
78
|
if (goal.completionSemantics === 'ensure-state' && attempts.length === 0) {
|
|
75
79
|
return { complete: true, operationProof: 'desired-state-only' };
|
|
76
80
|
}
|
|
77
|
-
if (!
|
|
78
|
-
|
|
81
|
+
if (!proofs.every((proof) => proof?.success && proof.toolUseId && observation.toolUseId
|
|
82
|
+
&& observation.sequence > proof.sequence)) return incomplete;
|
|
79
83
|
return { complete: true, operationProof: 'executed-and-verified' };
|
|
80
84
|
}
|
|
81
85
|
|
|
@@ -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}`);
|