@ngockhoale/ukit 3.0.3 → 3.0.5

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 (109) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/ukit +12 -4
  3. package/manifests/engineConformance.yaml +29 -0
  4. package/manifests/platform.full.yaml +13 -0
  5. package/package.json +2 -1
  6. package/scripts/audit/decision-coverage.mjs +295 -0
  7. package/scripts/bench/outline-savings.mjs +19 -4
  8. package/scripts/bench/parallel-agents.mjs +15 -4
  9. package/scripts/bench/runGold.mjs +22 -4
  10. package/scripts/bench/v3-ceremony.mjs +7 -1
  11. package/scripts/bug/triage.mjs +56 -17
  12. package/scripts/index/build-index.mjs +94 -28
  13. package/scripts/index/query-index.mjs +48 -14
  14. package/scripts/index/refresh-index.mjs +142 -62
  15. package/scripts/perf/audit-perf.mjs +8 -2
  16. package/scripts/skill/audit-skill.mjs +54 -25
  17. package/src/bug/triageBug.js +9 -6
  18. package/src/cli/adapters.js +6 -0
  19. package/src/cli/commands/code.js +7 -1
  20. package/src/cli/commands/indexArgs.js +4 -2
  21. package/src/cli/commands/indexTools.js +8 -1
  22. package/src/cli/commands/install.js +13 -0
  23. package/src/cli/commands/memory.js +13 -9
  24. package/src/cli/commands/status.js +17 -1
  25. package/src/cli/commands/update.js +7 -0
  26. package/src/context/detectProjectContext.js +3 -1
  27. package/src/core/codeintel/analogy.js +1 -1
  28. package/src/core/codeintel/diagnostics.js +9 -6
  29. package/src/core/codeintel/graph.js +14 -8
  30. package/src/core/codeintel/impact.js +0 -1
  31. package/src/core/codeintel/invalidation.js +5 -3
  32. package/src/core/codeintel/packet.js +11 -0
  33. package/src/core/codeintel/router.js +17 -3
  34. package/src/core/codeintel/semanticProvider.js +1 -1
  35. package/src/core/codeintel/summaries.js +11 -7
  36. package/src/core/compact/contextBudget.js +26 -12
  37. package/src/core/compact/index.js +15 -8
  38. package/src/core/docContracts.js +10 -2
  39. package/src/core/experiments/deliberation.js +321 -0
  40. package/src/core/experiments/dynamicWorkflow.js +492 -0
  41. package/src/core/fileOps.js +8 -1
  42. package/src/core/gatewayProbe.js +29 -1
  43. package/src/core/gatewayResilienceEnv.js +44 -3
  44. package/src/core/handoffDocValidator.js +3 -1
  45. package/src/core/hookChainDoctor.js +16 -1
  46. package/src/core/memory/deltaOverlays.js +448 -0
  47. package/src/core/memory/learningCandidates.js +302 -0
  48. package/src/core/memory/migrate.js +59 -32
  49. package/src/core/memory/recordStore.js +24 -1
  50. package/src/core/memory/store.js +44 -8
  51. package/src/core/memory/storeV2.js +48 -33
  52. package/src/core/memory/userMemory.js +26 -10
  53. package/src/core/output/index.js +15 -10
  54. package/src/core/permissionPolicy.js +8 -0
  55. package/src/core/runtimeConfig.js +224 -4
  56. package/src/core/sensitiveValueScanner.js +10 -2
  57. package/src/core/taskBudgetValidator.js +12 -17
  58. package/src/core/taskProgressGuard.js +59 -9
  59. package/src/core/unattendedDoctor.js +5 -2
  60. package/src/core/uninstall.js +37 -8
  61. package/src/decision/client.js +371 -0
  62. package/src/decision/lease.js +198 -0
  63. package/src/decision/preflight.js +492 -0
  64. package/src/decision/protocol.js +308 -0
  65. package/src/decision/registry.js +384 -0
  66. package/src/decision/shadow.js +281 -0
  67. package/src/decision/statePacket.js +165 -0
  68. package/src/diagnostics/failurePatterns.js +2 -1
  69. package/src/diagnostics/ledgerFiles.js +3 -1
  70. package/src/diagnostics/routeOutcomes.js +1 -29
  71. package/src/index/buildIndex.js +23 -11
  72. package/src/index/impactContext.js +21 -2
  73. package/src/index/importResolution.js +13 -7
  74. package/src/index/queryIndex.js +11 -5
  75. package/src/index/resolveContext.js +22 -9
  76. package/src/index/taskRouting.js +63 -2
  77. package/src/index/verificationPlan.js +12 -1
  78. package/src/learning/patternProposals.js +6 -0
  79. package/src/render/renderTemplate.js +1 -1
  80. package/src/skill/auditSkill.js +3 -1
  81. package/src/stack/detectStack.js +3 -1
  82. package/template_project/.claude/agents/handoff-planner.md +2 -5
  83. package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
  84. package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
  85. package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
  86. package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
  87. package/template_project/.claude/hooks/handoff-resume.sh +10 -1
  88. package/template_project/.claude/hooks/protect-files.sh +0 -1
  89. package/template_project/.claude/hooks/record-execution.mjs +13 -1
  90. package/template_project/.claude/hooks/sensitive-data-guard.mjs +80 -5
  91. package/template_project/.claude/hooks/session-episode.sh +9 -2
  92. package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
  93. package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
  94. package/template_project/.claude/ukit/index/lib/index-core.mjs +129 -42
  95. package/template_project/.claude/ukit/index/route-task.mjs +444 -0
  96. package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
  97. package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
  98. package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
  99. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
  100. package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
  101. package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
  102. package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
  103. package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
  104. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
  105. package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
  106. package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
  107. package/template_project/ukit/storage/config.json +34 -1
  108. package/template_project/.claude/ukit/skill-router-state.json +0 -1
  109. package/template_project/.ukit/storage/cache/hook-latency/unknown.jsonl +0 -4
