@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 +1 -1
- package/src/cli-help.mjs +4 -0
- package/src/todo-cli.mjs +74 -2
- package/src/todo-contracts.mjs +15 -1
- package/src/todo-gantt-layout.mjs +4 -0
- package/src/todo-gantt-nested.mjs +21 -1
- package/src/todo-status.mjs +10 -1
- package/src/todo-store.mjs +90 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "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:
|
|
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.
|
|
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({
|
package/src/todo-contracts.mjs
CHANGED
|
@@ -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([
|
|
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:
|
|
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
|
}
|
package/src/todo-status.mjs
CHANGED
|
@@ -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 {
|
|
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
|
package/src/todo-store.mjs
CHANGED
|
@@ -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
|
|
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() },
|