@quolu/lattice 0.51.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
package/src/cli-help.mjs CHANGED
@@ -99,6 +99,8 @@ Write commands:
99
99
  done --plan <key> --task <id> --evidence <file>
100
100
  reopen --plan <key> --task <id> --reason <text> [--override-reason <text>]
101
101
  evidence promote --plan <key> --task <id> --evidence <file>
102
+ dependency connect --from-plan <key> --from-task <id> --to-plan <key> --to-task <id> --reason <text>
103
+ # 開発中に発見したplan跨ぎ依存を明示接続する。依存の自動推定は行わない
102
104
  independence compile --plan <key> --input <file> # witness setとsensorから並列可否を記録する
103
105
  independence witness migrate --plan <key> # revision後の宣言をtask migrationで写す
104
106
  independence witness scaffold --plan <key> --input <draft> # 下書きとfresh観測から宣言を書き出す
@@ -247,6 +249,8 @@ const SUBCOMMAND_USAGE = Object.freeze({
247
249
  'todo reopen': 'todo reopen --plan <key> --task <id> --reason <text> [--override-reason <text>]',
248
250
  'todo evidence': 'todo evidence promote --plan <key> --task <id> --evidence <file>',
249
251
  'todo evidence promote': 'todo evidence promote --plan <key> --task <id> --evidence <file>',
252
+ 'todo dependency': 'todo dependency connect --from-plan <key> --from-task <id> --to-plan <key> --to-task <id> --reason <text>',
253
+ 'todo dependency connect': 'todo dependency connect --from-plan <key> --from-task <id> --to-plan <key> --to-task <id> --reason <text>',
250
254
  'todo split': 'todo split --plan <key> --input <file>',
251
255
  'todo revise': 'todo revise --plan <key> --input <file> | --schema --json',
252
256
  'todo revise-phase': 'todo revise-phase --plan <key> --input <file> | --schema --json',
package/src/todo-cli.mjs CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  createTodoStoreWriter,
47
47
  TodoStoreError,
48
48
  isPhaselessTodoPlanSchema,
49
+ projectTodoCrossPlanDependencies,
49
50
  TERMINAL_AUDIT_PHASE_ID,
50
51
  readTodoIndependenceArtifact,
51
52
  readTodoSeamProposalArtifact,
@@ -96,6 +97,7 @@ import {
96
97
  selectIndependenceGuidance,
97
98
  selectWitnessScaffoldGuidance,
98
99
  selectSeamProposalGuidance,
100
+ scopeExpansionRecommendations,
99
101
  } from './todo-independence-guidance.mjs';
100
102
  import {
101
103
  buildSeamProposalQuerySet,
@@ -280,9 +282,13 @@ function taskRef(plan, taskId) {
280
282
  }
281
283
 
282
284
  function mergedTopology(store) {
285
+ const crossPlanDependencies = projectTodoCrossPlanDependencies(store.members);
283
286
  return {
284
287
  nodes: store.members.flatMap(({ plan }) => plan.tasks.map(({ task_id: taskId }) => taskRef(plan, taskId))),
285
- hard_edges: store.members.flatMap(({ plan }) => plan.hard_dependencies),
288
+ hard_edges: [
289
+ ...store.members.flatMap(({ plan }) => plan.hard_dependencies),
290
+ ...crossPlanDependencies.map(({ from, to }) => ({ from, to })),
291
+ ],
286
292
  joins: store.members.flatMap(({ plan }) => plan.joins),
287
293
  };
288
294
  }
@@ -640,6 +646,7 @@ async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
640
646
  coverage: 'missing',
641
647
  drift_intersecting: null,
642
648
  conflicts_with_active: [],
649
+ scope_expansion_recommendations: [],
643
650
  uncovered_active_task_ids: projection.active_set
644
651
  .filter((task) => task.plan_key === planKey).map(({ task_id: id }) => id),
645
652
  self_unknowns: [{ kind: 'witness_missing', ref: 'no_independence_record' }],
@@ -683,6 +690,9 @@ async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
683
690
  drift_intersecting: projected.drift === null
684
691
  ? null : projected.drift.intersecting_task_ids.includes(taskId),
685
692
  conflicts_with_active: conflictsWithActive,
693
+ scope_expansion_recommendations: scopeExpansionRecommendations(
694
+ Array.isArray(artifact.scope_expanded) ? artifact.scope_expanded : [],
695
+ ).filter(({ task_id: expandedTaskId }) => expandedTaskId === taskId),
686
696
  uncovered_active_task_ids: projected.uncovered_active_task_ids,
687
697
  self_unknowns: selfUnknowns,
688
698
  guidance: selectIndependenceGuidance({
@@ -912,6 +922,57 @@ async function independenceMode({ repoRoot, env, planKey, mode, reason }) {
912
922
  return result;
913
923
  }
914
924
 
925
+ /** 開発中に発見したplan跨ぎ依存を、consumer planのplan-scoped chainへ接続する。 */
926
+ async function dependencyConnect({
927
+ repoRoot, env, fromPlanKey, fromTaskId, toPlanKey, toTaskId, reason,
928
+ }) {
929
+ const store = await readTodoStore({ repoRoot });
930
+ const dependencyMember = (planKey) => {
931
+ const member = store.members.find(({ plan }) => plan.plan_key === planKey);
932
+ if (member === undefined) {
933
+ throw new TodoStoreError('DEPENDENCY_INVALID', 'dependency_plan_not_found', undefined, {
934
+ plan_key: planKey,
935
+ });
936
+ }
937
+ return member;
938
+ };
939
+ const source = dependencyMember(fromPlanKey);
940
+ const target = dependencyMember(toPlanKey);
941
+ if (!source.plan.tasks.some(({ task_id: taskId }) => taskId === fromTaskId)) {
942
+ throw new TodoStoreError('DEPENDENCY_INVALID', 'dependency_task_not_found', undefined, {
943
+ plan_key: fromPlanKey, task_id: fromTaskId,
944
+ });
945
+ }
946
+ if (!target.plan.tasks.some(({ task_id: taskId }) => taskId === toTaskId)) {
947
+ throw new TodoStoreError('DEPENDENCY_INVALID', 'dependency_task_not_found', undefined, {
948
+ plan_key: toPlanKey, task_id: toTaskId,
949
+ });
950
+ }
951
+ const from = {
952
+ project_id: store.project_id, plan_key: fromPlanKey, task_id: fromTaskId,
953
+ expected_topology_digest: source.plan.topology_digest,
954
+ };
955
+ const to = {
956
+ project_id: store.project_id, plan_key: toPlanKey, task_id: toTaskId,
957
+ expected_topology_digest: target.plan.topology_digest,
958
+ };
959
+ const { event } = await appendTodoEvent({
960
+ repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }), planKey: toPlanKey,
961
+ event: {
962
+ kind: 'cross_plan_dependency', actor: mutationActor(env), payload: { from, to, reason },
963
+ },
964
+ });
965
+ const result = {
966
+ schema: 'lattice.todo_dependency_connect_result.v1', project_id: store.project_id,
967
+ from: event.payload.from, to: event.payload.to, reason: event.payload.reason,
968
+ connected_by: event.actor, connected_at: event.recorded_at,
969
+ event_digest: event.event_digest, plan_scoped_head_digest: event.event_digest,
970
+ result_digest: '',
971
+ };
972
+ result.result_digest = todoSelfDigest(result, 'result_digest');
973
+ return result;
974
+ }
975
+
915
976
  async function phaseStatus({ repoRoot, planKey }) {
916
977
  const store = await readTodoStore({ repoRoot });
917
978
  const [member] = selectMembers(store, planKey);
@@ -1657,7 +1718,7 @@ async function independenceCompile({ repoRoot, planKey, inputRef }) {
1657
1718
  const { ref } = await writeTodoIndependenceArtifact({ repoRoot, artifact });
1658
1719
 
1659
1720
  const result = {
1660
- schema: 'lattice.todo_independence_compile_result.v1',
1721
+ schema: 'lattice.todo_independence_compile_result.v2',
1661
1722
  project_id: artifact.project_id,
1662
1723
  plan_key: artifact.plan_key,
1663
1724
  plan_version: artifact.plan_version,
@@ -1667,6 +1728,7 @@ async function independenceCompile({ repoRoot, planKey, inputRef }) {
1667
1728
  task_count: artifact.task_ids.length,
1668
1729
  conflict_count: artifact.conflicts.length,
1669
1730
  unknown_count: artifact.unknowns.length,
1731
+ scope_expansion_recommendations: scopeExpansionRecommendations(artifact.scope_expanded),
1670
1732
  result_digest: '',
1671
1733
  };
1672
1734
  result.result_digest = todoSelfDigest(result, 'result_digest');
@@ -2823,6 +2885,16 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2823
2885
  action = (repoRoot) => independenceMode({
2824
2886
  repoRoot, env, planKey: argv[3], mode: argv[5], reason: argv[7],
2825
2887
  });
2888
+ } else if (argv.length === 12 && argv[0] === 'dependency' && argv[1] === 'connect'
2889
+ && argv[2] === '--from-plan' && isTodoIdentifier(argv[3])
2890
+ && argv[4] === '--from-task' && isTodoIdentifier(argv[5])
2891
+ && argv[6] === '--to-plan' && isTodoIdentifier(argv[7])
2892
+ && argv[8] === '--to-task' && isTodoIdentifier(argv[9])
2893
+ && argv[10] === '--reason' && argv[11].length > 0) {
2894
+ action = (repoRoot) => dependencyConnect({
2895
+ repoRoot, env, fromPlanKey: argv[3], fromTaskId: argv[5],
2896
+ toPlanKey: argv[7], toTaskId: argv[9], reason: argv[11],
2897
+ });
2826
2898
  } else if (argv.length === 6 && argv[0] === 'independence' && argv[1] === 'compile'
2827
2899
  && argv[2] === '--plan' && isTodoIdentifier(argv[3]) && argv[4] === '--input') {
2828
2900
  action = (repoRoot) => independenceCompile({
@@ -13,10 +13,15 @@ export const TODO_EVENT_KINDS = Object.freeze([
13
13
  // 「誰が選んだか」の帰属を持つ——witnessが全planの暗黙義務だった時に帰属が無く、正確な
14
14
  // 案内が素通りされたことへの是正である(オーナー裁定C①)。
15
15
  'coordination_mode',
16
+ // 開発中に発見したplan跨ぎの依存を、active plan topologyの追記改変ではなく
17
+ // version-boundなplan-scoped eventとして接続する。推定はせず、AIが発見した時だけ積む。
18
+ 'cross_plan_dependency',
16
19
  ]);
17
20
 
18
21
  /** planへ帰属し、taskにもPhaseにも属さないevent kind。 */
19
- export const TODO_PLAN_SCOPED_EVENT_KINDS = Object.freeze(['coordination_mode']);
22
+ export const TODO_PLAN_SCOPED_EVENT_KINDS = Object.freeze([
23
+ 'coordination_mode', 'cross_plan_dependency',
24
+ ]);
20
25
 
21
26
  /** 調整方式。witness=独立性を宣言し検証して並列する/conversation=会話で調整する。 */
22
27
  export const TODO_COORDINATION_MODES = Object.freeze(['witness', 'conversation']);
@@ -506,6 +511,15 @@ function validPayload(event) {
506
511
  && TODO_COORDINATION_MODES.includes(payload.mode)
507
512
  && nullableText(payload.reason) && payload.reason !== null;
508
513
  }
514
+ if (event.kind === 'cross_plan_dependency') {
515
+ const dependencyRef = (value) => exactRecord(value, [
516
+ 'project_id', 'plan_key', 'task_id', 'expected_topology_digest',
517
+ ]) && isTodoIdentifier(value.project_id) && isTodoIdentifier(value.plan_key)
518
+ && isTodoIdentifier(value.task_id) && isTodoDigest(value.expected_topology_digest);
519
+ return exactRecord(payload, ['from', 'to', 'reason'])
520
+ && dependencyRef(payload.from) && dependencyRef(payload.to)
521
+ && nullableText(payload.reason) && payload.reason !== null;
522
+ }
509
523
  if (event.kind === 'start') return exactRecord(payload, ['override_reason']) && nullableText(payload.override_reason);
510
524
  if (event.kind === 'start_retracted') return exactRecord(payload, ['reason', 'target_start_digest'])
511
525
  && nullableText(payload.reason) && payload.reason !== null
@@ -1,5 +1,6 @@
1
1
  import { TODO_GANTT_SCOPES, projectTodoGanttScope } from './todo-gantt-scope.mjs';
2
2
  import { buildTodoGanttHierarchy } from './todo-gantt-nested.mjs';
3
+ import { projectTodoCrossPlanDependencies } from './todo-store.mjs';
3
4
 
4
5
  const TASK_LIMIT = 2_000;
5
6
  const EDGE_LIMIT = 8_000;
@@ -230,6 +231,9 @@ function normalizeInput(readModel, chainProjection) {
230
231
  for (const after of join.after) addEdge(after, join.before, 'join', identity);
231
232
  }
232
233
  }
234
+ for (const dependency of projectTodoCrossPlanDependencies(members)) {
235
+ addEdge(dependency.from, dependency.to, 'cross_plan');
236
+ }
233
237
  if (edgeMap.size > EDGE_LIMIT) {
234
238
  fail('TODO_SCALE_EXCEEDED', 'todo gantt edge limit exceeded', {
235
239
  task_count: nodesByKey.size, task_limit: TASK_LIMIT,
@@ -1,4 +1,5 @@
1
1
  import { projectTodoChainV1 } from './todo-chain.mjs';
2
+ import { projectTodoCrossPlanDependencies } from './todo-store.mjs';
2
3
 
3
4
  const ROOT = Symbol('todo-gantt-root');
4
5
 
@@ -131,6 +132,18 @@ function projectMember(member, hierarchy, containerKey, selectedKeys) {
131
132
  phaseAcceptDependencies.push({ ...dependency, to });
132
133
  }
133
134
 
135
+ // plan-scopedのcross-plan edgeも階層levelのtaskへ射影する。子task同士の接続を
136
+ // root levelで捨てると、nested表示だけ依存線とwaveが消えるため、hard edgeと同じく
137
+ // 各endpointをそのlevel直下のbranchへ束縛し直す。
138
+ const planScopedEvents = (member.plan_scoped?.events ?? []).flatMap((event) => {
139
+ if (event.kind !== 'cross_plan_dependency') return [event];
140
+ const from = projectRef(hierarchy, containerKey, event.payload.from);
141
+ const to = projectRef(hierarchy, containerKey, event.payload.to);
142
+ if (from === null || to === null || !selectedKeys.has(refKey(from))
143
+ || !selectedKeys.has(refKey(to)) || refKey(from) === refKey(to)) return [];
144
+ return [{ ...event, payload: { ...event.payload, from, to } }];
145
+ });
146
+
134
147
  return {
135
148
  ...member,
136
149
  plan: {
@@ -140,6 +153,9 @@ function projectMember(member, hierarchy, containerKey, selectedKeys) {
140
153
  joins,
141
154
  phase_accept_dependencies: phaseAcceptDependencies,
142
155
  },
156
+ ...(member.plan_scoped === undefined ? {} : {
157
+ plan_scoped: { ...member.plan_scoped, events: planScopedEvents },
158
+ }),
143
159
  tasks: member.tasks.filter(({ task_id: taskId }) => tasks.some((task) => task.task_id === taskId)),
144
160
  };
145
161
  }
@@ -174,10 +190,14 @@ function filterIndependence(independence, selectedKeys) {
174
190
  }
175
191
 
176
192
  function topologyOf(readModel) {
193
+ const crossPlanDependencies = projectTodoCrossPlanDependencies(readModel.members);
177
194
  return {
178
195
  nodes: readModel.members.flatMap((member) => member.plan.tasks
179
196
  .map(({ task_id: taskId }) => taskRef(member, taskId))),
180
- hard_edges: readModel.members.flatMap((member) => member.plan.hard_dependencies),
197
+ hard_edges: [
198
+ ...readModel.members.flatMap((member) => member.plan.hard_dependencies),
199
+ ...crossPlanDependencies.map(({ from, to }) => ({ from, to })),
200
+ ],
181
201
  joins: readModel.members.flatMap((member) => member.plan.joins),
182
202
  };
183
203
  }
@@ -15,7 +15,11 @@ import {
15
15
  } from './todo-contracts.mjs';
16
16
  import { TODO_INDEPENDENCE_COVERAGE } from './todo-independence-contracts.mjs';
17
17
  import { todoLegacyReconciliationDigest } from './todo-revision.mjs';
18
- import { isPhaselessTodoPlanSchema, todoPhaseDefinitions } from './todo-store.mjs';
18
+ import {
19
+ isPhaselessTodoPlanSchema,
20
+ projectTodoCrossPlanDependencies,
21
+ todoPhaseDefinitions,
22
+ } from './todo-store.mjs';
19
23
 
20
24
  /**
21
25
  * v6で`plan_notes`を足す。ADR 0054・0063の前例どおり既存versionへのin-place追加はしない。
@@ -474,6 +478,11 @@ function buildTodoGraph(readModel) {
474
478
  }
475
479
  }
476
480
  }
481
+ // active plan topologyは追記改変しない。開発中に接続したplan-scoped edgeを同じincomingへ
482
+ // 合成することで、projectTodoStatusとcomputeReadyFrontierが同じ待機判定を即座に読む。
483
+ for (const dependency of projectTodoCrossPlanDependencies(members)) {
484
+ addPredecessor(dependency.from, dependency.to);
485
+ }
477
486
 
478
487
  auditPending.sort((left, right) => (
479
488
  left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1
@@ -363,6 +363,32 @@ export function projectTodoCoordination(events) {
363
363
  };
364
364
  }
365
365
 
366
+ /**
367
+ * active plan群へ接続済みのplan跨ぎ依存線を投影する。
368
+ *
369
+ * eventはconsumer(to)planのplan-scoped chainへ積まれる。自動発見も暗黙補完もせず、
370
+ * 記録された線だけを返す。順序はtask identityで固定し、status/Ganttが同じ入力を見る。
371
+ */
372
+ export function projectTodoCrossPlanDependencies(members) {
373
+ const dependencies = [];
374
+ for (const member of members) for (const event of member.plan_scoped?.events ?? []) {
375
+ if (event.kind !== 'cross_plan_dependency') continue;
376
+ dependencies.push({
377
+ from: event.payload.from,
378
+ to: event.payload.to,
379
+ reason: event.payload.reason,
380
+ connected_by: event.actor,
381
+ connected_at: event.recorded_at,
382
+ event_digest: event.event_digest,
383
+ });
384
+ }
385
+ return dependencies.sort((left, right) => {
386
+ const key = (entry) => `${entry.from.project_id}\0${entry.from.plan_key}\0${entry.from.task_id}`
387
+ + `\0${entry.to.project_id}\0${entry.to.plan_key}\0${entry.to.task_id}\0${entry.event_digest}`;
388
+ return key(left) < key(right) ? -1 : key(left) > key(right) ? 1 : 0;
389
+ });
390
+ }
391
+
366
392
  function derivedPhaseStatus(plan, taskStates, phaseStates, phaseId) {
367
393
  const state = phaseStates.get(phaseId);
368
394
  // ADR 0148: closed_unauditedも他の終端状態と同じく確定済みとして扱う。ここへ足さないと、
@@ -748,7 +774,7 @@ function snapshotFor(plan, events, tasks) {
748
774
  return snapshot;
749
775
  }
750
776
 
751
- function validateMergedGraph(members) {
777
+ function validateMergedGraph(members, crossPlanDependencies = []) {
752
778
  const tasks = new Map();
753
779
  for (const member of members) for (const task of member.plan.tasks) {
754
780
  tasks.set(`${member.plan.project_id}\0${member.plan.plan_key}\0${task.task_id}`, member.plan.topology_digest);
@@ -816,6 +842,15 @@ function validateMergedGraph(members) {
816
842
  }
817
843
  }
818
844
  }
845
+ for (const dependency of crossPlanDependencies) {
846
+ const owner = members.find(({ plan }) => plan.project_id === dependency.to.project_id
847
+ && plan.plan_key === dependency.to.plan_key);
848
+ if (owner === undefined) fail('STORE_INCONSISTENT', 'cross_plan_dependency_owner_missing');
849
+ const from = bind(dependency.from, owner.plan);
850
+ const to = bind(dependency.to, owner.plan);
851
+ if (from === to) fail('STORE_INCONSISTENT', 'self_edge');
852
+ adjacency.get(from).push(to);
853
+ }
819
854
  const colors = new Map();
820
855
  const visit = (node) => {
821
856
  if (colors.get(node) === 1) fail('STORE_INCONSISTENT', 'merged_cycle');
@@ -825,6 +860,48 @@ function validateMergedGraph(members) {
825
860
  for (const node of adjacency.keys()) visit(node);
826
861
  }
827
862
 
863
+ function crossPlanDependencyTask(store, ref) {
864
+ const member = store.members.find(({ plan }) => plan.project_id === ref.project_id
865
+ && plan.plan_key === ref.plan_key);
866
+ if (member === undefined) fail('DEPENDENCY_INVALID', 'dependency_plan_not_found', { ref });
867
+ if (member.plan.topology_digest !== ref.expected_topology_digest) {
868
+ fail('DEPENDENCY_STALE', 'dependency_topology_stale', {
869
+ ref, actual_topology_digest: member.plan.topology_digest,
870
+ });
871
+ }
872
+ const task = member.tasks.find(({ task_id: taskId }) => taskId === ref.task_id);
873
+ if (task === undefined) fail('DEPENDENCY_INVALID', 'dependency_task_not_found', { ref });
874
+ return { member, task };
875
+ }
876
+
877
+ function validateCrossPlanDependencyTransition(store, owner, input) {
878
+ const { from, to } = input.payload;
879
+ if (from.project_id !== store.project_id || to.project_id !== store.project_id) {
880
+ fail('DEPENDENCY_INVALID', 'dependency_project_mismatch');
881
+ }
882
+ if (from.plan_key === to.plan_key) fail('DEPENDENCY_INVALID', 'dependency_must_cross_plans');
883
+ if (to.plan_key !== owner.plan.plan_key || to.project_id !== owner.plan.project_id) {
884
+ fail('DEPENDENCY_INVALID', 'dependency_owner_mismatch');
885
+ }
886
+ const source = crossPlanDependencyTask(store, from);
887
+ const target = crossPlanDependencyTask(store, to);
888
+ if (source.task.status === 'done') fail('DEPENDENCY_INVALID', 'dependency_source_terminal');
889
+ if (target.task.status === 'done') fail('DEPENDENCY_INVALID', 'dependency_target_terminal');
890
+ const existing = projectTodoCrossPlanDependencies(store.members);
891
+ if (existing.some((dependency) => mergedTaskKey(dependency.from) === mergedTaskKey(from)
892
+ && mergedTaskKey(dependency.to) === mergedTaskKey(to))) {
893
+ fail('DEPENDENCY_EXISTS', 'cross_plan_dependency_duplicate', { from, to });
894
+ }
895
+ try {
896
+ validateMergedGraph(store.members, [...existing, { from, to }]);
897
+ } catch (error) {
898
+ if (error instanceof TodoStoreError && error.detail?.reason === 'merged_cycle') {
899
+ fail('DEPENDENCY_CYCLE', 'cross_plan_dependency_cycle', { from, to });
900
+ }
901
+ throw error;
902
+ }
903
+ }
904
+
828
905
  function mergedTaskKey(refValue) {
829
906
  return `${refValue.project_id}\0${refValue.plan_key}\0${refValue.task_id}`;
830
907
  }
@@ -889,6 +966,9 @@ function mergedPredecessorKeys(store, targetKey) {
889
966
  }
890
967
  }
891
968
  }
969
+ for (const dependency of projectTodoCrossPlanDependencies(store.members)) {
970
+ if (mergedTaskKey(dependency.to) === targetKey) result.push(mergedTaskKey(dependency.from));
971
+ }
892
972
  return [...new Set(result)];
893
973
  }
894
974
 
@@ -904,6 +984,9 @@ function mergedSuccessorKeys(store, targetKey) {
904
984
  }
905
985
  }
906
986
  }
987
+ for (const dependency of projectTodoCrossPlanDependencies(store.members)) {
988
+ if (mergedTaskKey(dependency.from) === targetKey) result.push(mergedTaskKey(dependency.to));
989
+ }
907
990
  return [...new Set(result)];
908
991
  }
909
992
 
@@ -1275,7 +1358,7 @@ export async function readTodoStore(options = {}) {
1275
1358
  snapshot: snapshotStale ? expectedSnapshot : snapshot,
1276
1359
  tasks, phases, coordination, snapshot_stale: snapshotStale });
1277
1360
  }
1278
- validateMergedGraph(loaded);
1361
+ validateMergedGraph(loaded, projectTodoCrossPlanDependencies(loaded));
1279
1362
  return {
1280
1363
  schema: 'lattice.todo_store_read.v1', project_id: manifest.project_id, manifest,
1281
1364
  members: loaded, snapshot_stale: loaded.some((member) => member.snapshot_stale),
@@ -1491,10 +1574,13 @@ export async function appendTodoEvent(options = {}) {
1491
1574
  const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now });
1492
1575
  const member = store.members.find(({ descriptor }) => descriptor.plan_key === options.planKey);
1493
1576
  if (!member) fail('STORE_INCONSISTENT', 'plan_not_active');
1494
- // planへ帰属するeventは別chainへ積む(ob03)。task状態もPhase状態も動かさないので、
1577
+ // planへ帰属するeventは別chainへ積む。task状態もPhase状態も直接は動かさないので、
1495
1578
  // replay・snapshot・manifestへは触れない——lifecycle journalのheadを進めるのは
1496
- // 「作業が進んだ」の意味であり、方式を選んだだけでそこを動かすと意味がずれる。
1579
+ // 「作業が進んだ」の意味であり、方式選択や依存接続でそこを動かすと意味がずれる。
1497
1580
  if (TODO_PLAN_SCOPED_EVENT_KINDS.includes(options.event.kind)) {
1581
+ if (options.event.kind === 'cross_plan_dependency') {
1582
+ validateCrossPlanDependencyTransition(store, member, options.event);
1583
+ }
1498
1584
  return appendPlanScopedEvent({
1499
1585
  repoRoot, member,
1500
1586
  input: { ...options.event, recorded_at: options.event.recorded_at ?? new Date().toISOString() },