@quolu/lattice 0.12.34 → 0.13.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/src/todo-cli.mjs CHANGED
@@ -36,9 +36,14 @@ import {
36
36
  applyTodoRevisionSet,
37
37
  createTodoStoreWriter,
38
38
  TodoStoreError,
39
+ readTodoIndependenceArtifact,
39
40
  readTodoStore,
41
+ readTodoWitnessSet,
42
+ todoWitnessRef,
43
+ writeTodoWitnessSet,
40
44
  readTodoStoreStable,
41
45
  rebuildTodoSnapshot,
46
+ writeTodoIndependenceArtifact,
42
47
  verifyEffectivePhaseTodoRevisionSources,
43
48
  verifyTodoRevisionSources,
44
49
  } from './todo-store.mjs';
@@ -46,7 +51,23 @@ import {
46
51
  appendTodoExtraction,
47
52
  validateTodoExtraction,
48
53
  } from './todo-migration.mjs';
49
- import { projectTodoBindings, projectTodoStatus } from './todo-status.mjs';
54
+ import {
55
+ computeReadyFrontier,
56
+ projectTodoBindings,
57
+ projectTodoStatus,
58
+ } from './todo-status.mjs';
59
+ import {
60
+ TODO_INDEPENDENCE_PROJECTION_SCHEMA,
61
+ explainTodoWitnessSet,
62
+ validateTodoIndependenceProjection,
63
+ } from './todo-independence-contracts.mjs';
64
+ import {
65
+ collectWitnessSensorEvidence,
66
+ compileTodoIndependence,
67
+ migrateWitnessSetTaskIds,
68
+ projectIndependenceFrontier,
69
+ } from './todo-independence.mjs';
70
+ import { selectIndependenceGuidance } from './todo-independence-guidance.mjs';
50
71
  import {
51
72
  parseTodoSourceRef, todoLegacyReconciliationDigest, validatePhaseTodoRevision,
52
73
  validateTodoRevision, validateTodoRevisionSet,
@@ -227,6 +248,12 @@ async function readRevisionInput(repoRoot, inputRef, {
227
248
  }
228
249
 
229
250
  async function readEvidenceInput(repoRoot, inputRef) {
251
+ return readJsonInput(repoRoot, inputRef, {
252
+ validate: validateEvidenceDescriptor, invalidCode: 'INVALID_EVIDENCE',
253
+ });
254
+ }
255
+
256
+ async function readJsonInput(repoRoot, inputRef, { validate, invalidCode }) {
230
257
  if (!isTodoRef(inputRef)) {
231
258
  throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo', undefined, { input_ref: inputRef });
232
259
  }
@@ -270,12 +297,32 @@ async function readEvidenceInput(repoRoot, inputRef) {
270
297
  try { descriptor = JSON.parse(text); } catch {
271
298
  throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
272
299
  }
273
- if (!validateEvidenceDescriptor(descriptor)) {
274
- throw new TodoStoreError('INVALID_EVIDENCE', 'schema_invalid');
300
+ if (!validate(descriptor)) {
301
+ throw new TodoStoreError(invalidCode, 'schema_invalid');
275
302
  }
276
303
  return descriptor;
277
304
  }
278
305
 
306
+ /** 安全読み取りとcanonical JSON規律を共有したまま、witness set契約で検証する。 */
307
+ async function readWitnessSetInput(repoRoot, inputRef) {
308
+ let explained = null;
309
+ const witnessSet = await readJsonInput(repoRoot, inputRef, {
310
+ validate: (value) => {
311
+ explained = explainTodoWitnessSet(value);
312
+ return explained.valid;
313
+ },
314
+ invalidCode: 'INVALID_TODO_WITNESS_SET',
315
+ }).catch((error) => {
316
+ if (error?.code === 'INVALID_TODO_WITNESS_SET' && explained !== null) {
317
+ throw new TodoStoreError('INVALID_TODO_WITNESS_SET', explained.reason, undefined, {
318
+ input_ref: inputRef, path: explained.path,
319
+ });
320
+ }
321
+ throw error;
322
+ });
323
+ return witnessSet;
324
+ }
325
+
279
326
  function mutationActor(env) {
280
327
  const entries = ACTOR_ENV_KEYS.map((key) => ({ key, value: env[key] }));
281
328
  const missingEnvironment = entries
@@ -295,7 +342,9 @@ function mutationActor(env) {
295
342
  return { host: entries[0].value, session: entries[1].value, agent: entries[2].value };
296
343
  }
297
344
 
298
- async function mutate({ repoRoot, env, planKey, taskId, kind, payload, evidenceRef }) {
345
+ async function mutate({
346
+ repoRoot, env, planKey, taskId, kind, payload, evidenceRef, advisory = null,
347
+ }) {
299
348
  const actor = mutationActor(env);
300
349
  const evidence = evidenceRef === null ? null : await readEvidenceInput(repoRoot, evidenceRef);
301
350
  let eventPayload = payload;
@@ -311,7 +360,7 @@ async function mutate({ repoRoot, env, planKey, taskId, kind, payload, evidenceR
311
360
  });
312
361
  const task = snapshot.tasks.find(({ task_id: current }) => current === event.task_id);
313
362
  const result = {
314
- schema: 'lattice.todo_mutation_result.v1',
363
+ schema: 'lattice.todo_mutation_result.v2',
315
364
  project_id: event.project_id,
316
365
  plan_key: event.plan_key,
317
366
  plan_version: event.plan_version,
@@ -322,14 +371,86 @@ async function mutate({ repoRoot, env, planKey, taskId, kind, payload, evidenceR
322
371
  journal_head_digest: event.event_digest,
323
372
  snapshot_digest: snapshot.snapshot_digest,
324
373
  status: task.status,
374
+ advisory,
325
375
  result_digest: '',
326
376
  };
327
377
  result.result_digest = todoSelfDigest(result, 'result_digest');
328
378
  return result;
329
379
  }
330
380
 
381
+ /**
382
+ * 着手しようとしているtaskについて、記録済みの独立性から助言を組む(ADR 0128 Decision 5)。
383
+ *
384
+ * 助言であって拒否ではない。ADR 0063のdispatch契約は変えない。ただし助言を計算できない
385
+ * 状況——git HEADが読めない等——はsilent degradeせず、呼び出し側でstart自体を止める。
386
+ */
387
+ async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
388
+ const artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
389
+ if (artifact === null) {
390
+ // 記録が無ければ鮮度を語る相手がいない。HEADを要求すると、commitがまだ無いrepoで
391
+ // 「判定できない」でなく「startできない」になってしまう。
392
+ return {
393
+ coverage: 'missing',
394
+ drift_intersecting: null,
395
+ conflicts_with_active: [],
396
+ uncovered_active_task_ids: projection.active_set
397
+ .filter((task) => task.plan_key === planKey).map(({ task_id: id }) => id),
398
+ self_unknowns: [{ kind: 'witness_missing', ref: 'no_independence_record' }],
399
+ guidance: selectIndependenceGuidance({
400
+ coverage: 'missing', taskDeclared: false, taskStale: false,
401
+ }),
402
+ };
403
+ }
404
+ // 記録があるなら鮮度の判定にHEADが要る。ここで読めないのは判定不能であり、
405
+ // 助言なしで通してよい状態ではない。
406
+ const currentBaseSha = currentHeadSha(repoRoot);
407
+ const changedPaths = artifact.base_sha !== currentBaseSha
408
+ ? changedPathsSince(repoRoot, artifact.base_sha) : null;
409
+ const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
410
+ const projected = projectIndependenceFrontier({
411
+ artifact,
412
+ readyTaskIds: projection.next_ready
413
+ .filter((task) => task.plan_key === planKey).map(({ task_id: id }) => id),
414
+ activeTaskIds: projection.active_set
415
+ .filter((task) => task.plan_key === planKey).map(({ task_id: id }) => id),
416
+ plan: member?.plan ?? { plan_version: null, topology_digest: null },
417
+ currentBaseSha,
418
+ changedPaths,
419
+ });
420
+ const selfUnknowns = projected.frontier.unknown
421
+ .find((entry) => entry.task_id === taskId)?.unknowns ?? [];
422
+ const conflictsWithActive = projected.frontier.conflicts_with_active
423
+ .filter((entry) => entry.ready_task_id === taskId)
424
+ .map((entry) => ({
425
+ active_task_id: entry.active_task_id,
426
+ type: entry.type,
427
+ detail: entry.detail,
428
+ kind: entry.kind,
429
+ severability: entry.severability,
430
+ }));
431
+ const readyConflict = projected.frontier.serialize_pairs
432
+ .find((pair) => pair.task_ids.includes(taskId)) ?? null;
433
+ const declared = !selfUnknowns.some(({ kind }) => kind === 'witness_missing');
434
+ return {
435
+ coverage: projected.coverage,
436
+ drift_intersecting: projected.drift === null
437
+ ? null : projected.drift.intersecting_task_ids.includes(taskId),
438
+ conflicts_with_active: conflictsWithActive,
439
+ uncovered_active_task_ids: projected.uncovered_active_task_ids,
440
+ self_unknowns: selfUnknowns,
441
+ guidance: selectIndependenceGuidance({
442
+ coverage: projected.coverage,
443
+ taskDeclared: declared,
444
+ taskStale: selfUnknowns.some(({ kind }) => kind === 'record_stale'),
445
+ conflictWithActive: conflictsWithActive[0]?.severability ?? null,
446
+ conflictBetweenReady: readyConflict?.severability ?? null,
447
+ }),
448
+ };
449
+ }
450
+
331
451
  async function startTask({ repoRoot, env, planKey, taskId, overrideReason, parallelFrontier }) {
332
- const projection = projectTodoStatus(await readTodoStore({ repoRoot }));
452
+ const store = await readTodoStore({ repoRoot });
453
+ const projection = projectTodoStatus(store);
333
454
  const readyTask = projection.next_ready.find((task) => (
334
455
  task.plan_key === planKey && task.task_id.toLowerCase() === taskId.toLowerCase()
335
456
  ));
@@ -347,8 +468,13 @@ async function startTask({ repoRoot, env, planKey, taskId, overrideReason, paral
347
468
  serial_reason_flag: '--override-reason',
348
469
  });
349
470
  }
350
- return mutate({ repoRoot, env, planKey, taskId: readyTask?.task_id ?? taskId, kind: 'start',
351
- payload: { override_reason: overrideReason }, evidenceRef: null });
471
+ const resolvedTaskId = readyTask?.task_id ?? taskId;
472
+ // 助言はjournalへ書く前に確定させる。計算できないならstart自体を止める。
473
+ const advisory = await startAdvisory({
474
+ repoRoot, store, projection, planKey, taskId: resolvedTaskId,
475
+ });
476
+ return mutate({ repoRoot, env, planKey, taskId: resolvedTaskId, kind: 'start',
477
+ payload: { override_reason: overrideReason }, evidenceRef: null, advisory });
352
478
  }
353
479
 
354
480
  function validatePhaseDecisionInput(value, outcome) {
@@ -478,6 +604,228 @@ async function bindings({ repoRoot, requestedPlanKey }) {
478
604
  return projectTodoBindings(await readTodoStore({ repoRoot }), { requestedPlanKey });
479
605
  }
480
606
 
607
+ function currentHeadSha(repoRoot) {
608
+ let head;
609
+ try {
610
+ head = execFileSync('git', ['rev-parse', 'HEAD'], {
611
+ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
612
+ }).trim();
613
+ } catch {
614
+ throw new TodoStoreError('INDEPENDENCE_BASE_UNRESOLVED', 'git_head_unresolved');
615
+ }
616
+ if (!/^[0-9a-f]{40}$/u.test(head)) {
617
+ throw new TodoStoreError('INDEPENDENCE_BASE_UNRESOLVED', 'git_head_invalid');
618
+ }
619
+ return head;
620
+ }
621
+
622
+ /**
623
+ * `base_sha..HEAD`で変わったrepo相対pathを返す。
624
+ *
625
+ * baseがgit historyから到達できない場合(rebase・shallow等)はnullを返す。
626
+ * 「変更なし」の空配列と「差分を確定できない」を同じ顔にしない——前者は記録の有効性を
627
+ * 支える事実だが、後者は支えない(ADR 0128 Decision 4)。
628
+ */
629
+ function changedPathsSince(repoRoot, baseSha) {
630
+ try {
631
+ execFileSync('git', ['cat-file', '-e', `${baseSha}^{commit}`], {
632
+ cwd: repoRoot, stdio: ['ignore', 'ignore', 'ignore'],
633
+ });
634
+ } catch {
635
+ return null;
636
+ }
637
+ let output;
638
+ try {
639
+ output = execFileSync('git', ['diff', '--name-only', '--no-renames', `${baseSha}..HEAD`], {
640
+ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
641
+ });
642
+ } catch {
643
+ return null;
644
+ }
645
+ return [...new Set(output.split('\n').map((line) => line.trim()).filter((line) => line.length > 0))]
646
+ .sort();
647
+ }
648
+
649
+ function requireCleanWorktree(repoRoot) {
650
+ let porcelain;
651
+ try {
652
+ porcelain = execFileSync('git', ['status', '--porcelain'], {
653
+ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
654
+ });
655
+ } catch {
656
+ throw new TodoStoreError('INDEPENDENCE_BASE_UNRESOLVED', 'git_status_unresolved');
657
+ }
658
+ const dirty = porcelain.split('\n').filter((line) => line.trim().length > 0);
659
+ if (dirty.length > 0) {
660
+ // 未commitの観測を検証済み証拠として固定化しない(ADR 0127 Decision 3)。
661
+ throw new TodoStoreError('INDEPENDENCE_WORKTREE_DIRTY', 'worktree_not_clean', undefined, {
662
+ changed_entries: dirty.length,
663
+ next_action: 'commit_or_stash_then_retry',
664
+ });
665
+ }
666
+ }
667
+
668
+ async function independenceCompile({ repoRoot, planKey, inputRef }) {
669
+ const witnessSet = await readWitnessSetInput(repoRoot, inputRef);
670
+ requireCleanWorktree(repoRoot);
671
+ const baseSha = currentHeadSha(repoRoot);
672
+ const store = await readTodoStore({ repoRoot });
673
+ const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
674
+ if (!member) throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, { plan_key: planKey });
675
+
676
+ const artifact = compileTodoIndependence({
677
+ witnessSet,
678
+ plan: member.plan,
679
+ baseSha,
680
+ compiledAt: new Date().toISOString(),
681
+ sensorEvidence: await collectWitnessSensorEvidence({ cwd: repoRoot, witnessSet }),
682
+ });
683
+ const { ref } = await writeTodoIndependenceArtifact({ repoRoot, artifact });
684
+
685
+ const result = {
686
+ schema: 'lattice.todo_independence_compile_result.v1',
687
+ project_id: artifact.project_id,
688
+ plan_key: artifact.plan_key,
689
+ plan_version: artifact.plan_version,
690
+ base_sha: artifact.base_sha,
691
+ artifact_ref: ref,
692
+ outcome: artifact.outcome,
693
+ task_count: artifact.task_ids.length,
694
+ conflict_count: artifact.conflicts.length,
695
+ unknown_count: artifact.unknowns.length,
696
+ result_digest: '',
697
+ };
698
+ result.result_digest = todoSelfDigest(result, 'result_digest');
699
+ return result;
700
+ }
701
+
702
+ /**
703
+ * revision後のwitness宣言をtask migrationで写す(ADR 0128 Decision 6)。
704
+ *
705
+ * compileしないので証拠を固定化せず、dirty worktreeを拒否しない。
706
+ * 想定運用は「移行 → commit → cleanな状態でcompile」である。
707
+ */
708
+ async function independenceWitnessMigrate({ repoRoot, planKey }) {
709
+ const store = await readTodoStore({ repoRoot });
710
+ const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
711
+ if (!member) {
712
+ throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, { plan_key: planKey });
713
+ }
714
+ const witnessSet = await readTodoWitnessSet({ repoRoot, planKey });
715
+ if (witnessSet === null) {
716
+ throw new TodoStoreError('WITNESS_MIGRATION_UNAVAILABLE', 'witness_set_absent', undefined, {
717
+ witness_ref: todoWitnessRef(planKey),
718
+ });
719
+ }
720
+ const revision = member.revision;
721
+ if (!revision || !Array.isArray(revision.task_migration)) {
722
+ // revisionを経ていないplanには写す先が無い。「移行済み」と装わない。
723
+ throw new TodoStoreError('WITNESS_MIGRATION_UNAVAILABLE', 'plan_has_no_revision', undefined, {
724
+ plan_key: planKey, plan_version: member.plan.plan_version,
725
+ });
726
+ }
727
+
728
+ const migration = migrateWitnessSetTaskIds({
729
+ witnessSet,
730
+ taskMigration: revision.task_migration,
731
+ planTaskIds: member.plan.tasks.map(({ task_id: taskId }) => taskId),
732
+ });
733
+ const { ref } = await writeTodoWitnessSet({ repoRoot, witnessSet: migration.witnessSet });
734
+
735
+ const result = {
736
+ schema: 'lattice.todo_witness_migrate_result.v1',
737
+ project_id: store.project_id,
738
+ plan_key: planKey,
739
+ plan_version: member.plan.plan_version,
740
+ witness_ref: ref,
741
+ migrated_count: migration.migrated_count,
742
+ removed_count: migration.removed_count,
743
+ unchanged_count: migration.unchanged_count,
744
+ witness_set_digest: migration.witnessSet.witness_set_digest,
745
+ result_digest: '',
746
+ };
747
+ result.result_digest = todoSelfDigest(result, 'result_digest');
748
+ return result;
749
+ }
750
+
751
+ async function independence({ repoRoot, requestedPlanKey }) {
752
+ const store = await readTodoStore({ repoRoot });
753
+ if (requestedPlanKey !== null
754
+ && !store.members.some(({ descriptor }) => descriptor.plan_key === requestedPlanKey)) {
755
+ throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, {
756
+ plan_key: requestedPlanKey,
757
+ });
758
+ }
759
+ const frontier = computeReadyFrontier(store);
760
+ const readyPlanKeys = [...new Set(frontier.map(({ plan_key: key }) => key))].sort();
761
+ // planを絞らない呼び出しではreadyがどのplanを指しているかで決める。readyが無い時は
762
+ // 全planが候補になる。どちらの場合も候補が複数なら、片方だけ見せて答えたことにしない。
763
+ // ここで黙ってnullへ倒すと、記録があるのにcoverage missingと報告してしまう。
764
+ const candidatePlanKeys = readyPlanKeys.length > 0
765
+ ? readyPlanKeys
766
+ : store.members.map(({ descriptor }) => descriptor.plan_key).sort();
767
+ if (requestedPlanKey === null && candidatePlanKeys.length > 1) {
768
+ throw new TodoStoreError('INDEPENDENCE_PLAN_AMBIGUOUS', 'plan_selection_ambiguous', undefined, {
769
+ plan_keys: candidatePlanKeys,
770
+ ready_plan_keys: readyPlanKeys,
771
+ next_action: 'rerun_with_plan_flag',
772
+ });
773
+ }
774
+ const planKey = requestedPlanKey ?? candidatePlanKeys[0] ?? null;
775
+
776
+ const currentBaseSha = currentHeadSha(repoRoot);
777
+ const ready = frontier.filter((task) => task.plan_key === planKey);
778
+ const member = planKey === null
779
+ ? undefined : store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
780
+ const artifact = member === undefined
781
+ ? null : await readTodoIndependenceArtifact({ repoRoot, store, planKey });
782
+ const active = projectTodoStatus(store).active_set
783
+ .filter((task) => task.plan_key === planKey);
784
+ // HEADが進んでいる時だけdiffを取る。一致していれば宣言境界を見るまでもない。
785
+ const changedPaths = artifact !== null && artifact.base_sha !== currentBaseSha
786
+ ? changedPathsSince(repoRoot, artifact.base_sha) : null;
787
+
788
+ const projected = projectIndependenceFrontier({
789
+ artifact,
790
+ readyTaskIds: ready.map(({ task_id: taskId }) => taskId),
791
+ activeTaskIds: active.map(({ task_id: taskId }) => taskId),
792
+ plan: member?.plan ?? { plan_version: null, topology_digest: null },
793
+ currentBaseSha,
794
+ changedPaths,
795
+ });
796
+
797
+ const result = {
798
+ schema: TODO_INDEPENDENCE_PROJECTION_SCHEMA,
799
+ project_id: store.project_id,
800
+ plan_key: planKey,
801
+ coverage: projected.coverage,
802
+ compiled_base_sha: artifact?.base_sha ?? null,
803
+ current_base_sha: currentBaseSha,
804
+ plan_version: artifact?.plan_version ?? null,
805
+ topology_digest: artifact?.topology_digest ?? null,
806
+ active_task_ids: projected.active_task_ids,
807
+ uncovered_active_task_ids: projected.uncovered_active_task_ids,
808
+ drift: projected.drift,
809
+ // planを読みに来た人にも、着手する人と同じ文言を返す(ADR 0130 Decision 1)。
810
+ guidance: selectIndependenceGuidance({
811
+ coverage: projected.coverage,
812
+ taskDeclared: projected.frontier.unknown
813
+ .every(({ unknowns }) => !unknowns.some(({ kind }) => kind === 'witness_missing')),
814
+ taskStale: projected.frontier.unknown
815
+ .some(({ unknowns }) => unknowns.some(({ kind }) => kind === 'record_stale')),
816
+ conflictWithActive: projected.frontier.conflicts_with_active[0]?.severability ?? null,
817
+ conflictBetweenReady: projected.frontier.serialize_pairs[0]?.severability ?? null,
818
+ }),
819
+ frontier: projected.frontier,
820
+ result_digest: '',
821
+ };
822
+ result.result_digest = todoSelfDigest(result, 'result_digest');
823
+ if (!validateTodoIndependenceProjection(result)) {
824
+ throw new TodoStoreError('INDEPENDENCE_PROJECTION_INVALID', 'independence_projection_invalid');
825
+ }
826
+ return result;
827
+ }
828
+
481
829
  async function readNarrative(repoRoot, ref) {
482
830
  const canonicalRoot = await realpath(repoRoot);
483
831
  const source = parseTodoSourceRef(ref);
@@ -644,6 +992,66 @@ function parseGanttDescriptor(bytes, descriptorRef) {
644
992
  return value;
645
993
  }
646
994
 
995
+ /**
996
+ * 図が描く全planについて独立性を投影する(ADR 0129 Decision 4)。
997
+ *
998
+ * Ganttは複数planを同時に描くので、単一plan前提の`todo independence`の曖昧判定は持ち込まない。
999
+ * plan単位で記録を引き、記録が無いplanは投影を持たないまま通す——描けない事実を
1000
+ * 図の側で作り出さない。
1001
+ */
1002
+ /**
1003
+ * live配信の更新検知に使うhead digest(ADR 0129 Decision 6)。
1004
+ *
1005
+ * manifest_digestだけでは、独立性の再compileもHEAD前進も検知できず画面が古いまま残る。
1006
+ * 同じ値を描画側と検知側で別々に組み立てると、更新されない状態が静かに再発するため、
1007
+ * 一つの関数だけが組む。
1008
+ */
1009
+ export async function ganttLiveHeadDigest({ repoRoot, store }) {
1010
+ const independence = await independenceForGantt({ repoRoot, store });
1011
+ return digestTodoArtifact({
1012
+ schema: 'lattice.todo_gantt_live_head.v1',
1013
+ manifest_digest: store.manifest.manifest_digest,
1014
+ independence: independence === null ? null : independence.map((entry) => ({
1015
+ plan_key: entry.plan_key,
1016
+ coverage: entry.coverage,
1017
+ frontier_digest: digestTodoArtifact(entry.frontier),
1018
+ })),
1019
+ });
1020
+ }
1021
+
1022
+ async function independenceForGantt({ repoRoot, store }) {
1023
+ const frontier = computeReadyFrontier(store);
1024
+ const status = projectTodoStatus(store);
1025
+ let currentBaseSha = null;
1026
+ const projections = [];
1027
+ for (const member of store.members) {
1028
+ const planKey = member.plan.plan_key;
1029
+ const artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
1030
+ if (artifact === null) continue;
1031
+ // 記録があるplanが1つでもあれば鮮度の判定にHEADが要る。
1032
+ if (currentBaseSha === null) currentBaseSha = currentHeadSha(repoRoot);
1033
+ const changedPaths = artifact.base_sha !== currentBaseSha
1034
+ ? changedPathsSince(repoRoot, artifact.base_sha) : null;
1035
+ const projected = projectIndependenceFrontier({
1036
+ artifact,
1037
+ readyTaskIds: frontier.filter((task) => task.plan_key === planKey)
1038
+ .map(({ task_id: taskId }) => taskId),
1039
+ activeTaskIds: status.active_set.filter((task) => task.plan_key === planKey)
1040
+ .map(({ task_id: taskId }) => taskId),
1041
+ plan: member.plan,
1042
+ currentBaseSha,
1043
+ changedPaths,
1044
+ });
1045
+ projections.push({
1046
+ project_id: member.plan.project_id,
1047
+ plan_key: planKey,
1048
+ coverage: projected.coverage,
1049
+ frontier: projected.frontier,
1050
+ });
1051
+ }
1052
+ return projections.length === 0 ? null : projections;
1053
+ }
1054
+
647
1055
  export async function renderTodoGanttForProject({
648
1056
  repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
649
1057
  scope = DEFAULT_GANTT_SCOPE,
@@ -656,11 +1064,12 @@ export async function renderTodoGanttForProject({
656
1064
  const presentation = await loadTodoGanttPresentation({ repoRoot, readModel: store });
657
1065
  const topology = mergedTopology(store);
658
1066
  const chain = projectTodoChainV1(topology);
659
- const layout = layoutTodoGantt(store, chain, { scope });
1067
+ const independence = await independenceForGantt({ repoRoot, store });
1068
+ const layout = layoutTodoGantt(store, chain, { scope, independence });
660
1069
  // When the diagram hides history, the page also carries the full diagram so
661
1070
  // the reader can bring it back in place. Nothing is hidden under `all`.
662
1071
  const expandedLayout = layout.scope.folded_task_count === 0
663
- ? null : layoutTodoGantt(store, chain, { scope: 'all' });
1072
+ ? null : layoutTodoGantt(store, chain, { scope: 'all', independence });
664
1073
  const narrative = await loadNarratives(store, repoRoot);
665
1074
  const anchorOutcomes = verifyNarrativeAnchors({
666
1075
  readModel: store,
@@ -785,12 +1194,15 @@ async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_S
785
1194
  displayName: identity.displayName,
786
1195
  port,
787
1196
  render: async () => {
788
- const { rendered, metadata } = await renderTodoGanttForProject({
789
- repoRoot, stable: true, displayName: identity.displayName, scope,
1197
+ const store = await readTodoStoreStable({ repoRoot });
1198
+ const { rendered } = await renderTodoGanttForProject({
1199
+ repoRoot, stable: true, displayName: identity.displayName, scope, readModel: store,
790
1200
  });
791
- return { html: rendered.html, head_digest: metadata.manifest_digest };
1201
+ return { html: rendered.html, head_digest: await ganttLiveHeadDigest({ repoRoot, store }) };
792
1202
  },
793
- readHead: async () => (await readTodoStoreStable({ repoRoot })).manifest.manifest_digest,
1203
+ readHead: async () => ganttLiveHeadDigest({
1204
+ repoRoot, store: await readTodoStoreStable({ repoRoot }),
1205
+ }),
794
1206
  });
795
1207
  const result = { schema: 'lattice.todo_gantt_live_result.v2', project_id: live.projectId,
796
1208
  host: live.host, port: live.port, project_path: live.projectPath,
@@ -937,6 +1349,21 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
937
1349
  && argv[1] === '--plan' && isTodoIdentifier(argv[2])
938
1350
  && (argv.length === 3 || argv[3] === '--json')) {
939
1351
  action = (repoRoot) => bindings({ repoRoot, requestedPlanKey: argv[2] });
1352
+ } else if (argv.length === 5 && argv[0] === 'independence' && argv[1] === 'witness'
1353
+ && argv[2] === 'migrate' && argv[3] === '--plan' && isTodoIdentifier(argv[4])) {
1354
+ action = (repoRoot) => independenceWitnessMigrate({ repoRoot, planKey: argv[4] });
1355
+ } else if (argv.length === 6 && argv[0] === 'independence' && argv[1] === 'compile'
1356
+ && argv[2] === '--plan' && isTodoIdentifier(argv[3]) && argv[4] === '--input') {
1357
+ action = (repoRoot) => independenceCompile({
1358
+ repoRoot, planKey: argv[3], inputRef: argv[5],
1359
+ });
1360
+ } else if ((argv.length === 1 && argv[0] === 'independence')
1361
+ || (argv.length === 2 && argv[0] === 'independence' && argv[1] === '--json')) {
1362
+ action = (repoRoot) => independence({ repoRoot, requestedPlanKey: null });
1363
+ } else if ((argv.length === 3 || argv.length === 4) && argv[0] === 'independence'
1364
+ && argv[1] === '--plan' && isTodoIdentifier(argv[2])
1365
+ && (argv.length === 3 || argv[3] === '--json')) {
1366
+ action = (repoRoot) => independence({ repoRoot, requestedPlanKey: argv[2] });
940
1367
  } else if ((argv.length === 1 && argv[0] === 'verify')
941
1368
  || (argv.length === 2 && argv[0] === 'verify' && argv[1] === '--json')) {
942
1369
  action = (repoRoot) => verify({ repoRoot, requestedPlanKey: null });