@quolu/lattice 0.35.0 → 0.36.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/README.ja.md +28 -1
- package/README.md +8 -0
- package/bin/lattice.mjs +9 -1
- package/docs/bridge-setup.md +13 -0
- package/package.json +1 -1
- package/src/cli-help.mjs +12 -7
- package/src/project-cli.mjs +103 -1
- package/src/todo-cli.mjs +103 -10
- package/src/todo-contracts.mjs +2 -2
- package/src/todo-gantt-html-shared.mjs +2 -1
- package/src/todo-gantt-layout.mjs +13 -3
- package/src/todo-gantt-scope.mjs +27 -2
- package/src/todo-migration.mjs +108 -0
- package/src/todo-revision.mjs +568 -2
- package/src/todo-status.mjs +5 -2
- package/src/todo-store.mjs +155 -33
package/src/todo-store.mjs
CHANGED
|
@@ -216,11 +216,19 @@ async function readJournal(repoRoot, journalRef) {
|
|
|
216
216
|
const phaseRevisionSchema = events[0].schema === 'lattice.todo_event.v4';
|
|
217
217
|
const phaseTail = (event) => event.schema === 'lattice.todo_event.v3';
|
|
218
218
|
const legacyTail = (event) => event.schema === 'lattice.todo_event.v1';
|
|
219
|
+
// ADR 0147: genesisがv1/v2(phase無しplan)でも、暗黙のterminal-audit Phaseへの
|
|
220
|
+
// phase_review/phase_accept/phase_reject/phase_reopenだけはv3 tail eventとして
|
|
221
|
+
// 混在を許す。task側のevent(start/done/block/unblock/reopen)は従来どおりv1のまま
|
|
222
|
+
// ——既存planの既存event bytesは1つも変わらない。新しく増えるのは、これまで
|
|
223
|
+
// phase無しplanには存在し得なかったphase_*event kindの受け皿だけである。
|
|
224
|
+
const implicitTerminalAuditTail = (event) => phaseTail(event)
|
|
225
|
+
&& ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen'].includes(event.kind);
|
|
226
|
+
const legacyOrImplicitPhaseTail = (event) => legacyTail(event) || implicitTerminalAuditTail(event);
|
|
219
227
|
if ((phaseSchema && events.some(({ schema }, index) => index === 0
|
|
220
228
|
? !['lattice.todo_event.v3', 'lattice.todo_event.v4'].includes(schema) : !phaseTail(events[index])))
|
|
221
|
-
|| (!phaseSchema && events.slice(1).some((event) => !
|
|
229
|
+
|| (!phaseSchema && events.slice(1).some((event) => !legacyOrImplicitPhaseTail(event)))
|
|
222
230
|
|| (!phaseSchema && !successorSchema && events.some((event, index) => index === 0
|
|
223
|
-
? event.schema !== 'lattice.todo_event.v1' : !
|
|
231
|
+
? event.schema !== 'lattice.todo_event.v1' : !legacyOrImplicitPhaseTail(event)))) {
|
|
224
232
|
fail('STORE_CORRUPT', 'journal_schema_sequence_invalid');
|
|
225
233
|
}
|
|
226
234
|
return { segments, events, activeBytes };
|
|
@@ -236,18 +244,46 @@ function emptyPhaseState(phaseId) {
|
|
|
236
244
|
decision_event_digest: null, decision_evidence: null };
|
|
237
245
|
}
|
|
238
246
|
|
|
247
|
+
// phase無しplan(todo_plan.v1/v2/v3)の終端に暗黙で挿入する予約Phase(ADR 0147)。全taskが
|
|
248
|
+
// doneになっても、この暗黙Phaseのaccept(review→evidence束縛accept)が記録されるまでplanを
|
|
249
|
+
// 「閉じた」ことにさせない——既存のPhase gate機構(review/accept/evidence slot/journal event)を
|
|
250
|
+
// そのまま再利用し、新しい状態機械を増やさない(ADR 0147裁定2)。phase_idはtask/plan由来の識別子
|
|
251
|
+
// と衝突しない予約名として固定する。
|
|
252
|
+
export const TERMINAL_AUDIT_PHASE_ID = 'terminal-audit';
|
|
253
|
+
|
|
254
|
+
// CLI側(migrate/plan create/最後のdone)が「終端重監査が要る」ことを通知するために
|
|
255
|
+
// 同じ判定を再利用する。判定基準を二重管理しないよう、ここから公開する。
|
|
256
|
+
export function isPhaselessTodoPlanSchema(schema) {
|
|
257
|
+
return !['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(schema);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function terminalAuditPhase() {
|
|
261
|
+
return { phase_id: TERMINAL_AUDIT_PHASE_ID, title: '終端重監査', gate_policy: 'terminal-audit',
|
|
262
|
+
predecessor_phase_ids: [], required_evidence_slots: ['terminal-audit'] };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// v4/v5はplan.phasesをそのまま使う。phase無しplan(v1/v2/v3)は暗黙のterminal-audit Phase
|
|
266
|
+
// 1つだけを持つものとして扱う(所属taskは常にそのplanの全task)。derivedPhaseStatus・
|
|
267
|
+
// projectPhaseStates・replayのphase event検証が同じ一覧を見るよう、この関数だけに集約する。
|
|
268
|
+
function phasesOf(plan) {
|
|
269
|
+
return isPhaselessTodoPlanSchema(plan.schema) ? [terminalAuditPhase()] : plan.phases;
|
|
270
|
+
}
|
|
271
|
+
|
|
239
272
|
function derivedPhaseStatus(plan, taskStates, phaseStates, phaseId) {
|
|
240
273
|
const state = phaseStates.get(phaseId);
|
|
241
274
|
if (['reviewing', 'accepted', 'rejected'].includes(state.status)) return state.status;
|
|
242
|
-
const phase = plan.
|
|
275
|
+
const phase = phasesOf(plan).find((entry) => entry.phase_id === phaseId);
|
|
243
276
|
if (!phase.predecessor_phase_ids.every((id) => phaseStates.get(id)?.status === 'accepted')) return 'locked';
|
|
244
|
-
|
|
277
|
+
// 暗黙のterminal-audit Phaseはtask側にphase_idフィールドが無い(v1/v2/v3にはそもそも
|
|
278
|
+
// 存在しない)ため、所属taskをフィルタで絞らずplan全taskとして扱う。
|
|
279
|
+
const tasks = phaseId === TERMINAL_AUDIT_PHASE_ID && isPhaselessTodoPlanSchema(plan.schema)
|
|
280
|
+
? plan.tasks : plan.tasks.filter((entry) => entry.phase_id === phaseId);
|
|
245
281
|
return tasks.every((entry) => taskStates.get(entry.task_id)?.status === 'done') ? 'gate_ready' : 'active';
|
|
246
282
|
}
|
|
247
283
|
|
|
248
284
|
function projectPhaseStates(plan, events, taskStates) {
|
|
249
|
-
|
|
250
|
-
const states = new Map(
|
|
285
|
+
const phases = phasesOf(plan);
|
|
286
|
+
const states = new Map(phases.map(({ phase_id }) => [phase_id, emptyPhaseState(phase_id)]));
|
|
251
287
|
for (const event of events) {
|
|
252
288
|
if (event.schema === 'lattice.todo_event.v4') {
|
|
253
289
|
for (const migration of event.phase_state_migration) {
|
|
@@ -271,7 +307,7 @@ function projectPhaseStates(plan, events, taskStates) {
|
|
|
271
307
|
Object.assign(states.get(event.phase_id), emptyPhaseState(event.phase_id));
|
|
272
308
|
}
|
|
273
309
|
}
|
|
274
|
-
for (const phase of
|
|
310
|
+
for (const phase of phases) {
|
|
275
311
|
const state = states.get(phase.phase_id);
|
|
276
312
|
state.status = derivedPhaseStatus(plan, taskStates, states, phase.phase_id);
|
|
277
313
|
}
|
|
@@ -298,7 +334,7 @@ function localSuccessors(plan, taskId) {
|
|
|
298
334
|
|
|
299
335
|
function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSource } = {}) {
|
|
300
336
|
const states = new Map(plan.tasks.map(({ task_id }) => [task_id, taskState(task_id)]));
|
|
301
|
-
const phaseStates = new Map((plan
|
|
337
|
+
const phaseStates = new Map(phasesOf(plan).map(({ phase_id }) => [phase_id, emptyPhaseState(phase_id)]));
|
|
302
338
|
const doneDigest = new Map();
|
|
303
339
|
const completion = new Map();
|
|
304
340
|
const importedGenesis = events[0]?.payload.historical_import === true;
|
|
@@ -331,7 +367,9 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
|
|
|
331
367
|
fail('STORE_INCONSISTENT', 'genesis_migration_target_invalid');
|
|
332
368
|
}
|
|
333
369
|
for (const migration of event.state_migration) {
|
|
334
|
-
|
|
370
|
+
// acquire_phaseもcarry系(状態を完全に持ち越す)。ADR 0147裁定4のPhase獲得はdoneを
|
|
371
|
+
// 保つことが目的であり、ここで除外するとreplayが状態を復元しない。
|
|
372
|
+
if (!['carry', 'carry_reconciled_metadata', 'acquire_phase'].includes(migration.state_policy)) continue;
|
|
335
373
|
const state = states.get(migration.to_task_id);
|
|
336
374
|
Object.assign(state, structuredClone(migration.state), { evidence_unverified: false });
|
|
337
375
|
if (state.evidence !== null) {
|
|
@@ -357,7 +395,11 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
|
|
|
357
395
|
continue;
|
|
358
396
|
}
|
|
359
397
|
if (event.kind.startsWith('phase_')) {
|
|
360
|
-
|
|
398
|
+
// phaseStatesはphasesOf(plan)から作られる(v4/v5なら実Phase、それ以外なら暗黙の
|
|
399
|
+
// terminal-audit Phaseだけ)。`has`判定だけで両方の場合を賄えるので、schemaでの
|
|
400
|
+
// 事前分岐は不要——phase無しplanは予約phase_id以外を宣言していないため、任意の
|
|
401
|
+
// phase_idを名乗るevent_phase_missingでの拒否は従来どおり効く。
|
|
402
|
+
if (!phaseStates.has(event.phase_id)) {
|
|
361
403
|
fail('STORE_INCONSISTENT', 'event_phase_missing');
|
|
362
404
|
}
|
|
363
405
|
const state = phaseStates.get(event.phase_id);
|
|
@@ -367,7 +409,7 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
|
|
|
367
409
|
state.status = 'reviewing'; state.review_event_digest = event.event_digest;
|
|
368
410
|
state.decision_event_digest = null; state.decision_evidence = null;
|
|
369
411
|
} else if (event.kind === 'phase_accept') {
|
|
370
|
-
const phase = plan.
|
|
412
|
+
const phase = phasesOf(plan).find(({ phase_id }) => phase_id === event.phase_id);
|
|
371
413
|
const slots = event.payload.evidence_slots.map(({ slot_id }) => slot_id);
|
|
372
414
|
if (currentStatus !== 'reviewing' || state.review_event_digest !== event.payload.review_event_digest
|
|
373
415
|
|| canonicalizeTodoArtifact(slots) !== canonicalizeTodoArtifact(phase.required_evidence_slots)) {
|
|
@@ -391,17 +433,21 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
|
|
|
391
433
|
|| state.decision_event_digest !== event.payload.target_decision_digest) {
|
|
392
434
|
fail('STORE_INCONSISTENT', 'phase_reopen_binding_invalid');
|
|
393
435
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
&& phaseStates.get(phase.phase_id).status !== 'locked'
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
436
|
+
// phase無しplanの暗黙Phaseは唯一のPhaseであり、他Phaseの前提にも
|
|
437
|
+
// phase_accept_dependenciesにもなり得ない(v1/v2/v3にはそのフィールド自体が無い)ため、
|
|
438
|
+
// successorStartedは常にfalseでよい。v4/v5の既存判定には一切手を入れない。
|
|
439
|
+
const successorStarted = currentStatus === 'accepted' && !isPhaselessTodoPlanSchema(plan.schema)
|
|
440
|
+
&& (plan.schema === 'lattice.todo_plan.v4'
|
|
441
|
+
? plan.phases.some((phase) => phase.predecessor_phase_ids.includes(event.phase_id)
|
|
442
|
+
&& (phaseStates.get(phase.phase_id).status !== 'locked'
|
|
443
|
+
|| plan.tasks.some((task) => task.phase_id === phase.phase_id
|
|
444
|
+
&& states.get(task.task_id).status !== 'pending')))
|
|
445
|
+
: plan.phases.some((phase) => phase.predecessor_phase_ids.includes(event.phase_id)
|
|
446
|
+
&& phaseStates.get(phase.phase_id).status !== 'locked')
|
|
447
|
+
|| plan.phase_accept_dependencies.some((edge) => edge.from.project_id === plan.project_id
|
|
448
|
+
&& edge.from.plan_key === plan.plan_key && edge.from.phase_id === event.phase_id
|
|
449
|
+
&& edge.to.project_id === plan.project_id && edge.to.plan_key === plan.plan_key
|
|
450
|
+
&& states.get(edge.to.task_id)?.status !== 'pending'));
|
|
405
451
|
if (successorStarted && event.payload.override_reason === null) {
|
|
406
452
|
fail('STORE_INCONSISTENT', 'phase_reopen_has_started_successor');
|
|
407
453
|
}
|
|
@@ -469,8 +515,14 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
|
|
|
469
515
|
if (state.status !== 'done' || doneDigest.get(event.task_id) !== event.payload.target_done_digest) {
|
|
470
516
|
fail('STORE_INCONSISTENT', 'invalid_reopen_binding');
|
|
471
517
|
}
|
|
472
|
-
|
|
473
|
-
|
|
518
|
+
{
|
|
519
|
+
// 暗黙のterminal-audit Phaseがacceptedの後にtaskだけを無警告でreopenできると、
|
|
520
|
+
// 監査済みのまま(gantt上も畳まれたまま)裏で作業が再開する抜け道になり、ADR 0147の
|
|
521
|
+
// 「監査の記録なしに閉じたことにさせない」を潜脱する。v4/v5の既存Phaseと同じ規律を
|
|
522
|
+
// 暗黙Phaseにも及ぼし、phase_reopenを先に通させる。
|
|
523
|
+
const phaseId = isPhaselessTodoPlanSchema(plan.schema)
|
|
524
|
+
? TERMINAL_AUDIT_PHASE_ID
|
|
525
|
+
: plan.tasks.find(({ task_id }) => task_id === event.task_id).phase_id;
|
|
474
526
|
if (derivedPhaseStatus(plan, states, phaseStates, phaseId) === 'accepted') {
|
|
475
527
|
fail('STORE_INCONSISTENT', 'task_reopen_requires_phase_reopen');
|
|
476
528
|
}
|
|
@@ -486,6 +538,11 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
|
|
|
486
538
|
|
|
487
539
|
function snapshotFor(plan, events, tasks) {
|
|
488
540
|
const head = events.at(-1);
|
|
541
|
+
// snapshot artifactの形式は変えない(store canonical形式の非目標)。既存on-disk snapshot
|
|
542
|
+
// (phase無しplanはv1・`phases`キー無し)との canonical比較がここで外れると、全既存storeが
|
|
543
|
+
// snapshot_staleになりforWrite(start/done/revise等)が丸ごとSTORE_WRITE_REFUSEDへ落ちる
|
|
544
|
+
// ——ADR 0147以降の暗黙terminal-audit Phaseの状態は、この関数の外(readTodoStore/
|
|
545
|
+
// appendTodoEventが返す`phases`という導出ビュー)で供給する。
|
|
489
546
|
const phasePlan = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema);
|
|
490
547
|
const snapshot = {
|
|
491
548
|
schema: phasePlan ? 'lattice.todo_snapshot.v2' : 'lattice.todo_snapshot.v1',
|
|
@@ -1003,8 +1060,12 @@ export async function readTodoStore(options = {}) {
|
|
|
1003
1060
|
else throw error;
|
|
1004
1061
|
}
|
|
1005
1062
|
if (options.forWrite === true && snapshotStale) fail('STORE_WRITE_REFUSED', 'snapshot_stale');
|
|
1063
|
+
// 導出済みphase状態(ADR 0147)。snapshot artifactの形式(v1/v2)には縛られず、v4/v5・
|
|
1064
|
+
// phase無しplanのどちらでも同じ形(暗黙のterminal-audit Phase込み)で常に埋める。
|
|
1065
|
+
// 消費者はここを読み、snapshot.phases(v1には存在しない)を直接読まない。
|
|
1066
|
+
const phases = projectPhaseStates(plan, journal.events, new Map(tasks.map((task) => [task.task_id, task])));
|
|
1006
1067
|
loaded.push({ descriptor, plan, revision, journal, snapshot: snapshotStale ? expectedSnapshot : snapshot,
|
|
1007
|
-
tasks, snapshot_stale: snapshotStale });
|
|
1068
|
+
tasks, phases, snapshot_stale: snapshotStale });
|
|
1008
1069
|
}
|
|
1009
1070
|
validateMergedGraph(loaded);
|
|
1010
1071
|
return {
|
|
@@ -1112,14 +1173,20 @@ function nextEvent(input, storeMember) {
|
|
|
1112
1173
|
const payload = input.kind === 'done' && exactRecord(input.payload, ['evidence'])
|
|
1113
1174
|
? { done_mode: 'authored', imported: false, evidence: input.payload.evidence }
|
|
1114
1175
|
: input.payload;
|
|
1176
|
+
const phaseCapablePlan = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(storeMember.plan.schema);
|
|
1177
|
+
const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen'].includes(input.kind);
|
|
1178
|
+
// ADR 0147: phase無しplanでも、暗黙のterminal-audit Phaseへのphase_*eventだけは
|
|
1179
|
+
// v3 tail eventとして書く(readJournalのlegacyOrImplicitPhaseTailが同じ規則で受理する)。
|
|
1180
|
+
// task側のevent(start/done等)はphase無しplanのままv1で書き続ける——既存の
|
|
1181
|
+
// canonical形式を変えるのはphase_*eventの新設分だけに限る。
|
|
1182
|
+
const usesPhaseEventShape = phaseCapablePlan || phaseKind;
|
|
1115
1183
|
const event = {
|
|
1116
|
-
schema:
|
|
1117
|
-
|
|
1184
|
+
schema: usesPhaseEventShape ? 'lattice.todo_event.v3' : 'lattice.todo_event.v1',
|
|
1185
|
+
project_id: storeMember.plan.project_id,
|
|
1118
1186
|
plan_key: storeMember.plan.plan_key, plan_version: storeMember.plan.plan_version,
|
|
1119
1187
|
sequence: previous.sequence + 1, previous_digest: previous.event_digest,
|
|
1120
1188
|
kind: input.kind, task_id: input.task_id ?? null,
|
|
1121
|
-
...(
|
|
1122
|
-
? { phase_id: input.phase_id ?? null } : {}),
|
|
1189
|
+
...(usesPhaseEventShape ? { phase_id: input.phase_id ?? null } : {}),
|
|
1123
1190
|
actor: input.actor, recorded_at: input.recorded_at,
|
|
1124
1191
|
provenance: input.provenance ?? null, payload, event_digest: '',
|
|
1125
1192
|
};
|
|
@@ -1148,7 +1215,7 @@ function resolveDoneBindingDigest(storeMember, taskId) {
|
|
|
1148
1215
|
const genesis = events[0];
|
|
1149
1216
|
if (genesis?.kind !== 'plan_genesis' || !Array.isArray(genesis.state_migration)) return null;
|
|
1150
1217
|
const carried = genesis.state_migration.some((migration) => (
|
|
1151
|
-
['carry', 'carry_reconciled_metadata'].includes(migration.state_policy)
|
|
1218
|
+
['carry', 'carry_reconciled_metadata', 'acquire_phase'].includes(migration.state_policy)
|
|
1152
1219
|
&& migration.to_task_id === taskId && migration.state?.status === 'done'
|
|
1153
1220
|
));
|
|
1154
1221
|
return carried ? genesis.event_digest : null;
|
|
@@ -1252,7 +1319,12 @@ export async function appendTodoEvent(options = {}) {
|
|
|
1252
1319
|
member.descriptor.journal_head_digest = event.event_digest;
|
|
1253
1320
|
store.manifest.manifest_digest = todoSelfDigest(store.manifest, 'manifest_digest');
|
|
1254
1321
|
await atomicWrite(path.resolve(repoRoot, MANIFEST_REF), canonicalLine(store.manifest));
|
|
1255
|
-
|
|
1322
|
+
// snapshot artifactの形式(v1/v2)はplanのschemaで決まったまま変えない。呼び出し側
|
|
1323
|
+
// (todo-cli.mjsのphaseMutation・done advisory)がPhase状態(暗黙のterminal-audit含む)を
|
|
1324
|
+
// 見るための導出ビューは、snapshotと分けてここで別途返す。
|
|
1325
|
+
const phases = projectPhaseStates(member.plan, [...member.journal.events, event],
|
|
1326
|
+
new Map(tasks.map((task) => [task.task_id, task])));
|
|
1327
|
+
return { event, snapshot, plan: member.plan, phases };
|
|
1256
1328
|
});
|
|
1257
1329
|
}
|
|
1258
1330
|
|
|
@@ -1894,6 +1966,42 @@ function validatePhaseV3Carry(previous, revision, migration, idMap, state) {
|
|
|
1894
1966
|
}
|
|
1895
1967
|
}
|
|
1896
1968
|
|
|
1969
|
+
/**
|
|
1970
|
+
* acquire_phase専用のcarry検証(ADR 0147裁定4)。既存carry/carry_reconciled_metadataの
|
|
1971
|
+
* 分岐(validatePhaseV3Carry)には一切手を入れず、「Phase割当ての獲得だけ」を許す別分岐として
|
|
1972
|
+
* 独立に置く——同じcarry比較へphase_idだけ除外する特例を混ぜると、以後の意味論比較すべてに
|
|
1973
|
+
* 例外条件が混入する経路を開いてしまう(裁定4が名指しで禁じる形)。
|
|
1974
|
+
*
|
|
1975
|
+
* 獲得の定義: predecessor側がphase無し(phase_idがnullに正規化される)→successor側がphase有り、
|
|
1976
|
+
* という向きだけを許す。既にphaseを持つtaskの付け替えは意味論の変更であり、acquire_phaseでは
|
|
1977
|
+
* 許さない(carryへ回して従来どおりcarry_semantics_changedで拒否させる)。phase_id以外の
|
|
1978
|
+
* 属性(title/lane/narrative_ref/narrative_anchor/compile_binding/parent_task_id)と
|
|
1979
|
+
* 依存辺(incoming)は、phaseV3CarrySemanticsのtaskからphase_idだけを取り除いた上で
|
|
1980
|
+
* carryと同じ完全一致を要求する。outgoingの扱い(successor側が上位集合であること)もcarryと同じ。
|
|
1981
|
+
*/
|
|
1982
|
+
function validateAcquirePhaseCarry(previous, revision, migration, idMap) {
|
|
1983
|
+
const phaseIdMap = new Map(revision.phase_migration
|
|
1984
|
+
.filter(({ from_phase_id, to_phase_id }) => from_phase_id !== null && to_phase_id !== 'removed')
|
|
1985
|
+
.map(({ from_phase_id, to_phase_id }) => [from_phase_id, to_phase_id]));
|
|
1986
|
+
const before = phaseV3CarrySemantics(previous.plan, migration.from_task_id, idMap, phaseIdMap);
|
|
1987
|
+
const after = phaseV3CarrySemantics(revision.desired_plan, migration.to_task_id, new Map(), new Map());
|
|
1988
|
+
if (before.task.phase_id !== null) {
|
|
1989
|
+
fail('REVISION_INVALID', 'acquire_phase_requires_unassigned_predecessor', { from_task_id: migration.from_task_id });
|
|
1990
|
+
}
|
|
1991
|
+
if (after.task.phase_id === null) {
|
|
1992
|
+
fail('REVISION_INVALID', 'acquire_phase_requires_assigned_successor', { from_task_id: migration.from_task_id });
|
|
1993
|
+
}
|
|
1994
|
+
const stripPhase = ({ phase_id, ...rest }) => rest;
|
|
1995
|
+
if (canonicalizeTodoArtifact(stripPhase(before.task)) !== canonicalizeTodoArtifact(stripPhase(after.task))
|
|
1996
|
+
|| canonicalizeTodoArtifact(before.incoming) !== canonicalizeTodoArtifact(after.incoming)) {
|
|
1997
|
+
fail('REVISION_INVALID', 'carry_semantics_changed', { from_task_id: migration.from_task_id });
|
|
1998
|
+
}
|
|
1999
|
+
const successorOutgoing = new Set(after.outgoing);
|
|
2000
|
+
if (!before.outgoing.every((edge) => successorOutgoing.has(edge))) {
|
|
2001
|
+
fail('REVISION_INVALID', 'carry_outgoing_edge_removed', { from_task_id: migration.from_task_id });
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1897
2005
|
function stateMigrationFor(previous, revision) {
|
|
1898
2006
|
const oldIds = previous.plan.tasks.map(({ task_id }) => task_id);
|
|
1899
2007
|
const migrationIds = revision.task_migration.map(({ from_task_id }) => from_task_id);
|
|
@@ -1905,12 +2013,26 @@ function stateMigrationFor(previous, revision) {
|
|
|
1905
2013
|
.map(({ from_task_id, to_task_id }) => [from_task_id, to_task_id]));
|
|
1906
2014
|
const states = new Map(previous.tasks.map((state) => [state.task_id, state]));
|
|
1907
2015
|
return revision.task_migration.map((migration) => {
|
|
1908
|
-
const carriesState = ['carry', 'carry_reconciled_metadata'].includes(migration.state_policy);
|
|
2016
|
+
const carriesState = ['carry', 'carry_reconciled_metadata', 'acquire_phase'].includes(migration.state_policy);
|
|
1909
2017
|
if (!carriesState) return { ...migration, state: null };
|
|
1910
2018
|
const reconciliationMetadata = migration.state_policy === 'carry_reconciled_metadata';
|
|
1911
2019
|
const state = states.get(migration.from_task_id);
|
|
1912
2020
|
if (!state) fail('STORE_INCONSISTENT', 'predecessor_task_state_missing');
|
|
1913
|
-
if (
|
|
2021
|
+
if (migration.state_policy === 'acquire_phase') {
|
|
2022
|
+
// acquire_phaseはv3 phase revision専用の獲得判定を持つ。v3以外(v1/v2 phase revisionや
|
|
2023
|
+
// 平文todo_revision)ではtaskSemantics自体がphase_idを比較対象へ含めないため、carryと
|
|
2024
|
+
// 同じ比較で意味論は保たれる——ここを別ロジックにする理由が無い(既知のv1/v2の緩さは
|
|
2025
|
+
// ADR 0147の対象外であり、緩めも締めもしない)。
|
|
2026
|
+
if (revision.schema === 'lattice.phase_todo_revision.v3') {
|
|
2027
|
+
validateAcquirePhaseCarry(previous, revision, migration, idMap);
|
|
2028
|
+
} else {
|
|
2029
|
+
const before = taskSemantics(previous.plan, migration.from_task_id, idMap, {});
|
|
2030
|
+
const after = taskSemantics(revision.desired_plan, migration.to_task_id, new Map(), {});
|
|
2031
|
+
if (canonicalizeTodoArtifact(before) !== canonicalizeTodoArtifact(after)) {
|
|
2032
|
+
fail('REVISION_INVALID', 'carry_semantics_changed', { from_task_id: migration.from_task_id });
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
} else if (revision.schema === 'lattice.phase_todo_revision.v3') {
|
|
1914
2036
|
validatePhaseV3Carry(previous, revision, migration, idMap, state);
|
|
1915
2037
|
} else {
|
|
1916
2038
|
const before = taskSemantics(previous.plan, migration.from_task_id, idMap,
|