@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/manifests/documentation.yaml +33 -0
  3. package/manifests/instructionRules.yaml +10 -0
  4. package/manifests/platform.full.yaml +67 -0
  5. package/package.json +1 -1
  6. package/src/core/applyPlan.js +111 -2
  7. package/src/core/buildPlan.js +3 -1
  8. package/src/core/diffPlan.js +49 -1
  9. package/src/core/executionContracts.js +49 -8
  10. package/src/core/metadata.js +45 -0
  11. package/src/core/runInstallPipeline.js +30 -2
  12. package/src/core/uninstall.js +106 -2
  13. package/src/manifest/validateManifest.js +25 -1
  14. package/template_project/.claude/agents/code-reviewer.md +22 -9
  15. package/template_project/.claude/agents/handoff-planner.md +97 -8
  16. package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
  17. package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
  18. package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
  19. package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
  20. package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
  21. package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
  22. package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
  23. package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
  24. package/template_project/.claude/hooks/handoff-resume.sh +141 -1
  25. package/template_project/.claude/hooks/record-execution.mjs +140 -9
  26. package/template_project/.claude/hooks/skill-router.sh +173 -8
  27. package/template_project/.claude/settings.json +2 -2
  28. package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
  29. package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
  30. package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
  31. package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
  32. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  33. package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
  34. package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
  35. package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
  36. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
  37. package/template_project/.claude/ukit/index/route-resolver.mjs +48 -8
  38. package/template_project/.claude/ukit/index/route-task.mjs +180 -11
  39. package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
  40. package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
  41. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
  42. package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
  43. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  44. package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
  45. package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
  46. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
  47. package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
  48. package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
  49. package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
  50. package/template_project/.codex/README.md +25 -0
  51. package/template_project/.omp/RULES.md +22 -22
  52. package/template_project/.omp/agents/code-reviewer.md +22 -9
  53. package/template_project/.omp/agents/handoff-planner.md +102 -12
  54. package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
  55. package/template_project/AGENTS.md +5 -1
  56. package/template_project/CLAUDE.md +1 -1
  57. package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
  58. package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
  59. package/template_project/instructions/core.md +5 -1
  60. package/template_project/instructions/layout.yaml +2 -0
  61. package/template_project/instructions/overlays/omp-rules.md +22 -22
  62. 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 { pathToFileURL } from 'node:url';
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 [, , targetPath, criteriaArg] = process.argv;
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 (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
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
- const GATE_CONTINUATION_DIAGNOSTICS = /\s\[task=\S+ contract=\S+ accepted-count=\d+ rejected=[^\]\s]*\]\s*$/;
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
- if (result.reason) result.reason += ` [task=${diagnostics.taskInstanceId || 'unknown'} contract=${diagnostics.contract || 'unknown'} accepted-count=${diagnostics.acceptedEvidence.length} rejected=${diagnostics.rejectedEvidence.join(',') || 'none'}]`;
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
- cur = ['Phase', 'Cursor', 'Next'].map((n) => t.match(new RegExp(`^${n}:\\s*(.+)$`, 'm'))?.[1] || '').join('|');
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 saved = await save(projectRoot, loaded, applySubtaskStatus(plan, subtaskId, 'running'));
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, { success }) {
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 saved = await save(projectRoot, loaded, applySubtaskStatus(plan, subtaskId, success ? 'done' : 'pending'));
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' };