@@ -0,0 +1,596 @@
1
+ #!/usr/bin/env node
2
+ // TASK-006 (C52 M04.1, SPEC §5 FR-012..FR-014): the compact resumable run record
3
+ // (C10). One versioned, bounded, redacted record per taskId persisted under
4
+ // `.ukit/storage/runs/<safeTaskId>.json` so a compacted/interrupted run can
5
+ // resume phase/nextAction/hypotheses without rereading master docs.
6
+ //
7
+ // Persistence reuses the ledger's fail-closed discipline (TASK-027): every write
8
+ // goes through withAsyncLock with a bounded deadline; on timeout/abort the record
9
+ // is journaled to `<target>.journal` (JSONL, bounded, newest-wins snapshots) and
10
+ // NEVER written unlocked — the next acquired lock drains the journal first and
11
+ // lands the newest record in one atomic tmp+rename write.
12
+ //
13
+ // Freshness: `sourceSnapshot` carries source/index/config FINGERPRINTS (never
14
+ // file copies). A material change on read marks the record `stale` and
15
+ // selectively invalidates dependent plans (hypotheses/nextAction, decisions on
16
+ // config change) while completed receipts (evidenceRefs, workflow.completedBlocks)
17
+ // are kept. Corrupt state degrades to `invalid` with one concrete warning.
18
+ //
19
+ // Dual-read tolerance: unknown/newer fields are ignored by readers and preserved
20
+ // by validation; records with a higher schemaVersion still validate so an older
21
+ import crypto from 'node:crypto';
22
+ import fs from 'node:fs/promises';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ import { withAsyncLock, LOCK_MAX_SLICE_MS } from './async-lock.mjs';
27
+ import { scanText } from './sensitive-value-scanner.mjs';
28
+ import { buildRuntimePaths, readJson } from './token-utils.mjs';
29
+
30
+ // Deadline policy lives at the hook entry point (reinject-context.mjs arms
31
+ // UKIT_HOOK_DEADLINE_MS and announces via §8 systemMessage). A library module
32
+ // must never arm its own process.exit timer at import time — it raced the
33
+ // importer's announcing deadline and produced silent exit-0 (BUG-C23-04 class).
34
+
35
+ export const RESUMABLE_RUN_SCHEMA_VERSION = 1;
36
+
37
+ // C10 bounds (SPEC §7): hypotheses ≤8, decisions ≤12, evidenceRefs ≤24,
38
+ // escalationHistory ≤8. invariants/unresolvedFailures/completedBlocks get the
39
+ // same bounded treatment so the record can never grow without limit.
40
+ const ARRAY_BOUNDS = {
41
+ invariants: 16,
42
+ hypotheses: 8,
43
+ decisions: 12,
44
+ evidenceRefs: 24,
45
+ escalationHistory: 8,
46
+ unresolvedFailures: 8,
47
+ };
48
+ const MAX_COMPLETED_BLOCKS = 32;
49
+ const MAX_STRING_LENGTH = 512;
50
+ const MAX_SNAPSHOT_KEYS = 16;
51
+ const MAX_SERIALIZED_BYTES = 64 * 1024;
52
+ const RUN_JOURNAL_MAX_RECORDS = 32;
53
+ const RUN_JOURNAL_LOCK_BUDGET_MS = 400;
54
+
55
+ // Redaction: a record must never carry prompt bodies, source text, or secrets.
56
+ // Key names are matched normalized (lowercase, separators stripped) so
57
+ // `promptText`, `prompt_text`, `api-key` all hit the same rule. `token` alone is
58
+ // deliberately absent — budget fields legitimately count tokens; the value
59
+ // scanner below still catches real secret VALUES anywhere in the record.
60
+ const FORBIDDEN_KEY_NAMES = new Set([
61
+ 'prompt', 'prompttext', 'rawprompt', 'userprompt', 'systemprompt',
62
+ 'secret', 'password', 'passwd', 'credential', 'credentials',
63
+ 'apikey', 'apisecret', 'accesstoken', 'authtoken', 'refreshtoken',
64
+ 'sessiontoken', 'bearertoken', 'privatekey', 'signingkey',
65
+ 'sourcetext', 'sourcecode', 'rawsource', 'filecontent', 'filecontents',
66
+ ]);
67
+
68
+ // Selective invalidation map (FR-014): a material source/index change invalidates
69
+ // plan-shaped sections only; a config change additionally invalidates decisions.
70
+ // Completed receipts (evidenceRefs, workflow.completedBlocks) are never touched —
71
+ // they are facts, not plans.
72
+ const INVALIDATION_CODES = {
73
+ plans: ['hypotheses', 'nextAction'],
74
+ decisions: ['decisions'],
75
+ receipts: ['evidenceRefs'],
76
+ escalations: ['escalationHistory'],
77
+ failures: ['unresolvedFailures'],
78
+ };
79
+ const CHANGED_KEY_TO_CODES = {
80
+ source: ['plans'],
81
+ index: ['plans'],
82
+ config: ['plans', 'decisions'],
83
+ };
84
+
85
+ function isPlainObject(value) {
86
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
87
+ }
88
+
89
+ // Filesystem-safe taskId segment: strips path separators, traversal dots, and
90
+ // control characters; bounded length so the filename stays portable.
91
+ export function safeTaskId(taskId) {
92
+ const cleaned = String(taskId ?? '')
93
+ .replace(/[\\/]/g, '_')
94
+ .replace(/\.+/g, '_')
95
+ .replace(/[^A-Za-z0-9._-]/g, '_')
96
+ .replace(/^_+|_+$/g, '')
97
+ .slice(0, 80);
98
+ return cleaned || 'task';
99
+ }
100
+
101
+ export function resumableRunPath(projectRoot, taskId) {
102
+ return path.join(
103
+ buildRuntimePaths(projectRoot).storageRoot,
104
+ 'runs',
105
+ `${safeTaskId(taskId)}.json`,
106
+ );
107
+ }
108
+
109
+ function runJournalPathFor(target) {
110
+ return `${target}.journal`;
111
+ }
112
+
113
+ // --- validation ---------------------------------------------------------------
114
+
115
+ function collectRedactionErrors(value, pathLabel, errors) {
116
+ if (typeof value === 'string') {
117
+ if (value.length > MAX_STRING_LENGTH) {
118
+ errors.push(`${pathLabel} exceeds the ${MAX_STRING_LENGTH}-char bound.`);
119
+ }
120
+ const scan = scanText(value);
121
+ if (scan.hasSecret) {
122
+ errors.push(`${pathLabel} carries a secret-shaped value (${scan.labels.join(', ')}).`);
123
+ }
124
+ return;
125
+ }
126
+ if (Array.isArray(value)) {
127
+ value.forEach((item, index) => collectRedactionErrors(item, `${pathLabel}[${index}]`, errors));
128
+ return;
129
+ }
130
+ if (isPlainObject(value)) {
131
+ for (const [key, child] of Object.entries(value)) {
132
+ const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
133
+ const childPath = pathLabel ? `${pathLabel}.${key}` : key;
134
+ if (FORBIDDEN_KEY_NAMES.has(normalized)) {
135
+ errors.push(`${childPath} is a forbidden field (prompt/secret/source-shaped).`);
136
+ continue; // do not descend — the field itself is the violation
137
+ }
138
+ collectRedactionErrors(child, childPath, errors);
139
+ }
140
+ }
141
+ }
142
+
143
+ function requireObjectField(record, field, shape, errors) {
144
+ const value = record[field];
145
+ if (!isPlainObject(value)) {
146
+ errors.push(`${field} must be an object.`);
147
+ return;
148
+ }
149
+ for (const [subField, kind] of Object.entries(shape)) {
150
+ const sub = value[subField];
151
+ if (kind === 'string' && typeof sub !== 'string') {
152
+ errors.push(`${field}.${subField} must be a string.`);
153
+ } else if (kind === 'number' && (typeof sub !== 'number' || !Number.isFinite(sub))) {
154
+ errors.push(`${field}.${subField} must be a finite number.`);
155
+ } else if (kind === 'string[]' && (!Array.isArray(sub) || sub.some((v) => typeof v !== 'string'))) {
156
+ errors.push(`${field}.${subField} must be an array of strings.`);
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Validate a C10 record. Returns { valid, errors } — errors name the offending
163
+ * field and the violated bound. Unknown fields are tolerated (dual-read), but
164
+ * every known field must be well-typed, bounded, and free of prompt/secret
165
+ * material.
166
+ */
167
+ export function validateResumableRun(record) {
168
+ const errors = [];
169
+ if (!isPlainObject(record)) {
170
+ return { valid: false, errors: ['record must be a plain object.'] };
171
+ }
172
+
173
+ if (!Number.isInteger(record.schemaVersion) || record.schemaVersion < 1) {
174
+ errors.push('schemaVersion must be an integer >= 1.');
175
+ }
176
+ if (typeof record.taskId !== 'string' || record.taskId.length === 0) {
177
+ errors.push('taskId must be a non-empty string.');
178
+ }
179
+ if (typeof record.taskBoundary !== 'string' || record.taskBoundary.length === 0) {
180
+ errors.push('taskBoundary must be a non-empty string.');
181
+ }
182
+
183
+ requireObjectField(record, 'route', {
184
+ routeVersion: 'string',
185
+ mode: 'string',
186
+ rigor: 'string',
187
+ contractVersion: 'string',
188
+ }, errors);
189
+ requireObjectField(record, 'budget', {
190
+ policyVersion: 'string',
191
+ consumed: 'number',
192
+ remaining: 'number',
193
+ }, errors);
194
+ requireObjectField(record, 'workflow', {
195
+ workflowId: 'string',
196
+ workflowVersion: 'string',
197
+ phase: 'string',
198
+ completedBlocks: 'string[]',
199
+ }, errors);
200
+ if (Array.isArray(record.workflow?.completedBlocks)
201
+ && record.workflow.completedBlocks.length > MAX_COMPLETED_BLOCKS) {
202
+ errors.push(`workflow.completedBlocks exceeds the ${MAX_COMPLETED_BLOCKS}-entry bound.`);
203
+ }
204
+
205
+ for (const [field, bound] of Object.entries(ARRAY_BOUNDS)) {
206
+ const value = record[field];
207
+ if (!Array.isArray(value)) {
208
+ errors.push(`${field} must be an array.`);
209
+ continue;
210
+ }
211
+ if (value.length > bound) {
212
+ errors.push(`${field} exceeds the ${bound}-entry bound (${value.length} entries).`);
213
+ }
214
+ }
215
+
216
+ if (record.nextAction !== null && typeof record.nextAction !== 'string') {
217
+ errors.push('nextAction must be a string or null.');
218
+ }
219
+
220
+ // sourceSnapshot = fingerprints, not file copies: flat string→string map only.
221
+ if (!isPlainObject(record.sourceSnapshot)) {
222
+ errors.push('sourceSnapshot must be an object of fingerprints.');
223
+ } else {
224
+ const entries = Object.entries(record.sourceSnapshot);
225
+ if (entries.length > MAX_SNAPSHOT_KEYS) {
226
+ errors.push(`sourceSnapshot exceeds the ${MAX_SNAPSHOT_KEYS}-key bound.`);
227
+ }
228
+ for (const [key, value] of entries) {
229
+ if (typeof value !== 'string') {
230
+ errors.push(`sourceSnapshot.${key} must be a fingerprint string, not a file copy.`);
231
+ }
232
+ }
233
+ }
234
+
235
+ collectRedactionErrors(record, '', errors);
236
+
237
+ if (errors.length === 0) {
238
+ try {
239
+ const size = Buffer.byteLength(JSON.stringify(record), 'utf8');
240
+ if (size > MAX_SERIALIZED_BYTES) {
241
+ errors.push(`record exceeds the ${MAX_SERIALIZED_BYTES}-byte serialized bound.`);
242
+ }
243
+ } catch {
244
+ errors.push('record is not serializable.');
245
+ }
246
+ }
247
+
248
+ return { valid: errors.length === 0, errors };
249
+ }
250
+
251
+ // --- persistence ---------------------------------------------------------------
252
+
253
+ async function readStageConfig(projectRoot) {
254
+ const config = await readJson(buildRuntimePaths(projectRoot).configPath, null);
255
+ const stage = config?.continuity?.resumableRun?.stage;
256
+ return typeof stage === 'string' ? stage : 'off';
257
+ }
258
+
259
+ let atomicWriteCounter = 0;
260
+
261
+ async function writeJsonAtomic(filePath, value) {
262
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
263
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${atomicWriteCounter++}`;
264
+ try {
265
+ await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
266
+ try {
267
+ await fs.rename(tempPath, filePath);
268
+ } catch (renameError) {
269
+ // EXDEV: tmp and destination on different mounts — copy over and unlink.
270
+ if (renameError?.code !== 'EXDEV') throw renameError;
271
+ await fs.copyFile(tempPath, filePath);
272
+ await fs.rm(tempPath, { force: true });
273
+ }
274
+ } catch (error) {
275
+ try {
276
+ await fs.rm(tempPath, { force: true });
277
+ } catch {
278
+ // best-effort cleanup
279
+ }
280
+ throw error;
281
+ }
282
+ }
283
+
284
+ // Append a dropped record to the per-target JSONL journal under a journal-local
285
+ // lock (the record lock is unavailable — that is why this path runs). Bounded:
286
+ // snapshots are newest-wins, so a full journal trims the oldest entries rather
287
+ // than rejecting the newest state.
288
+ async function appendRunJournal(target, record) {
289
+ const journalPath = runJournalPathFor(target);
290
+ try {
291
+ const outcome = await withAsyncLock(
292
+ journalPath,
293
+ { deadlineMs: RUN_JOURNAL_LOCK_BUDGET_MS },
294
+ async () => {
295
+ let lines = [];
296
+ try {
297
+ lines = (await fs.readFile(journalPath, 'utf8'))
298
+ .split('\n')
299
+ .filter((line) => line.trim());
300
+ } catch (error) {
301
+ if (error?.code !== 'ENOENT') return false;
302
+ }
303
+ lines.push(JSON.stringify({ v: 1, ts: Date.now(), record }));
304
+ if (lines.length > RUN_JOURNAL_MAX_RECORDS) {
305
+ lines = lines.slice(lines.length - RUN_JOURNAL_MAX_RECORDS);
306
+ }
307
+ await fs.writeFile(journalPath, `${lines.join('\n')}\n`, 'utf8');
308
+ return true;
309
+ },
310
+ );
311
+ return outcome?.ok === true && outcome.value === true;
312
+ } catch {
313
+ return false; // journaling is best-effort; never resurrect the write over it
314
+ }
315
+ }
316
+
317
+ // Read every journaled record for this target. A torn trailing line is skipped,
318
+ // never applied — the next lock retries it only if the writer journaled again.
319
+ async function drainRunJournal(target) {
320
+ const journalPath = runJournalPathFor(target);
321
+ let text;
322
+ try {
323
+ text = await fs.readFile(journalPath, 'utf8');
324
+ } catch {
325
+ return [];
326
+ }
327
+ const records = [];
328
+ for (const line of text.split('\n')) {
329
+ if (!line.trim()) continue;
330
+ try {
331
+ const parsed = JSON.parse(line);
332
+ if (isPlainObject(parsed?.record)) records.push(parsed.record);
333
+ } catch {
334
+ // torn line — skip
335
+ }
336
+ }
337
+ return records;
338
+ }
339
+
340
+ function recordTimestamp(record) {
341
+ return Number.isFinite(record?.updatedAt) ? record.updatedAt : 0;
342
+ }
343
+
344
+ /**
345
+ * Persist a C10 record. Fail-closed: invalid or redacted-failing records are
346
+ * never written; a busy lock journals the record instead of writing unlocked.
347
+ * Gated on `continuity.resumableRun.stage` (absence = off) unless the caller
348
+ * injects `{ config }` — wiring (TASK-007) passes the resolved stage.
349
+ *
350
+ * @returns {Promise<{ok:true, path:string} | {ok:false, reason:string, journaled?:boolean, errors?:string[]}>}
351
+ */
352
+ export async function writeResumableRun(projectRoot, record, {
353
+ signal,
354
+ deadlineMs = LOCK_MAX_SLICE_MS,
355
+ config,
356
+ } = {}) {
357
+ const stage = config !== undefined
358
+ ? (config?.continuity?.resumableRun?.stage ?? 'off')
359
+ : await readStageConfig(projectRoot);
360
+ if (stage === 'off' || typeof stage !== 'string') {
361
+ return { ok: false, reason: 'stage-off' };
362
+ }
363
+
364
+ const validation = validateResumableRun(record);
365
+ if (!validation.valid) {
366
+ return { ok: false, reason: 'invalid', errors: validation.errors };
367
+ }
368
+
369
+ const target = resumableRunPath(projectRoot, record.taskId);
370
+ const stamped = {
371
+ ...record,
372
+ schemaVersion: record.schemaVersion ?? RESUMABLE_RUN_SCHEMA_VERSION,
373
+ recordedAt: Number.isFinite(record.recordedAt) ? record.recordedAt : Date.now(),
374
+ // A caller-provided updatedAt is honored so reconciliation ordering is
375
+ // deterministic: the newest stamped record wins, whether it arrives through
376
+ // this write or was journaled by an earlier busy one.
377
+ updatedAt: Number.isFinite(record.updatedAt) ? record.updatedAt : Date.now(),
378
+ };
379
+
380
+ const outcome = await withAsyncLock(target, { signal, deadlineMs }, async () => {
381
+ // Reconcile first: journaled snapshots from earlier busy writes are
382
+ // candidates alongside the incoming record; the newest updatedAt wins and
383
+ // lands in ONE atomic write. The journal is removed only after the write
384
+ // lands, so a crash mid-section leaves the records re-appliable.
385
+ const journaled = await drainRunJournal(target);
386
+ const candidates = [...journaled, stamped];
387
+ const winner = candidates.reduce((a, b) => (recordTimestamp(b) >= recordTimestamp(a) ? b : a));
388
+ await writeJsonAtomic(target, winner);
389
+ try {
390
+ await fs.rm(runJournalPathFor(target), { force: true });
391
+ } catch {
392
+ // a leftover journal re-applies idempotently on the next lock
393
+ }
394
+ return winner;
395
+ });
396
+
397
+ if (outcome?.ok === true) {
398
+ return { ok: true, path: target };
399
+ }
400
+ const journaled = await appendRunJournal(target, stamped);
401
+ return { ok: false, reason: outcome?.reason ?? 'busy', journaled };
402
+ }
403
+
404
+ // --- resume / freshness --------------------------------------------------------
405
+
406
+ function changedSnapshotKeys(snapshot, fingerprint) {
407
+ if (fingerprint === undefined || fingerprint === null) return [];
408
+ const source = isPlainObject(snapshot) ? snapshot : {};
409
+ if (typeof fingerprint === 'string') {
410
+ return source.source === fingerprint ? [] : ['source'];
411
+ }
412
+ if (!isPlainObject(fingerprint)) return [];
413
+ return Object.keys(fingerprint).filter((key) => source[key] !== fingerprint[key]);
414
+ }
415
+
416
+ function applyInvalidationCodes(record, codes) {
417
+ const fields = new Set();
418
+ for (const code of codes) {
419
+ for (const field of INVALIDATION_CODES[code] ?? []) fields.add(field);
420
+ }
421
+ const next = { ...record };
422
+ for (const field of fields) {
423
+ next[field] = field === 'nextAction' ? null : [];
424
+ }
425
+ return { record: next, invalidated: [...fields] };
426
+ }
427
+
428
+ /**
429
+ * Read the C10 record for a task. Never throws on corrupt state.
430
+ *
431
+ * @returns {Promise<{status:'fresh'|'stale'|'invalid'|'absent', record:object|null,
432
+ * invalidated?:string[], warnings:string[]}>}
433
+ */
434
+ export async function readResumableRun(projectRoot, {
435
+ taskId,
436
+ taskBoundary,
437
+ sourceFingerprint,
438
+ } = {}) {
439
+ const target = resumableRunPath(projectRoot, taskId);
440
+ let text;
441
+ try {
442
+ text = await fs.readFile(target, 'utf8');
443
+ } catch (error) {
444
+ if (error?.code === 'ENOENT') return { status: 'absent', record: null, warnings: [] };
445
+ return {
446
+ status: 'invalid',
447
+ record: null,
448
+ warnings: [`resumable run record unreadable: ${error?.code ?? error?.message}`],
449
+ };
450
+ }
451
+
452
+ let parsed;
453
+ try {
454
+ parsed = JSON.parse(text);
455
+ } catch (error) {
456
+ return {
457
+ status: 'invalid',
458
+ record: null,
459
+ warnings: [`resumable run record at ${target} is corrupt JSON: ${error.message}`],
460
+ };
461
+ }
462
+
463
+ const validation = validateResumableRun(parsed);
464
+ if (!validation.valid) {
465
+ return {
466
+ status: 'invalid',
467
+ record: null,
468
+ warnings: [`resumable run record at ${target} failed validation: ${validation.errors[0]}`],
469
+ };
470
+ }
471
+
472
+ // A new explicit task boundary resets the record — the persisted state belongs
473
+ // to a different logical task.
474
+ if (typeof taskBoundary === 'string' && taskBoundary.length > 0
475
+ && parsed.taskBoundary !== taskBoundary) {
476
+ return { status: 'absent', record: null, warnings: [] };
477
+ }
478
+
479
+ const changed = changedSnapshotKeys(parsed.sourceSnapshot, sourceFingerprint);
480
+ if (changed.length > 0) {
481
+ const codes = changed.flatMap((key) => CHANGED_KEY_TO_CODES[key] ?? ['plans']);
482
+ const { record, invalidated } = applyInvalidationCodes(parsed, codes);
483
+ return {
484
+ status: 'stale',
485
+ record,
486
+ invalidated,
487
+ warnings: [`source/index fingerprint changed (${changed.join(', ')}); dependent plans invalidated`],
488
+ };
489
+ }
490
+
491
+ return { status: 'fresh', record: parsed, warnings: [] };
492
+ }
493
+
494
+ /**
495
+ * Selectively invalidate sections of a persisted record by code
496
+ * ('plans' | 'decisions' | 'receipts' | 'escalations' | 'failures'). Locked like
497
+ * a write; a busy lock journals nothing (invalidation is safe to retry).
498
+ */
499
+ export async function invalidateResumableRun(projectRoot, taskId, codes, {
500
+ signal,
501
+ deadlineMs = LOCK_MAX_SLICE_MS,
502
+ } = {}) {
503
+ const target = resumableRunPath(projectRoot, taskId);
504
+ const codeList = Array.isArray(codes) ? codes : [codes];
505
+
506
+ const outcome = await withAsyncLock(target, { signal, deadlineMs }, async () => {
507
+ // Reconcile the journal first so invalidation applies to the newest known
508
+ // state, not a superseded on-disk record.
509
+ const journaled = await drainRunJournal(target);
510
+ let parsed;
511
+ try {
512
+ parsed = JSON.parse(await fs.readFile(target, 'utf8'));
513
+ } catch (error) {
514
+ if (error?.code !== 'ENOENT') return { ok: false, reason: 'invalid' };
515
+ parsed = null;
516
+ }
517
+ const candidates = [...journaled, ...(parsed ? [parsed] : [])];
518
+ if (candidates.length === 0) return { ok: false, reason: 'absent' };
519
+ const base = candidates.reduce((a, b) => (recordTimestamp(b) >= recordTimestamp(a) ? b : a));
520
+ const { record, invalidated } = applyInvalidationCodes(base, codeList);
521
+ record.updatedAt = Date.now();
522
+ await writeJsonAtomic(target, record);
523
+ try {
524
+ await fs.rm(runJournalPathFor(target), { force: true });
525
+ } catch {
526
+ // leftover journal re-applies idempotently on the next lock
527
+ }
528
+ return { ok: true, invalidated };
529
+ });
530
+
531
+ if (outcome?.ok === true) return outcome.value;
532
+ return { ok: false, reason: outcome?.reason ?? 'busy' };
533
+ }
534
+
535
+ /**
536
+ * The shared resume-side fingerprint (TASK-007, FR-014/FR-015): the two keys a
537
+ * consumer can recompute cheaply at resume time — `index` (the code-index meta
538
+ * artifact's generatedAt stamp; a rebuild always changes it) and `config` (a
539
+ * content hash of the raw project runtime config). Producers MAY snapshot more
540
+ * keys (e.g. a project-verification `source` fingerprint); readers only compare
541
+ * the keys they pass in. `indexGeneratedAtMs` lets the route path pass the value
542
+ * it already computed instead of re-reading the artifact.
543
+ */
544
+ export async function resumableRunSourceFingerprint(projectRoot, { indexGeneratedAtMs } = {}) {
545
+ const runtimePaths = buildRuntimePaths(projectRoot);
546
+ let index = indexGeneratedAtMs;
547
+ if (index === undefined) {
548
+ const meta = await readJson(
549
+ path.join(projectRoot, '.cache', 'index', 'meta.json'),
550
+ null,
551
+ );
552
+ const parsed = Date.parse(String(meta?.generatedAt ?? ''));
553
+ index = Number.isNaN(parsed) ? null : parsed;
554
+ }
555
+ let configText = null;
556
+ try {
557
+ configText = await fs.readFile(runtimePaths.configPath, 'utf8');
558
+ } catch {
559
+ configText = null;
560
+ }
561
+ return {
562
+ index: index === null || index === undefined ? 'none' : String(index),
563
+ config: configText === null
564
+ ? 'none'
565
+ : crypto.createHash('sha256').update(configText).digest('hex').slice(0, 32),
566
+ };
567
+ }
568
+
569
+ // --- CLI (diagnostics) ---------------------------------------------------------
570
+
571
+ async function main() {
572
+ const [command, rootArg, taskId] = process.argv.slice(2);
573
+ const projectRoot = path.resolve(rootArg || process.cwd());
574
+ if (command === 'read' && taskId) {
575
+ const result = await readResumableRun(projectRoot, { taskId });
576
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
577
+ return;
578
+ }
579
+ process.stderr.write('usage: resumable-run.mjs read <projectRoot> <taskId>\n');
580
+ process.exitCode = 2;
581
+ }
582
+
583
+ function isDirectRun() {
584
+ const argvPath = process.argv[1];
585
+ if (!argvPath) return Promise.resolve(false);
586
+ // Compare real paths: a project under a symlinked root makes import.meta.url
587
+ // resolve to the real path while argv[1] keeps the symlinked spelling.
588
+ return fs.realpath(argvPath)
589
+ .then((real) => real === fileURLToPath(import.meta.url))
590
+ .catch(() => false);
591
+ }
592
+
593
+ isDirectRun().then((direct) => {
594
+ if (direct) return main();
595
+ return undefined;
596
+ });
@@ -19,13 +19,10 @@
19
19
 
20
20
  import { createHash } from 'node:crypto';
21
21
 
22
- // Hook-context self-deadline (2.4.1 orphan-leak class): a hook wrapper passes
23
- // UKIT_HOOK_DEADLINE_MS so a wedged scan can never orphan this process past
24
- // the hook budget. CLI usage never sets it and is never self-killed.
25
- const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
26
- if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
27
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
28
- }
22
+ // No import-time deadline: this is a pure library (no CLI entry). Deadline
23
+ // policy belongs to the hook entry point that imports it — an import-time
24
+ // process.exit timer races the importer's §8 announce and exits silently
25
+ // (BUG-C23-04 class regression).
29
26
 
30
27
  function sha256(value) {
31
28
  return createHash('sha256').update(value).digest('hex');
@@ -497,7 +497,7 @@ async function evalIndexAllDone(projectRoot) {
497
497
  const rows = String(text)
498
498
  .split('\n')
499
499
  .map((line) => line.trim())
500
- .filter((line) => /^\|TASK-/i.test(line));
500
+ .filter((line) => /^\|\s*TASK-/i.test(line));
501
501
  if (rows.length === 0) return { pass: false, detail: 'INDEX.md has no task rows' };
502
502
  const failing = [];
503
503
  for (const row of rows) {
@@ -12,21 +12,21 @@ QUALITY GATE: mỗi task split ra phải kèm Test Plan (xem mục bên dưới)
12
12
  Không có Test Plan → task không được phép chuyển sang status `ready`.
13
13
  -->
14
14
 
15
- ## 1. Intent / Goal
15
+ ## §1 Intent / Goal
16
16
 
17
17
  <!-- 1-2 câu mô tả thứ user muốn đạt. Không paste lại nguyên prompt. -->
18
18
 
19
- ## 2. Scope
19
+ ## §2 Scope
20
20
 
21
21
  - In scope:
22
22
  - Out of scope:
23
23
  - Risk surface (file/module rủi ro share):
24
24
 
25
- ## 3. Approach
25
+ ## §3 Approach
26
26
 
27
27
  <!-- Cách làm ngắn gọn. Reuse code có sẵn trước khi tạo mới. -->
28
28
 
29
- ## 4. Test Plan (REQUIRED — TDD-style)
29
+ ## §4 Test Plan (REQUIRED — TDD-style)
30
30
 
31
31
  Liệt kê test sẽ viết TRƯỚC khi code. Mỗi test phải có:
32
32
 
@@ -42,7 +42,7 @@ Bắt buộc tối thiểu:
42
42
 
43
43
  Nếu task không thể test (config-only, doc-only, prototype throw-away): ghi `Test plan: N/A — lý do: <…>` và đính kèm phương án verify thủ công.
44
44
 
45
- ## 5. Verification Commands
45
+ ## §5 Verification Commands
46
46
 
47
47
  Lệnh chính xác executor sẽ chạy:
48
48
 
@@ -53,14 +53,14 @@ Lệnh chính xác executor sẽ chạy:
53
53
  # node scripts/smoke.mjs
54
54
  ```
55
55
 
56
- ## 6. Acceptance Criteria
56
+ ## §6 Acceptance Criteria
57
57
 
58
58
  - [ ] Tất cả test ở Test Plan PASS (kèm output trong report).
59
59
  - [ ] Không có regression ở suite liên quan.
60
60
  - [ ] Reviewer (model riêng) báo `APPROVED` hoặc `APPROVED-WITH-MINOR`.
61
61
  - [ ] Docs/CHANGELOG cập nhật nếu user-facing.
62
62
 
63
- ## 7. Task Split (Phase 2 — TDD-embedded, MANDATORY)
63
+ ## Task Split (Phase 2 — TDD-embedded, MANDATORY)
64
64
 
65
65
  Khi human approve plan, AI tạo từng `tasks/TASK-xxx.md` theo cấu trúc ở `tasks/_TEMPLATE.md`.
66
66
 
@@ -158,7 +158,7 @@ chạy **đến khi không còn gì để làm**:
158
158
  - Output: PLAN.md đầy đủ + Planner Self-Audit. Chạy standalone (`/ukit:handoff-create`) thì dừng ở đây chờ human xem; chạy trong `/ukit:handoff-fullstack` thì đi thẳng tiếp sang Phase 2 — plan review độc lập là gate thay cho human.
159
159
 
160
160
  **Phase 2 — Create Tasks (TDD-embedded, MANDATORY)** (smart/reasoning model, thường cùng phase 1)
161
- - Human approve plan → AI split `PLAN.md §7` sang nhiều `tasks/TASK-xxx.md`.
161
+ - Human approve plan → AI split `PLAN.md` sang nhiều `tasks/TASK-xxx.md`.
162
162
  - **Mỗi TASK file BẮT BUỘC có Test Plan của riêng nó**, không chỉ trỏ về PLAN.md. Cụ thể:
163
163
  - `§ Test Cases`: bảng test (loại, tên test, expected) cho phần task này — happy + ≥2 edge case KHÁC loại nhau (vd null/empty + boundary/concurrent, không tính 2 case gần giống nhau) + regression (nếu fix bug).
164
164
  - `§ Test Files`: đường dẫn cụ thể file test sẽ tạo/sửa (ví dụ `tests/auth/login.test.js`).