@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.
- package/CHANGELOG.md +26 -0
- package/bin/ukit +12 -4
- package/manifests/engineConformance.yaml +29 -0
- package/manifests/platform.full.yaml +13 -0
- package/package.json +2 -1
- package/scripts/audit/decision-coverage.mjs +295 -0
- package/scripts/bench/outline-savings.mjs +19 -4
- package/scripts/bench/parallel-agents.mjs +15 -4
- package/scripts/bench/runGold.mjs +22 -4
- package/scripts/bench/v3-ceremony.mjs +7 -1
- package/scripts/bug/triage.mjs +56 -17
- package/scripts/index/build-index.mjs +94 -28
- package/scripts/index/query-index.mjs +48 -14
- package/scripts/index/refresh-index.mjs +142 -62
- package/scripts/perf/audit-perf.mjs +8 -2
- package/scripts/skill/audit-skill.mjs +54 -25
- package/src/bug/triageBug.js +9 -6
- package/src/cli/adapters.js +6 -0
- package/src/cli/commands/code.js +7 -1
- package/src/cli/commands/indexArgs.js +4 -2
- package/src/cli/commands/indexTools.js +8 -1
- package/src/cli/commands/install.js +13 -0
- package/src/cli/commands/memory.js +13 -9
- package/src/cli/commands/status.js +17 -1
- package/src/cli/commands/update.js +7 -0
- package/src/context/detectProjectContext.js +3 -1
- package/src/core/codeintel/analogy.js +1 -1
- package/src/core/codeintel/diagnostics.js +9 -6
- package/src/core/codeintel/graph.js +14 -8
- package/src/core/codeintel/impact.js +0 -1
- package/src/core/codeintel/invalidation.js +5 -3
- package/src/core/codeintel/packet.js +11 -0
- package/src/core/codeintel/router.js +17 -3
- package/src/core/codeintel/semanticProvider.js +1 -1
- package/src/core/codeintel/summaries.js +11 -7
- package/src/core/compact/contextBudget.js +26 -12
- package/src/core/compact/index.js +15 -8
- package/src/core/docContracts.js +10 -2
- package/src/core/experiments/deliberation.js +321 -0
- package/src/core/experiments/dynamicWorkflow.js +492 -0
- package/src/core/fileOps.js +8 -1
- package/src/core/gatewayProbe.js +29 -1
- package/src/core/gatewayResilienceEnv.js +44 -3
- package/src/core/handoffDocValidator.js +3 -1
- package/src/core/hookChainDoctor.js +16 -1
- package/src/core/memory/deltaOverlays.js +448 -0
- package/src/core/memory/learningCandidates.js +302 -0
- package/src/core/memory/migrate.js +59 -32
- package/src/core/memory/recordStore.js +24 -1
- package/src/core/memory/store.js +44 -8
- package/src/core/memory/storeV2.js +48 -33
- package/src/core/memory/userMemory.js +26 -10
- package/src/core/output/index.js +15 -10
- package/src/core/permissionPolicy.js +8 -0
- package/src/core/runtimeConfig.js +224 -4
- package/src/core/sensitiveValueScanner.js +10 -2
- package/src/core/taskBudgetValidator.js +12 -17
- package/src/core/taskProgressGuard.js +59 -9
- package/src/core/unattendedDoctor.js +5 -2
- package/src/core/uninstall.js +37 -8
- package/src/decision/client.js +371 -0
- package/src/decision/lease.js +198 -0
- package/src/decision/preflight.js +492 -0
- package/src/decision/protocol.js +308 -0
- package/src/decision/registry.js +384 -0
- package/src/decision/shadow.js +281 -0
- package/src/decision/statePacket.js +165 -0
- package/src/diagnostics/failurePatterns.js +2 -1
- package/src/diagnostics/ledgerFiles.js +3 -1
- package/src/diagnostics/routeOutcomes.js +1 -29
- package/src/index/buildIndex.js +23 -11
- package/src/index/impactContext.js +21 -2
- package/src/index/importResolution.js +13 -7
- package/src/index/queryIndex.js +11 -5
- package/src/index/resolveContext.js +22 -9
- package/src/index/taskRouting.js +63 -2
- package/src/index/verificationPlan.js +12 -1
- package/src/learning/patternProposals.js +6 -0
- package/src/render/renderTemplate.js +1 -1
- package/src/skill/auditSkill.js +3 -1
- package/src/stack/detectStack.js +3 -1
- package/template_project/.claude/agents/handoff-planner.md +2 -5
- package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
- package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
- package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
- package/template_project/.claude/hooks/handoff-resume.sh +10 -1
- package/template_project/.claude/hooks/protect-files.sh +0 -1
- package/template_project/.claude/hooks/record-execution.mjs +13 -1
- package/template_project/.claude/hooks/sensitive-data-guard.mjs +80 -5
- package/template_project/.claude/hooks/session-episode.sh +9 -2
- package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
- package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
- package/template_project/.claude/ukit/index/lib/index-core.mjs +129 -42
- package/template_project/.claude/ukit/index/route-task.mjs +444 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
- package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
- package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
- package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
- package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
- package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
- package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
- package/template_project/ukit/storage/config.json +34 -1
- package/template_project/.claude/ukit/skill-router-state.json +0 -1
- 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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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
|
|
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`).
|