@quolu/lattice 0.38.0 → 0.39.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.
@@ -252,10 +252,18 @@ function emptyPhaseState(phaseId) {
252
252
  // と衝突しない予約名として固定する。
253
253
  export const TERMINAL_AUDIT_PHASE_ID = 'terminal-audit';
254
254
 
255
+ export function isPhaseTodoPlanSchema(schema) {
256
+ return ['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(schema);
257
+ }
258
+
259
+ export function isDecoupledPhaseTodoPlanSchema(schema) {
260
+ return ['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(schema);
261
+ }
262
+
255
263
  // CLI側(migrate/plan create/最後のdone)が「終端重監査が要る」ことを通知するために
256
264
  // 同じ判定を再利用する。判定基準を二重管理しないよう、ここから公開する。
257
265
  export function isPhaselessTodoPlanSchema(schema) {
258
- return !['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(schema);
266
+ return !isPhaseTodoPlanSchema(schema);
259
267
  }
260
268
 
261
269
  function terminalAuditPhase() {
@@ -358,7 +366,13 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
358
366
  if (!isStrictTodoTimestamp(event.recorded_at)) fail('STORE_CORRUPT', 'timestamp_invalid');
359
367
  const time = Date.parse(event.recorded_at);
360
368
  if (previousTime !== null && time < previousTime) fail('STORE_INCONSISTENT', 'clock_reversal');
361
- if (time > now.valueOf() + MAX_FUTURE_SKEW_MS) fail('STORE_INCONSISTENT', 'future_clock_skew');
369
+ if (time > now.valueOf() + MAX_FUTURE_SKEW_MS) {
370
+ fail('STORE_INCONSISTENT', 'future_clock_skew', {
371
+ recorded_at: event.recorded_at,
372
+ current_time: now.toISOString(),
373
+ max_future_skew_ms: MAX_FUTURE_SKEW_MS,
374
+ });
375
+ }
362
376
  previousTime = time;
363
377
  if (event.kind === 'plan_genesis') {
364
378
  if (event.payload.plan_digest !== plan.plan_digest || event.payload.topology_digest !== plan.topology_digest
@@ -386,8 +400,12 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
386
400
  Object.assign(state, structuredClone(migration.state), { evidence_unverified: false });
387
401
  if (state.evidence !== null) {
388
402
  if (state.imported) {
389
- if (verifyImportSource) verifyImportSource(state.evidence);
390
- } else if (verifyEvidence) verifyEvidence(state.evidence);
403
+ if (verifyImportSource) verifyImportSource(state.evidence, {
404
+ plan_key: plan.plan_key, task_id: migration.to_task_id,
405
+ });
406
+ } else if (verifyEvidence) verifyEvidence(state.evidence, {
407
+ plan_key: plan.plan_key, task_id: migration.to_task_id,
408
+ });
391
409
  }
392
410
  if (state.status === 'done') {
393
411
  doneDigest.set(migration.to_task_id, event.event_digest);
@@ -484,7 +502,9 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
484
502
  if (!importedGenesis || state.status !== 'pending') {
485
503
  fail('STORE_INCONSISTENT', 'invalid_historical_import_start_transition');
486
504
  }
487
- if (verifyImportSource) verifyImportSource(event.payload.evidence);
505
+ if (verifyImportSource) verifyImportSource(event.payload.evidence, {
506
+ plan_key: plan.plan_key, task_id: event.task_id,
507
+ });
488
508
  state.status = 'in-progress';
489
509
  state.started_at = event.payload.started_at === 'unknown_requires_evidence'
490
510
  ? null : event.payload.started_at;
@@ -509,13 +529,17 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
509
529
  } else if (event.kind === 'done') {
510
530
  if (event.payload.done_mode === 'authored') {
511
531
  if (state.status !== 'in-progress' || !dependenciesDone) fail('STORE_INCONSISTENT', 'invalid_done_transition');
512
- if (verifyEvidence) verifyEvidence(event.payload.evidence);
532
+ if (verifyEvidence) verifyEvidence(event.payload.evidence, {
533
+ plan_key: plan.plan_key, task_id: event.task_id,
534
+ });
513
535
  state.status = 'done'; state.done_at = event.recorded_at; state.evidence = event.payload.evidence;
514
536
  state.imported = false;
515
537
  completion.set(event.task_id, { mode: 'authored', completed_at: event.recorded_at });
516
538
  } else if (event.payload.done_mode === 'historical_import') {
517
539
  if (!importedGenesis || state.status !== 'pending') fail('STORE_INCONSISTENT', 'invalid_historical_import_transition');
518
- if (verifyImportSource) verifyImportSource(event.payload.evidence);
540
+ if (verifyImportSource) verifyImportSource(event.payload.evidence, {
541
+ plan_key: plan.plan_key, task_id: event.task_id,
542
+ });
519
543
  state.status = 'done'; state.done_at = event.payload.completed_at === 'unknown_requires_evidence'
520
544
  ? null : event.payload.completed_at;
521
545
  state.evidence = event.payload.evidence; state.imported = true;
@@ -527,7 +551,9 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
527
551
  || doneDigest.get(event.task_id) !== event.payload.target_done_digest) {
528
552
  fail('STORE_INCONSISTENT', 'invalid_evidence_promotion');
529
553
  }
530
- if (verifyEvidence) verifyEvidence(event.payload.evidence);
554
+ if (verifyEvidence) verifyEvidence(event.payload.evidence, {
555
+ plan_key: plan.plan_key, task_id: event.task_id,
556
+ });
531
557
  state.evidence = event.payload.evidence;
532
558
  completion.set(event.task_id, { mode: 'evidence_promotion', completed_at: current.completed_at });
533
559
  }
@@ -566,7 +592,7 @@ function snapshotFor(plan, events, tasks) {
566
592
  // snapshot_staleになりforWrite(start/done/revise等)が丸ごとSTORE_WRITE_REFUSEDへ落ちる
567
593
  // ——ADR 0147以降の暗黙terminal-audit Phaseの状態は、この関数の外(readTodoStore/
568
594
  // appendTodoEventが返す`phases`という導出ビュー)で供給する。
569
- const phasePlan = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema);
595
+ const phasePlan = isPhaseTodoPlanSchema(plan.schema);
570
596
  const snapshot = {
571
597
  schema: phasePlan ? 'lattice.todo_snapshot.v2' : 'lattice.todo_snapshot.v1',
572
598
  project_id: plan.project_id, plan_key: plan.plan_key,
@@ -587,7 +613,7 @@ function validateMergedGraph(members) {
587
613
  const adjacency = new Map([...tasks.keys()].map((key) => [key, []]));
588
614
  const phases = new Map();
589
615
  for (const member of members) {
590
- if (member.plan.schema !== 'lattice.todo_plan.v5') continue;
616
+ if (!isDecoupledPhaseTodoPlanSchema(member.plan.schema)) continue;
591
617
  for (const phase of member.plan.phases) {
592
618
  const key = `phase\0${member.plan.project_id}\0${member.plan.plan_key}\0${phase.phase_id}`;
593
619
  phases.set(key, member.plan.topology_digest);
@@ -630,7 +656,7 @@ function validateMergedGraph(members) {
630
656
  adjacency.get(after).push(before);
631
657
  }
632
658
  }
633
- if (plan.schema === 'lattice.todo_plan.v5') {
659
+ if (isDecoupledPhaseTodoPlanSchema(plan.schema)) {
634
660
  for (const task of plan.tasks) {
635
661
  const taskKey = `${plan.project_id}\0${plan.plan_key}\0${task.task_id}`;
636
662
  const phaseKey = `phase\0${plan.project_id}\0${plan.plan_key}\0${task.phase_id}`;
@@ -675,7 +701,7 @@ function mergedTaskPhase(store, taskKey) {
675
701
  project_id: member.plan.project_id, plan_key: member.plan.plan_key, task_id: entry.task_id,
676
702
  }) === taskKey);
677
703
  if (task === undefined) continue;
678
- if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(member.plan.schema)) return null;
704
+ if (!isPhaseTodoPlanSchema(member.plan.schema)) return null;
679
705
  return { schema: member.plan.schema, plan_key: member.plan.plan_key, phase_id: task.phase_id,
680
706
  status: member.snapshot.phases.find(({ phase_id }) => phase_id === task.phase_id)?.status };
681
707
  }
@@ -687,7 +713,7 @@ function mergedDependencyPhaseReady(store, predecessorKey, targetKey) {
687
713
  const target = mergedTaskPhase(store, targetKey);
688
714
  if (predecessor === undefined) return false;
689
715
  if (predecessor === null) return true;
690
- if (predecessor.schema === 'lattice.todo_plan.v5') return true;
716
+ if (isDecoupledPhaseTodoPlanSchema(predecessor.schema)) return true;
691
717
  if (target !== null && target !== undefined && predecessor.plan_key === target.plan_key
692
718
  && predecessor.phase_id === target.phase_id) return true;
693
719
  return predecessor.status === 'accepted';
@@ -695,7 +721,7 @@ function mergedDependencyPhaseReady(store, predecessorKey, targetKey) {
695
721
 
696
722
  function mergedPhaseAcceptReady(store, targetKey) {
697
723
  for (const member of store.members) {
698
- if (member.plan.schema !== 'lattice.todo_plan.v5') continue;
724
+ if (!isDecoupledPhaseTodoPlanSchema(member.plan.schema)) continue;
699
725
  for (const edge of member.plan.phase_accept_dependencies) {
700
726
  if (mergedTaskKey(edge.to) !== targetKey) continue;
701
727
  const source = store.members.find((candidate) => candidate.plan.project_id === edge.from.project_id
@@ -766,7 +792,7 @@ function validateMergedTransition(store, member, event) {
766
792
  plan_key: member.plan.plan_key, task_id }))
767
793
  .flatMap((key) => mergedSuccessorKeys(store, key))
768
794
  .some((key) => states.get(key) !== 'pending')
769
- : store.members.some((candidate) => candidate.plan.schema === 'lattice.todo_plan.v5'
795
+ : store.members.some((candidate) => isDecoupledPhaseTodoPlanSchema(candidate.plan.schema)
770
796
  && candidate.plan.phase_accept_dependencies.some((edge) => (
771
797
  edge.from.project_id === member.plan.project_id
772
798
  && edge.from.plan_key === member.plan.plan_key && edge.from.phase_id === event.phase_id
@@ -806,7 +832,7 @@ function reachableObjects(absoluteRepo) {
806
832
 
807
833
  function evidenceVerifier(manifest, repoRoot, hard) {
808
834
  const repositories = new Map(manifest.repositories.map((repo) => [repo.repo_id, repo.path]));
809
- return (descriptor) => {
835
+ return (descriptor, context = {}) => {
810
836
  if (!validateEvidenceDescriptor(descriptor)) fail('STORE_INCONSISTENT', 'evidence_descriptor_invalid');
811
837
  const repoRef = repositories.get(descriptor.repo_id);
812
838
  if (repoRef === undefined) fail('STORE_INCONSISTENT', 'evidence_repo_missing');
@@ -829,7 +855,12 @@ function evidenceVerifier(manifest, repoRoot, hard) {
829
855
  if (sha256Bytes(bytes) !== descriptor.content_digest) throw new Error('digest mismatch');
830
856
  return true;
831
857
  } catch {
832
- if (hard) fail('STORE_INCONSISTENT', 'evidence_unverified');
858
+ if (hard) fail('STORE_INCONSISTENT', 'evidence_unverified', {
859
+ ...context,
860
+ next_action: context.plan_key === undefined
861
+ ? 'verify_the_evidence_descriptor_and_retry'
862
+ : `lattice todo verify --plan ${context.plan_key} --json`,
863
+ });
833
864
  return false;
834
865
  }
835
866
  };
@@ -886,13 +917,16 @@ function liveReplacementPreservesListStructure(lineBytes, replacement) {
886
917
  }
887
918
 
888
919
  function importSourceVerifier(repoRoot, hard, cache = null) {
889
- return (descriptor) => {
920
+ return (descriptor, context = {}) => {
890
921
  if (!validateTodoImportSource(descriptor)) fail('STORE_INCONSISTENT', 'import_source_descriptor_invalid');
891
922
  try {
892
923
  pinnedSourceLine(repoRoot, descriptor, cache);
893
924
  return true;
894
925
  } catch {
895
- if (hard) fail('STORE_INCONSISTENT', 'import_source_unverified');
926
+ if (hard) fail('STORE_INCONSISTENT', 'import_source_unverified', {
927
+ ...context,
928
+ next_action: 'verify_source_commit_origin_path_and_line_then_retry',
929
+ });
896
930
  return false;
897
931
  }
898
932
  };
@@ -911,7 +945,8 @@ function narrativeAnchorSource(value) {
911
945
 
912
946
  function materializeImportedNarrativeAnchors(repoRoot, planInput, sources) {
913
947
  if (sources === undefined) return planInput;
914
- if (planInput?.schema !== 'lattice.todo_plan.v2' || !Array.isArray(sources)
948
+ if (!['lattice.todo_plan.v2', 'lattice.todo_plan.v6'].includes(planInput?.schema)
949
+ || !Array.isArray(sources)
915
950
  || sources.length > TODO_LIMITS.tasksPerPlan || !sources.every(narrativeAnchorSource)) {
916
951
  throw new TypeError('imported narrative anchor sources are invalid');
917
952
  }
@@ -947,8 +982,10 @@ function materializeImportedNarrativeAnchors(repoRoot, planInput, sources) {
947
982
  }
948
983
 
949
984
  function verifyPlanNarrativeAnchors(repoRoot, plan, trustedPlan = null) {
950
- if (!['lattice.todo_plan.v2', 'lattice.todo_plan.v3', 'lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema)) return;
951
- const trusted = new Map((['lattice.todo_plan.v2', 'lattice.todo_plan.v3', 'lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(trustedPlan?.schema)
985
+ if (!['lattice.todo_plan.v2', 'lattice.todo_plan.v3', 'lattice.todo_plan.v4',
986
+ 'lattice.todo_plan.v5', 'lattice.todo_plan.v6', 'lattice.todo_plan.v7'].includes(plan.schema)) return;
987
+ const trusted = new Map((['lattice.todo_plan.v2', 'lattice.todo_plan.v3', 'lattice.todo_plan.v4',
988
+ 'lattice.todo_plan.v5', 'lattice.todo_plan.v6', 'lattice.todo_plan.v7'].includes(trustedPlan?.schema)
952
989
  ? trustedPlan.tasks : [])
953
990
  .map((task) => [task.task_id, task.narrative_anchor]));
954
991
  for (const task of plan.tasks) {
@@ -1196,7 +1233,7 @@ function nextEvent(input, storeMember) {
1196
1233
  const payload = input.kind === 'done' && exactRecord(input.payload, ['evidence'])
1197
1234
  ? { done_mode: 'authored', imported: false, evidence: input.payload.evidence }
1198
1235
  : input.payload;
1199
- const phaseCapablePlan = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(storeMember.plan.schema);
1236
+ const phaseCapablePlan = isPhaseTodoPlanSchema(storeMember.plan.schema);
1200
1237
  const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen', 'phase_close_unaudited']
1201
1238
  .includes(input.kind);
1202
1239
  // ADR 0147: phase無しplanでも、暗黙のterminal-audit Phaseへのphase_*eventだけは
@@ -1361,8 +1398,8 @@ export function buildTodoPlan(input) {
1361
1398
  plan.topology_digest = digestTodoArtifact({
1362
1399
  project_id: plan.project_id, plan_key: plan.plan_key, plan_version: plan.plan_version,
1363
1400
  tasks: plan.tasks, hard_dependencies: plan.hard_dependencies, joins: plan.joins,
1364
- ...(['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema) ? { phases: plan.phases } : {}),
1365
- ...(plan.schema === 'lattice.todo_plan.v5'
1401
+ ...(isPhaseTodoPlanSchema(plan.schema) ? { phases: plan.phases } : {}),
1402
+ ...(isDecoupledPhaseTodoPlanSchema(plan.schema)
1366
1403
  ? { phase_accept_dependencies: plan.phase_accept_dependencies } : {}),
1367
1404
  });
1368
1405
  plan.plan_digest = todoSelfDigest(plan, 'plan_digest');
@@ -1372,12 +1409,12 @@ export function buildTodoPlan(input) {
1372
1409
 
1373
1410
  export function buildPlanGenesis(plan, input) {
1374
1411
  const event = {
1375
- schema: ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema)
1412
+ schema: isPhaseTodoPlanSchema(plan.schema)
1376
1413
  ? 'lattice.todo_event.v3' : 'lattice.todo_event.v1',
1377
1414
  project_id: plan.project_id, plan_key: plan.plan_key,
1378
1415
  plan_version: plan.plan_version, sequence: 0, previous_digest: input.previous_digest ?? null,
1379
1416
  kind: 'plan_genesis', task_id: null,
1380
- ...(['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema) ? { phase_id: null } : {}),
1417
+ ...(isPhaseTodoPlanSchema(plan.schema) ? { phase_id: null } : {}),
1381
1418
  actor: input.actor, recorded_at: input.recorded_at,
1382
1419
  provenance: input.provenance ?? null,
1383
1420
  payload: { plan_digest: plan.plan_digest, topology_digest: plan.topology_digest,
@@ -1920,7 +1957,7 @@ function taskSemantics(plan, taskId, idMap, { reconciliationMetadata = false } =
1920
1957
  .sort((left, right) => canonicalizeTodoArtifact(left) < canonicalizeTodoArtifact(right) ? -1 : 1),
1921
1958
  before: mappedNodeRef(join.before, plan, idMap),
1922
1959
  })).sort((left, right) => left.id < right.id ? -1 : 1);
1923
- const phaseAcceptDependencies = plan.schema === 'lattice.todo_plan.v5'
1960
+ const phaseAcceptDependencies = isDecoupledPhaseTodoPlanSchema(plan.schema)
1924
1961
  ? plan.phase_accept_dependencies
1925
1962
  .filter(({ to }) => localTaskRef(to, plan, taskId))
1926
1963
  .map(({ from, to }) => ({ from, to: mappedNodeRef(to, plan, idMap) }))
@@ -1966,7 +2003,7 @@ function phaseV3CarrySemantics(plan, taskId, taskIdMap, phaseIdMap,
1966
2003
  if (localTaskRef(after, plan, taskId)) outgoing.push(tuple);
1967
2004
  }
1968
2005
  }
1969
- if (plan.schema === 'lattice.todo_plan.v5') for (const edge of plan.phase_accept_dependencies) {
2006
+ if (isDecoupledPhaseTodoPlanSchema(plan.schema)) for (const edge of plan.phase_accept_dependencies) {
1970
2007
  if (localTaskRef(edge.to, plan, taskId)) incoming.push({ kind: 'phase_accept',
1971
2008
  from: mapPhaseRef(edge.from), to: mapTaskRef(edge.to) });
1972
2009
  }
@@ -2079,7 +2116,7 @@ function stateMigrationFor(previous, revision) {
2079
2116
  }
2080
2117
 
2081
2118
  function phaseStateMigrationFor(previous, revision) {
2082
- const predecessorPhases = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(previous.plan.schema)
2119
+ const predecessorPhases = isPhaseTodoPlanSchema(previous.plan.schema)
2083
2120
  ? previous.plan.phases : [];
2084
2121
  const sourceIds = revision.phase_migration
2085
2122
  .filter(({ from_phase_id }) => from_phase_id !== null).map(({ from_phase_id }) => from_phase_id);
@@ -3685,7 +3722,9 @@ export async function createSuccessorTodoPlan(options = {}) {
3685
3722
  }
3686
3723
  const schemaRank = new Map([
3687
3724
  ['lattice.todo_plan.v1', 1], ['lattice.todo_plan.v2', 2], ['lattice.todo_plan.v3', 3],
3688
- ['lattice.todo_plan.v4', 4], ['lattice.todo_plan.v5', 5],
3725
+ // v6はphase無しv3へdesign_memoを追加した枝であり、phase世代より後という意味ではない。
3726
+ ['lattice.todo_plan.v4', 4], ['lattice.todo_plan.v5', 5], ['lattice.todo_plan.v6', 3],
3727
+ ['lattice.todo_plan.v7', 5],
3689
3728
  ]);
3690
3729
  if (schemaRank.get(plan.schema) < schemaRank.get(previous.plan.schema)) {
3691
3730
  throw new TypeError(previous.plan.schema === 'lattice.todo_plan.v2'
@@ -3693,6 +3732,10 @@ export async function createSuccessorTodoPlan(options = {}) {
3693
3732
  ? 'todo plan schema cannot regress from v2 to v1'
3694
3733
  : 'todo plan schema cannot regress');
3695
3734
  }
3735
+ if (['lattice.todo_plan.v6', 'lattice.todo_plan.v7'].includes(previous.plan.schema)
3736
+ && !['lattice.todo_plan.v6', 'lattice.todo_plan.v7'].includes(plan.schema)) {
3737
+ throw new TypeError('todo plan design_memo cannot be removed by successor schema');
3738
+ }
3696
3739
  verifyPlanNarrativeAnchors(repoRoot, plan, previous.plan);
3697
3740
  const migration = options.genesis.task_migration ?? [];
3698
3741
  const oldIds = previous.plan.tasks.map(({ task_id }) => task_id).sort();