@quolu/lattice 0.50.1 → 0.52.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/bin/lattice-work-order-adapter.mjs +20 -0
- package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -0
- package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -0
- package/package.json +5 -2
- package/src/boundary-observation-compiler-v2.mjs +1 -1
- package/src/cli-help.mjs +33 -2
- package/src/rc3-actual-dogfood.mjs +6 -2
- package/src/rc3-scripted-campaign.mjs +37 -10
- package/src/rc4-stage1-dogfood.mjs +6 -2
- package/src/runtime-adapter-registry.mjs +21 -7
- package/src/runtime-cli.mjs +476 -34
- package/src/runtime-contracts.mjs +59 -13
- package/src/runtime-controller-protocol.mjs +48 -3
- package/src/runtime-decision-verifier.mjs +70 -0
- package/src/runtime-diff-observer.mjs +66 -4
- package/src/runtime-direct-os-observer.mjs +25 -8
- package/src/runtime-driver-state.mjs +162 -0
- package/src/runtime-engine.mjs +37 -6
- package/src/runtime-front-end.mjs +39 -1
- package/src/runtime-managed-supervisor.mjs +80 -14
- package/src/runtime-multi-epoch-store.mjs +87 -14
- package/src/runtime-pull-intake.mjs +1188 -0
- package/src/runtime-work-order-contracts.mjs +91 -0
- package/src/runtime-work-order-controller.mjs +1167 -0
- package/src/seam-proposal-queries.mjs +1 -1
- package/src/todo-cli.mjs +273 -7
- package/src/todo-contracts.mjs +19 -2
- package/src/todo-gantt-html-independence.mjs +3 -2
- package/src/todo-gantt-html-shared.mjs +1 -2
- package/src/todo-gantt-html-style.mjs +13 -0
- package/src/todo-gantt-html.mjs +15 -2
- package/src/todo-gantt-layout.mjs +75 -1
- package/src/todo-gantt-nested.mjs +263 -0
- package/src/todo-gantt-svg.mjs +80 -5
- package/src/todo-independence-contracts.mjs +73 -7
- package/src/todo-independence-guidance.mjs +30 -1
- package/src/todo-independence.mjs +89 -7
- package/src/todo-revision.mjs +1 -1
- package/src/todo-split.mjs +472 -0
- package/src/todo-status.mjs +10 -1
- package/src/todo-store-git-transaction.mjs +418 -0
- package/src/todo-store.mjs +144 -4
|
@@ -86,7 +86,7 @@ function conflictsFrom(verdicts, resources) {
|
|
|
86
86
|
.map((verdict) => {
|
|
87
87
|
const resource = resourceById.get(verdict.resource_id);
|
|
88
88
|
const kind = resource?.kind;
|
|
89
|
-
if (!['symbol', 'path', 'state', 'effect'].includes(kind)) {
|
|
89
|
+
if (!['symbol', 'path', 'state', 'effect', 'line'].includes(kind)) {
|
|
90
90
|
fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
|
|
91
91
|
resource_id: verdict.resource_id, observed_kind: kind ?? null,
|
|
92
92
|
});
|
|
@@ -128,6 +128,9 @@ function boundaryPathsOf(witness) {
|
|
|
128
128
|
for (const path of witness.reads) paths.add(path);
|
|
129
129
|
for (const path of witness.affected_tests) paths.add(path);
|
|
130
130
|
for (const entry of witness.sensor_provenance.queries) paths.add(entry.expect.path);
|
|
131
|
+
for (const line of witness.lines ?? []) {
|
|
132
|
+
for (const anchor of line.anchors) paths.add(anchor.path);
|
|
133
|
+
}
|
|
131
134
|
return [...paths].sort(compareText);
|
|
132
135
|
}
|
|
133
136
|
|
|
@@ -173,7 +176,7 @@ function unknownsFrom(detail) {
|
|
|
173
176
|
}
|
|
174
177
|
|
|
175
178
|
/**
|
|
176
|
-
* witness setとsensor evidenceから`lattice.todo_independence.
|
|
179
|
+
* witness setとsensor evidenceから`lattice.todo_independence.v5`を作る。
|
|
177
180
|
*
|
|
178
181
|
* compileは宣言済みtaskの部分集合へ閉じる(ADR 0127 Decision 4)。未宣言taskを混ぜると、
|
|
179
182
|
* unknownが1件でも出た時点で宣言済みtask同士の判定まで失われるためである。
|
|
@@ -181,8 +184,80 @@ function unknownsFrom(detail) {
|
|
|
181
184
|
* unknownをtask単位で記録してwave planを持たない。それ以外のnon-dispatchableは
|
|
182
185
|
* 契約違反または観測不成立であり、artifactへ丸めずtyped errorで止める。
|
|
183
186
|
*/
|
|
187
|
+
/**
|
|
188
|
+
* 前回artifactと今回の宣言を突き合わせて、task別の膨張を事実だけ記録する(s1)。
|
|
189
|
+
*
|
|
190
|
+
* **判定しない。** 上流の契約確定に追従した膨張・自分の変更の後始末・元から在った面の見落とし・
|
|
191
|
+
* 思いつきで盛った分は、**機械には区別できない**(2026-08-09の実測: 1つのtaskへ前3種が同時に出た)。
|
|
192
|
+
* 装置が言えるのは「増えた」「何が増減した」「何回目か」「合流点か」までで、仕分けはAIがやる。
|
|
193
|
+
*
|
|
194
|
+
* 前回が無い(初回・plan version違い・legacy版)時は、比較相手なしとして今回の宣言を初回として置く。
|
|
195
|
+
* **「前回が読めなかった」を「膨張ゼロ」に化かさない**ため、compared_witness_digestで区別できる。
|
|
196
|
+
*/
|
|
197
|
+
function scopeExpansionFrom({ taskIds, boundaries, previousArtifact, plan }) {
|
|
198
|
+
const comparable = previousArtifact !== null && previousArtifact !== undefined
|
|
199
|
+
&& previousArtifact.schema === TODO_INDEPENDENCE_SCHEMA
|
|
200
|
+
&& previousArtifact.plan_key === plan.plan_key
|
|
201
|
+
&& previousArtifact.plan_version === plan.plan_version
|
|
202
|
+
&& previousArtifact.topology_digest === plan.topology_digest;
|
|
203
|
+
const previousBoundaries = new Map(comparable
|
|
204
|
+
? previousArtifact.task_boundaries.map((entry) => [entry.task_id, entry.paths])
|
|
205
|
+
: []);
|
|
206
|
+
const previousExpansion = new Map(comparable
|
|
207
|
+
? previousArtifact.scope_expanded.map((entry) => [entry.task_id, entry])
|
|
208
|
+
: []);
|
|
209
|
+
// 合流点(gate形)はplanのtopologyが持つ事実で、宣言とは独立に決まる。
|
|
210
|
+
const incoming = new Map(taskIds.map((taskId) => [taskId, 0]));
|
|
211
|
+
for (const edge of plan.hard_dependencies ?? []) {
|
|
212
|
+
const to = edge?.to?.task_id ?? edge?.to_task_id;
|
|
213
|
+
if (incoming.has(to)) incoming.set(to, incoming.get(to) + 1);
|
|
214
|
+
}
|
|
215
|
+
return taskIds.map((taskId) => {
|
|
216
|
+
const current = boundaries.get(taskId) ?? [];
|
|
217
|
+
const previous = previousBoundaries.get(taskId) ?? null;
|
|
218
|
+
const prior = previousExpansion.get(taskId) ?? null;
|
|
219
|
+
// **subset入替を「初回」へ偽装しない。** witnessはwave単位のsubsetで書き出せるので、
|
|
220
|
+
// A→B→Aと入れ替わると直前artifactにAが居ない。そこで今回を初回と置くと、
|
|
221
|
+
// **本当の初回宣言数と膨張回数が消える**(suzune の監査で実測・room [1152])。
|
|
222
|
+
// 比較相手はあるのにこのtaskが居ない時は、**数を主張せずnullで「分からない」と言う。**
|
|
223
|
+
if (comparable && previous === null) {
|
|
224
|
+
return {
|
|
225
|
+
task_id: taskId,
|
|
226
|
+
compared_witness_digest: previousArtifact.witness_set_digest,
|
|
227
|
+
first_seen_path_count: null,
|
|
228
|
+
path_count: current.length,
|
|
229
|
+
added_paths: [],
|
|
230
|
+
removed_paths: [],
|
|
231
|
+
growth_events: null,
|
|
232
|
+
gate_shape: (incoming.get(taskId) ?? 0) >= 2,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const currentSet = new Set(current);
|
|
236
|
+
const previousSet = new Set(previous ?? []);
|
|
237
|
+
const added = previous === null ? [] : current.filter((path) => !previousSet.has(path));
|
|
238
|
+
const removed = previous === null ? [] : previous.filter((path) => !currentSet.has(path));
|
|
239
|
+
return {
|
|
240
|
+
task_id: taskId,
|
|
241
|
+
compared_witness_digest: comparable ? previousArtifact.witness_set_digest : null,
|
|
242
|
+
// **unknown は後続へ伝播する。** 一度 subset gap で累計が分からなくなった task は、
|
|
243
|
+
// 次の compile で `(null ?? 0) + 1` のように既知へ戻してはいけない——
|
|
244
|
+
// **欠落前の累計が分からないことは、その後も分からないまま**である(suzune [1173])。
|
|
245
|
+
first_seen_path_count: prior === null
|
|
246
|
+
? current.length
|
|
247
|
+
: prior.first_seen_path_count,
|
|
248
|
+
path_count: current.length,
|
|
249
|
+
added_paths: [...added].sort(compareText),
|
|
250
|
+
removed_paths: [...removed].sort(compareText),
|
|
251
|
+
growth_events: prior !== null && prior.growth_events === null
|
|
252
|
+
? null
|
|
253
|
+
: (prior?.growth_events ?? 0) + (added.length > 0 ? 1 : 0),
|
|
254
|
+
gate_shape: (incoming.get(taskId) ?? 0) >= 2,
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
184
259
|
export function compileTodoIndependence(options = {}) {
|
|
185
|
-
const { witnessSet, plan, baseSha, compiledAt, sensorEvidence } = options;
|
|
260
|
+
const { witnessSet, plan, baseSha, compiledAt, sensorEvidence, previousArtifact = null } = options;
|
|
186
261
|
if (!validateTodoWitnessSet(witnessSet)) {
|
|
187
262
|
fail('INDEPENDENCE_WITNESS_INVALID', 'witness_set_invalid');
|
|
188
263
|
}
|
|
@@ -222,6 +297,10 @@ export function compileTodoIndependence(options = {}) {
|
|
|
222
297
|
const conflictProjection = dispatchable
|
|
223
298
|
? conflictsFrom(compiled.pairwise_verdicts, compiled.resources)
|
|
224
299
|
: { conflicts: [], conflictResources: [] };
|
|
300
|
+
const boundaryEntries = taskIds.map((taskId) => ({
|
|
301
|
+
task_id: taskId,
|
|
302
|
+
paths: boundaryPathsOf(witnessSet.manual_witness[taskId]),
|
|
303
|
+
}));
|
|
225
304
|
const artifact = {
|
|
226
305
|
schema: TODO_INDEPENDENCE_SCHEMA,
|
|
227
306
|
project_id: plan.project_id,
|
|
@@ -232,10 +311,7 @@ export function compileTodoIndependence(options = {}) {
|
|
|
232
311
|
witness_set_digest: witnessSet.witness_set_digest,
|
|
233
312
|
compiled_at: compiledAt,
|
|
234
313
|
task_ids: taskIds,
|
|
235
|
-
task_boundaries:
|
|
236
|
-
task_id: taskId,
|
|
237
|
-
paths: boundaryPathsOf(witnessSet.manual_witness[taskId]),
|
|
238
|
-
})),
|
|
314
|
+
task_boundaries: boundaryEntries,
|
|
239
315
|
conflict_resources: conflictProjection.conflictResources,
|
|
240
316
|
conflicts: conflictProjection.conflicts,
|
|
241
317
|
precedences: dispatchable ? precedencesFrom(compiled.pairwise_verdicts) : [],
|
|
@@ -248,6 +324,12 @@ export function compileTodoIndependence(options = {}) {
|
|
|
248
324
|
minimum_feasible_waves: compiled.schedule.minimum_feasible_waves,
|
|
249
325
|
}
|
|
250
326
|
: null,
|
|
327
|
+
scope_expanded: scopeExpansionFrom({
|
|
328
|
+
taskIds,
|
|
329
|
+
boundaries: new Map(boundaryEntries.map((entry) => [entry.task_id, entry.paths])),
|
|
330
|
+
previousArtifact,
|
|
331
|
+
plan,
|
|
332
|
+
}),
|
|
251
333
|
outcome: dispatchable ? 'compiled' : 'unknown',
|
|
252
334
|
result_digest: '',
|
|
253
335
|
};
|
package/src/todo-revision.mjs
CHANGED
|
@@ -215,7 +215,7 @@ function canonicalizeForCompare(value) {
|
|
|
215
215
|
)));
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
function todoTaskMigrationDigest(taskMigration) {
|
|
218
|
+
export function todoTaskMigrationDigest(taskMigration) {
|
|
219
219
|
return todoSelfDigest({ task_migration: taskMigration, task_migration_digest: '' },
|
|
220
220
|
'task_migration_digest');
|
|
221
221
|
}
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
lstat, readFile, realpath,
|
|
4
|
+
} from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
canonicalizeTodoArtifact,
|
|
9
|
+
digestTodoArtifact,
|
|
10
|
+
exactRecord,
|
|
11
|
+
isTodoDesignMemo,
|
|
12
|
+
isTodoDigest,
|
|
13
|
+
isTodoIdentifier,
|
|
14
|
+
isTodoRef,
|
|
15
|
+
todoSelfDigest,
|
|
16
|
+
} from './todo-contracts.mjs';
|
|
17
|
+
import { migrateWitnessSetTaskIds } from './todo-independence.mjs';
|
|
18
|
+
import { buildTodoPlan } from './todo-store.mjs';
|
|
19
|
+
import {
|
|
20
|
+
parseTodoSourceRef,
|
|
21
|
+
phaseTodoRevisionPlanVersion,
|
|
22
|
+
todoCutoverArchiveSourceRef,
|
|
23
|
+
todoLegacyReconciliationDigest,
|
|
24
|
+
todoReconciliationDigest,
|
|
25
|
+
todoRevisionPlanVersion,
|
|
26
|
+
todoSourceInventoryDigest,
|
|
27
|
+
todoTaskMigrationDigest,
|
|
28
|
+
validatePhaseTodoRevision,
|
|
29
|
+
validateTodoRevision,
|
|
30
|
+
} from './todo-revision.mjs';
|
|
31
|
+
|
|
32
|
+
const TODO_SPLIT_SCHEMA = 'lattice.todo_split.v1';
|
|
33
|
+
const SUPPORTED_PLAN_SCHEMAS = new Set(['lattice.todo_plan.v6', 'lattice.todo_plan.v7']);
|
|
34
|
+
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
|
|
35
|
+
|
|
36
|
+
export class TodoSplitError extends Error {
|
|
37
|
+
constructor(message, detail = {}) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = 'TodoSplitError';
|
|
40
|
+
this.code = 'TODO_SPLIT_INVALID';
|
|
41
|
+
this.detail = detail;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const boundedText = (value) => typeof value === 'string' && value.trim().length > 0
|
|
46
|
+
&& Buffer.byteLength(value, 'utf8') <= 16_384 && !CONTROL.test(value);
|
|
47
|
+
|
|
48
|
+
function invalid(reason, pointer = '') {
|
|
49
|
+
throw new TodoSplitError(reason, { reason, pointer });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function validateTodoSplitProposal(value) {
|
|
53
|
+
try {
|
|
54
|
+
if (!exactRecord(value, [
|
|
55
|
+
'schema', 'project_id', 'plan_key', 'task_id', 'reason', 'evidence_digests',
|
|
56
|
+
'archive_ref', 'residual', 'extracted_tasks',
|
|
57
|
+
]) || value.schema !== TODO_SPLIT_SCHEMA
|
|
58
|
+
|| !isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
|
|
59
|
+
|| !isTodoIdentifier(value.task_id) || !boundedText(value.reason)
|
|
60
|
+
|| !isTodoRef(value.archive_ref) || !value.archive_ref.endsWith('.md')
|
|
61
|
+
|| parseTodoSourceRef(value.archive_ref) !== null
|
|
62
|
+
|| !exactRecord(value.residual, ['title', 'lane', 'design_memo'])
|
|
63
|
+
|| !boundedText(value.residual.title) || !isTodoIdentifier(value.residual.lane)
|
|
64
|
+
|| !isTodoDesignMemo(value.residual.design_memo)
|
|
65
|
+
|| !Array.isArray(value.evidence_digests) || value.evidence_digests.length < 1
|
|
66
|
+
|| value.evidence_digests.length > 512 || !value.evidence_digests.every(isTodoDigest)
|
|
67
|
+
|| new Set(value.evidence_digests).size !== value.evidence_digests.length
|
|
68
|
+
|| !Array.isArray(value.extracted_tasks) || value.extracted_tasks.length < 1
|
|
69
|
+
|| value.extracted_tasks.length > 511) return false;
|
|
70
|
+
const childIds = new Set();
|
|
71
|
+
const sourceRefs = new Set();
|
|
72
|
+
for (const child of value.extracted_tasks) {
|
|
73
|
+
if (!exactRecord(child, [
|
|
74
|
+
'task_id', 'title', 'lane', 'design_memo', 'source_ref', 'depends_on',
|
|
75
|
+
]) || !isTodoIdentifier(child.task_id) || child.task_id === value.task_id
|
|
76
|
+
|| childIds.has(child.task_id) || !boundedText(child.title)
|
|
77
|
+
|| !isTodoIdentifier(child.lane) || !isTodoDesignMemo(child.design_memo)
|
|
78
|
+
|| parseTodoSourceRef(child.source_ref) === null || sourceRefs.has(child.source_ref)
|
|
79
|
+
|| !Array.isArray(child.depends_on) || child.depends_on.length > 511
|
|
80
|
+
|| !child.depends_on.every(isTodoIdentifier)
|
|
81
|
+
|| new Set(child.depends_on).size !== child.depends_on.length
|
|
82
|
+
|| child.depends_on.includes(child.task_id)) return false;
|
|
83
|
+
childIds.add(child.task_id);
|
|
84
|
+
sourceRefs.add(child.source_ref);
|
|
85
|
+
}
|
|
86
|
+
return value.extracted_tasks.every(({ depends_on: dependencies }) => (
|
|
87
|
+
dependencies.every((taskId) => childIds.has(taskId))
|
|
88
|
+
));
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function within(root, target) {
|
|
95
|
+
const relative = path.relative(root, target);
|
|
96
|
+
return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`)
|
|
97
|
+
&& !path.isAbsolute(relative);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function splitLines(bytes) {
|
|
101
|
+
const lines = [];
|
|
102
|
+
let start = 0;
|
|
103
|
+
for (let index = 0; index <= bytes.length; index += 1) {
|
|
104
|
+
if (index === bytes.length || bytes[index] === 0x0a) {
|
|
105
|
+
lines.push(bytes.subarray(start, index));
|
|
106
|
+
start = index + 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function sourceLine(repoRoot, sourceRef, { checkbox = false } = {}) {
|
|
113
|
+
const parsed = parseTodoSourceRef(sourceRef);
|
|
114
|
+
if (parsed === null) invalid('source_ref_invalid', '/extracted_tasks/source_ref');
|
|
115
|
+
const canonicalRoot = await realpath(repoRoot);
|
|
116
|
+
const absolute = path.resolve(canonicalRoot, parsed.path);
|
|
117
|
+
if (!within(canonicalRoot, absolute)) invalid('source_outside_repository', sourceRef);
|
|
118
|
+
let metadata;
|
|
119
|
+
try { metadata = await lstat(absolute); } catch { invalid('source_missing', sourceRef); }
|
|
120
|
+
if (metadata.isSymbolicLink() || !metadata.isFile()) invalid('source_path_unsafe', sourceRef);
|
|
121
|
+
const resolved = await realpath(absolute);
|
|
122
|
+
if (resolved !== absolute || !within(canonicalRoot, resolved)) invalid('source_path_alias', sourceRef);
|
|
123
|
+
const line = splitLines(await readFile(resolved))[parsed.line - 1];
|
|
124
|
+
if (line === undefined) invalid('source_line_missing', sourceRef);
|
|
125
|
+
let text;
|
|
126
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(line); }
|
|
127
|
+
catch { invalid('source_invalid_utf8', sourceRef); }
|
|
128
|
+
const match = /^([\t ]*)([-+*]|\d+[A-Za-z]?\.|\d+\))[\t ]+\[[ xX]\](?:[\t ]+.*)?\r?$/u.exec(text);
|
|
129
|
+
if (checkbox && match === null) invalid('source_item_not_todo', sourceRef);
|
|
130
|
+
return {
|
|
131
|
+
bytes: line,
|
|
132
|
+
digest: createHash('sha256').update(line).digest('hex'),
|
|
133
|
+
marker: match === null ? null : `${match[1]}${match[2]}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const compareCanonical = (left, right) => {
|
|
138
|
+
const leftBytes = canonicalizeTodoArtifact(left);
|
|
139
|
+
const rightBytes = canonicalizeTodoArtifact(right);
|
|
140
|
+
return leftBytes < rightBytes ? -1 : leftBytes > rightBytes ? 1 : 0;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
function predecessorReconciliationDigest(member, phasePlan) {
|
|
144
|
+
const genesis = member.journal.events[0];
|
|
145
|
+
if (genesis.schema === 'lattice.todo_event.v2') return genesis.reconciliation_digest;
|
|
146
|
+
if (phasePlan && isTodoDigest(member.revision?.reconciliation?.reconciliation_digest)) {
|
|
147
|
+
return member.revision.reconciliation.reconciliation_digest;
|
|
148
|
+
}
|
|
149
|
+
return todoLegacyReconciliationDigest({
|
|
150
|
+
planDigest: member.plan.plan_digest,
|
|
151
|
+
journalHeadDigest: member.journal.events.at(-1).event_digest,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function existingSourceInventory(repoRoot, member) {
|
|
156
|
+
const inheritedInventory = member.revision?.source_inventory !== undefined;
|
|
157
|
+
const inherited = new Map((member.revision?.source_inventory?.active ?? [])
|
|
158
|
+
.map((entry) => [entry.task_id, structuredClone(entry)]));
|
|
159
|
+
const active = [];
|
|
160
|
+
for (const task of member.plan.tasks) {
|
|
161
|
+
const entry = inherited.get(task.task_id);
|
|
162
|
+
if (entry !== undefined) {
|
|
163
|
+
active.push(entry);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (typeof task.narrative_ref !== 'string' || parseTodoSourceRef(task.narrative_ref) === null) {
|
|
167
|
+
invalid('predecessor_source_inventory_unavailable', `/tasks/${task.task_id}/narrative_ref`);
|
|
168
|
+
}
|
|
169
|
+
const source = await sourceLine(repoRoot, task.narrative_ref);
|
|
170
|
+
active.push({ task_id: task.task_id, source_ref: task.narrative_ref, source_digest: source.digest });
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
active,
|
|
174
|
+
excluded_tombstones: structuredClone(
|
|
175
|
+
member.revision?.source_inventory?.excluded_tombstones ?? [],
|
|
176
|
+
),
|
|
177
|
+
inherited: inheritedInventory,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function localTaskRef(plan, taskId) {
|
|
182
|
+
return { project_id: plan.project_id, plan_key: plan.plan_key, task_id: taskId };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function compileSourceCutover(repoRoot, proposal, {
|
|
186
|
+
existingTasks = [], reservedSourceRefs = [],
|
|
187
|
+
} = {}) {
|
|
188
|
+
const definitions = [
|
|
189
|
+
...existingTasks.map((task) => ({
|
|
190
|
+
task_id: task.task_id,
|
|
191
|
+
title: task.task_id === proposal.task_id ? proposal.residual.title : task.title,
|
|
192
|
+
source_ref: task.narrative_ref,
|
|
193
|
+
})),
|
|
194
|
+
...proposal.extracted_tasks,
|
|
195
|
+
];
|
|
196
|
+
const occupiedSourceRefs = new Set(reservedSourceRefs);
|
|
197
|
+
const rows = [];
|
|
198
|
+
for (const definition of definitions) {
|
|
199
|
+
if (occupiedSourceRefs.has(definition.source_ref)) {
|
|
200
|
+
invalid('source_ref_conflicts_with_predecessor', definition.source_ref);
|
|
201
|
+
}
|
|
202
|
+
occupiedSourceRefs.add(definition.source_ref);
|
|
203
|
+
const line = await sourceLine(repoRoot, definition.source_ref, { checkbox: true });
|
|
204
|
+
rows.push({ definition, line });
|
|
205
|
+
}
|
|
206
|
+
rows.sort((left, right) => left.definition.source_ref.localeCompare(right.definition.source_ref));
|
|
207
|
+
const batch = {
|
|
208
|
+
batch_id: `split-${digestTodoArtifact(proposal).slice(0, 24)}`,
|
|
209
|
+
archive_ref: proposal.archive_ref,
|
|
210
|
+
operations: rows.map(({ definition, line }) => ({
|
|
211
|
+
task_id: definition.task_id,
|
|
212
|
+
disposition: 'active',
|
|
213
|
+
source_ref: definition.source_ref,
|
|
214
|
+
source_digest: line.digest,
|
|
215
|
+
live_replacement: `${line.marker} Lattice todo splitへ移行済み(${definition.task_id}): ${definition.title}`,
|
|
216
|
+
})),
|
|
217
|
+
batch_digest: '',
|
|
218
|
+
};
|
|
219
|
+
batch.batch_digest = todoSelfDigest(batch, 'batch_digest');
|
|
220
|
+
return { batch, archivedByTask: new Map(rows.map(({ definition }, index) => [
|
|
221
|
+
definition.task_id,
|
|
222
|
+
{ source_ref: todoCutoverArchiveSourceRef(batch, index),
|
|
223
|
+
source_digest: batch.operations[index].source_digest },
|
|
224
|
+
])) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function compileTasks(plan, proposal, archivedByTask) {
|
|
228
|
+
const residual = plan.tasks.find(({ task_id: taskId }) => taskId === proposal.task_id);
|
|
229
|
+
const phasePlan = plan.schema === 'lattice.todo_plan.v7';
|
|
230
|
+
const existing = plan.tasks.map((task) => {
|
|
231
|
+
const updated = task.task_id === proposal.task_id ? {
|
|
232
|
+
...task,
|
|
233
|
+
title: proposal.residual.title,
|
|
234
|
+
lane: proposal.residual.lane,
|
|
235
|
+
design_memo: proposal.residual.design_memo,
|
|
236
|
+
} : task;
|
|
237
|
+
const archived = archivedByTask.get(task.task_id);
|
|
238
|
+
return archived === undefined ? updated : { ...updated, narrative_ref: archived.source_ref };
|
|
239
|
+
});
|
|
240
|
+
const children = [...proposal.extracted_tasks]
|
|
241
|
+
.sort((left, right) => left.task_id.localeCompare(right.task_id))
|
|
242
|
+
.map((child) => ({
|
|
243
|
+
task_id: child.task_id,
|
|
244
|
+
title: child.title,
|
|
245
|
+
lane: child.lane,
|
|
246
|
+
design_memo: child.design_memo,
|
|
247
|
+
narrative_ref: archivedByTask.get(child.task_id).source_ref,
|
|
248
|
+
narrative_anchor: null,
|
|
249
|
+
compile_binding: null,
|
|
250
|
+
parent_task_id: proposal.task_id,
|
|
251
|
+
...(phasePlan ? { phase_id: residual.phase_id } : {}),
|
|
252
|
+
}));
|
|
253
|
+
return [...existing, ...children]
|
|
254
|
+
.sort((left, right) => left.task_id < right.task_id ? -1 : left.task_id > right.task_id ? 1 : 0);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function compileDependencies(plan, proposal) {
|
|
258
|
+
const edges = [...plan.hard_dependencies];
|
|
259
|
+
for (const child of proposal.extracted_tasks) {
|
|
260
|
+
for (const predecessorId of child.depends_on) {
|
|
261
|
+
edges.push({
|
|
262
|
+
from: localTaskRef(plan, predecessorId),
|
|
263
|
+
to: localTaskRef(plan, child.task_id),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
edges.push({
|
|
267
|
+
from: localTaskRef(plan, child.task_id),
|
|
268
|
+
to: localTaskRef(plan, proposal.task_id),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
edges.sort(compareCanonical);
|
|
272
|
+
if (new Set(edges.map(canonicalizeTodoArtifact)).size !== edges.length) {
|
|
273
|
+
invalid('duplicate_dependency', '/extracted_tasks/depends_on');
|
|
274
|
+
}
|
|
275
|
+
return edges;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function compileTaskMigration(plan, taskId, reconciledTaskIds = new Set()) {
|
|
279
|
+
return plan.tasks.map(({ task_id: currentTaskId }) => ({
|
|
280
|
+
from_task_id: currentTaskId,
|
|
281
|
+
to_task_id: currentTaskId,
|
|
282
|
+
state_policy: currentTaskId === taskId ? 'reset_pending'
|
|
283
|
+
: reconciledTaskIds.has(currentTaskId) ? 'carry_reconciled_metadata' : 'carry',
|
|
284
|
+
})).sort((left, right) => left.from_task_id.localeCompare(right.from_task_id));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function compileRuntimeTaskMigration(plan, proposal) {
|
|
288
|
+
const children = proposal.extracted_tasks.map(({ task_id: taskId }) => taskId).sort();
|
|
289
|
+
const evidenceDigests = [...proposal.evidence_digests].sort();
|
|
290
|
+
const value = {
|
|
291
|
+
schema: 'lattice.runtime_task_migration.v1',
|
|
292
|
+
entries: plan.tasks.map(({ task_id: taskId }) => taskId === proposal.task_id ? {
|
|
293
|
+
predecessor_task_id: taskId,
|
|
294
|
+
disposition: 'split',
|
|
295
|
+
successor_task_ids: [taskId, ...children],
|
|
296
|
+
reason: proposal.reason,
|
|
297
|
+
evidence_digests: evidenceDigests,
|
|
298
|
+
} : {
|
|
299
|
+
predecessor_task_id: taskId,
|
|
300
|
+
disposition: 'carry',
|
|
301
|
+
successor_task_ids: [taskId],
|
|
302
|
+
reason: 'splitの影響を受けないため状態を維持する',
|
|
303
|
+
evidence_digests: evidenceDigests,
|
|
304
|
+
}).sort((left, right) => left.predecessor_task_id.localeCompare(right.predecessor_task_id)),
|
|
305
|
+
migration_digest: '',
|
|
306
|
+
};
|
|
307
|
+
value.migration_digest = todoSelfDigest(value, 'migration_digest');
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function compileTodoSplit({ repoRoot, member, proposal }) {
|
|
312
|
+
if (!validateTodoSplitProposal(proposal)) invalid('proposal_schema_invalid');
|
|
313
|
+
const plan = member?.plan;
|
|
314
|
+
if (plan?.project_id !== proposal.project_id || plan?.plan_key !== proposal.plan_key) {
|
|
315
|
+
invalid('proposal_plan_binding_mismatch');
|
|
316
|
+
}
|
|
317
|
+
if (!SUPPORTED_PLAN_SCHEMAS.has(plan.schema)) invalid('successor_schema_not_supported');
|
|
318
|
+
const sourceTask = plan.tasks.find(({ task_id: taskId }) => taskId === proposal.task_id);
|
|
319
|
+
if (sourceTask === undefined) invalid('source_task_not_active', '/task_id');
|
|
320
|
+
const runtimeTask = member.tasks.find(({ task_id: taskId }) => taskId === proposal.task_id);
|
|
321
|
+
if (runtimeTask?.status !== 'in-progress') invalid('source_task_not_in_progress', '/task_id');
|
|
322
|
+
const existingIds = new Set(plan.tasks.map(({ task_id: taskId }) => taskId));
|
|
323
|
+
const collision = proposal.extracted_tasks.find(({ task_id: taskId }) => existingIds.has(taskId));
|
|
324
|
+
if (collision !== undefined) invalid('successor_task_id_already_active', `/extracted_tasks/${collision.task_id}`);
|
|
325
|
+
|
|
326
|
+
const predecessor = {
|
|
327
|
+
plan_digest: plan.plan_digest,
|
|
328
|
+
journal_head_digest: member.journal.events.at(-1).event_digest,
|
|
329
|
+
plan_version: plan.plan_version,
|
|
330
|
+
};
|
|
331
|
+
const previousInventory = await existingSourceInventory(repoRoot, member);
|
|
332
|
+
const phasePlan = plan.schema === 'lattice.todo_plan.v7';
|
|
333
|
+
// phase revisionのgenesisではpredecessor inventoryがまだrevisionへ保存されていない。
|
|
334
|
+
// apply側は全既存sourceのarchive移送を差分として要求するため、既存taskも同じbatchへ載せる。
|
|
335
|
+
const cutoverExistingTasks = phasePlan && !previousInventory.inherited ? plan.tasks : [];
|
|
336
|
+
const reservedSourceRefs = cutoverExistingTasks.length === 0
|
|
337
|
+
? previousInventory.active.map(({ source_ref: sourceRef }) => sourceRef)
|
|
338
|
+
: [];
|
|
339
|
+
const { batch: sourceCutoverBatch, archivedByTask } = await compileSourceCutover(
|
|
340
|
+
repoRoot, proposal, { existingTasks: cutoverExistingTasks, reservedSourceRefs },
|
|
341
|
+
);
|
|
342
|
+
const tasks = compileTasks(plan, proposal, archivedByTask);
|
|
343
|
+
const hardDependencies = compileDependencies(plan, proposal);
|
|
344
|
+
const taskMigration = compileTaskMigration(plan, proposal.task_id,
|
|
345
|
+
new Set(cutoverExistingTasks.map(({ task_id: taskId }) => taskId)));
|
|
346
|
+
const sourceInventory = {
|
|
347
|
+
active: tasks.map(({ task_id: taskId }) => {
|
|
348
|
+
const archived = archivedByTask.get(taskId);
|
|
349
|
+
if (archived !== undefined) return { task_id: taskId, ...archived };
|
|
350
|
+
return previousInventory.active
|
|
351
|
+
.find(({ task_id: previousTaskId }) => previousTaskId === taskId);
|
|
352
|
+
}),
|
|
353
|
+
excluded_tombstones: previousInventory.excluded_tombstones,
|
|
354
|
+
};
|
|
355
|
+
const desiredSeed = {
|
|
356
|
+
schema: plan.schema,
|
|
357
|
+
project_id: plan.project_id,
|
|
358
|
+
plan_key: plan.plan_key,
|
|
359
|
+
predecessor_plan_digest: plan.plan_digest,
|
|
360
|
+
tasks,
|
|
361
|
+
hard_dependencies: hardDependencies,
|
|
362
|
+
joins: plan.joins,
|
|
363
|
+
...(phasePlan ? {
|
|
364
|
+
phases: plan.phases,
|
|
365
|
+
phase_accept_dependencies: plan.phase_accept_dependencies,
|
|
366
|
+
} : {}),
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
if (!phasePlan) {
|
|
370
|
+
const planVersion = todoRevisionPlanVersion({
|
|
371
|
+
projectId: plan.project_id,
|
|
372
|
+
planKey: plan.plan_key,
|
|
373
|
+
predecessor,
|
|
374
|
+
desiredPlan: desiredSeed,
|
|
375
|
+
taskMigration,
|
|
376
|
+
sourceInventory,
|
|
377
|
+
sourceCutoverBatch,
|
|
378
|
+
});
|
|
379
|
+
const desiredPlan = buildTodoPlan({ ...desiredSeed, plan_version: planVersion });
|
|
380
|
+
const reconciliation = {
|
|
381
|
+
predecessor_reconciliation_digest: predecessorReconciliationDigest(member, false),
|
|
382
|
+
source_inventory_digest: todoSourceInventoryDigest(sourceInventory),
|
|
383
|
+
reconciliation_digest: '',
|
|
384
|
+
};
|
|
385
|
+
reconciliation.reconciliation_digest = todoReconciliationDigest({
|
|
386
|
+
predecessorReconciliationDigest: reconciliation.predecessor_reconciliation_digest,
|
|
387
|
+
sourceInventoryDigest: reconciliation.source_inventory_digest,
|
|
388
|
+
predecessor,
|
|
389
|
+
desiredPlanDigest: desiredPlan.plan_digest,
|
|
390
|
+
taskMigration,
|
|
391
|
+
sourceCutoverBatch,
|
|
392
|
+
});
|
|
393
|
+
const revision = {
|
|
394
|
+
schema: 'lattice.todo_revision.v2',
|
|
395
|
+
project_id: plan.project_id,
|
|
396
|
+
plan_key: plan.plan_key,
|
|
397
|
+
predecessor,
|
|
398
|
+
desired_plan: desiredPlan,
|
|
399
|
+
task_migration: taskMigration,
|
|
400
|
+
source_inventory: sourceInventory,
|
|
401
|
+
reconciliation,
|
|
402
|
+
source_cutover_batch: sourceCutoverBatch,
|
|
403
|
+
revision_digest: '',
|
|
404
|
+
};
|
|
405
|
+
revision.revision_digest = todoSelfDigest(revision, 'revision_digest');
|
|
406
|
+
if (!validateTodoRevision(revision)) invalid('compiled_revision_invalid');
|
|
407
|
+
return { revision, extracted_task_ids: proposal.extracted_tasks
|
|
408
|
+
.map(({ task_id: taskId }) => taskId).sort() };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const runtimeTaskMigration = compileRuntimeTaskMigration(plan, proposal);
|
|
412
|
+
const phaseMigration = plan.phases.map(({ phase_id: phaseId }) => ({
|
|
413
|
+
from_phase_id: phaseId,
|
|
414
|
+
to_phase_id: phaseId,
|
|
415
|
+
state_policy: phaseId === sourceTask.phase_id ? 'reset' : 'carry',
|
|
416
|
+
}));
|
|
417
|
+
const planVersion = phaseTodoRevisionPlanVersion({
|
|
418
|
+
projectId: plan.project_id,
|
|
419
|
+
planKey: plan.plan_key,
|
|
420
|
+
predecessor,
|
|
421
|
+
desiredPlan: desiredSeed,
|
|
422
|
+
taskMigration,
|
|
423
|
+
phaseMigration,
|
|
424
|
+
});
|
|
425
|
+
const desiredPlan = buildTodoPlan({ ...desiredSeed, plan_version: planVersion });
|
|
426
|
+
const reconciliation = {
|
|
427
|
+
predecessor_reconciliation_digest: predecessorReconciliationDigest(member, true),
|
|
428
|
+
source_inventory_digest: todoSourceInventoryDigest(sourceInventory),
|
|
429
|
+
desired_plan_digest: desiredPlan.plan_digest,
|
|
430
|
+
runtime_task_migration_digest: runtimeTaskMigration.migration_digest,
|
|
431
|
+
task_migration_digest: todoTaskMigrationDigest(taskMigration),
|
|
432
|
+
phase_migration_digest: digestTodoArtifact(phaseMigration),
|
|
433
|
+
source_cutover_batch_digest: sourceCutoverBatch.batch_digest,
|
|
434
|
+
reconciliation_digest: '',
|
|
435
|
+
};
|
|
436
|
+
reconciliation.reconciliation_digest = todoSelfDigest(reconciliation, 'reconciliation_digest');
|
|
437
|
+
const revision = {
|
|
438
|
+
schema: 'lattice.phase_todo_revision.v3',
|
|
439
|
+
project_id: plan.project_id,
|
|
440
|
+
plan_key: plan.plan_key,
|
|
441
|
+
predecessor,
|
|
442
|
+
desired_plan: desiredPlan,
|
|
443
|
+
runtime_task_migration: runtimeTaskMigration,
|
|
444
|
+
task_migration: taskMigration,
|
|
445
|
+
phase_migration: phaseMigration,
|
|
446
|
+
source_inventory: sourceInventory,
|
|
447
|
+
reconciliation,
|
|
448
|
+
source_cutover_batch: sourceCutoverBatch,
|
|
449
|
+
revision_digest: '',
|
|
450
|
+
};
|
|
451
|
+
revision.revision_digest = todoSelfDigest(revision, 'revision_digest');
|
|
452
|
+
if (!validatePhaseTodoRevision(revision)) invalid('compiled_phase_revision_invalid');
|
|
453
|
+
return { revision, extracted_task_ids: proposal.extracted_tasks
|
|
454
|
+
.map(({ task_id: taskId }) => taskId).sort() };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* splitは既存taskを同じidへ写し、子taskは新規追加するだけなので、既存witness bytesを変えない。
|
|
459
|
+
* apply前にこの不変条件を検査し、解決不能な宣言や将来の非identity migrationをstore更新前に止める。
|
|
460
|
+
*/
|
|
461
|
+
export function prepareTodoSplitWitnessMigration({ witnessSet, revision }) {
|
|
462
|
+
const migration = migrateWitnessSetTaskIds({
|
|
463
|
+
witnessSet,
|
|
464
|
+
taskMigration: revision.task_migration,
|
|
465
|
+
planTaskIds: revision.desired_plan.tasks.map(({ task_id: taskId }) => taskId),
|
|
466
|
+
});
|
|
467
|
+
if (migration.witnessSet.witness_set_digest !== witnessSet.witness_set_digest
|
|
468
|
+
|| canonicalizeTodoArtifact(migration.witnessSet) !== canonicalizeTodoArtifact(witnessSet)) {
|
|
469
|
+
invalid('split_witness_migration_not_identity', '/task_migration');
|
|
470
|
+
}
|
|
471
|
+
return migration;
|
|
472
|
+
}
|
package/src/todo-status.mjs
CHANGED
|
@@ -15,7 +15,11 @@ import {
|
|
|
15
15
|
} from './todo-contracts.mjs';
|
|
16
16
|
import { TODO_INDEPENDENCE_COVERAGE } from './todo-independence-contracts.mjs';
|
|
17
17
|
import { todoLegacyReconciliationDigest } from './todo-revision.mjs';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
isPhaselessTodoPlanSchema,
|
|
20
|
+
projectTodoCrossPlanDependencies,
|
|
21
|
+
todoPhaseDefinitions,
|
|
22
|
+
} from './todo-store.mjs';
|
|
19
23
|
|
|
20
24
|
/**
|
|
21
25
|
* v6で`plan_notes`を足す。ADR 0054・0063の前例どおり既存versionへのin-place追加はしない。
|
|
@@ -474,6 +478,11 @@ function buildTodoGraph(readModel) {
|
|
|
474
478
|
}
|
|
475
479
|
}
|
|
476
480
|
}
|
|
481
|
+
// active plan topologyは追記改変しない。開発中に接続したplan-scoped edgeを同じincomingへ
|
|
482
|
+
// 合成することで、projectTodoStatusとcomputeReadyFrontierが同じ待機判定を即座に読む。
|
|
483
|
+
for (const dependency of projectTodoCrossPlanDependencies(members)) {
|
|
484
|
+
addPredecessor(dependency.from, dependency.to);
|
|
485
|
+
}
|
|
477
486
|
|
|
478
487
|
auditPending.sort((left, right) => (
|
|
479
488
|
left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1
|