ultra-builder-pro-cli 0.21.1 → 0.23.0
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/.ultra-template/tasks/tasks.json +10 -3
- package/.ultra-template/templates/task-context.md +19 -0
- package/CHANGELOG.md +78 -0
- package/README.md +210 -36
- package/adapters/_shared/codex-assets.cjs +8 -3
- package/adapters/_shared/interaction-contract.cjs +13 -6
- package/adapters/_shared/runtime-assets.cjs +1 -1
- package/adapters/claude.js +1 -1
- package/adapters/kimi.js +1 -1
- package/adapters/opencode.js +24 -13
- package/docs/AGENT-CONTEXT.md +58 -51
- package/docs/ARCHITECTURE.md +82 -51
- package/docs/ARTIFACT-AUTHORITY.md +83 -44
- package/docs/COMMIT-HASH-BACKFILL.md +33 -90
- package/docs/DECISIONS.md +9 -7
- package/docs/LEGACY-CLI-CROSSWALK.md +10 -2
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +3 -2
- package/docs/ROADMAP.md +3 -3
- package/docs/RUNTIME-COMPAT-MATRIX.md +13 -10
- package/docs/STATE-DB-ACCESS-POLICY.md +47 -19
- package/docs/WORKFLOW-LIFECYCLE.md +79 -49
- package/hooks/README.md +5 -4
- package/hooks/active_task_context.py +41 -39
- package/mcp-server/lib/artifact-registry.cjs +32 -7
- package/mcp-server/lib/baseline-workflow.cjs +60 -24
- package/mcp-server/lib/change-workflow.cjs +42 -6
- package/mcp-server/lib/context-paths.cjs +34 -3
- package/mcp-server/lib/doctor.cjs +28 -0
- package/mcp-server/lib/git-bootstrap.cjs +0 -1
- package/mcp-server/lib/init-project.cjs +52 -2
- package/mcp-server/lib/projector.cjs +43 -8
- package/mcp-server/lib/state-authority.cjs +19 -3
- package/mcp-server/lib/state-ops.cjs +6 -1
- package/mcp-server/lib/task-ledger.cjs +1342 -0
- package/mcp-server/lib/ultra-facade.cjs +646 -0
- package/mcp-server/lib/workflow-state.cjs +166 -2
- package/mcp-server/server.cjs +214 -26
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +38 -90
- package/skills/ultra-deliver/SKILL.md +42 -62
- package/skills/ultra-dev/SKILL.md +34 -73
- package/skills/ultra-doctor/SKILL.md +26 -39
- package/skills/ultra-init/SKILL.md +37 -69
- package/skills/ultra-plan/SKILL.md +52 -125
- package/skills/ultra-research/SKILL.md +41 -101
- package/skills/ultra-review/SKILL.md +25 -44
- package/skills/ultra-status/SKILL.md +21 -40
- package/skills/ultra-test/SKILL.md +25 -60
- package/skills/ultra-think/SKILL.md +29 -48
- package/skills/ultra-think/references/decision-dialogue.md +6 -4
- package/spec/cli-protocol.md +15 -0
- package/spec/mcp-tools.yaml +286 -0
- package/spec/schemas/context-file.v4.5.schema.json +1 -1
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +1 -1
- package/spec/schemas/task-ledger.v1.schema.json +217 -0
- package/ultra-tools/commands/migrate.cjs +5 -2
- package/.ultra-template/tasks/contexts/TEMPLATE.md +0 -19
|
@@ -0,0 +1,1342 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const util = require('node:util');
|
|
7
|
+
|
|
8
|
+
const ops = require('./state-ops.cjs');
|
|
9
|
+
|
|
10
|
+
const LEDGER_KIND = 'ultra-team-task-ledger';
|
|
11
|
+
const LEDGER_SCHEMA_VERSION = '1.0';
|
|
12
|
+
const LEDGER_RELATIVE_PATH = '.ultra/tasks/tasks.json';
|
|
13
|
+
const HASH_PATTERN = /^[0-9a-f]{64}$/;
|
|
14
|
+
const MAX_ANCESTORS = 64;
|
|
15
|
+
const DURABLE_BASELINE_FIELDS = Object.freeze([
|
|
16
|
+
'id',
|
|
17
|
+
'project_name',
|
|
18
|
+
'project_type',
|
|
19
|
+
'stack',
|
|
20
|
+
'mode',
|
|
21
|
+
'status',
|
|
22
|
+
'scope',
|
|
23
|
+
'repository_revision',
|
|
24
|
+
'repository_branch',
|
|
25
|
+
'worktree_state',
|
|
26
|
+
'worktree_digest',
|
|
27
|
+
'worktree_accepted',
|
|
28
|
+
'known_red_accepted',
|
|
29
|
+
'spec_refs',
|
|
30
|
+
'evidence',
|
|
31
|
+
'verification',
|
|
32
|
+
'unknowns',
|
|
33
|
+
'gaps',
|
|
34
|
+
'classification',
|
|
35
|
+
'provider_refs',
|
|
36
|
+
'research_run_id',
|
|
37
|
+
'approved_by',
|
|
38
|
+
'approval_note',
|
|
39
|
+
'converged_at',
|
|
40
|
+
]);
|
|
41
|
+
const DURABLE_TASK_FIELDS = Object.freeze([
|
|
42
|
+
'id',
|
|
43
|
+
'title',
|
|
44
|
+
'type',
|
|
45
|
+
'priority',
|
|
46
|
+
'complexity',
|
|
47
|
+
'estimated_days',
|
|
48
|
+
'status',
|
|
49
|
+
'deps',
|
|
50
|
+
'files_modified',
|
|
51
|
+
'stale',
|
|
52
|
+
'trace_to',
|
|
53
|
+
'outcome',
|
|
54
|
+
'slice_kind',
|
|
55
|
+
'public_seam',
|
|
56
|
+
'verification_command',
|
|
57
|
+
'acceptance',
|
|
58
|
+
'context_refs',
|
|
59
|
+
'docs_impact',
|
|
60
|
+
'ownership',
|
|
61
|
+
'change_id',
|
|
62
|
+
'parent_id',
|
|
63
|
+
]);
|
|
64
|
+
const DURABLE_CHANGE_FIELDS = Object.freeze([
|
|
65
|
+
'id',
|
|
66
|
+
'title',
|
|
67
|
+
'kind',
|
|
68
|
+
'status',
|
|
69
|
+
'intent',
|
|
70
|
+
'docs_impact',
|
|
71
|
+
'provider_refs',
|
|
72
|
+
'baseline_bypass',
|
|
73
|
+
'contract',
|
|
74
|
+
'classification',
|
|
75
|
+
'research_disposition',
|
|
76
|
+
'base_commit',
|
|
77
|
+
'artifact_root',
|
|
78
|
+
'closed_at',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
class TaskLedgerError extends Error {
|
|
82
|
+
constructor(code, message, details) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = 'TaskLedgerError';
|
|
85
|
+
this.code = code;
|
|
86
|
+
if (details) this.details = details;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ledgerPath(rootDir) {
|
|
91
|
+
return path.join(path.resolve(rootDir || '.'), ...LEDGER_RELATIVE_PATH.split('/'));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function safeDirectoryChain(rootDir, components, { create = false } = {}) {
|
|
95
|
+
const root = path.resolve(rootDir || '.');
|
|
96
|
+
const rootStat = fs.lstatSync(root, { throwIfNoEntry: false });
|
|
97
|
+
if (!rootStat || rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
98
|
+
throw new TaskLedgerError('TASK_LEDGER_UNSAFE', `project root is unsafe: ${root}`);
|
|
99
|
+
}
|
|
100
|
+
const physicalRoot = fs.realpathSync.native(root);
|
|
101
|
+
let current = root;
|
|
102
|
+
for (const component of components) {
|
|
103
|
+
current = path.join(current, component);
|
|
104
|
+
let stat = fs.lstatSync(current, { throwIfNoEntry: false });
|
|
105
|
+
if (!stat && create) {
|
|
106
|
+
fs.mkdirSync(current);
|
|
107
|
+
stat = fs.lstatSync(current);
|
|
108
|
+
}
|
|
109
|
+
if (!stat) return null;
|
|
110
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
111
|
+
throw new TaskLedgerError(
|
|
112
|
+
'TASK_LEDGER_UNSAFE',
|
|
113
|
+
`task ledger directory is unsafe: ${current}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const physical = fs.realpathSync.native(current);
|
|
117
|
+
if (physical !== physicalRoot && !physical.startsWith(`${physicalRoot}${path.sep}`)) {
|
|
118
|
+
throw new TaskLedgerError(
|
|
119
|
+
'TASK_LEDGER_UNSAFE',
|
|
120
|
+
`task ledger directory escapes the project: ${current}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return current;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sha256(value) {
|
|
128
|
+
return crypto.createHash('sha256').update(
|
|
129
|
+
typeof value === 'string' ? value : JSON.stringify(value),
|
|
130
|
+
).digest('hex');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseJson(value, fallback) {
|
|
134
|
+
if (value == null || value === '') return fallback;
|
|
135
|
+
try { return JSON.parse(value); }
|
|
136
|
+
catch { return fallback; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function compactObject(entries) {
|
|
140
|
+
return Object.fromEntries(entries.filter(([, value]) => value !== null && value !== undefined));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function portableBaseline(row) {
|
|
144
|
+
if (!row) return null;
|
|
145
|
+
return compactObject([
|
|
146
|
+
['id', row.id],
|
|
147
|
+
['project_name', row.project_name],
|
|
148
|
+
['project_type', row.project_type],
|
|
149
|
+
['stack', row.stack],
|
|
150
|
+
['mode', row.mode],
|
|
151
|
+
['status', row.status],
|
|
152
|
+
['scope', Array.isArray(row.scope) ? row.scope : parseJson(row.scope_json, ['.'])],
|
|
153
|
+
['repository_revision', row.repository_revision],
|
|
154
|
+
['repository_branch', row.repository_branch],
|
|
155
|
+
['worktree_state', row.worktree_state],
|
|
156
|
+
['worktree_digest', row.worktree_digest],
|
|
157
|
+
['worktree_accepted', Boolean(row.worktree_accepted)],
|
|
158
|
+
['known_red_accepted', Boolean(row.known_red_accepted)],
|
|
159
|
+
['spec_refs', Array.isArray(row.spec_refs) ? row.spec_refs : parseJson(row.spec_refs_json, [])],
|
|
160
|
+
['evidence', Array.isArray(row.evidence) ? row.evidence : parseJson(row.evidence_json, [])],
|
|
161
|
+
['verification', Array.isArray(row.verification)
|
|
162
|
+
? row.verification : parseJson(row.verification_json, [])],
|
|
163
|
+
['unknowns', Array.isArray(row.unknowns) ? row.unknowns : parseJson(row.unknowns_json, [])],
|
|
164
|
+
['gaps', Array.isArray(row.gaps) ? row.gaps : parseJson(row.gaps_json, [])],
|
|
165
|
+
['classification', row.classification && typeof row.classification === 'object'
|
|
166
|
+
? row.classification : parseJson(row.classification_json, {})],
|
|
167
|
+
['provider_refs', row.provider_refs && typeof row.provider_refs === 'object'
|
|
168
|
+
? row.provider_refs : parseJson(row.provider_refs_json, {})],
|
|
169
|
+
['research_run_id', row.research_run_id],
|
|
170
|
+
['approved_by', row.approved_by],
|
|
171
|
+
['approval_note', row.approval_note],
|
|
172
|
+
['converged_at', row.converged_at],
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function durableBaseline(row, previous = null) {
|
|
177
|
+
if (!row) return null;
|
|
178
|
+
const value = portableBaseline(row);
|
|
179
|
+
const digest = sha256(value);
|
|
180
|
+
if (previous?.digest === digest) return previous;
|
|
181
|
+
return {
|
|
182
|
+
...value,
|
|
183
|
+
revision: previous ? Number(previous.revision || 0) + 1 : 1,
|
|
184
|
+
parent_digest: previous?.digest || null,
|
|
185
|
+
digest,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readBaselineForLedger(db, previous = null) {
|
|
190
|
+
const row = db.prepare(
|
|
191
|
+
`SELECT * FROM baselines
|
|
192
|
+
WHERE status <> 'superseded'
|
|
193
|
+
ORDER BY CASE status WHEN 'ready' THEN 0 ELSE 1 END, updated_at DESC, rowid DESC
|
|
194
|
+
LIMIT 1`,
|
|
195
|
+
).get();
|
|
196
|
+
return durableBaseline(row, previous);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function portableChange(row) {
|
|
200
|
+
return compactObject([
|
|
201
|
+
['id', row.id],
|
|
202
|
+
['title', row.title],
|
|
203
|
+
['kind', row.kind],
|
|
204
|
+
['status', row.status],
|
|
205
|
+
['intent', row.intent],
|
|
206
|
+
['docs_impact', parseJson(row.docs_impact_json, {})],
|
|
207
|
+
['provider_refs', parseJson(row.provider_refs_json, {})],
|
|
208
|
+
['baseline_bypass', parseJson(row.baseline_bypass_json, null)],
|
|
209
|
+
['contract', parseJson(row.contract_json, {})],
|
|
210
|
+
['classification', parseJson(row.classification_json, {})],
|
|
211
|
+
['research_disposition', parseJson(row.research_disposition_json, {})],
|
|
212
|
+
['base_commit', row.base_commit],
|
|
213
|
+
['artifact_root', row.artifact_root],
|
|
214
|
+
['closed_at', row.closed_at],
|
|
215
|
+
]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function durableChange(row, previous = null) {
|
|
219
|
+
const value = portableChange(row);
|
|
220
|
+
const digest = sha256(value);
|
|
221
|
+
if (previous?.digest === digest) return previous;
|
|
222
|
+
return {
|
|
223
|
+
...value,
|
|
224
|
+
revision: previous ? Number(previous.revision || 0) + 1 : 1,
|
|
225
|
+
parent_digest: previous?.digest || null,
|
|
226
|
+
digest,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readChangesForLedger(db, taskRows, previousChanges = new Map()) {
|
|
231
|
+
const rows = db.prepare('SELECT * FROM changes ORDER BY id').all();
|
|
232
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
233
|
+
for (const task of taskRows) {
|
|
234
|
+
if (!task.change_id) continue;
|
|
235
|
+
const row = byId.get(task.change_id);
|
|
236
|
+
if (!row) {
|
|
237
|
+
throw new TaskLedgerError(
|
|
238
|
+
'TASK_LEDGER_CHANGE_MISSING',
|
|
239
|
+
`task ledger cannot publish missing owning change ${task.change_id}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return rows.map((row) => durableChange(row, previousChanges.get(row.id)));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function durableStatus(task, previous) {
|
|
247
|
+
if (task.status !== 'in_progress') return task.status;
|
|
248
|
+
if (previous && previous.status !== 'in_progress') return previous.status;
|
|
249
|
+
return 'pending';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function durableTask(task, previous = null) {
|
|
253
|
+
const value = compactObject([
|
|
254
|
+
['id', task.id],
|
|
255
|
+
['title', task.title],
|
|
256
|
+
['type', task.type],
|
|
257
|
+
['priority', task.priority],
|
|
258
|
+
['complexity', task.complexity],
|
|
259
|
+
['estimated_days', task.estimated_days],
|
|
260
|
+
['status', durableStatus(task, previous)],
|
|
261
|
+
['deps', Array.isArray(task.deps) ? task.deps : []],
|
|
262
|
+
['files_modified', Array.isArray(task.files_modified) ? task.files_modified : []],
|
|
263
|
+
['stale', Boolean(task.stale)],
|
|
264
|
+
['trace_to', task.trace_to],
|
|
265
|
+
['outcome', task.outcome],
|
|
266
|
+
['slice_kind', task.slice_kind],
|
|
267
|
+
['public_seam', task.public_seam],
|
|
268
|
+
['verification_command', task.verification_command],
|
|
269
|
+
['acceptance', Array.isArray(task.acceptance) ? task.acceptance : []],
|
|
270
|
+
['context_refs', Array.isArray(task.context_refs) ? task.context_refs : []],
|
|
271
|
+
['docs_impact', task.docs_impact || { status: 'unknown', files: [], rationale: null }],
|
|
272
|
+
['ownership', task.ownership || {}],
|
|
273
|
+
['change_id', task.change_id],
|
|
274
|
+
['parent_id', task.parent_id],
|
|
275
|
+
]);
|
|
276
|
+
const digest = sha256(value);
|
|
277
|
+
if (previous && previous.digest === digest) return previous;
|
|
278
|
+
return {
|
|
279
|
+
...value,
|
|
280
|
+
revision: previous ? Number(previous.revision || 0) + 1 : 1,
|
|
281
|
+
parent_digest: previous?.digest || null,
|
|
282
|
+
digest,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function statePayload({ baseline, changes, tasks }) {
|
|
287
|
+
return {
|
|
288
|
+
baseline: baseline || null,
|
|
289
|
+
changes: Array.isArray(changes) ? changes : [],
|
|
290
|
+
tasks: Array.isArray(tasks) ? tasks : [],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function validateTask(entry) {
|
|
295
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
296
|
+
throw new TaskLedgerError('TASK_LEDGER_INVALID', 'every task ledger entry must be an object');
|
|
297
|
+
}
|
|
298
|
+
for (const field of ['id', 'title', 'type', 'priority', 'status', 'revision', 'digest']) {
|
|
299
|
+
if (entry[field] === undefined || entry[field] === null || entry[field] === '') {
|
|
300
|
+
throw new TaskLedgerError(
|
|
301
|
+
'TASK_LEDGER_INVALID',
|
|
302
|
+
`task ledger entry ${entry.id || '(unknown)'} is missing ${field}`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!HASH_PATTERN.test(entry.digest) || entry.digest !== sha256(
|
|
307
|
+
Object.fromEntries(DURABLE_TASK_FIELDS
|
|
308
|
+
.filter((field) => entry[field] !== undefined)
|
|
309
|
+
.map((field) => [field, entry[field]])),
|
|
310
|
+
)) {
|
|
311
|
+
throw new TaskLedgerError(
|
|
312
|
+
'TASK_LEDGER_DIGEST_MISMATCH',
|
|
313
|
+
`task ledger entry ${entry.id} does not match its digest`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (entry.parent_digest != null && !HASH_PATTERN.test(entry.parent_digest)) {
|
|
317
|
+
throw new TaskLedgerError(
|
|
318
|
+
'TASK_LEDGER_INVALID',
|
|
319
|
+
`task ledger entry ${entry.id} has an invalid parent_digest`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function validateBaseline(entry) {
|
|
325
|
+
if (entry == null) return;
|
|
326
|
+
if (typeof entry !== 'object' || Array.isArray(entry)) {
|
|
327
|
+
throw new TaskLedgerError('TASK_LEDGER_INVALID', 'baseline ledger entry must be an object');
|
|
328
|
+
}
|
|
329
|
+
for (const field of [
|
|
330
|
+
'id', 'project_name', 'mode', 'status', 'scope', 'revision', 'digest',
|
|
331
|
+
]) {
|
|
332
|
+
if (entry[field] === undefined || entry[field] === null || entry[field] === '') {
|
|
333
|
+
throw new TaskLedgerError(
|
|
334
|
+
'TASK_LEDGER_INVALID',
|
|
335
|
+
`baseline ledger entry ${entry.id || '(unknown)'} is missing ${field}`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const value = Object.fromEntries(DURABLE_BASELINE_FIELDS
|
|
340
|
+
.filter((field) => entry[field] !== undefined)
|
|
341
|
+
.map((field) => [field, entry[field]]));
|
|
342
|
+
if (!HASH_PATTERN.test(entry.digest) || entry.digest !== sha256(value)) {
|
|
343
|
+
throw new TaskLedgerError(
|
|
344
|
+
'TASK_LEDGER_DIGEST_MISMATCH',
|
|
345
|
+
`baseline ledger entry ${entry.id} does not match its digest`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
if (entry.parent_digest != null && !HASH_PATTERN.test(entry.parent_digest)) {
|
|
349
|
+
throw new TaskLedgerError(
|
|
350
|
+
'TASK_LEDGER_INVALID',
|
|
351
|
+
`baseline ledger entry ${entry.id} has an invalid parent_digest`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function validateChange(entry) {
|
|
357
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
358
|
+
throw new TaskLedgerError('TASK_LEDGER_INVALID', 'every Change ledger entry must be an object');
|
|
359
|
+
}
|
|
360
|
+
for (const field of [
|
|
361
|
+
'id', 'title', 'kind', 'status', 'intent', 'artifact_root', 'revision', 'digest',
|
|
362
|
+
]) {
|
|
363
|
+
if (entry[field] === undefined || entry[field] === null || entry[field] === '') {
|
|
364
|
+
throw new TaskLedgerError(
|
|
365
|
+
'TASK_LEDGER_INVALID',
|
|
366
|
+
`Change ledger entry ${entry.id || '(unknown)'} is missing ${field}`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const value = Object.fromEntries(DURABLE_CHANGE_FIELDS
|
|
371
|
+
.filter((field) => entry[field] !== undefined)
|
|
372
|
+
.map((field) => [field, entry[field]]));
|
|
373
|
+
if (!HASH_PATTERN.test(entry.digest) || entry.digest !== sha256(value)) {
|
|
374
|
+
throw new TaskLedgerError(
|
|
375
|
+
'TASK_LEDGER_DIGEST_MISMATCH',
|
|
376
|
+
`Change ledger entry ${entry.id} does not match its digest`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
if (entry.parent_digest != null && !HASH_PATTERN.test(entry.parent_digest)) {
|
|
380
|
+
throw new TaskLedgerError(
|
|
381
|
+
'TASK_LEDGER_INVALID',
|
|
382
|
+
`Change ledger entry ${entry.id} has an invalid parent_digest`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function validateLedger(document, file = '(memory)') {
|
|
388
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)
|
|
389
|
+
|| document.kind !== LEDGER_KIND
|
|
390
|
+
|| document.schema_version !== LEDGER_SCHEMA_VERSION
|
|
391
|
+
|| !Number.isInteger(document.generation)
|
|
392
|
+
|| document.generation < 0
|
|
393
|
+
|| !Array.isArray(document.ancestors)
|
|
394
|
+
|| !Array.isArray(document.tasks)
|
|
395
|
+
|| !Array.isArray(document.changes)) {
|
|
396
|
+
throw new TaskLedgerError(
|
|
397
|
+
'TASK_LEDGER_INVALID',
|
|
398
|
+
`${file} is not an Ultra team task ledger v${LEDGER_SCHEMA_VERSION}`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (document.ancestors.length > MAX_ANCESTORS
|
|
402
|
+
|| document.ancestors.some((digest) => !HASH_PATTERN.test(digest))) {
|
|
403
|
+
throw new TaskLedgerError(
|
|
404
|
+
'TASK_LEDGER_INVALID',
|
|
405
|
+
`${file} contains an invalid checkpoint ancestry chain`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
validateBaseline(document.baseline);
|
|
409
|
+
const seen = new Set();
|
|
410
|
+
for (const task of document.tasks) {
|
|
411
|
+
validateTask(task);
|
|
412
|
+
if (seen.has(task.id)) {
|
|
413
|
+
throw new TaskLedgerError('TASK_LEDGER_INVALID', `${file} contains duplicate task ${task.id}`);
|
|
414
|
+
}
|
|
415
|
+
seen.add(task.id);
|
|
416
|
+
}
|
|
417
|
+
const seenChanges = new Set();
|
|
418
|
+
for (const change of document.changes) {
|
|
419
|
+
validateChange(change);
|
|
420
|
+
if (seenChanges.has(change.id)) {
|
|
421
|
+
throw new TaskLedgerError(
|
|
422
|
+
'TASK_LEDGER_INVALID',
|
|
423
|
+
`${file} contains duplicate Change ${change.id}`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
seenChanges.add(change.id);
|
|
427
|
+
}
|
|
428
|
+
const expected = sha256(statePayload(document));
|
|
429
|
+
if (!HASH_PATTERN.test(document.state_digest || '') || document.state_digest !== expected) {
|
|
430
|
+
throw new TaskLedgerError(
|
|
431
|
+
'TASK_LEDGER_DIGEST_MISMATCH',
|
|
432
|
+
`${file} state_digest does not match its durable payload`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
if (document.parent_digest != null && !HASH_PATTERN.test(document.parent_digest)) {
|
|
436
|
+
throw new TaskLedgerError('TASK_LEDGER_INVALID', `${file} has an invalid parent_digest`);
|
|
437
|
+
}
|
|
438
|
+
return document;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function readTaskLedger(rootDir, { optional = true } = {}) {
|
|
442
|
+
const file = ledgerPath(rootDir);
|
|
443
|
+
const directory = safeDirectoryChain(rootDir, ['.ultra', 'tasks']);
|
|
444
|
+
if (!directory) {
|
|
445
|
+
if (optional) return null;
|
|
446
|
+
throw new TaskLedgerError('TASK_LEDGER_INVALID', `task ledger is missing: ${file}`);
|
|
447
|
+
}
|
|
448
|
+
let stat;
|
|
449
|
+
try { stat = fs.lstatSync(file); }
|
|
450
|
+
catch (error) {
|
|
451
|
+
if (error.code === 'ENOENT' && optional) return null;
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
455
|
+
throw new TaskLedgerError('TASK_LEDGER_UNSAFE', `task ledger must be a regular file: ${file}`);
|
|
456
|
+
}
|
|
457
|
+
let parsed;
|
|
458
|
+
try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
459
|
+
catch (error) {
|
|
460
|
+
throw new TaskLedgerError('TASK_LEDGER_INVALID', `cannot read ${file}: ${error.message}`);
|
|
461
|
+
}
|
|
462
|
+
return validateLedger(parsed, file);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function writeLedgerAtomic(rootDir, document) {
|
|
466
|
+
const file = ledgerPath(rootDir);
|
|
467
|
+
const dir = safeDirectoryChain(rootDir, ['.ultra', 'tasks'], { create: true });
|
|
468
|
+
if (fs.existsSync(file)) {
|
|
469
|
+
const stat = fs.lstatSync(file);
|
|
470
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
471
|
+
throw new TaskLedgerError('TASK_LEDGER_UNSAFE', `task ledger target is unsafe: ${file}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const temp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
475
|
+
try {
|
|
476
|
+
fs.writeFileSync(temp, `${JSON.stringify(document, null, 2)}\n`, { flag: 'wx' });
|
|
477
|
+
fs.renameSync(temp, file);
|
|
478
|
+
} finally {
|
|
479
|
+
try { fs.rmSync(temp, { force: true }); } catch { /* best effort */ }
|
|
480
|
+
}
|
|
481
|
+
return file;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function migrateLegacyProjection(db, rootDir, originalError) {
|
|
485
|
+
const file = ledgerPath(rootDir);
|
|
486
|
+
let bytes;
|
|
487
|
+
let document;
|
|
488
|
+
try {
|
|
489
|
+
bytes = fs.readFileSync(file);
|
|
490
|
+
document = JSON.parse(bytes.toString('utf8'));
|
|
491
|
+
} catch {
|
|
492
|
+
throw originalError;
|
|
493
|
+
}
|
|
494
|
+
if (document?.kind === LEDGER_KIND
|
|
495
|
+
|| !document || !Array.isArray(document.tasks)
|
|
496
|
+
|| !['4.4', '4.5'].includes(String(document.version || document.schema_version))) {
|
|
497
|
+
throw originalError;
|
|
498
|
+
}
|
|
499
|
+
const legacyIds = document.tasks.map((task) => String(task?.id || '')).sort();
|
|
500
|
+
if (legacyIds.some((id) => !id) || new Set(legacyIds).size !== legacyIds.length) {
|
|
501
|
+
throw new TaskLedgerError(
|
|
502
|
+
'TASK_LEDGER_LEGACY_CONFLICT',
|
|
503
|
+
'legacy tasks projection has missing or duplicate task ids',
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const currentIds = ops.listTasks(db, { status: 'any', limit: 1000 })
|
|
507
|
+
.map((task) => task.id)
|
|
508
|
+
.sort();
|
|
509
|
+
if (JSON.stringify(legacyIds) !== JSON.stringify(currentIds)) {
|
|
510
|
+
throw new TaskLedgerError(
|
|
511
|
+
'TASK_LEDGER_LEGACY_CONFLICT',
|
|
512
|
+
'legacy tasks projection does not match current SQLite task authority',
|
|
513
|
+
{ legacy_task_ids: legacyIds, current_task_ids: currentIds },
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
const currentById = new Map(
|
|
517
|
+
ops.listTasks(db, { status: 'any', limit: 1000 }).map((task) => [task.id, task]),
|
|
518
|
+
);
|
|
519
|
+
for (const legacyTask of document.tasks) {
|
|
520
|
+
const current = currentById.get(legacyTask.id);
|
|
521
|
+
for (const field of DURABLE_TASK_FIELDS) {
|
|
522
|
+
if (legacyTask[field] === undefined) continue;
|
|
523
|
+
if (!util.isDeepStrictEqual(legacyTask[field], current[field])) {
|
|
524
|
+
throw new TaskLedgerError(
|
|
525
|
+
'TASK_LEDGER_LEGACY_CONFLICT',
|
|
526
|
+
`legacy task ${legacyTask.id} field ${field} does not match SQLite authority`,
|
|
527
|
+
{
|
|
528
|
+
task_id: legacyTask.id,
|
|
529
|
+
field,
|
|
530
|
+
legacy_value: legacyTask[field],
|
|
531
|
+
current_value: current[field],
|
|
532
|
+
},
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const digest = sha256(bytes);
|
|
538
|
+
const backupDir = safeDirectoryChain(
|
|
539
|
+
rootDir,
|
|
540
|
+
['.ultra', '.runtime', 'backups', 'task-ledger'],
|
|
541
|
+
{ create: true },
|
|
542
|
+
);
|
|
543
|
+
const backup = path.join(backupDir, `legacy-tasks-${digest.slice(0, 16)}.json`);
|
|
544
|
+
if (fs.existsSync(backup)) {
|
|
545
|
+
const stat = fs.lstatSync(backup);
|
|
546
|
+
if (stat.isSymbolicLink() || !stat.isFile()
|
|
547
|
+
|| !fs.readFileSync(backup).equals(bytes)) {
|
|
548
|
+
throw new TaskLedgerError(
|
|
549
|
+
'TASK_LEDGER_UNSAFE',
|
|
550
|
+
`legacy task ledger backup is unsafe or conflicting: ${backup}`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
fs.writeFileSync(backup, bytes, { flag: 'wx' });
|
|
555
|
+
}
|
|
556
|
+
return { previous: null, migrated: true, backup };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function priorLedgerForPublish(db, rootDir) {
|
|
560
|
+
try {
|
|
561
|
+
return { previous: readTaskLedger(rootDir), migrated: false, backup: null };
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (!['TASK_LEDGER_INVALID', 'TASK_LEDGER_DIGEST_MISMATCH'].includes(error.code)) {
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
return migrateLegacyProjection(db, rootDir, error);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function syncTaskLedger(db, { rootDir = '.' } = {}) {
|
|
571
|
+
let document;
|
|
572
|
+
try {
|
|
573
|
+
document = readTaskLedger(rootDir);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (!['TASK_LEDGER_INVALID', 'TASK_LEDGER_DIGEST_MISMATCH'].includes(error.code)) {
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
const raw = JSON.parse(fs.readFileSync(ledgerPath(rootDir), 'utf8'));
|
|
580
|
+
if (raw && Array.isArray(raw.tasks)
|
|
581
|
+
&& ['4.4', '4.5'].includes(String(raw.version || raw.schema_version))) {
|
|
582
|
+
return { status: 'legacy', imported: 0, already_current: false };
|
|
583
|
+
}
|
|
584
|
+
} catch {
|
|
585
|
+
// Preserve the typed ledger error from the validated read.
|
|
586
|
+
}
|
|
587
|
+
throw error;
|
|
588
|
+
}
|
|
589
|
+
if (!document) {
|
|
590
|
+
return { status: 'missing', imported: 0, already_current: false };
|
|
591
|
+
}
|
|
592
|
+
const checkpoint = latestImportedCheckpoint(db);
|
|
593
|
+
if (checkpoint.state_digest === document.state_digest) {
|
|
594
|
+
return {
|
|
595
|
+
status: 'current',
|
|
596
|
+
imported: 0,
|
|
597
|
+
preserved: document.tasks.length,
|
|
598
|
+
deleted: 0,
|
|
599
|
+
imported_changes: 0,
|
|
600
|
+
preserved_changes: document.changes.length,
|
|
601
|
+
imported_baseline: false,
|
|
602
|
+
preserved_baseline: Boolean(document.baseline),
|
|
603
|
+
generation: document.generation,
|
|
604
|
+
state_digest: document.state_digest,
|
|
605
|
+
requires_plan_revalidation: false,
|
|
606
|
+
requires_baseline_revalidation: false,
|
|
607
|
+
already_current: true,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
status: 'imported',
|
|
612
|
+
...importTaskLedger(db, { rootDir }),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function taskDigestMap(tasks) {
|
|
617
|
+
return Object.fromEntries(tasks.map((task) => [task.id, task.digest]));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function recordLedgerEvent(db, type, ledger, extra = {}) {
|
|
621
|
+
ops.appendEvent(db, {
|
|
622
|
+
type,
|
|
623
|
+
payload: {
|
|
624
|
+
generation: ledger.generation,
|
|
625
|
+
state_digest: ledger.state_digest,
|
|
626
|
+
baseline_digest: ledger.baseline?.digest || null,
|
|
627
|
+
baseline_checkpoint: ledger.baseline || null,
|
|
628
|
+
task_digests: taskDigestMap(ledger.tasks),
|
|
629
|
+
change_digests: taskDigestMap(ledger.changes),
|
|
630
|
+
...extra,
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function publishTaskLedger(db, {
|
|
636
|
+
rootDir = '.',
|
|
637
|
+
reason = 'manual_checkpoint',
|
|
638
|
+
} = {}) {
|
|
639
|
+
let prior = priorLedgerForPublish(db, rootDir);
|
|
640
|
+
if (prior.previous && !prior.migrated) {
|
|
641
|
+
syncTaskLedger(db, { rootDir });
|
|
642
|
+
prior = {
|
|
643
|
+
previous: readTaskLedger(rootDir),
|
|
644
|
+
migrated: false,
|
|
645
|
+
backup: null,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
const previous = prior.previous;
|
|
649
|
+
const priorTasks = new Map((previous?.tasks || []).map((task) => [task.id, task]));
|
|
650
|
+
const priorChanges = new Map((previous?.changes || []).map((change) => [change.id, change]));
|
|
651
|
+
const rows = ops.listTasks(db, { status: 'any', limit: 1000 })
|
|
652
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
653
|
+
const tasks = rows.map((task) => durableTask(task, priorTasks.get(task.id)));
|
|
654
|
+
const baseline = readBaselineForLedger(db, previous?.baseline || null);
|
|
655
|
+
if (baseline?.gaps?.some((gap) => (
|
|
656
|
+
gap.id === 'team-ledger-revalidation-required' && gap.status === 'open'
|
|
657
|
+
))) {
|
|
658
|
+
throw new TaskLedgerError(
|
|
659
|
+
'TASK_LEDGER_REVALIDATION_REQUIRED',
|
|
660
|
+
'the imported team baseline must converge in this checkout before publication',
|
|
661
|
+
{ baseline_id: baseline.id, ledger_path: LEDGER_RELATIVE_PATH },
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
const changes = readChangesForLedger(db, rows, priorChanges);
|
|
665
|
+
const digest = sha256(statePayload({ baseline, changes, tasks }));
|
|
666
|
+
if (previous?.state_digest === digest) {
|
|
667
|
+
return {
|
|
668
|
+
changed: false,
|
|
669
|
+
path: ledgerPath(rootDir),
|
|
670
|
+
ledger: previous,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
const document = {
|
|
674
|
+
kind: LEDGER_KIND,
|
|
675
|
+
schema_version: LEDGER_SCHEMA_VERSION,
|
|
676
|
+
generation: Number(previous?.generation || 0) + 1,
|
|
677
|
+
parent_digest: previous?.state_digest || null,
|
|
678
|
+
ancestors: previous
|
|
679
|
+
? [previous.state_digest, ...(previous.ancestors || [])].slice(0, MAX_ANCESTORS)
|
|
680
|
+
: [],
|
|
681
|
+
state_digest: digest,
|
|
682
|
+
published_at: new Date().toISOString(),
|
|
683
|
+
reason,
|
|
684
|
+
baseline,
|
|
685
|
+
changes,
|
|
686
|
+
tasks,
|
|
687
|
+
};
|
|
688
|
+
validateLedger(document);
|
|
689
|
+
const file = writeLedgerAtomic(rootDir, document);
|
|
690
|
+
recordLedgerEvent(db, 'task_ledger_published', document, {
|
|
691
|
+
reason,
|
|
692
|
+
migrated_legacy_projection: prior.migrated,
|
|
693
|
+
legacy_backup_path: prior.backup,
|
|
694
|
+
});
|
|
695
|
+
return {
|
|
696
|
+
changed: true,
|
|
697
|
+
path: file,
|
|
698
|
+
ledger: document,
|
|
699
|
+
migrated_legacy_projection: prior.migrated,
|
|
700
|
+
legacy_backup_path: prior.backup,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function latestImportedCheckpoint(db) {
|
|
705
|
+
const row = db.prepare(
|
|
706
|
+
`SELECT payload_json FROM events
|
|
707
|
+
WHERE type IN ('task_ledger_imported', 'task_ledger_published')
|
|
708
|
+
ORDER BY id DESC LIMIT 1`,
|
|
709
|
+
).get();
|
|
710
|
+
const payload = parseJson(row?.payload_json, {});
|
|
711
|
+
return {
|
|
712
|
+
generation: Number.isInteger(payload.generation) ? payload.generation : null,
|
|
713
|
+
state_digest: HASH_PATTERN.test(payload.state_digest || '') ? payload.state_digest : null,
|
|
714
|
+
task_digests: payload.task_digests && typeof payload.task_digests === 'object'
|
|
715
|
+
? payload.task_digests
|
|
716
|
+
: {},
|
|
717
|
+
change_digests: payload.change_digests && typeof payload.change_digests === 'object'
|
|
718
|
+
? payload.change_digests
|
|
719
|
+
: {},
|
|
720
|
+
baseline_digest: HASH_PATTERN.test(payload.baseline_digest || '')
|
|
721
|
+
? payload.baseline_digest
|
|
722
|
+
: null,
|
|
723
|
+
baseline_checkpoint: payload.baseline_checkpoint
|
|
724
|
+
&& typeof payload.baseline_checkpoint === 'object'
|
|
725
|
+
? payload.baseline_checkpoint
|
|
726
|
+
: null,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function baselineForLocalCheckout(baseline) {
|
|
731
|
+
const value = portableBaseline(baseline);
|
|
732
|
+
const requiresRevalidation = baseline.status === 'ready';
|
|
733
|
+
if (!requiresRevalidation) return { value, requiresRevalidation: false };
|
|
734
|
+
const gaps = [...(value.gaps || [])];
|
|
735
|
+
if (requiresRevalidation && !gaps.some((gap) => gap.id === 'team-ledger-revalidation-required')) {
|
|
736
|
+
gaps.push({
|
|
737
|
+
id: 'team-ledger-revalidation-required',
|
|
738
|
+
category: 'baseline_blocker',
|
|
739
|
+
status: 'open',
|
|
740
|
+
blocking: true,
|
|
741
|
+
summary: 'The imported team checkpoint must be revalidated against this checkout.',
|
|
742
|
+
evidence_refs: [LEDGER_RELATIVE_PATH],
|
|
743
|
+
owner: 'project-owner',
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
value: compactObject(Object.entries({
|
|
748
|
+
...value,
|
|
749
|
+
status: value.mode === 'greenfield' ? 'draft' : 'adopting',
|
|
750
|
+
worktree_accepted: false,
|
|
751
|
+
known_red_accepted: false,
|
|
752
|
+
gaps,
|
|
753
|
+
research_run_id: undefined,
|
|
754
|
+
approved_by: undefined,
|
|
755
|
+
approval_note: undefined,
|
|
756
|
+
converged_at: undefined,
|
|
757
|
+
})),
|
|
758
|
+
requiresRevalidation: true,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function writeBaseline(db, baseline, { update = false } = {}) {
|
|
763
|
+
const local = baselineForLocalCheckout(baseline);
|
|
764
|
+
const value = local.value;
|
|
765
|
+
if (update) {
|
|
766
|
+
db.prepare(
|
|
767
|
+
`UPDATE baselines SET
|
|
768
|
+
project_name = ?, project_type = ?, stack = ?, mode = ?, status = ?,
|
|
769
|
+
scope_json = ?, repository_revision = ?, repository_branch = ?,
|
|
770
|
+
worktree_state = ?, worktree_digest = ?, worktree_accepted = ?,
|
|
771
|
+
known_red_accepted = ?, spec_refs_json = ?, evidence_json = ?,
|
|
772
|
+
verification_json = ?, unknowns_json = ?, gaps_json = ?,
|
|
773
|
+
classification_json = ?, provider_refs_json = ?, research_run_id = ?,
|
|
774
|
+
approved_by = ?, approval_note = ?, converged_at = ?, updated_at = ?
|
|
775
|
+
WHERE id = ?`,
|
|
776
|
+
).run(
|
|
777
|
+
value.project_name,
|
|
778
|
+
value.project_type || null,
|
|
779
|
+
value.stack || null,
|
|
780
|
+
value.mode,
|
|
781
|
+
value.status,
|
|
782
|
+
JSON.stringify(value.scope || ['.']),
|
|
783
|
+
value.repository_revision || null,
|
|
784
|
+
value.repository_branch || null,
|
|
785
|
+
value.worktree_state || 'unavailable',
|
|
786
|
+
value.worktree_digest || null,
|
|
787
|
+
value.worktree_accepted ? 1 : 0,
|
|
788
|
+
value.known_red_accepted ? 1 : 0,
|
|
789
|
+
JSON.stringify(value.spec_refs || []),
|
|
790
|
+
JSON.stringify(value.evidence || []),
|
|
791
|
+
JSON.stringify(value.verification || []),
|
|
792
|
+
JSON.stringify(value.unknowns || []),
|
|
793
|
+
JSON.stringify(value.gaps || []),
|
|
794
|
+
JSON.stringify(value.classification || {}),
|
|
795
|
+
JSON.stringify(value.provider_refs || {}),
|
|
796
|
+
value.research_run_id || null,
|
|
797
|
+
value.approved_by || null,
|
|
798
|
+
value.approval_note || null,
|
|
799
|
+
value.converged_at || null,
|
|
800
|
+
new Date().toISOString(),
|
|
801
|
+
value.id,
|
|
802
|
+
);
|
|
803
|
+
return local;
|
|
804
|
+
}
|
|
805
|
+
db.prepare(
|
|
806
|
+
`INSERT INTO baselines
|
|
807
|
+
(id, project_name, project_type, stack, mode, status, repository_root, scope_json,
|
|
808
|
+
repository_revision, repository_branch, worktree_state, worktree_digest,
|
|
809
|
+
worktree_accepted, known_red_accepted, spec_refs_json, evidence_json,
|
|
810
|
+
verification_json, unknowns_json, gaps_json, classification_json,
|
|
811
|
+
provider_refs_json, research_run_id, approved_by, approval_note, converged_at)
|
|
812
|
+
VALUES (?, ?, ?, ?, ?, ?, '.', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
813
|
+
).run(
|
|
814
|
+
value.id,
|
|
815
|
+
value.project_name,
|
|
816
|
+
value.project_type || null,
|
|
817
|
+
value.stack || null,
|
|
818
|
+
value.mode,
|
|
819
|
+
value.status,
|
|
820
|
+
JSON.stringify(value.scope || ['.']),
|
|
821
|
+
value.repository_revision || null,
|
|
822
|
+
value.repository_branch || null,
|
|
823
|
+
value.worktree_state || 'unavailable',
|
|
824
|
+
value.worktree_digest || null,
|
|
825
|
+
value.worktree_accepted ? 1 : 0,
|
|
826
|
+
value.known_red_accepted ? 1 : 0,
|
|
827
|
+
JSON.stringify(value.spec_refs || []),
|
|
828
|
+
JSON.stringify(value.evidence || []),
|
|
829
|
+
JSON.stringify(value.verification || []),
|
|
830
|
+
JSON.stringify(value.unknowns || []),
|
|
831
|
+
JSON.stringify(value.gaps || []),
|
|
832
|
+
JSON.stringify(value.classification || {}),
|
|
833
|
+
JSON.stringify(value.provider_refs || {}),
|
|
834
|
+
value.research_run_id || null,
|
|
835
|
+
value.approved_by || null,
|
|
836
|
+
value.approval_note || null,
|
|
837
|
+
value.converged_at || null,
|
|
838
|
+
);
|
|
839
|
+
return local;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function localBaselineMatchesRevalidation(row, checkpointBaseline) {
|
|
843
|
+
if (!row || !checkpointBaseline || row.id !== checkpointBaseline.id
|
|
844
|
+
|| checkpointBaseline.status !== 'ready') return false;
|
|
845
|
+
const expected = baselineForLocalCheckout(checkpointBaseline).value;
|
|
846
|
+
return util.isDeepStrictEqual(portableBaseline(row), expected);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function mergeBaseline(db, incoming, checkpoint) {
|
|
850
|
+
if (!incoming) {
|
|
851
|
+
return { imported: false, preserved: false, requiresRevalidation: false };
|
|
852
|
+
}
|
|
853
|
+
const active = db.prepare(
|
|
854
|
+
`SELECT * FROM baselines
|
|
855
|
+
WHERE status <> 'superseded'
|
|
856
|
+
ORDER BY CASE status WHEN 'ready' THEN 0 ELSE 1 END, updated_at DESC, rowid DESC
|
|
857
|
+
LIMIT 1`,
|
|
858
|
+
).get();
|
|
859
|
+
if (!active) {
|
|
860
|
+
const written = writeBaseline(db, incoming);
|
|
861
|
+
return {
|
|
862
|
+
imported: true,
|
|
863
|
+
preserved: false,
|
|
864
|
+
requiresRevalidation: written.requiresRevalidation,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const current = durableBaseline(active, null);
|
|
869
|
+
const lastDigest = checkpoint.baseline_digest;
|
|
870
|
+
const effectiveCurrentDigest = localBaselineMatchesRevalidation(
|
|
871
|
+
active,
|
|
872
|
+
checkpoint.baseline_checkpoint,
|
|
873
|
+
) ? lastDigest : current.digest;
|
|
874
|
+
if (active.id === incoming.id && current.digest === incoming.digest) {
|
|
875
|
+
return { imported: false, preserved: true, requiresRevalidation: false };
|
|
876
|
+
}
|
|
877
|
+
if (!lastDigest) {
|
|
878
|
+
throw new TaskLedgerError(
|
|
879
|
+
'TASK_LEDGER_BASELINE_CONFLICT',
|
|
880
|
+
`baseline ${active.id} exists locally without a common team checkpoint`,
|
|
881
|
+
{
|
|
882
|
+
local_baseline_id: active.id,
|
|
883
|
+
incoming_baseline_id: incoming.id,
|
|
884
|
+
local_digest: current.digest,
|
|
885
|
+
incoming_digest: incoming.digest,
|
|
886
|
+
},
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
if (effectiveCurrentDigest !== lastDigest && incoming.digest === lastDigest) {
|
|
890
|
+
return { imported: false, preserved: true, requiresRevalidation: false };
|
|
891
|
+
}
|
|
892
|
+
if (effectiveCurrentDigest !== lastDigest && incoming.digest !== lastDigest) {
|
|
893
|
+
throw new TaskLedgerError(
|
|
894
|
+
'TASK_LEDGER_BASELINE_CONFLICT',
|
|
895
|
+
'baseline changed both locally and in the Git ledger',
|
|
896
|
+
{
|
|
897
|
+
local_baseline_id: active.id,
|
|
898
|
+
incoming_baseline_id: incoming.id,
|
|
899
|
+
last_digest: lastDigest,
|
|
900
|
+
local_digest: current.digest,
|
|
901
|
+
incoming_digest: incoming.digest,
|
|
902
|
+
},
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
if (active.id !== incoming.id) {
|
|
906
|
+
db.prepare(
|
|
907
|
+
"UPDATE baselines SET status = 'superseded', updated_at = ? WHERE id = ?",
|
|
908
|
+
).run(new Date().toISOString(), active.id);
|
|
909
|
+
const written = writeBaseline(db, incoming);
|
|
910
|
+
return {
|
|
911
|
+
imported: true,
|
|
912
|
+
preserved: false,
|
|
913
|
+
requiresRevalidation: written.requiresRevalidation,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
const written = writeBaseline(db, incoming, { update: true });
|
|
917
|
+
return {
|
|
918
|
+
imported: true,
|
|
919
|
+
preserved: false,
|
|
920
|
+
requiresRevalidation: written.requiresRevalidation,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function insertChange(db, change) {
|
|
925
|
+
db.prepare(
|
|
926
|
+
`INSERT INTO changes
|
|
927
|
+
(id, title, kind, status, intent, docs_impact_json, provider_refs_json,
|
|
928
|
+
baseline_bypass_json, contract_json, classification_json,
|
|
929
|
+
research_disposition_json, base_commit, artifact_root, closed_at)
|
|
930
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
931
|
+
).run(
|
|
932
|
+
change.id,
|
|
933
|
+
change.title,
|
|
934
|
+
change.kind,
|
|
935
|
+
change.status,
|
|
936
|
+
change.intent,
|
|
937
|
+
JSON.stringify(change.docs_impact || {}),
|
|
938
|
+
JSON.stringify(change.provider_refs || {}),
|
|
939
|
+
change.baseline_bypass ? JSON.stringify(change.baseline_bypass) : null,
|
|
940
|
+
JSON.stringify(change.contract || {}),
|
|
941
|
+
JSON.stringify(change.classification || {}),
|
|
942
|
+
JSON.stringify(change.research_disposition || {}),
|
|
943
|
+
change.base_commit || null,
|
|
944
|
+
change.artifact_root,
|
|
945
|
+
change.closed_at || null,
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function updateChangeFromLedger(db, change) {
|
|
950
|
+
db.prepare(
|
|
951
|
+
`UPDATE changes SET
|
|
952
|
+
title = ?, kind = ?, status = ?, intent = ?, docs_impact_json = ?,
|
|
953
|
+
provider_refs_json = ?, baseline_bypass_json = ?, contract_json = ?,
|
|
954
|
+
classification_json = ?, research_disposition_json = ?, base_commit = ?,
|
|
955
|
+
artifact_root = ?, closed_at = ?, updated_at = ?
|
|
956
|
+
WHERE id = ?`,
|
|
957
|
+
).run(
|
|
958
|
+
change.title,
|
|
959
|
+
change.kind,
|
|
960
|
+
change.status,
|
|
961
|
+
change.intent,
|
|
962
|
+
JSON.stringify(change.docs_impact || {}),
|
|
963
|
+
JSON.stringify(change.provider_refs || {}),
|
|
964
|
+
change.baseline_bypass ? JSON.stringify(change.baseline_bypass) : null,
|
|
965
|
+
JSON.stringify(change.contract || {}),
|
|
966
|
+
JSON.stringify(change.classification || {}),
|
|
967
|
+
JSON.stringify(change.research_disposition || {}),
|
|
968
|
+
change.base_commit || null,
|
|
969
|
+
change.artifact_root,
|
|
970
|
+
change.closed_at || null,
|
|
971
|
+
new Date().toISOString(),
|
|
972
|
+
change.id,
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function insertTaskFromLedger(db, task) {
|
|
977
|
+
const created = ops.createTask(db, {
|
|
978
|
+
id: task.id,
|
|
979
|
+
title: task.title,
|
|
980
|
+
type: task.type,
|
|
981
|
+
priority: task.priority,
|
|
982
|
+
complexity: task.complexity,
|
|
983
|
+
estimated_days: task.estimated_days,
|
|
984
|
+
deps: task.deps || [],
|
|
985
|
+
files_modified: task.files_modified || [],
|
|
986
|
+
stale: Boolean(task.stale),
|
|
987
|
+
trace_to: task.trace_to,
|
|
988
|
+
outcome: task.outcome,
|
|
989
|
+
slice_kind: task.slice_kind,
|
|
990
|
+
public_seam: task.public_seam,
|
|
991
|
+
verification_command: task.verification_command,
|
|
992
|
+
acceptance: task.acceptance || [],
|
|
993
|
+
context_refs: task.context_refs || [],
|
|
994
|
+
docs_impact: task.docs_impact || { status: 'unknown', files: [], rationale: null },
|
|
995
|
+
ownership: task.ownership || {},
|
|
996
|
+
change_id: task.change_id || null,
|
|
997
|
+
parent_id: null,
|
|
998
|
+
});
|
|
999
|
+
if (task.status !== created.status) updateTaskFromLedger(db, task);
|
|
1000
|
+
return ops.readTask(db, task.id);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function updateTaskFromLedger(db, task) {
|
|
1004
|
+
db.prepare(
|
|
1005
|
+
`UPDATE tasks SET
|
|
1006
|
+
title = ?, type = ?, priority = ?, complexity = ?, estimated_days = ?,
|
|
1007
|
+
status = ?, deps = ?, files_modified = ?, session_id = NULL, stale = ?,
|
|
1008
|
+
trace_to = ?, outcome = ?, slice_kind = ?, public_seam = ?,
|
|
1009
|
+
verification_command = ?, acceptance_json = ?, context_refs_json = ?,
|
|
1010
|
+
docs_impact_json = ?, ownership_json = ?, change_id = ?, updated_at = ?
|
|
1011
|
+
WHERE id = ?`,
|
|
1012
|
+
).run(
|
|
1013
|
+
task.title,
|
|
1014
|
+
task.type,
|
|
1015
|
+
task.priority,
|
|
1016
|
+
task.complexity ?? null,
|
|
1017
|
+
task.estimated_days ?? null,
|
|
1018
|
+
task.status,
|
|
1019
|
+
JSON.stringify(task.deps || []),
|
|
1020
|
+
JSON.stringify(task.files_modified || []),
|
|
1021
|
+
task.stale ? 1 : 0,
|
|
1022
|
+
task.trace_to || null,
|
|
1023
|
+
task.outcome || null,
|
|
1024
|
+
task.slice_kind || null,
|
|
1025
|
+
task.public_seam || null,
|
|
1026
|
+
task.verification_command || null,
|
|
1027
|
+
JSON.stringify(task.acceptance || []),
|
|
1028
|
+
JSON.stringify(task.context_refs || []),
|
|
1029
|
+
JSON.stringify(task.docs_impact || { status: 'unknown', files: [], rationale: null }),
|
|
1030
|
+
JSON.stringify(task.ownership || {}),
|
|
1031
|
+
task.change_id || null,
|
|
1032
|
+
new Date().toISOString(),
|
|
1033
|
+
task.id,
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function importTaskLedger(db, {
|
|
1038
|
+
rootDir = '.',
|
|
1039
|
+
} = {}) {
|
|
1040
|
+
const document = readTaskLedger(rootDir, { optional: false });
|
|
1041
|
+
const checkpoint = latestImportedCheckpoint(db);
|
|
1042
|
+
const lastDigests = checkpoint.task_digests;
|
|
1043
|
+
const lastChangeDigests = checkpoint.change_digests;
|
|
1044
|
+
if (checkpoint.state_digest && checkpoint.state_digest !== document.state_digest) {
|
|
1045
|
+
if (document.generation < checkpoint.generation) {
|
|
1046
|
+
throw new TaskLedgerError(
|
|
1047
|
+
'TASK_LEDGER_STALE',
|
|
1048
|
+
`task ledger generation ${document.generation} is older than local generation ${checkpoint.generation}`,
|
|
1049
|
+
{
|
|
1050
|
+
incoming_generation: document.generation,
|
|
1051
|
+
local_generation: checkpoint.generation,
|
|
1052
|
+
},
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
if (document.generation === checkpoint.generation
|
|
1056
|
+
|| !document.ancestors.includes(checkpoint.state_digest)) {
|
|
1057
|
+
throw new TaskLedgerError(
|
|
1058
|
+
'TASK_LEDGER_HISTORY_CONFLICT',
|
|
1059
|
+
'task ledger does not descend from the last local team checkpoint',
|
|
1060
|
+
{
|
|
1061
|
+
incoming_generation: document.generation,
|
|
1062
|
+
local_generation: checkpoint.generation,
|
|
1063
|
+
incoming_parent_digest: document.parent_digest,
|
|
1064
|
+
local_state_digest: checkpoint.state_digest,
|
|
1065
|
+
},
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
if (checkpoint.state_digest === document.state_digest) {
|
|
1070
|
+
return {
|
|
1071
|
+
imported: 0,
|
|
1072
|
+
preserved: document.tasks.length,
|
|
1073
|
+
deleted: 0,
|
|
1074
|
+
imported_changes: 0,
|
|
1075
|
+
preserved_changes: document.changes.length,
|
|
1076
|
+
imported_baseline: false,
|
|
1077
|
+
preserved_baseline: Boolean(document.baseline),
|
|
1078
|
+
generation: document.generation,
|
|
1079
|
+
state_digest: document.state_digest,
|
|
1080
|
+
requires_plan_revalidation: false,
|
|
1081
|
+
requires_baseline_revalidation: false,
|
|
1082
|
+
already_current: true,
|
|
1083
|
+
ledger: document,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
const parentUpdates = [];
|
|
1087
|
+
let imported = 0;
|
|
1088
|
+
let preserved = 0;
|
|
1089
|
+
let deleted = 0;
|
|
1090
|
+
let importedChanges = 0;
|
|
1091
|
+
let preservedChanges = 0;
|
|
1092
|
+
let baselineImport = {
|
|
1093
|
+
imported: false,
|
|
1094
|
+
preserved: false,
|
|
1095
|
+
requiresRevalidation: false,
|
|
1096
|
+
};
|
|
1097
|
+
const changesById = new Set((document.changes || []).map((change) => change.id));
|
|
1098
|
+
for (const task of document.tasks) {
|
|
1099
|
+
if (task.change_id && !changesById.has(task.change_id)
|
|
1100
|
+
&& !db.prepare('SELECT 1 FROM changes WHERE id = ?').get(task.change_id)) {
|
|
1101
|
+
throw new TaskLedgerError(
|
|
1102
|
+
'TASK_LEDGER_CHANGE_MISSING',
|
|
1103
|
+
`task ${task.id} references missing change ${task.change_id}`,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
ops.tx(db, () => {
|
|
1109
|
+
baselineImport = mergeBaseline(db, document.baseline, checkpoint);
|
|
1110
|
+
for (const incoming of document.changes) {
|
|
1111
|
+
const row = db.prepare('SELECT * FROM changes WHERE id = ?').get(incoming.id);
|
|
1112
|
+
if (!row) {
|
|
1113
|
+
insertChange(db, incoming);
|
|
1114
|
+
importedChanges += 1;
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
const current = durableChange(row, null);
|
|
1118
|
+
const last = lastChangeDigests[incoming.id] || null;
|
|
1119
|
+
if (current.digest === incoming.digest) {
|
|
1120
|
+
preservedChanges += 1;
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
if (!last) {
|
|
1124
|
+
throw new TaskLedgerError(
|
|
1125
|
+
'TASK_LEDGER_CHANGE_CONFLICT',
|
|
1126
|
+
`Change ${incoming.id} exists locally without a common team checkpoint`,
|
|
1127
|
+
{
|
|
1128
|
+
change_id: incoming.id,
|
|
1129
|
+
local_digest: current.digest,
|
|
1130
|
+
incoming_digest: incoming.digest,
|
|
1131
|
+
},
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
if (current.digest !== last && incoming.digest === last) {
|
|
1135
|
+
preservedChanges += 1;
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
if (current.digest !== last && incoming.digest !== last) {
|
|
1139
|
+
throw new TaskLedgerError(
|
|
1140
|
+
'TASK_LEDGER_CHANGE_CONFLICT',
|
|
1141
|
+
`Change ${incoming.id} changed both locally and in the Git ledger`,
|
|
1142
|
+
{
|
|
1143
|
+
change_id: incoming.id,
|
|
1144
|
+
last_digest: last,
|
|
1145
|
+
local_digest: current.digest,
|
|
1146
|
+
incoming_digest: incoming.digest,
|
|
1147
|
+
},
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
updateChangeFromLedger(db, incoming);
|
|
1151
|
+
importedChanges += 1;
|
|
1152
|
+
}
|
|
1153
|
+
for (const incoming of document.tasks) {
|
|
1154
|
+
const current = ops.readTask(db, incoming.id);
|
|
1155
|
+
const last = lastDigests[incoming.id] || null;
|
|
1156
|
+
if (!current) {
|
|
1157
|
+
insertTaskFromLedger(db, incoming);
|
|
1158
|
+
parentUpdates.push([incoming.id, incoming.parent_id || null]);
|
|
1159
|
+
imported += 1;
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
1162
|
+
const currentDurable = durableTask(current, null);
|
|
1163
|
+
if (currentDurable.digest === incoming.digest) {
|
|
1164
|
+
preserved += 1;
|
|
1165
|
+
parentUpdates.push([incoming.id, incoming.parent_id || null]);
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
if (current.status === 'in_progress' && last && incoming.digest !== last) {
|
|
1169
|
+
throw new TaskLedgerError(
|
|
1170
|
+
'TASK_LEDGER_ACTIVE_TASK_CONFLICT',
|
|
1171
|
+
`task ${incoming.id} changed in Git while a local session is in progress`,
|
|
1172
|
+
{ task_id: incoming.id, local_status: current.status },
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
if (last && currentDurable.digest !== last && incoming.digest === last) {
|
|
1176
|
+
preserved += 1;
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
if (last && currentDurable.digest !== last && incoming.digest !== last) {
|
|
1180
|
+
throw new TaskLedgerError(
|
|
1181
|
+
'TASK_LEDGER_CONFLICT',
|
|
1182
|
+
`task ${incoming.id} changed both locally and in the Git ledger`,
|
|
1183
|
+
{
|
|
1184
|
+
task_id: incoming.id,
|
|
1185
|
+
last_digest: last,
|
|
1186
|
+
local_digest: currentDurable.digest,
|
|
1187
|
+
incoming_digest: incoming.digest,
|
|
1188
|
+
},
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
updateTaskFromLedger(db, incoming);
|
|
1192
|
+
parentUpdates.push([incoming.id, incoming.parent_id || null]);
|
|
1193
|
+
imported += 1;
|
|
1194
|
+
}
|
|
1195
|
+
const incomingIds = new Set(document.tasks.map((task) => task.id));
|
|
1196
|
+
for (const [id, last] of Object.entries(lastDigests)) {
|
|
1197
|
+
if (incomingIds.has(id)) continue;
|
|
1198
|
+
const current = ops.readTask(db, id);
|
|
1199
|
+
if (!current) continue;
|
|
1200
|
+
const activeSession = db.prepare(
|
|
1201
|
+
"SELECT sid FROM sessions WHERE task_id = ? AND status = 'running' LIMIT 1",
|
|
1202
|
+
).get(id);
|
|
1203
|
+
if (current.status === 'in_progress' || current.session_id || activeSession) {
|
|
1204
|
+
throw new TaskLedgerError(
|
|
1205
|
+
'TASK_LEDGER_ACTIVE_TASK_CONFLICT',
|
|
1206
|
+
`task ${id} was removed from Git while local work is in progress`,
|
|
1207
|
+
{ task_id: id, local_status: current.status },
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
const currentDurable = durableTask(current, null);
|
|
1211
|
+
if (currentDurable.digest !== last) {
|
|
1212
|
+
throw new TaskLedgerError(
|
|
1213
|
+
'TASK_LEDGER_CONFLICT',
|
|
1214
|
+
`task ${id} was removed from Git after it changed locally`,
|
|
1215
|
+
{
|
|
1216
|
+
task_id: id,
|
|
1217
|
+
last_digest: last,
|
|
1218
|
+
local_digest: currentDurable.digest,
|
|
1219
|
+
incoming_digest: null,
|
|
1220
|
+
},
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
const sessionCount = db.prepare(
|
|
1224
|
+
'SELECT COUNT(*) AS count FROM sessions WHERE task_id = ?',
|
|
1225
|
+
).get(id).count;
|
|
1226
|
+
if (sessionCount > 0) {
|
|
1227
|
+
throw new TaskLedgerError(
|
|
1228
|
+
'TASK_LEDGER_CONFLICT',
|
|
1229
|
+
`task ${id} cannot be removed because local session history still references it`,
|
|
1230
|
+
{ task_id: id, session_count: sessionCount },
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
|
|
1234
|
+
deleted += 1;
|
|
1235
|
+
}
|
|
1236
|
+
for (const [id, parentId] of parentUpdates) {
|
|
1237
|
+
if (parentId && !db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(parentId)) {
|
|
1238
|
+
throw new TaskLedgerError(
|
|
1239
|
+
'TASK_LEDGER_PARENT_MISSING',
|
|
1240
|
+
`task ${id} references missing parent ${parentId}`,
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
db.prepare('UPDATE tasks SET parent_id = ? WHERE id = ?').run(parentId, id);
|
|
1244
|
+
}
|
|
1245
|
+
recordLedgerEvent(db, 'task_ledger_imported', document, {
|
|
1246
|
+
imported,
|
|
1247
|
+
preserved,
|
|
1248
|
+
deleted,
|
|
1249
|
+
imported_changes: importedChanges,
|
|
1250
|
+
preserved_changes: preservedChanges,
|
|
1251
|
+
});
|
|
1252
|
+
});
|
|
1253
|
+
return {
|
|
1254
|
+
imported,
|
|
1255
|
+
preserved,
|
|
1256
|
+
deleted,
|
|
1257
|
+
imported_changes: importedChanges,
|
|
1258
|
+
preserved_changes: preservedChanges,
|
|
1259
|
+
imported_baseline: baselineImport.imported,
|
|
1260
|
+
preserved_baseline: baselineImport.preserved,
|
|
1261
|
+
generation: document.generation,
|
|
1262
|
+
state_digest: document.state_digest,
|
|
1263
|
+
requires_plan_revalidation: imported > 0
|
|
1264
|
+
|| deleted > 0
|
|
1265
|
+
|| importedChanges > 0
|
|
1266
|
+
|| baselineImport.imported,
|
|
1267
|
+
requires_baseline_revalidation: baselineImport.requiresRevalidation,
|
|
1268
|
+
already_current: false,
|
|
1269
|
+
ledger: document,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function inspectTaskLedger(db, { rootDir = '.' } = {}) {
|
|
1274
|
+
const document = readTaskLedger(rootDir);
|
|
1275
|
+
if (!document) {
|
|
1276
|
+
return {
|
|
1277
|
+
status: 'missing',
|
|
1278
|
+
path: ledgerPath(rootDir),
|
|
1279
|
+
generation: null,
|
|
1280
|
+
state_digest: null,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
const localTasks = ops.listTasks(db, { status: 'any', limit: 1000 });
|
|
1284
|
+
const previous = new Map(document.tasks.map((task) => [task.id, task]));
|
|
1285
|
+
const local = localTasks.map((task) => durableTask(task, previous.get(task.id)))
|
|
1286
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
1287
|
+
const localChanges = readChangesForLedger(
|
|
1288
|
+
db,
|
|
1289
|
+
localTasks,
|
|
1290
|
+
new Map(document.changes.map((change) => [change.id, change])),
|
|
1291
|
+
);
|
|
1292
|
+
const localBaseline = readBaselineForLedger(db, document.baseline || null);
|
|
1293
|
+
const localDigest = sha256(statePayload({
|
|
1294
|
+
baseline: localBaseline,
|
|
1295
|
+
changes: localChanges,
|
|
1296
|
+
tasks: local,
|
|
1297
|
+
}));
|
|
1298
|
+
const baselineRequiresRevalidation = Array.isArray(localBaseline?.gaps)
|
|
1299
|
+
&& localBaseline.gaps.some((gap) => (
|
|
1300
|
+
gap.id === 'team-ledger-revalidation-required' && gap.status === 'open'
|
|
1301
|
+
));
|
|
1302
|
+
const importedPayloadDigest = baselineRequiresRevalidation
|
|
1303
|
+
? sha256(statePayload({
|
|
1304
|
+
baseline: document.baseline,
|
|
1305
|
+
changes: localChanges,
|
|
1306
|
+
tasks: local,
|
|
1307
|
+
}))
|
|
1308
|
+
: null;
|
|
1309
|
+
const status = localDigest === document.state_digest
|
|
1310
|
+
? 'current'
|
|
1311
|
+
: (importedPayloadDigest === document.state_digest
|
|
1312
|
+
? 'revalidation_required'
|
|
1313
|
+
: 'drifted');
|
|
1314
|
+
return {
|
|
1315
|
+
status,
|
|
1316
|
+
path: ledgerPath(rootDir),
|
|
1317
|
+
generation: document.generation,
|
|
1318
|
+
state_digest: document.state_digest,
|
|
1319
|
+
local_state_digest: localDigest,
|
|
1320
|
+
baseline_revalidation_required: baselineRequiresRevalidation,
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
module.exports = {
|
|
1325
|
+
DURABLE_BASELINE_FIELDS,
|
|
1326
|
+
DURABLE_CHANGE_FIELDS,
|
|
1327
|
+
DURABLE_TASK_FIELDS,
|
|
1328
|
+
LEDGER_KIND,
|
|
1329
|
+
LEDGER_RELATIVE_PATH,
|
|
1330
|
+
LEDGER_SCHEMA_VERSION,
|
|
1331
|
+
TaskLedgerError,
|
|
1332
|
+
durableChange,
|
|
1333
|
+
durableBaseline,
|
|
1334
|
+
durableTask,
|
|
1335
|
+
importTaskLedger,
|
|
1336
|
+
inspectTaskLedger,
|
|
1337
|
+
ledgerPath,
|
|
1338
|
+
publishTaskLedger,
|
|
1339
|
+
readTaskLedger,
|
|
1340
|
+
syncTaskLedger,
|
|
1341
|
+
validateLedger,
|
|
1342
|
+
};
|