@ngockhoale/ukit 3.4.67 → 3.4.68
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 +12 -0
- package/manifests/documentation.yaml +33 -0
- package/manifests/instructionRules.yaml +10 -0
- package/manifests/platform.full.yaml +67 -0
- package/package.json +1 -1
- package/src/core/applyPlan.js +111 -2
- package/src/core/buildPlan.js +3 -1
- package/src/core/diffPlan.js +49 -1
- package/src/core/executionContracts.js +49 -8
- package/src/core/metadata.js +45 -0
- package/src/core/runInstallPipeline.js +30 -2
- package/src/core/uninstall.js +106 -2
- package/src/manifest/validateManifest.js +25 -1
- package/template_project/.claude/agents/code-reviewer.md +22 -9
- package/template_project/.claude/agents/handoff-planner.md +97 -8
- package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
- package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
- package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
- package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
- package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
- package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
- package/template_project/.claude/hooks/handoff-resume.sh +141 -1
- package/template_project/.claude/hooks/record-execution.mjs +140 -9
- package/template_project/.claude/hooks/skill-router.sh +173 -8
- package/template_project/.claude/settings.json +2 -2
- package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
- package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
- package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
- package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
- package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
- package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
- package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
- package/template_project/.claude/ukit/index/route-resolver.mjs +48 -8
- package/template_project/.claude/ukit/index/route-task.mjs +180 -11
- package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
- package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
- package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
- package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
- package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
- package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
- package/template_project/.codex/README.md +25 -0
- package/template_project/.omp/RULES.md +22 -22
- package/template_project/.omp/agents/code-reviewer.md +22 -9
- package/template_project/.omp/agents/handoff-planner.md +102 -12
- package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
- package/template_project/AGENTS.md +5 -1
- package/template_project/CLAUDE.md +1 -1
- package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
- package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
- package/template_project/instructions/core.md +5 -1
- package/template_project/instructions/layout.yaml +2 -0
- package/template_project/instructions/overlays/omp-rules.md +22 -22
- package/template_project/ukit/storage/config.json +1 -1
|
@@ -6,15 +6,29 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Usage:
|
|
8
8
|
// node .claude/ukit/index/task-budget-validator.mjs <path-to-TASK-xxx.md> [criteria JSON]
|
|
9
|
+
// node .claude/ukit/index/task-budget-validator.mjs --record <path-to-TASK-xxx.md>
|
|
9
10
|
//
|
|
10
11
|
// Output:
|
|
11
12
|
// First stdout line: `VERDICT: ok` or `VERDICT: needs_breakdown`
|
|
12
13
|
// Then one `REASON: <criterion>: <detail>` line per reason.
|
|
14
|
+
// In --record mode: an extra `RECORD:` line — `RECORD: ok <written line>` when the
|
|
15
|
+
// `Validator: ok <YYYY-MM-DD> <tool-version>` line was appended to `## Discussion`,
|
|
16
|
+
// `RECORD: already recorded` when one already exists (idempotent), and
|
|
17
|
+
// `RECORD: not recorded — <reason>` when the verdict is not ok or the section is missing.
|
|
13
18
|
// ALWAYS exits 0 — advisory gate, must never break a pipeline.
|
|
14
19
|
|
|
15
20
|
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
16
22
|
import process from 'node:process';
|
|
17
|
-
import {
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
function isDirectRun() {
|
|
26
|
+
try {
|
|
27
|
+
return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
18
32
|
|
|
19
33
|
const DEFAULT_CRITERIA = Object.freeze({
|
|
20
34
|
maxTargetFiles: 3,
|
|
@@ -216,8 +230,80 @@ export function validateTaskFile(markdown, criteria = DEFAULT_CRITERIA) {
|
|
|
216
230
|
};
|
|
217
231
|
}
|
|
218
232
|
|
|
233
|
+
function localDateStamp() {
|
|
234
|
+
const d = new Date();
|
|
235
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Stamp the writing tool into the recorded line (`Validator: ok 2026-10-24 ukit@3.4.67`).
|
|
239
|
+
// The version comes from the installer-written `.ukit/storage/cli.json` (src/core/metadata.js
|
|
240
|
+
// CLI_LOCATOR_REL); the script lives four levels under the project root regardless of cwd.
|
|
241
|
+
function toolVersionStamp() {
|
|
242
|
+
try {
|
|
243
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
|
|
244
|
+
const cli = JSON.parse(fs.readFileSync(path.join(root, '.ukit/storage/cli.json'), 'utf8'));
|
|
245
|
+
if (cli && typeof cli.version === 'string' && cli.version) return `ukit@${cli.version}`;
|
|
246
|
+
} catch {
|
|
247
|
+
// missing/corrupt locator — still record, just without a trustworthy version
|
|
248
|
+
}
|
|
249
|
+
return 'ukit@unknown';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// CR-08: append `Validator: ok <date> <tool-version>` at the bottom of the `## Discussion`
|
|
253
|
+
// thread so the flip-to-ready step reads the verdict from disk, not from session memory
|
|
254
|
+
// (compaction-safe). Preserves the file's BOM and LF/CRLF convention; refuses non-ok
|
|
255
|
+
// verdicts; idempotent on an existing ok line. Returns { raw, action }.
|
|
256
|
+
export function appendValidatorOkLine(raw, opts = {}) {
|
|
257
|
+
const text = typeof raw === 'string' ? raw : '';
|
|
258
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
259
|
+
// The heading match must consume its whole line terminator (\r\n or \n) — `\s*` used to
|
|
260
|
+
// swallow the \r and shift the splice point, doubling \r on CRLF files.
|
|
261
|
+
const headingMatch = /^##[ \t]+Discussion[ \t]*(?:\r?\n|$)/m.exec(text);
|
|
262
|
+
if (!headingMatch) return { raw: text, action: 'no-discussion' };
|
|
263
|
+
const afterHeading = headingMatch.index + headingMatch[0].length;
|
|
264
|
+
const rest = text.slice(afterHeading);
|
|
265
|
+
const boundaryMatch = /^##\s|^-{3,}\s*$/m.exec(rest);
|
|
266
|
+
const sectionEnd = boundaryMatch ? afterHeading + boundaryMatch.index : text.length;
|
|
267
|
+
const section = text.slice(afterHeading, sectionEnd);
|
|
268
|
+
// Only the exact tool-written shape counts as recorded; any other `Validator: ok …` line
|
|
269
|
+
// (forged 2-field, hand-typed) is non-conforming and is replaced, never treated as recorded.
|
|
270
|
+
if (/^Validator: ok \d{4}-\d{2}-\d{2} ukit@\S+[ \t\r]*$/m.test(section)) return { raw: text, action: 'already' };
|
|
271
|
+
const line = `Validator: ok ${opts.date || localDateStamp()} ${opts.toolVersion || toolVersionStamp()}`;
|
|
272
|
+
const cleaned = section
|
|
273
|
+
.split(/\r?\n/)
|
|
274
|
+
.filter((l) => !/^[ \t]*Validator: ok\b/.test(l))
|
|
275
|
+
.join(eol)
|
|
276
|
+
.replace(/\s+$/, '');
|
|
277
|
+
// When another section follows, keep one blank line before its heading.
|
|
278
|
+
const gap = boundaryMatch ? eol : '';
|
|
279
|
+
const insertion = cleaned.length > 0
|
|
280
|
+
? `${cleaned}${eol}${line}${eol}${gap}`
|
|
281
|
+
: `${eol}${line}${eol}${gap}`;
|
|
282
|
+
return { raw: text.slice(0, afterHeading) + insertion + text.slice(sectionEnd), action: 'recorded', line };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Atomic temp-file + rename so a crash mid-write can never leave a torn task file.
|
|
286
|
+
function atomicWrite(file, content) {
|
|
287
|
+
const tmp = `${file}.tmp-record-${process.pid}`;
|
|
288
|
+
try {
|
|
289
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
290
|
+
fs.renameSync(tmp, file);
|
|
291
|
+
return true;
|
|
292
|
+
} catch {
|
|
293
|
+
try {
|
|
294
|
+
fs.unlinkSync(tmp);
|
|
295
|
+
} catch {
|
|
296
|
+
// nothing to clean
|
|
297
|
+
}
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
219
302
|
function main() {
|
|
220
|
-
const [, ,
|
|
303
|
+
const [, , firstArg, secondArg] = process.argv;
|
|
304
|
+
const recordMode = firstArg === '--record';
|
|
305
|
+
const targetPath = recordMode ? secondArg : firstArg;
|
|
306
|
+
const criteriaArg = recordMode ? process.argv[4] : secondArg;
|
|
221
307
|
let criteria = DEFAULT_CRITERIA;
|
|
222
308
|
if (criteriaArg) {
|
|
223
309
|
try {
|
|
@@ -240,9 +326,29 @@ function main() {
|
|
|
240
326
|
for (const r of reasons) {
|
|
241
327
|
console.log(`REASON: ${r.criterion}: ${r.detail}`);
|
|
242
328
|
}
|
|
329
|
+
if (recordMode) {
|
|
330
|
+
if (verdict !== 'ok') {
|
|
331
|
+
console.log('RECORD: not recorded — verdict needs_breakdown (fix the reasons above, then re-run)');
|
|
332
|
+
process.exit(0);
|
|
333
|
+
}
|
|
334
|
+
const { raw, action, line } = appendValidatorOkLine(markdown);
|
|
335
|
+
if (action === 'already') {
|
|
336
|
+
console.log('RECORD: already recorded');
|
|
337
|
+
process.exit(0);
|
|
338
|
+
}
|
|
339
|
+
if (action === 'no-discussion') {
|
|
340
|
+
console.log('RECORD: not recorded — task file has no `## Discussion` section');
|
|
341
|
+
process.exit(0);
|
|
342
|
+
}
|
|
343
|
+
if (action !== 'recorded' || !atomicWrite(targetPath, raw)) {
|
|
344
|
+
console.log('RECORD: not recorded — write failed (file left unchanged)');
|
|
345
|
+
process.exit(0);
|
|
346
|
+
}
|
|
347
|
+
console.log(`RECORD: ok ${line}`);
|
|
348
|
+
}
|
|
243
349
|
process.exit(0);
|
|
244
350
|
}
|
|
245
351
|
|
|
246
|
-
if (
|
|
352
|
+
if (isDirectRun()) {
|
|
247
353
|
main();
|
|
248
354
|
}
|
|
@@ -476,8 +476,10 @@ export async function writeResumeIntent(projectRoot, payload = {}, { now = Date.
|
|
|
476
476
|
// marker, and a user-declared origin never consumes a marker at all.
|
|
477
477
|
export const GATE_CONTINUATION_TTL_MS = 30 * 60 * 1000;
|
|
478
478
|
const GATE_CONTINUATION_PREFIX = /^UKit completion gate:/;
|
|
479
|
-
// The exact diagnostics tail evaluateCompletion appends to every gate reason.
|
|
480
|
-
|
|
479
|
+
// The exact diagnostics tail evaluateCompletion appends to every gate reason. The
|
|
480
|
+
// observed=/unmet=/cover= fields are optional in the shape so gate text emitted by an
|
|
481
|
+
// older runtime (bare accepted-count/rejected tail) still binds.
|
|
482
|
+
const GATE_CONTINUATION_DIAGNOSTICS = /\s\[task=\S+ contract=\S+ accepted-count=\d+ rejected=[^\]\s]*(?:\sobserved=[^\]\s]*)?(?:\sunmet=[^\]\s]*)?(?:\scover=\d+\/\d+)?\]\s*$/;
|
|
481
483
|
const GATE_CONTINUATION_MAX_CHARS = 2000;
|
|
482
484
|
|
|
483
485
|
export function isCompletionGateContinuationText(text) {
|
|
@@ -4443,7 +4445,22 @@ export function evaluateCompletion(args = {}) {
|
|
|
4443
4445
|
rejectedEvidence: args.ledger && owned !== args.ledger ? ['ledger-task-ownership-mismatch'] : [],
|
|
4444
4446
|
};
|
|
4445
4447
|
result.diagnostics = diagnostics;
|
|
4446
|
-
|
|
4448
|
+
// Field 3.4.67 (narrow diagnostic hardening): accepted-count counts satisfied
|
|
4449
|
+
// obligation CLASSES and rejected= is ledger-task ownership only — a bare
|
|
4450
|
+
// "accepted-count=0 rejected=none" was misread as "the gate saw nothing / is
|
|
4451
|
+
// broken", sending the agent to re-read the same files. The tail now exposes
|
|
4452
|
+
// what the LEDGER observed (observed=) and which obligations remain unmet
|
|
4453
|
+
// (unmet=, plus expected source-path coverage cover=matched/total when the
|
|
4454
|
+
// route names files). Ledger-scoped only: "none" means no LEDGER-captured
|
|
4455
|
+
// evidence — never a claim about reads a host hook may not have captured, and
|
|
4456
|
+
// Bash workspace activity is never counted as source evidence.
|
|
4457
|
+
const diagExpectedFiles = Array.isArray(args.state?.routeSummary?.expectedSourceFiles)
|
|
4458
|
+
? args.state.routeSummary.expectedSourceFiles
|
|
4459
|
+
: [];
|
|
4460
|
+
const diagCoveredCount = diagExpectedFiles.length
|
|
4461
|
+
? diagExpectedFiles.filter((expected) => (Array.isArray(owned.sourceFiles) ? owned.sourceFiles : []).some((file) => fileMatchesExpected(file, expected))).length
|
|
4462
|
+
: 0;
|
|
4463
|
+
if (result.reason) result.reason += ` [task=${diagnostics.taskInstanceId || 'unknown'} contract=${diagnostics.contract || 'unknown'} accepted-count=${diagnostics.acceptedEvidence.length} rejected=${diagnostics.rejectedEvidence.join(',') || 'none'} observed=${diagnostics.observedEvidence.join(',') || 'none'} unmet=${diagnostics.unmetObligations.join(',') || 'none'}${diagExpectedFiles.length ? ` cover=${diagCoveredCount}/${diagExpectedFiles.length}` : ''}]`;
|
|
4447
4464
|
try {
|
|
4448
4465
|
const acceptance = evaluateFeatureAcceptance({ state: args.state, ledger: args.ledger, cwd: args.cwd });
|
|
4449
4466
|
if (acceptance.applicable) result.playbookAcceptance = acceptance;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// handoff-task-scope.mjs — frozen RUN scope selection (pure, no fs). TaskScope:
|
|
2
|
+
// <KEY> = queue:<slug> | plan:<path> | repair:<KEY>; KEY = TASK-001 (legacy) |
|
|
3
|
+
// cycle-01/TASK-001 (ids restart per cycle). normalizeScopeCycle mirrors
|
|
4
|
+
// parseRunCycle/normalizeClaimCycle; context cycle = opts.cycle (archive) else RUN
|
|
5
|
+
// `Cycle:`. Exact cycle-NN/<id> wins; a bare id applies only while no other cycle
|
|
6
|
+
// qualified it (else include + reconcile). repair: qualified parent exact; a bare
|
|
7
|
+
// parent resolves in the CURRENT HOP's cycle (a qualified hop re-anchors context).
|
|
8
|
+
// Rows stay only when mapped into the frozen set (absent = legacy). Ids/slugs fold
|
|
9
|
+
// case; PLAN PATHS are case-sensitive always (A.md ≠ a.md; duplicates conflict).
|
|
10
|
+
// Unknown/malformed/conflicting/cyclic → include + diagnostic; status never read.
|
|
11
|
+
|
|
12
|
+
const SCOPE_QUEUE = /^ScopeQueue:(.*)$/i;
|
|
13
|
+
const SCOPE_PLANS = /^ScopePlans:(.*)$/i;
|
|
14
|
+
const TASK_SCOPE = /^TaskScope:(.*)$/i;
|
|
15
|
+
const MAPPING = /^(\S+)\s*=\s*(queue|plan|repair):(\S.*)$/i;
|
|
16
|
+
const IMPLICIT_REPAIR = /^(.+)-R\d+$/i;
|
|
17
|
+
const RUN_CYCLE_FIELD = /^Cycle:(.*)$/im;
|
|
18
|
+
|
|
19
|
+
const norm = (v) => String(v ?? '').trim().toLowerCase();
|
|
20
|
+
|
|
21
|
+
export function normalizeScopeCycle(value) {
|
|
22
|
+
const m = String(value ?? '').trim().match(/^(?:cycle-)?(\d+)$/i);
|
|
23
|
+
return m ? `cycle-${m[1].padStart(2, '0')}` : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// `cycle-1/X` → `cycle-01/x`; a bare id stays literal.
|
|
27
|
+
function normScopeKey(raw) {
|
|
28
|
+
const key = norm(raw);
|
|
29
|
+
const m = key.match(/^(?:cycle-)?(\d+)\/(.+)$/);
|
|
30
|
+
if (m) {
|
|
31
|
+
const cyc = normalizeScopeCycle(`cycle-${m[1]}`);
|
|
32
|
+
if (cyc) return `${cyc}/${m[2]}`;
|
|
33
|
+
}
|
|
34
|
+
return key;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function keyParts(key) {
|
|
38
|
+
const i = key.indexOf('/');
|
|
39
|
+
if (i <= 0) return { cycle: null, base: key };
|
|
40
|
+
const cycle = normalizeScopeCycle(key.slice(0, i));
|
|
41
|
+
return cycle ? { cycle, base: key.slice(i + 1) } : { cycle: null, base: key };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// null = legacy.
|
|
45
|
+
function setKey(value, fold) {
|
|
46
|
+
const v = value.trim();
|
|
47
|
+
if (!v) return null;
|
|
48
|
+
if (v.toLowerCase() === 'none') return '';
|
|
49
|
+
return [...new Set(v.split(',').map((x) => (fold ? norm(x) : x.trim())).filter(Boolean))].sort().join(',');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readFrozenSet(lines, re, label, diag, fold) {
|
|
53
|
+
const keys = [];
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const m = line.match(re);
|
|
56
|
+
if (m) keys.push(setKey(m[1], fold));
|
|
57
|
+
}
|
|
58
|
+
if (keys.length === 0) return null;
|
|
59
|
+
if (new Set(keys).size > 1) {
|
|
60
|
+
diag(`conflicting ${label} values (${keys.map((k) => k ?? 'legacy').join(' | ')}); treated as legacy, all rows included`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const key = keys[0];
|
|
64
|
+
if (key === null) return null;
|
|
65
|
+
return new Set(key === '' ? [] : key.split(','));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function selectTaskScope(runText, rows, opts = {}) {
|
|
69
|
+
const diags = new Set();
|
|
70
|
+
const diag = (msg) => diags.add(msg);
|
|
71
|
+
const lines = String(runText ?? '').split(/\r?\n/).map((l) => l.trim());
|
|
72
|
+
|
|
73
|
+
const queueSet = readFrozenSet(lines, SCOPE_QUEUE, 'ScopeQueue', diag, true);
|
|
74
|
+
const planSet = readFrozenSet(lines, SCOPE_PLANS, 'ScopePlans', diag, false);
|
|
75
|
+
const anyScope = queueSet !== null || planSet !== null;
|
|
76
|
+
const ctx = normalizeScopeCycle(opts?.cycle)
|
|
77
|
+
?? normalizeScopeCycle(String(runText ?? '').match(RUN_CYCLE_FIELD)?.[1] || '');
|
|
78
|
+
|
|
79
|
+
const shown = new Map(
|
|
80
|
+
rows.filter((r) => typeof r?.id === 'string' && r.id.trim()).map((r) => [norm(r.id), r.id.trim()]),
|
|
81
|
+
);
|
|
82
|
+
const writtenKeys = new Map();
|
|
83
|
+
const label = (id) => shown.get(id) ?? writtenKeys.get(id) ?? id;
|
|
84
|
+
|
|
85
|
+
const mappings = new Map();
|
|
86
|
+
const qualifiedByBase = new Map();
|
|
87
|
+
const conflicted = new Set();
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const m = line.match(TASK_SCOPE);
|
|
90
|
+
if (!m) continue;
|
|
91
|
+
const mm = m[1].trim().match(MAPPING);
|
|
92
|
+
if (!mm) {
|
|
93
|
+
diag(`malformed TaskScope line ignored for reconciliation: "${line}"`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const key = normScopeKey(mm[1]);
|
|
97
|
+
if (!writtenKeys.has(key)) writtenKeys.set(key, mm[1].trim());
|
|
98
|
+
const kind = mm[2].toLowerCase();
|
|
99
|
+
// repair values are KEYS; plan paths keep case.
|
|
100
|
+
const value = kind === 'repair' ? normScopeKey(mm[3]) : kind === 'plan' ? mm[3].trim() : norm(mm[3]);
|
|
101
|
+
const parts = keyParts(key);
|
|
102
|
+
if (parts.cycle) {
|
|
103
|
+
if (!qualifiedByBase.has(parts.base)) qualifiedByBase.set(parts.base, new Set());
|
|
104
|
+
qualifiedByBase.get(parts.base).add(parts.cycle);
|
|
105
|
+
}
|
|
106
|
+
const prev = mappings.get(key);
|
|
107
|
+
if (prev && (prev.kind !== kind || prev.value !== value)) {
|
|
108
|
+
conflicted.add(key);
|
|
109
|
+
diag(`conflicting TaskScope mapping for ${label(key)}; row included`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
mappings.set(key, { kind, value });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const rowIds = new Set(
|
|
116
|
+
rows.map((r) => (typeof r?.id === 'string' ? norm(r.id) : '')).filter(Boolean),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// id in `c` → { map, key } | { ambiguous, cycles } | { otherCycleOnly, cycles } | { none }.
|
|
120
|
+
function resolve(id, c) {
|
|
121
|
+
if (c) {
|
|
122
|
+
const exact = mappings.get(`${c}/${id}`);
|
|
123
|
+
if (exact) return { map: exact, key: `${c}/${id}` };
|
|
124
|
+
}
|
|
125
|
+
const unq = mappings.get(id);
|
|
126
|
+
const cycles = qualifiedByBase.get(id);
|
|
127
|
+
if (unq) {
|
|
128
|
+
if (c && cycles && [...cycles].some((x) => x !== c)) {
|
|
129
|
+
return { ambiguous: true, cycles: [...cycles].sort() };
|
|
130
|
+
}
|
|
131
|
+
return { map: unq, key: id };
|
|
132
|
+
}
|
|
133
|
+
if (cycles && cycles.size > 0) return { otherCycleOnly: true, cycles: [...cycles].sort() };
|
|
134
|
+
return { none: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function decideId(id, trail, c) {
|
|
138
|
+
if (trail.includes(id)) {
|
|
139
|
+
diag(`repair cycle through ${label(id)}; rows included`);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
if (conflicted.has(id) || (c && conflicted.has(`${c}/${id}`))) return true;
|
|
143
|
+
const r = resolve(id, c);
|
|
144
|
+
if (r.map) {
|
|
145
|
+
if (r.map.kind === 'queue') return queueSet === null || queueSet.has(r.map.value);
|
|
146
|
+
if (r.map.kind === 'plan') return planSet === null || planSet.has(r.map.value);
|
|
147
|
+
return decideKey(r.map.value, [...trail, id], c);
|
|
148
|
+
}
|
|
149
|
+
if (!anyScope) return true;
|
|
150
|
+
if (r.ambiguous) {
|
|
151
|
+
diag(`ambiguous TaskScope for ${label(id)}: unqualified mapping while cycle-qualified mappings exist for other cycle(s) (${r.cycles.join(', ')}); included for reconciliation`);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
if (r.otherCycleOnly) {
|
|
155
|
+
diag(`row ${label(id)} is mapped only for other cycle(s) (${r.cycles.join(', ')}); included for reconciliation`);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
const m = id.match(IMPLICIT_REPAIR);
|
|
159
|
+
if (m) {
|
|
160
|
+
const base = norm(m[1]);
|
|
161
|
+
const br = resolve(base, c);
|
|
162
|
+
if (br.map || rowIds.has(base)) return decideId(base, [...trail, id], c);
|
|
163
|
+
}
|
|
164
|
+
diag(`unmapped row ${label(id)} under active scope; included for reconciliation`);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// A qualified key re-anchors `c` down the chain.
|
|
169
|
+
function decideKey(key, trail, c) {
|
|
170
|
+
if (trail.includes(key)) {
|
|
171
|
+
diag(`repair cycle through ${label(key)}; rows included`);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
if (conflicted.has(key)) return true;
|
|
175
|
+
const parts = keyParts(key);
|
|
176
|
+
const r = parts.cycle ? { map: mappings.get(key), key } : resolve(parts.base, c);
|
|
177
|
+
if (r.map) {
|
|
178
|
+
if (conflicted.has(r.key)) return true; // retain before membership
|
|
179
|
+
if (r.map.kind === 'queue') return queueSet === null || queueSet.has(r.map.value);
|
|
180
|
+
if (r.map.kind === 'plan') return planSet === null || planSet.has(r.map.value);
|
|
181
|
+
return decideKey(r.map.value, [...trail, key], parts.cycle || c);
|
|
182
|
+
}
|
|
183
|
+
if (parts.cycle) {
|
|
184
|
+
diag(`repair ${label(key)} has absent parent ${label(key)}; included for reconciliation`);
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
if (!rowIds.has(parts.base) && !r.ambiguous && !r.otherCycleOnly) {
|
|
188
|
+
diag(`repair ${label(parts.base)} has absent parent ${label(parts.base)}; included for reconciliation`);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
return decideId(parts.base, trail, c);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const selected = [];
|
|
195
|
+
for (const row of rows) {
|
|
196
|
+
const id = typeof row?.id === 'string' ? norm(row.id) : '';
|
|
197
|
+
if (!id) {
|
|
198
|
+
diag(`malformed row id ${JSON.stringify(row?.id ?? null)}; row included for reconciliation`);
|
|
199
|
+
selected.push(row);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (decideId(id, [], ctx)) selected.push(row);
|
|
203
|
+
}
|
|
204
|
+
return { rows: selected, reconciliation: [...diags] };
|
|
205
|
+
}
|
|
@@ -38,7 +38,9 @@ export async function progressFingerprint(root, runRoot = root, payload = {}) {
|
|
|
38
38
|
let cur = 'no-run';
|
|
39
39
|
try {
|
|
40
40
|
const t = await fs.readFile(path.join(runRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
|
|
41
|
-
|
|
41
|
+
// Cycle is part of progress identity: a reused task id under a new cycle is a new cycle's
|
|
42
|
+
// progress, not the previous cycle's stalled fingerprint carried over.
|
|
43
|
+
cur = ['Cycle', 'Phase', 'Cursor', 'Next'].map((n) => t.match(new RegExp(`^${n}:\\s*(.+)$`, 'm'))?.[1] || '').join('|');
|
|
42
44
|
} catch {}
|
|
43
45
|
const id = payload.session_id ? String(payload.session_id).trim().replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96)
|
|
44
46
|
: payload.transcript_path ? `transcript-${crypto.createHash('sha256').update(String(payload.transcript_path)).digest('hex').slice(0, 20)}` : 'default';
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// existing Agent|Task PreToolUse/PostToolUse hooks. Fail-open on IO trouble, fail-closed
|
|
4
4
|
// on an explicit dependency/duplicate violation.
|
|
5
5
|
import {
|
|
6
|
-
readResumableRun, writeResumableRun,
|
|
6
|
+
readResumableRun, writeResumableRun, resumableRunPath,
|
|
7
7
|
} from './resumable-run.mjs';
|
|
8
|
+
import { withAsyncLock } from './async-lock.mjs';
|
|
8
9
|
import { evaluateContextBudget, SUBTASK_MARKER_RE, checkDispatch, applySubtaskStatus } from './context-budget-policy.mjs';
|
|
9
10
|
|
|
10
11
|
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' };
|
|
@@ -45,14 +46,27 @@ async function save(projectRoot, loaded, plan) {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/** PreToolUse: `{ok:true}` (marks running) or `{ok:false, reason, missing?}`. */
|
|
48
|
-
export async function dispatchSubtask(projectRoot, subtaskId, toolInput = null) {
|
|
49
|
+
export async function dispatchSubtask(projectRoot, subtaskId, toolInput = null, identity = {}) {
|
|
50
|
+
const result = await withAsyncLock(`${resumableRunPath(projectRoot, parentOf(subtaskId))}.dispatch`, {},
|
|
51
|
+
() => dispatchLocked(projectRoot, subtaskId, toolInput, identity));
|
|
52
|
+
return result.ok ? result.value : { ok: false, reason: result.reason };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function dispatchLocked(projectRoot, subtaskId, toolInput, identity) {
|
|
49
56
|
try {
|
|
50
57
|
const loaded = await load(projectRoot, subtaskId);
|
|
51
58
|
if (!loaded) return { ok: true, tracked: false };
|
|
52
59
|
const plan = loaded.decisions[loaded.index];
|
|
53
60
|
const verdict = checkDispatch(plan, subtaskId);
|
|
54
61
|
if (!verdict.ok) return verdict;
|
|
55
|
-
const
|
|
62
|
+
const next = applySubtaskStatus(plan, subtaskId, 'running');
|
|
63
|
+
next.attempts = { ...plan.attempts };
|
|
64
|
+
if (identity.toolUseId && identity.sessionId) {
|
|
65
|
+
next.attempts[subtaskId] = { toolUseId: identity.toolUseId, sessionId: identity.sessionId };
|
|
66
|
+
} else {
|
|
67
|
+
delete next.attempts[subtaskId];
|
|
68
|
+
}
|
|
69
|
+
const saved = await save(projectRoot, loaded, next);
|
|
56
70
|
return { ok: true, tracked: saved.ok === true, budget: subtaskBudgetAdvice(verdict.task, toolInput) };
|
|
57
71
|
} catch {
|
|
58
72
|
return { ok: true, tracked: false };
|
|
@@ -60,13 +74,26 @@ export async function dispatchSubtask(projectRoot, subtaskId, toolInput = null)
|
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
/** PostToolUse: done on success, back to pending (retryable) on failure. */
|
|
63
|
-
export async function completeSubtask(projectRoot, subtaskId,
|
|
77
|
+
export async function completeSubtask(projectRoot, subtaskId, options) {
|
|
78
|
+
const result = await withAsyncLock(`${resumableRunPath(projectRoot, parentOf(subtaskId))}.dispatch`, {},
|
|
79
|
+
() => completeLocked(projectRoot, subtaskId, options));
|
|
80
|
+
return result.ok ? result.value : { ok: false, reason: result.reason };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function completeLocked(projectRoot, subtaskId, { success, toolUseId = null, sessionId = null, authenticated = false }) {
|
|
64
84
|
try {
|
|
65
85
|
const loaded = await load(projectRoot, subtaskId);
|
|
66
86
|
if (!loaded) return { ok: false, reason: 'no-plan' };
|
|
67
87
|
const plan = loaded.decisions[loaded.index];
|
|
68
88
|
if (!plan.tasks?.some((t) => t.id === subtaskId)) return { ok: false, reason: 'unknown-subtask' };
|
|
69
|
-
const
|
|
89
|
+
const attempt = plan.attempts?.[subtaskId];
|
|
90
|
+
if ((attempt || authenticated) && (!attempt || attempt.toolUseId !== toolUseId || attempt.sessionId !== sessionId)) {
|
|
91
|
+
return { ok: false, reason: 'attempt-mismatch' };
|
|
92
|
+
}
|
|
93
|
+
const next = applySubtaskStatus(plan, subtaskId, success ? 'done' : 'pending');
|
|
94
|
+
next.attempts = { ...plan.attempts };
|
|
95
|
+
delete next.attempts[subtaskId];
|
|
96
|
+
const saved = await save(projectRoot, loaded, next);
|
|
70
97
|
return { ok: saved.ok === true };
|
|
71
98
|
} catch {
|
|
72
99
|
return { ok: false, reason: 'io' };
|