@quolu/lattice 0.78.0 → 0.79.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.
@@ -0,0 +1,628 @@
1
+ import { lstat, readFile, realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import {
5
+ canonicalizeTodoArtifact, exactRecord, isTodoDesignMemo, isTodoDigest,
6
+ isTodoIdentifier, isTodoRef,
7
+ digestTodoArtifact, todoSelfDigest,
8
+ } from './todo-contracts.mjs';
9
+ import {
10
+ appendImportedPlan, applyPhaseTodoRevision, buildTodoPlan, createTodoStoreWriter,
11
+ readTodoStore, TodoStoreError,
12
+ } from './todo-store.mjs';
13
+ import { gitSync } from './git-process.mjs';
14
+ import { readAuthoringJsonFile } from './todo-authoring-input.mjs';
15
+ import {
16
+ explainPhaseTodoRevision, phaseTodoRevisionPlanVersion, validatePhaseTodoRevision,
17
+ } from './todo-revision.mjs';
18
+
19
+ export const BACKCHAIN_SCHEMA = 'lattice.plan_backchain.v1';
20
+ const CREATE_SCHEMA = 'lattice.plan_create_input.v4';
21
+ const MAX_FEATURES = 2_048;
22
+ const MAX_TEXT_BYTES = 16_384;
23
+ const MAX_BINDING_BYTES = 8_388_608;
24
+ const BINDING_SCHEMA = 'lattice.plan_backchain_binding.v1';
25
+
26
+ function fail(reason, pointer, detail = {}) {
27
+ throw new TodoStoreError('PLAN_BACKCHAIN_INVALID', reason, undefined, {
28
+ pointer, ...detail,
29
+ });
30
+ }
31
+
32
+ function text(value) {
33
+ return typeof value === 'string' && value.trim().length > 0
34
+ && Buffer.byteLength(value, 'utf8') <= MAX_TEXT_BYTES;
35
+ }
36
+
37
+ function identifiers(value, { nonempty = false } = {}) {
38
+ return Array.isArray(value) && (!nonempty || value.length > 0)
39
+ && value.length <= MAX_FEATURES && value.every(isTodoIdentifier)
40
+ && new Set(value).size === value.length;
41
+ }
42
+
43
+ function provider(value) {
44
+ return (exactRecord(value, ['kind', 'task_id'])
45
+ && value.kind === 'task' && isTodoIdentifier(value.task_id))
46
+ || (exactRecord(value, ['kind', 'evidence_ref'])
47
+ && value.kind === 'existing' && isTodoRef(value.evidence_ref))
48
+ || (exactRecord(value, ['kind', 'reason', 'question'])
49
+ && value.kind === 'unknown' && text(value.reason) && text(value.question));
50
+ }
51
+
52
+ function registeredTasks(authoring) {
53
+ if (!Array.isArray(authoring?.tasks) || !Array.isArray(authoring?.hard_dependencies)) {
54
+ fail('authoring_invalid', '/authoring_ref');
55
+ }
56
+ if (authoring.schema === CREATE_SCHEMA) return authoring.tasks;
57
+ fail('authoring_schema_unsupported', '/authoring_ref', {
58
+ expected: CREATE_SCHEMA, actual: authoring?.schema ?? null,
59
+ });
60
+ }
61
+
62
+ /**
63
+ * AIの機能分解を検査する。意味の正しさを推測せず、最終機能からの到達性と
64
+ * producer/consumer の契約が閉じていることだけを機械が保証する。
65
+ */
66
+ export function validatePlanBackchain(chain, authoring) {
67
+ const keys = [
68
+ 'schema', 'authoring_ref', 'project_id', 'plan_key', 'plan_version',
69
+ 'goal', 'features', 'tasks', 'chain_digest',
70
+ ];
71
+ if (!(exactRecord(chain, keys) || exactRecord(chain, [...keys, 'continuity']))
72
+ || chain.schema !== BACKCHAIN_SCHEMA || !isTodoRef(chain.authoring_ref)
73
+ || !isTodoIdentifier(chain.project_id) || !isTodoIdentifier(chain.plan_key)
74
+ || !isTodoIdentifier(chain.plan_version)) fail('schema_invalid', '/');
75
+ if (chain.continuity !== undefined && (!Array.isArray(chain.continuity)
76
+ || chain.continuity.length > 512
77
+ || !chain.continuity.every((entry) => exactRecord(entry, ['task_id', 'policy', 'reason'])
78
+ && isTodoIdentifier(entry.task_id) && ['carry', 'reset', 'retire'].includes(entry.policy)
79
+ && text(entry.reason))
80
+ || new Set(chain.continuity.map(({ task_id: id }) => id)).size !== chain.continuity.length)) {
81
+ fail('continuity_invalid', '/continuity');
82
+ }
83
+ if (!isTodoDigest(chain.chain_digest)
84
+ || chain.chain_digest !== todoSelfDigest(chain, 'chain_digest')) {
85
+ fail('digest_mismatch', '/chain_digest');
86
+ }
87
+ if (!exactRecord(chain.goal, ['purpose', 'outcome', 'acceptance', 'required_feature_ids'])
88
+ || !text(chain.goal.purpose) || !text(chain.goal.outcome) || !text(chain.goal.acceptance)
89
+ || !identifiers(chain.goal.required_feature_ids, { nonempty: true })) {
90
+ fail('goal_invalid', '/goal');
91
+ }
92
+ if (!Array.isArray(chain.features) || chain.features.length === 0
93
+ || chain.features.length > MAX_FEATURES) fail('features_invalid', '/features');
94
+ const features = new Map();
95
+ for (const [index, feature] of chain.features.entries()) {
96
+ if (!exactRecord(feature, [
97
+ 'feature_id', 'capability', 'acceptance', 'requires_feature_ids', 'provider',
98
+ ]) || !isTodoIdentifier(feature.feature_id) || !text(feature.capability)
99
+ || !text(feature.acceptance) || !identifiers(feature.requires_feature_ids)
100
+ || !provider(feature.provider)) fail('feature_invalid', `/features/${index}`);
101
+ if (features.has(feature.feature_id)) fail('feature_duplicate', `/features/${index}/feature_id`);
102
+ features.set(feature.feature_id, feature);
103
+ }
104
+ for (const [index, feature] of chain.features.entries()) {
105
+ for (const id of feature.requires_feature_ids) {
106
+ if (!features.has(id)) fail('required_feature_missing', `/features/${index}/requires_feature_ids`, {
107
+ feature_id: feature.feature_id, required_feature_id: id,
108
+ });
109
+ }
110
+ }
111
+ const authored = registeredTasks(authoring);
112
+ if (chain.project_id !== authoring.project_id || chain.plan_key !== authoring.plan_key
113
+ || chain.plan_version !== authoring.plan_version) fail('authoring_identity_mismatch', '/authoring_ref');
114
+ if (!Array.isArray(chain.tasks) || chain.tasks.length !== authored.length) {
115
+ fail('task_coverage_missing', '/tasks');
116
+ }
117
+ const authoredIds = new Set(authored.map(({ task_id: id }) => id));
118
+ const taskMap = new Map();
119
+ for (const [index, task] of chain.tasks.entries()) {
120
+ if (!exactRecord(task, ['task_id', 'requires_feature_ids', 'provides_feature_ids'])
121
+ || !isTodoIdentifier(task.task_id) || !authoredIds.has(task.task_id)
122
+ || !identifiers(task.requires_feature_ids)
123
+ || !identifiers(task.provides_feature_ids, { nonempty: true })) {
124
+ fail('task_invalid', `/tasks/${index}`);
125
+ }
126
+ if (taskMap.has(task.task_id)) fail('task_duplicate', `/tasks/${index}/task_id`);
127
+ taskMap.set(task.task_id, task);
128
+ }
129
+ for (const [index, task] of chain.tasks.entries()) {
130
+ for (const id of task.provides_feature_ids) {
131
+ const feature = features.get(id);
132
+ if (feature?.provider.kind !== 'task' || feature.provider.task_id !== task.task_id) {
133
+ fail('task_output_provider_mismatch', `/tasks/${index}/provides_feature_ids`, { feature_id: id });
134
+ }
135
+ }
136
+ const supplied = new Set(task.provides_feature_ids);
137
+ const needed = [...new Set(task.provides_feature_ids.flatMap((id) =>
138
+ features.get(id).requires_feature_ids.filter((required) => !supplied.has(required))))];
139
+ if (needed.some((id) => !task.requires_feature_ids.includes(id))) {
140
+ fail('task_inputs_incomplete', `/tasks/${index}/requires_feature_ids`, {
141
+ expected: needed.sort(), actual: task.requires_feature_ids,
142
+ });
143
+ }
144
+ }
145
+ for (const feature of features.values()) {
146
+ if (feature.provider.kind !== 'task') continue;
147
+ const task = taskMap.get(feature.provider.task_id);
148
+ if (task === undefined || !task.provides_feature_ids.includes(feature.feature_id)) {
149
+ fail('feature_provider_missing', `/features/${feature.feature_id}/provider`, {
150
+ feature_id: feature.feature_id,
151
+ });
152
+ }
153
+ }
154
+ const reached = new Set();
155
+ const visiting = new Set();
156
+ const visit = (id) => {
157
+ if (!features.has(id)) fail('required_feature_missing', '/goal/required_feature_ids', { feature_id: id });
158
+ if (visiting.has(id)) fail('feature_cycle', `/features/${id}/requires_feature_ids`);
159
+ if (reached.has(id)) return;
160
+ visiting.add(id);
161
+ const feature = features.get(id);
162
+ for (const required of feature.requires_feature_ids) visit(required);
163
+ if (feature.provider.kind === 'task') {
164
+ for (const required of taskMap.get(feature.provider.task_id).requires_feature_ids) visit(required);
165
+ }
166
+ visiting.delete(id);
167
+ reached.add(id);
168
+ };
169
+ for (const id of chain.goal.required_feature_ids) visit(id);
170
+ if (reached.size !== features.size) {
171
+ fail('orphan_feature', '/features', {
172
+ feature_ids: [...features.keys()].filter((id) => !reached.has(id)).sort(),
173
+ });
174
+ }
175
+ const edges = new Set(authoring.hard_dependencies.map(({ from, to }) =>
176
+ `${from.project_id}/${from.plan_key}/${from.task_id}->${to.project_id}/${to.plan_key}/${to.task_id}`));
177
+ for (const [index, task] of chain.tasks.entries()) {
178
+ for (const id of task.requires_feature_ids) {
179
+ const feature = features.get(id);
180
+ if (feature === undefined) fail('task_input_missing', `/tasks/${index}/requires_feature_ids`, {
181
+ feature_id: id,
182
+ });
183
+ if (feature.provider.kind !== 'task') continue;
184
+ const sourceId = feature.provider.task_id;
185
+ if (sourceId === task.task_id) fail('task_requires_own_output', `/tasks/${index}/requires_feature_ids`);
186
+ const edge = `${chain.project_id}/${chain.plan_key}/${sourceId}->${chain.project_id}/${chain.plan_key}/${task.task_id}`;
187
+ if (!edges.has(edge)) fail('dependency_missing', `/tasks/${index}/requires_feature_ids`, {
188
+ feature_id: id, from_task_id: sourceId, to_task_id: task.task_id,
189
+ });
190
+ }
191
+ }
192
+ return { features, tasks: taskMap };
193
+ }
194
+
195
+ /** 作業者に下流の用途まで渡す。文の生成は入力済みの定義を転記するだけ。 */
196
+ export function backchainTaskMemo(chain, taskId) {
197
+ const features = new Map(chain.features.map((feature) => [feature.feature_id, feature]));
198
+ const task = chain.tasks.find(({ task_id: id }) => id === taskId);
199
+ const lines = [
200
+ '## 機能契約',
201
+ `全体の目的: ${chain.goal.purpose}`,
202
+ `最終ゴール: ${chain.goal.outcome}`,
203
+ `ゴールの受入条件: ${chain.goal.acceptance}`,
204
+ '必要とする機能:',
205
+ ...task.requires_feature_ids.map((id) => {
206
+ const feature = features.get(id);
207
+ const provider = feature.provider.kind === 'task'
208
+ ? `工程 ${feature.provider.task_id}`
209
+ : feature.provider.kind === 'existing'
210
+ ? `既存機能として申告(参照: ${feature.provider.evidence_ref})`
211
+ : `未解決(${feature.provider.reason}。確認すること: ${feature.provider.question})`;
212
+ return `- ${id}: ${feature.capability}(提供元: ${provider})`;
213
+ }),
214
+ '作る機能:',
215
+ ...task.provides_feature_ids.map((id) => {
216
+ const feature = features.get(id);
217
+ const consumers = chain.tasks.filter((other) => other.requires_feature_ids.includes(id))
218
+ .map(({ task_id: consumerId }) => consumerId).sort();
219
+ return `- ${id}: ${feature.capability}。受入条件: ${feature.acceptance}。後続: ${consumers.join(', ') || '最終成果'}`;
220
+ }),
221
+ ];
222
+ return lines.join('\n');
223
+ }
224
+
225
+ function localDesignMemo(memo, chain, taskId) {
226
+ const generated = chain?.tasks.some(({ task_id: id }) => id === taskId)
227
+ ? backchainTaskMemo(chain, taskId) : null;
228
+ const text = memo.trimEnd();
229
+ return generated !== null && text.endsWith(`\n\n${generated}`)
230
+ ? text.slice(0, -generated.length - 2) : text;
231
+ }
232
+
233
+ export function attachBackchainMemos(chain, authoring, { previousChain = null } = {}) {
234
+ const tasks = new Set(chain.tasks.map(({ task_id: id }) => id));
235
+ const amended = {
236
+ ...authoring,
237
+ tasks: authoring.tasks.map((task) => {
238
+ if (!tasks.has(task.task_id)) return task;
239
+ const baseMemo = localDesignMemo(task.design_memo, previousChain, task.task_id);
240
+ const designMemo = `${baseMemo}\n\n${backchainTaskMemo(chain, task.task_id)}`;
241
+ if (!isTodoDesignMemo(designMemo)) fail('design_memo_limit', `/tasks/${task.task_id}/design_memo`);
242
+ return { ...task, design_memo: designMemo };
243
+ }),
244
+ };
245
+ const digestField = 'input_digest';
246
+ amended[digestField] = todoSelfDigest(amended, digestField);
247
+ return amended;
248
+ }
249
+
250
+ export async function readPlanBackchainBinding({ repoRoot, plan }) {
251
+ const ref = `.lattice/todo/plans/${plan.plan_key}/${plan.plan_version}/backchain.json`;
252
+ const absolute = path.resolve(repoRoot, ref);
253
+ let bytes;
254
+ try {
255
+ const state = await lstat(absolute);
256
+ if (!state.isFile() || state.isSymbolicLink() || state.size > MAX_BINDING_BYTES
257
+ || await realpath(absolute) !== absolute) fail('binding_unsafe', ref);
258
+ bytes = await readFile(absolute);
259
+ } catch (error) {
260
+ if (error?.code === 'ENOENT') return null;
261
+ if (error instanceof TodoStoreError) throw error;
262
+ fail('binding_unreadable', ref);
263
+ }
264
+ let binding;
265
+ try { binding = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
266
+ catch { fail('binding_invalid_json', ref); }
267
+ if (!exactRecord(binding, ['schema', 'plan_digest', 'chain', 'binding_digest'])
268
+ || binding.schema !== BINDING_SCHEMA || binding.plan_digest !== plan.plan_digest
269
+ || !isTodoDigest(binding.binding_digest)
270
+ || binding.binding_digest !== todoSelfDigest(binding, 'binding_digest')) {
271
+ fail('binding_invalid', ref);
272
+ }
273
+ const chain = binding.chain;
274
+ validatePlanBackchain(chain, {
275
+ schema: CREATE_SCHEMA, project_id: plan.project_id, plan_key: plan.plan_key,
276
+ plan_version: plan.plan_version, tasks: plan.tasks,
277
+ hard_dependencies: plan.hard_dependencies,
278
+ });
279
+ return binding;
280
+ }
281
+
282
+ export async function readTaskBackchainContext({ repoRoot, plan, taskId }) {
283
+ const binding = await readPlanBackchainBinding({ repoRoot, plan });
284
+ if (binding === null) return null;
285
+ const chain = binding.chain;
286
+ const task = chain.tasks.find(({ task_id: id }) => id === taskId);
287
+ const ref = `.lattice/todo/plans/${plan.plan_key}/${plan.plan_version}/backchain.json`;
288
+ if (task === undefined) fail('binding_task_missing', ref, { task_id: taskId });
289
+ const features = new Map(chain.features.map((feature) => [feature.feature_id, feature]));
290
+ const downstream = chain.tasks.filter((other) => other.task_id !== taskId
291
+ && other.requires_feature_ids.some((id) => task.provides_feature_ids.includes(id)))
292
+ .map(({ task_id: id }) => id).sort();
293
+ const required = task.requires_feature_ids.map((id) => features.get(id));
294
+ return {
295
+ schema: 'lattice.todo_feature_context.v1',
296
+ plan_key: chain.plan_key, plan_version: chain.plan_version,
297
+ goal: chain.goal,
298
+ requires: required,
299
+ provides: task.provides_feature_ids.map((id) => features.get(id)),
300
+ downstream_task_ids: downstream,
301
+ unknown_feature_ids: required.filter(({ provider }) => provider.kind === 'unknown')
302
+ .map(({ feature_id: id }) => id),
303
+ chain_digest: chain.chain_digest,
304
+ };
305
+ }
306
+
307
+ function gitRoot(cwd) {
308
+ try {
309
+ return path.resolve(gitSync(['rev-parse', '--show-toplevel'], {
310
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
311
+ }).replace(/\r?\n$/u, ''));
312
+ } catch { fail('repository_unresolved', '/'); }
313
+ }
314
+
315
+ async function verifyExistingFeatureReferences(repoRoot, chain) {
316
+ const canonicalRoot = await realpath(repoRoot);
317
+ for (const feature of chain.features) {
318
+ if (feature.provider.kind !== 'existing') continue;
319
+ const ref = feature.provider.evidence_ref;
320
+ const absolute = path.resolve(canonicalRoot, ref);
321
+ if (!absolute.startsWith(`${canonicalRoot}${path.sep}`)) {
322
+ fail('existing_feature_reference_unsafe', `/features/${feature.feature_id}/provider`, {
323
+ evidence_ref: ref,
324
+ });
325
+ }
326
+ let state;
327
+ try { state = await lstat(absolute); }
328
+ catch { fail('existing_feature_reference_missing', `/features/${feature.feature_id}/provider`, {
329
+ evidence_ref: ref,
330
+ }); }
331
+ let resolved;
332
+ try { resolved = await realpath(absolute); }
333
+ catch { fail('existing_feature_reference_unreadable', `/features/${feature.feature_id}/provider`, {
334
+ evidence_ref: ref,
335
+ }); }
336
+ if (!state.isFile() || state.isSymbolicLink()
337
+ || !resolved.startsWith(`${canonicalRoot}${path.sep}`)) {
338
+ fail('existing_feature_reference_unsafe', `/features/${feature.feature_id}/provider`, {
339
+ evidence_ref: ref,
340
+ });
341
+ }
342
+ }
343
+ }
344
+
345
+ function planSeed(authoring, predecessorPlanDigest) {
346
+ return {
347
+ schema: 'lattice.todo_plan.v7', project_id: authoring.project_id,
348
+ plan_key: authoring.plan_key, predecessor_plan_digest: predecessorPlanDigest,
349
+ tasks: authoring.tasks, phases: authoring.phases,
350
+ hard_dependencies: authoring.hard_dependencies, joins: authoring.joins,
351
+ phase_accept_dependencies: authoring.phase_accept_dependencies,
352
+ };
353
+ }
354
+
355
+ function featureGraphDigest(chain) {
356
+ return digestTodoArtifact({ goal: chain.goal, features: chain.features, tasks: chain.tasks });
357
+ }
358
+
359
+ function taskSignature(chain, taskId) {
360
+ const task = chain?.tasks?.find(({ task_id: id }) => id === taskId);
361
+ if (task === undefined) return null;
362
+ const byId = new Map(chain.features.map((feature) => [feature.feature_id, feature]));
363
+ const ids = [...new Set([...task.requires_feature_ids, ...task.provides_feature_ids])].sort();
364
+ return canonicalizeTodoArtifact({
365
+ requires_feature_ids: task.requires_feature_ids,
366
+ provides_feature_ids: task.provides_feature_ids,
367
+ features: ids.map((id) => byId.get(id)),
368
+ });
369
+ }
370
+
371
+ function incoming(plan, taskId) {
372
+ return canonicalizeTodoArtifact({
373
+ hard: plan.hard_dependencies.filter(({ to }) => to.task_id === taskId),
374
+ phase: plan.phase_accept_dependencies.filter(({ to }) => to.task_id === taskId),
375
+ joins: plan.joins.filter(({ before }) => before.task_id === taskId),
376
+ });
377
+ }
378
+
379
+ function compileTaskMigration(previousPlan, oldChain, desiredSeed, newChain) {
380
+ const desired = new Map(desiredSeed.tasks.map((task) => [task.task_id, task]));
381
+ const decisions = new Map((newChain.continuity ?? []).map((entry) => [entry.task_id, entry]));
382
+ const oldIds = new Set(previousPlan.tasks.map(({ task_id: id }) => id));
383
+ if ([...decisions.keys()].some((id) => !oldIds.has(id))) fail('continuity_task_missing', '/continuity');
384
+ return previousPlan.tasks.map((oldTask) => {
385
+ const id = oldTask.task_id;
386
+ const nextTask = desired.get(id);
387
+ const decision = decisions.get(id);
388
+ if (nextTask === undefined) {
389
+ if (decision !== undefined && decision.policy !== 'retire') {
390
+ fail('continuity_removed_task_mismatch', '/continuity', { task_id: id });
391
+ }
392
+ return { from_task_id: id, to_task_id: 'removed', state_policy: 'removed' };
393
+ }
394
+ if (decision?.policy === 'retire') fail('continuity_live_task_mismatch', '/continuity', { task_id: id });
395
+ const sameContract = taskSignature(oldChain, id) === taskSignature(newChain, id);
396
+ const sameTask = oldTask.title === nextTask.title && oldTask.lane === nextTask.lane
397
+ && oldTask.phase_id === nextTask.phase_id
398
+ && oldTask.parent_task_id === nextTask.parent_task_id
399
+ && localDesignMemo(oldTask.design_memo, oldChain, id)
400
+ === localDesignMemo(nextTask.design_memo, newChain, id)
401
+ && canonicalizeTodoArtifact(oldTask.compile_binding)
402
+ === canonicalizeTodoArtifact(nextTask.compile_binding)
403
+ && incoming(previousPlan, id) === incoming(desiredSeed, id);
404
+ const carry = decision?.policy === 'carry'
405
+ || (decision === undefined && sameContract && sameTask);
406
+ return { from_task_id: id, to_task_id: id,
407
+ state_policy: carry ? 'carry_reconciled_metadata' : 'reset_pending' };
408
+ }).sort((left, right) => left.from_task_id.localeCompare(right.from_task_id));
409
+ }
410
+
411
+ function compilePhaseMigration(previousPlan, desiredSeed, taskMigration) {
412
+ const nextPhases = new Map(desiredSeed.phases.map((phase) => [phase.phase_id, phase]));
413
+ const oldPhases = new Set(previousPlan.phases.map(({ phase_id: id }) => id));
414
+ const affected = new Set(taskMigration.filter(({ state_policy }) =>
415
+ !['carry', 'carry_reconciled_metadata'].includes(state_policy))
416
+ .map(({ from_task_id: id }) => previousPlan.tasks.find(({ task_id }) => task_id === id)?.phase_id));
417
+ for (const task of desiredSeed.tasks) {
418
+ if (!previousPlan.tasks.some(({ task_id: id }) => id === task.task_id)) affected.add(task.phase_id);
419
+ }
420
+ return [
421
+ ...previousPlan.phases.map((phase) => {
422
+ const next = nextPhases.get(phase.phase_id);
423
+ if (next === undefined) return { from_phase_id: phase.phase_id,
424
+ to_phase_id: 'removed', state_policy: 'removed' };
425
+ const carry = !affected.has(phase.phase_id)
426
+ && canonicalizeTodoArtifact(phase) === canonicalizeTodoArtifact(next);
427
+ return { from_phase_id: phase.phase_id, to_phase_id: phase.phase_id,
428
+ state_policy: carry ? 'carry' : 'reset' };
429
+ }),
430
+ ...desiredSeed.phases.filter(({ phase_id: id }) => !oldPhases.has(id))
431
+ .map(({ phase_id: id }) => ({ from_phase_id: null,
432
+ to_phase_id: id, state_policy: 'reset' })),
433
+ ];
434
+ }
435
+
436
+ async function compileBackchainRevision({ repoRoot, member, chain, authoring }) {
437
+ if (member.plan.schema !== 'lattice.todo_plan.v7') {
438
+ fail('predecessor_plan_schema_unsupported', '/plan_key', {
439
+ actual: member.plan.schema, next_action: 'author_a_new_backchain_plan',
440
+ });
441
+ }
442
+ const previousBinding = await readPlanBackchainBinding({ repoRoot, plan: member.plan });
443
+ const previousChain = previousBinding?.chain ?? null;
444
+ const amended = attachBackchainMemos(chain, authoring, { previousChain });
445
+ const predecessor = {
446
+ plan_digest: member.plan.plan_digest,
447
+ journal_head_digest: member.journal.events.at(-1).event_digest,
448
+ plan_version: member.plan.plan_version,
449
+ };
450
+ const seed = planSeed(amended, predecessor.plan_digest);
451
+ const taskMigration = compileTaskMigration(member.plan, previousChain, seed, chain);
452
+ const phaseMigration = compilePhaseMigration(member.plan, seed, taskMigration);
453
+ const version = phaseTodoRevisionPlanVersion({
454
+ projectId: chain.project_id, planKey: chain.plan_key, predecessor,
455
+ desiredPlan: seed, taskMigration, phaseMigration,
456
+ featureGraphDigest: featureGraphDigest(chain),
457
+ });
458
+ const successorChain = { ...chain, plan_version: version, chain_digest: '' };
459
+ successorChain.chain_digest = todoSelfDigest(successorChain, 'chain_digest');
460
+ const desiredPlan = buildTodoPlan({ ...seed, plan_version: version });
461
+ validatePlanBackchain(successorChain, {
462
+ schema: CREATE_SCHEMA, project_id: desiredPlan.project_id,
463
+ plan_key: desiredPlan.plan_key, plan_version: desiredPlan.plan_version,
464
+ tasks: desiredPlan.tasks, hard_dependencies: desiredPlan.hard_dependencies,
465
+ });
466
+ const revision = {
467
+ schema: 'lattice.phase_todo_revision.v4',
468
+ project_id: chain.project_id, plan_key: chain.plan_key,
469
+ predecessor, desired_plan: desiredPlan, task_migration: taskMigration,
470
+ phase_migration: phaseMigration, backchain: successorChain, revision_digest: '',
471
+ };
472
+ revision.revision_digest = todoSelfDigest(revision, 'revision_digest');
473
+ if (!validatePhaseTodoRevision(revision)) {
474
+ fail('revision_invalid', '/revision', { diagnosis: explainPhaseTodoRevision(revision) });
475
+ }
476
+ return revision;
477
+ }
478
+
479
+ async function activeIntentUnchanged({ repoRoot, member, chain, authoring }) {
480
+ if (member.plan.schema !== 'lattice.todo_plan.v7') return false;
481
+ const binding = await readPlanBackchainBinding({ repoRoot, plan: member.plan });
482
+ if (binding === null || featureGraphDigest(binding.chain) !== featureGraphDigest(chain)) return false;
483
+ const amended = attachBackchainMemos(chain, authoring, { previousChain: binding.chain });
484
+ return canonicalizeTodoArtifact({
485
+ tasks: amended.tasks, phases: amended.phases,
486
+ hard_dependencies: amended.hard_dependencies, joins: amended.joins,
487
+ phase_accept_dependencies: amended.phase_accept_dependencies,
488
+ }) === canonicalizeTodoArtifact({
489
+ tasks: member.plan.tasks, phases: member.plan.phases,
490
+ hard_dependencies: member.plan.hard_dependencies, joins: member.plan.joins,
491
+ phase_accept_dependencies: member.plan.phase_accept_dependencies,
492
+ });
493
+ }
494
+
495
+ async function rebindCarriedWork({ repoRoot, planKey, env }) {
496
+ const store = await readTodoStore({ repoRoot });
497
+ const member = store.members.find(({ plan }) => plan.plan_key === planKey);
498
+ const rebound = [];
499
+ for (const state of member.tasks.filter(({ status }) => status === 'in-progress')) {
500
+ const hasCurrentStart = member.journal.events.some((event) => event.kind === 'start'
501
+ && event.task_id === state.task_id && event.payload?.start_mode !== 'historical_import');
502
+ if (hasCurrentStart) continue;
503
+ const { rebindStart } = await import('./todo-cli.mjs');
504
+ try {
505
+ await rebindStart({ repoRoot, env, planKey, taskId: state.task_id,
506
+ reason: 'ゴール変更後のplan versionへ作業中ToDoを結び直す' });
507
+ } catch (error) {
508
+ throw new TodoStoreError('BACKCHAIN_REBIND_PENDING', 'carried_task_rebind_failed', undefined, {
509
+ task_id: state.task_id, plan_version: member.plan.plan_version,
510
+ cause_code: error?.code ?? 'unknown',
511
+ next_action: `lattice todo start --plan ${planKey} --task ${state.task_id} --rebind --reason <text>`,
512
+ });
513
+ }
514
+ rebound.push(state.task_id);
515
+ }
516
+ return rebound;
517
+ }
518
+
519
+ export async function runPlanBackchain({ cwd, inputRef, stdout, dryRun = false, env = process.env }) {
520
+ const repoRoot = gitRoot(cwd);
521
+ const chain = await readAuthoringJsonFile(repoRoot, inputRef, { invalidCode: 'PLAN_BACKCHAIN_INVALID' });
522
+ const authoring = await readAuthoringJsonFile(repoRoot, chain.authoring_ref, {
523
+ invalidCode: 'PLAN_BACKCHAIN_INVALID',
524
+ });
525
+ validatePlanBackchain(chain, authoring);
526
+ await verifyExistingFeatureReferences(repoRoot, chain);
527
+ let member = null;
528
+ let store = null;
529
+ try {
530
+ await lstat(path.join(repoRoot, '.lattice', 'todo', 'manifest.json'));
531
+ store = await readTodoStore({ repoRoot });
532
+ if (store.project_id !== chain.project_id) fail('project_id_mismatch', '/project_id');
533
+ member = store.members.find(({ plan }) => plan.plan_key === chain.plan_key) ?? null;
534
+ } catch (error) {
535
+ if (error?.code !== 'ENOENT') throw error;
536
+ }
537
+ if (member === null && (chain.continuity?.length ?? 0) > 0) {
538
+ fail('continuity_without_predecessor', '/continuity');
539
+ }
540
+ const unchanged = member !== null && await activeIntentUnchanged({
541
+ repoRoot, member, chain, authoring,
542
+ });
543
+ const revision = member === null || unchanged ? null
544
+ : await compileBackchainRevision({ repoRoot, member, chain, authoring });
545
+ const amended = revision === null ? attachBackchainMemos(chain, authoring) : null;
546
+ if (!unchanged && revision === null) {
547
+ try { buildTodoPlan({ ...planSeed(amended, null), plan_version: chain.plan_version }); }
548
+ catch { fail('authoring_plan_invalid', '/authoring_ref'); }
549
+ }
550
+ const unknownFeatureIds = chain.features.filter(({ provider: source }) => source.kind === 'unknown')
551
+ .map(({ feature_id: id }) => id).sort();
552
+ if (dryRun) {
553
+ const checked = {
554
+ schema: 'lattice.plan_backchain_check.v1', valid: true,
555
+ validation_scope: 'feature_chain_and_plan_shape',
556
+ project_id: chain.project_id, plan_key: chain.plan_key,
557
+ task_count: chain.tasks.length, feature_count: chain.features.length,
558
+ unknown_feature_ids: unknownFeatureIds, chain_digest: chain.chain_digest,
559
+ operation: unchanged ? 'unchanged' : revision !== null ? 'revise'
560
+ : store === null ? 'create' : 'add',
561
+ task_migration: revision?.task_migration ?? null,
562
+ result_digest: '',
563
+ };
564
+ checked.result_digest = todoSelfDigest(checked, 'result_digest');
565
+ stdout.write(`${JSON.stringify(checked)}\n`);
566
+ return 0;
567
+ }
568
+ let created;
569
+ let effectiveChain = chain;
570
+ if (unchanged) {
571
+ const binding = await readPlanBackchainBinding({ repoRoot, plan: member.plan });
572
+ effectiveChain = binding.chain;
573
+ created = { schema: 'lattice.plan_backchain_unchanged.v1',
574
+ plan_key: member.plan.plan_key, plan_version: member.plan.plan_version,
575
+ plan_digest: member.plan.plan_digest };
576
+ } else if (revision !== null) {
577
+ created = await applyPhaseTodoRevision({
578
+ repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
579
+ revision, actor: authoring.actor, recordedAt: new Date().toISOString(),
580
+ });
581
+ effectiveChain = revision.backchain;
582
+ } else if (store === null) {
583
+ const { runPlanCreate } = await import('./project-cli.mjs');
584
+ let output = '';
585
+ await runPlanCreate({ cwd: repoRoot, inputRef: chain.authoring_ref,
586
+ inputValue: amended, featureChain: chain,
587
+ stdout: { write(value) { output += value; } } });
588
+ created = JSON.parse(output);
589
+ } else {
590
+ const plan = buildTodoPlan({
591
+ ...planSeed(amended, null), plan_version: chain.plan_version,
592
+ });
593
+ const added = await appendImportedPlan({
594
+ repoRoot, writer: createTodoStoreWriter({ caller: 'g4-migration' }),
595
+ plan, genesis: { actor: authoring.actor, recorded_at: authoring.recorded_at,
596
+ provenance: null }, featureChain: chain,
597
+ });
598
+ created = {
599
+ schema: 'lattice.plan_backchain_add_result.v1',
600
+ plan_key: added.plan.plan_key, plan_version: added.plan.plan_version,
601
+ plan_digest: added.plan.plan_digest,
602
+ };
603
+ }
604
+ const reboundTaskIds = unchanged || revision !== null
605
+ ? await rebindCarriedWork({ repoRoot, planKey: chain.plan_key, env }) : [];
606
+ const finalStore = await readTodoStore({ repoRoot });
607
+ const finalMember = finalStore.members.find(({ plan }) => plan.plan_key === chain.plan_key);
608
+ const result = {
609
+ schema: 'lattice.plan_backchain_result.v1',
610
+ project_id: chain.project_id, plan_key: chain.plan_key,
611
+ plan_version: created.plan_version, plan_digest: created.plan_digest ?? null,
612
+ feature_count: chain.features.length, task_count: chain.tasks.length,
613
+ unknown_feature_ids: unknownFeatureIds,
614
+ chain_digest: effectiveChain.chain_digest, authoring_result: created,
615
+ rebound_task_ids: reboundTaskIds,
616
+ operation: unchanged ? 'unchanged' : revision !== null ? 'revise'
617
+ : store === null ? 'create' : 'add',
618
+ journal_head_digest: finalMember.journal.events.at(-1).event_digest,
619
+ result_digest: '',
620
+ };
621
+ result.result_digest = todoSelfDigest(result, 'result_digest');
622
+ stdout.write(`${JSON.stringify(result)}\n`);
623
+ return 0;
624
+ }
625
+
626
+ export async function backchainSchema() {
627
+ return JSON.parse(await readFile(new URL('../docs/schemas/lattice.plan_backchain.v1.schema.json', import.meta.url), 'utf8'));
628
+ }
@@ -596,10 +596,10 @@ function describeCreateInputViolation(value) {
596
596
  return null;
597
597
  }
598
598
 
599
- export async function runPlanCreate({ cwd, inputRef, stdout }) {
599
+ export async function runPlanCreate({ cwd, inputRef, stdout, inputValue = null, featureChain = null }) {
600
600
  const repoRoot = resolveRepoRoot(cwd);
601
601
  if (repoRoot === null) throw new TodoStoreError('REPO_UNRESOLVED', 'git_toplevel_unresolved');
602
- const input = await readCanonicalInput(repoRoot, inputRef);
602
+ const input = inputValue ?? await readCanonicalInput(repoRoot, inputRef);
603
603
  if (input?.schema !== MEMO_PHASE_CREATE_INPUT_SCHEMA) {
604
604
  throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_retired', undefined, {
605
605
  violation_kind: 'const_mismatch', pointer: '/schema',
@@ -656,6 +656,7 @@ export async function runPlanCreate({ cwd, inputRef, stdout }) {
656
656
  ? { phase_accept_dependencies: input.phase_accept_dependencies } : {}),
657
657
  },
658
658
  genesis: { actor: input.actor, recorded_at: input.recorded_at, provenance: null },
659
+ ...(featureChain === null ? {} : { featureChain }),
659
660
  });
660
661
  const member = store.members[0];
661
662
  const result = {
@@ -42,6 +42,11 @@ export function terminalPublicLabel(terminalId) {
42
42
  return `terminal-${digest.slice(0, LABEL_HEX_LENGTH)}`;
43
43
  }
44
44
 
45
+ /** Whether a recorded host name is a public label — the only shape a published page may show as-is (ADR 0198). */
46
+ export function isTerminalPublicLabel(value) {
47
+ return typeof value === 'string' && /^terminal-[0-9a-f]{12}$/u.test(value);
48
+ }
49
+
45
50
  /**
46
51
  * Synchronous twin of `readOrCreateBridgeHubTerminalId`: same file, same
47
52
  * schema, same no-overwrite publish. The todo CLI resolves its actor
@@ -16,6 +16,7 @@ import { TodoStoreError } from './todo-store.mjs';
16
16
  const MAX_AUTHORING_INPUT_BYTES = 8_388_608;
17
17
 
18
18
  export const AUTHORING_DIGEST_FIELDS = Object.freeze({
19
+ 'lattice.plan_backchain.v1': 'chain_digest',
19
20
  'lattice.todo_witness_set.v1': 'witness_set_digest',
20
21
  'lattice.todo_witness_set.v2': 'witness_set_digest',
21
22
  'lattice.todo_witness_set.v3': 'witness_set_digest',
@@ -33,6 +34,7 @@ export const AUTHORING_DIGEST_FIELDS = Object.freeze({
33
34
  'lattice.phase_todo_revision.v1': 'revision_digest',
34
35
  'lattice.phase_todo_revision.v2': 'revision_digest',
35
36
  'lattice.phase_todo_revision.v3': 'revision_digest',
37
+ 'lattice.phase_todo_revision.v4': 'revision_digest',
36
38
  'lattice.todo_revision_set.v1': 'revision_set_digest',
37
39
  'lattice.todo_revision_set.v2': 'revision_set_digest',
38
40
  'lattice.todo_revision_set.v3': 'revision_set_digest',