@quolu/lattice 0.50.1 → 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.
Files changed (42) hide show
  1. package/bin/lattice-work-order-adapter.mjs +20 -0
  2. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -0
  3. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -0
  4. package/package.json +5 -2
  5. package/src/boundary-observation-compiler-v2.mjs +1 -1
  6. package/src/cli-help.mjs +33 -2
  7. package/src/rc3-actual-dogfood.mjs +6 -2
  8. package/src/rc3-scripted-campaign.mjs +37 -10
  9. package/src/rc4-stage1-dogfood.mjs +6 -2
  10. package/src/runtime-adapter-registry.mjs +21 -7
  11. package/src/runtime-cli.mjs +476 -34
  12. package/src/runtime-contracts.mjs +59 -13
  13. package/src/runtime-controller-protocol.mjs +48 -3
  14. package/src/runtime-decision-verifier.mjs +70 -0
  15. package/src/runtime-diff-observer.mjs +66 -4
  16. package/src/runtime-direct-os-observer.mjs +25 -8
  17. package/src/runtime-driver-state.mjs +162 -0
  18. package/src/runtime-engine.mjs +37 -6
  19. package/src/runtime-front-end.mjs +39 -1
  20. package/src/runtime-managed-supervisor.mjs +80 -14
  21. package/src/runtime-multi-epoch-store.mjs +87 -14
  22. package/src/runtime-pull-intake.mjs +1188 -0
  23. package/src/runtime-work-order-contracts.mjs +91 -0
  24. package/src/runtime-work-order-controller.mjs +1167 -0
  25. package/src/seam-proposal-queries.mjs +1 -1
  26. package/src/todo-cli.mjs +273 -7
  27. package/src/todo-contracts.mjs +19 -2
  28. package/src/todo-gantt-html-independence.mjs +3 -2
  29. package/src/todo-gantt-html-shared.mjs +1 -2
  30. package/src/todo-gantt-html-style.mjs +13 -0
  31. package/src/todo-gantt-html.mjs +15 -2
  32. package/src/todo-gantt-layout.mjs +75 -1
  33. package/src/todo-gantt-nested.mjs +263 -0
  34. package/src/todo-gantt-svg.mjs +80 -5
  35. package/src/todo-independence-contracts.mjs +73 -7
  36. package/src/todo-independence-guidance.mjs +30 -1
  37. package/src/todo-independence.mjs +89 -7
  38. package/src/todo-revision.mjs +1 -1
  39. package/src/todo-split.mjs +472 -0
  40. package/src/todo-status.mjs +10 -1
  41. package/src/todo-store-git-transaction.mjs +418 -0
  42. package/src/todo-store.mjs +144 -4
@@ -1,4 +1,6 @@
1
1
  import { TODO_GANTT_SCOPES, projectTodoGanttScope } from './todo-gantt-scope.mjs';
2
+ import { buildTodoGanttHierarchy } from './todo-gantt-nested.mjs';
3
+ import { projectTodoCrossPlanDependencies } from './todo-store.mjs';
2
4
 
3
5
  const TASK_LIMIT = 2_000;
4
6
  const EDGE_LIMIT = 8_000;
@@ -229,6 +231,9 @@ function normalizeInput(readModel, chainProjection) {
229
231
  for (const after of join.after) addEdge(after, join.before, 'join', identity);
230
232
  }
231
233
  }
234
+ for (const dependency of projectTodoCrossPlanDependencies(members)) {
235
+ addEdge(dependency.from, dependency.to, 'cross_plan');
236
+ }
232
237
  if (edgeMap.size > EDGE_LIMIT) {
233
238
  fail('TODO_SCALE_EXCEEDED', 'todo gantt edge limit exceeded', {
234
239
  task_count: nodesByKey.size, task_limit: TASK_LIMIT,
@@ -521,7 +526,7 @@ function normalizeSeamProposals(value) {
521
526
  return { summary: { plans } };
522
527
  }
523
528
 
524
- export function layoutTodoGantt(readModel, chainProjection, options = {}) {
529
+ function layoutTodoGanttFlat(readModel, chainProjection, options = {}) {
525
530
  const scope = options.scope ?? 'live';
526
531
  if (!TODO_GANTT_SCOPES.includes(scope)) {
527
532
  fail('TODO_LAYOUT_INVALID_INPUT', `scope must be one of ${TODO_GANTT_SCOPES.join(', ')}`);
@@ -898,3 +903,72 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
898
903
  },
899
904
  };
900
905
  }
906
+
907
+ function applyHierarchySemantics(level, semanticByKey) {
908
+ const layout = {
909
+ ...level.layout,
910
+ nodes: level.layout.nodes.map((node) => {
911
+ const semantic = semanticByKey.get(refKey(node.ref));
912
+ return semantic === undefined ? node : { ...node, visibility: { ...semantic.visibility } };
913
+ }),
914
+ };
915
+ return {
916
+ layout,
917
+ children: level.children.map((child) => ({
918
+ ...child,
919
+ level: applyHierarchySemantics(child.level, semanticByKey),
920
+ })),
921
+ };
922
+ }
923
+
924
+ function descendantNodes(level) {
925
+ return level.children.flatMap((child) => [
926
+ ...child.level.layout.nodes,
927
+ ...descendantNodes(child.level),
928
+ ]);
929
+ }
930
+
931
+ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
932
+ const fullLayout = layoutTodoGanttFlat(readModel, chainProjection, options);
933
+ let nested;
934
+ try {
935
+ const visibleTaskKeys = (options.scope ?? 'live') === 'all' ? null
936
+ : new Set(fullLayout.nodes.map((node) => refKey(node.ref)));
937
+ nested = buildTodoGanttHierarchy(
938
+ readModel, options, layoutTodoGanttFlat, visibleTaskKeys,
939
+ );
940
+ } catch (error) {
941
+ if (error?.code !== 'TODO_LAYOUT_INVALID_HIERARCHY') throw error;
942
+ fail(error.code, error.message, error.detail);
943
+ }
944
+ if (nested === null) return fullLayout;
945
+
946
+ // 階層ごとの縮約graphは座標だけを決める。ready/最長鎖/独立性まで縮約graphで
947
+ // 再計算すると、未完の子を持つdone親が後続をreadyへ進めるなど、実graphと違う判断を描く。
948
+ const semanticLayout = options.scope === 'all'
949
+ ? fullLayout : layoutTodoGanttFlat(readModel, chainProjection, { ...options, scope: 'all' });
950
+ const semanticByKey = new Map(semanticLayout.nodes.map((node) => [refKey(node.ref), node]));
951
+ const semanticRoot = applyHierarchySemantics(nested.root, semanticByKey);
952
+ const rootLayout = semanticRoot.layout;
953
+ return {
954
+ ...rootLayout,
955
+ full_edges: fullLayout.full_edges,
956
+ groups: fullLayout.groups,
957
+ scope: fullLayout.scope,
958
+ folded: fullLayout.folded,
959
+ hierarchy_nodes: descendantNodes(semanticRoot),
960
+ metrics: {
961
+ ...rootLayout.metrics,
962
+ visible_node_count: nested.metrics.visibleNodeCount,
963
+ visible_edge_count: nested.metrics.visibleEdgeCount,
964
+ task_count: fullLayout.metrics.task_count,
965
+ edge_count: fullLayout.metrics.edge_count,
966
+ },
967
+ hierarchy: {
968
+ schema: 'lattice.todo_gantt_hierarchy.v1',
969
+ children: semanticRoot.children,
970
+ maximum_depth: nested.metrics.maximumDepth,
971
+ task_count: nested.metrics.taskCount,
972
+ },
973
+ };
974
+ }
@@ -0,0 +1,263 @@
1
+ import { projectTodoChainV1 } from './todo-chain.mjs';
2
+ import { projectTodoCrossPlanDependencies } from './todo-store.mjs';
3
+
4
+ const ROOT = Symbol('todo-gantt-root');
5
+
6
+ function refKey(ref) {
7
+ return JSON.stringify([ref.project_id, ref.plan_key, ref.task_id]);
8
+ }
9
+
10
+ function compareText(left, right) {
11
+ return left < right ? -1 : left > right ? 1 : 0;
12
+ }
13
+
14
+ function fail(message, detail = null) {
15
+ const error = new Error(message);
16
+ error.code = 'TODO_LAYOUT_INVALID_HIERARCHY';
17
+ error.detail = detail;
18
+ throw error;
19
+ }
20
+
21
+ function taskRef(member, taskId) {
22
+ return {
23
+ project_id: member.plan.project_id,
24
+ plan_key: member.plan.plan_key,
25
+ task_id: taskId,
26
+ };
27
+ }
28
+
29
+ function normalizeHierarchy(readModel) {
30
+ const tasks = new Map();
31
+ const parentByKey = new Map();
32
+ const childrenByParent = new Map([[ROOT, []]]);
33
+ let hasHierarchy = false;
34
+
35
+ for (const member of readModel.members) {
36
+ const ids = new Set(member.plan.tasks.map(({ task_id: taskId }) => taskId));
37
+ for (const task of member.plan.tasks) {
38
+ const ref = taskRef(member, task.task_id);
39
+ const key = refKey(ref);
40
+ const parentId = task.parent_task_id ?? null;
41
+ if (parentId !== null && (typeof parentId !== 'string' || !ids.has(parentId))) {
42
+ fail(`parent_task_id must reference a task in the same plan: ${task.task_id}`, {
43
+ ref, parent_task_id: parentId,
44
+ });
45
+ }
46
+ const parentKey = parentId === null ? ROOT : refKey(taskRef(member, parentId));
47
+ tasks.set(key, { ref, task, member });
48
+ parentByKey.set(key, parentKey);
49
+ if (!childrenByParent.has(parentKey)) childrenByParent.set(parentKey, []);
50
+ childrenByParent.get(parentKey).push(key);
51
+ if (!childrenByParent.has(key)) childrenByParent.set(key, []);
52
+ hasHierarchy ||= parentId !== null;
53
+ }
54
+ }
55
+ if (!hasHierarchy) return null;
56
+
57
+ for (const key of tasks.keys()) {
58
+ const seen = new Set([key]);
59
+ let cursor = parentByKey.get(key);
60
+ while (cursor !== ROOT) {
61
+ if (seen.has(cursor)) {
62
+ fail('parent_task_id hierarchy contains a cycle', {
63
+ task: tasks.get(key).ref,
64
+ parent: tasks.get(cursor)?.ref ?? null,
65
+ });
66
+ }
67
+ seen.add(cursor);
68
+ cursor = parentByKey.get(cursor);
69
+ if (cursor === undefined) fail('parent_task_id hierarchy is disconnected');
70
+ }
71
+ }
72
+ for (const children of childrenByParent.values()) children.sort(compareText);
73
+ return { tasks, parentByKey, childrenByParent };
74
+ }
75
+
76
+ function branchUnder(hierarchy, containerKey, taskKey) {
77
+ let cursor = taskKey;
78
+ while (cursor !== ROOT) {
79
+ const parent = hierarchy.parentByKey.get(cursor);
80
+ if (parent === containerKey) return cursor;
81
+ cursor = parent;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function projectRef(hierarchy, containerKey, value) {
87
+ if (value === null || typeof value !== 'object') return null;
88
+ const branch = branchUnder(hierarchy, containerKey, refKey(value));
89
+ return branch === null ? null : { ...hierarchy.tasks.get(branch).ref };
90
+ }
91
+
92
+ function uniqueRefs(refs) {
93
+ const byKey = new Map(refs.map((ref) => [refKey(ref), ref]));
94
+ return [...byKey.values()].sort((left, right) => compareText(refKey(left), refKey(right)));
95
+ }
96
+
97
+ function projectMember(member, hierarchy, containerKey, selectedKeys) {
98
+ const tasks = member.plan.tasks.filter((task) => selectedKeys.has(refKey(taskRef(member, task.task_id))));
99
+ if (tasks.length === 0) return null;
100
+
101
+ const hardDependencies = [];
102
+ const seenHard = new Set();
103
+ for (const edge of member.plan.hard_dependencies) {
104
+ const from = projectRef(hierarchy, containerKey, edge.from);
105
+ const to = projectRef(hierarchy, containerKey, edge.to);
106
+ if (from === null || to === null || !selectedKeys.has(refKey(from))
107
+ || !selectedKeys.has(refKey(to)) || refKey(from) === refKey(to)) continue;
108
+ const key = JSON.stringify([refKey(from), refKey(to)]);
109
+ if (seenHard.has(key)) continue;
110
+ seenHard.add(key);
111
+ hardDependencies.push({ from, to });
112
+ }
113
+
114
+ const joins = [];
115
+ for (const join of member.plan.joins) {
116
+ const before = projectRef(hierarchy, containerKey, join.before);
117
+ if (before === null || !selectedKeys.has(refKey(before))) continue;
118
+ const after = uniqueRefs(join.after.map((ref) => projectRef(hierarchy, containerKey, ref))
119
+ .filter((ref) => ref !== null && selectedKeys.has(refKey(ref))
120
+ && refKey(ref) !== refKey(before)));
121
+ if (after.length > 0) joins.push({ ...join, after, before });
122
+ }
123
+
124
+ const phaseAcceptDependencies = [];
125
+ const seenPhaseAccept = new Set();
126
+ for (const dependency of member.plan.phase_accept_dependencies ?? []) {
127
+ const to = projectRef(hierarchy, containerKey, dependency.to);
128
+ if (to === null || !selectedKeys.has(refKey(to))) continue;
129
+ const key = JSON.stringify([dependency.from, refKey(to)]);
130
+ if (seenPhaseAccept.has(key)) continue;
131
+ seenPhaseAccept.add(key);
132
+ phaseAcceptDependencies.push({ ...dependency, to });
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
+
147
+ return {
148
+ ...member,
149
+ plan: {
150
+ ...member.plan,
151
+ tasks,
152
+ hard_dependencies: hardDependencies,
153
+ joins,
154
+ phase_accept_dependencies: phaseAcceptDependencies,
155
+ },
156
+ ...(member.plan_scoped === undefined ? {} : {
157
+ plan_scoped: { ...member.plan_scoped, events: planScopedEvents },
158
+ }),
159
+ tasks: member.tasks.filter(({ task_id: taskId }) => tasks.some((task) => task.task_id === taskId)),
160
+ };
161
+ }
162
+
163
+ function filterIndependence(independence, selectedKeys) {
164
+ if (!Array.isArray(independence)) return independence;
165
+ const selectedByPlan = new Map();
166
+ for (const key of selectedKeys) {
167
+ const [projectId, planKey, taskId] = JSON.parse(key);
168
+ selectedByPlan.set(JSON.stringify([projectId, planKey]),
169
+ new Set([...(selectedByPlan.get(JSON.stringify([projectId, planKey])) ?? []), taskId]));
170
+ }
171
+ return independence.flatMap((projection) => {
172
+ const selected = selectedByPlan.get(JSON.stringify([projection.project_id, projection.plan_key]));
173
+ if (selected === undefined) return [];
174
+ const frontier = projection.frontier;
175
+ return [{
176
+ ...projection,
177
+ frontier: {
178
+ ...frontier,
179
+ parallel_groups: (frontier.parallel_groups ?? []).map((group) => ({
180
+ ...group, task_ids: group.task_ids.filter((taskId) => selected.has(taskId)),
181
+ })).filter((group) => group.task_ids.length > 0),
182
+ serialize_pairs: (frontier.serialize_pairs ?? [])
183
+ .filter((pair) => pair.task_ids.every((taskId) => selected.has(taskId))),
184
+ conflicts_with_active: (frontier.conflicts_with_active ?? []).filter((entry) =>
185
+ selected.has(entry.ready_task_id) && selected.has(entry.active_task_id)),
186
+ unknown: (frontier.unknown ?? []).filter((entry) => selected.has(entry.task_id)),
187
+ },
188
+ }];
189
+ });
190
+ }
191
+
192
+ function topologyOf(readModel) {
193
+ const crossPlanDependencies = projectTodoCrossPlanDependencies(readModel.members);
194
+ return {
195
+ nodes: readModel.members.flatMap((member) => member.plan.tasks
196
+ .map(({ task_id: taskId }) => taskRef(member, taskId))),
197
+ hard_edges: [
198
+ ...readModel.members.flatMap((member) => member.plan.hard_dependencies),
199
+ ...crossPlanDependencies.map(({ from, to }) => ({ from, to })),
200
+ ],
201
+ joins: readModel.members.flatMap((member) => member.plan.joins),
202
+ };
203
+ }
204
+
205
+ function buildLevel(readModel, hierarchy, containerKey, options, layoutFlat, includesSubtree) {
206
+ const selected = (hierarchy.childrenByParent.get(containerKey) ?? []).filter(includesSubtree);
207
+ const selectedKeys = new Set(selected);
208
+ const members = readModel.members.map((member) =>
209
+ projectMember(member, hierarchy, containerKey, selectedKeys)).filter(Boolean);
210
+ const projectedRead = { ...readModel, members };
211
+ const levelOptions = {
212
+ ...options,
213
+ // live foldingは元のfull graphで一度だけ行い、下で選んだtask集合へ反映済み。
214
+ // 縮約levelでもう一度foldすると、可視な子を持つdone親containerが消える。
215
+ scope: 'all',
216
+ independence: filterIndependence(options.independence ?? null, selectedKeys),
217
+ seamProposals: containerKey === ROOT ? options.seamProposals ?? null : null,
218
+ };
219
+ const layout = layoutFlat(projectedRead, projectTodoChainV1(topologyOf(projectedRead)), levelOptions);
220
+ const children = selected.flatMap((parentKey) => {
221
+ if ((hierarchy.childrenByParent.get(parentKey) ?? []).length === 0) return [];
222
+ const childLevel = buildLevel(
223
+ readModel, hierarchy, parentKey, options, layoutFlat, includesSubtree,
224
+ );
225
+ if (childLevel.layout.nodes.length === 0 && childLevel.children.length === 0) return [];
226
+ return [{
227
+ parent_ref: { ...hierarchy.tasks.get(parentKey).ref },
228
+ level: childLevel,
229
+ }];
230
+ });
231
+ return { layout, children };
232
+ }
233
+
234
+ function levelMetrics(level, depth = 1) {
235
+ let taskCount = level.layout.nodes.length;
236
+ let visibleNodeCount = level.layout.metrics.visible_node_count;
237
+ let visibleEdgeCount = level.layout.metrics.visible_edge_count;
238
+ let maximumDepth = depth;
239
+ for (const child of level.children) {
240
+ const nested = levelMetrics(child.level, depth + 1);
241
+ taskCount += nested.taskCount;
242
+ visibleNodeCount += nested.visibleNodeCount;
243
+ visibleEdgeCount += nested.visibleEdgeCount;
244
+ maximumDepth = Math.max(maximumDepth, nested.maximumDepth);
245
+ }
246
+ return { taskCount, visibleNodeCount, visibleEdgeCount, maximumDepth };
247
+ }
248
+
249
+ export function buildTodoGanttHierarchy(readModel, options, layoutFlat, visibleTaskKeys = null) {
250
+ const hierarchy = normalizeHierarchy(readModel);
251
+ if (hierarchy === null) return null;
252
+ const memo = new Map();
253
+ const includesSubtree = (key) => {
254
+ if (visibleTaskKeys === null) return true;
255
+ if (memo.has(key)) return memo.get(key);
256
+ const included = visibleTaskKeys.has(key)
257
+ || (hierarchy.childrenByParent.get(key) ?? []).some(includesSubtree);
258
+ memo.set(key, included);
259
+ return included;
260
+ };
261
+ const root = buildLevel(readModel, hierarchy, ROOT, options, layoutFlat, includesSubtree);
262
+ return { root, metrics: levelMetrics(root) };
263
+ }
@@ -166,10 +166,8 @@ function renderNode(node, maps) {
166
166
  const lane = maps.lanes.get(nodeLaneKey);
167
167
  const laneLabel = lane === undefined ? node.lane : `${node.lane}、${lane.name}`;
168
168
  const taskNumber = maps.taskNumbers.get(key);
169
- const visibleReference = taskNumber === undefined
170
- ? `ID ${node.ref.task_id}` : `工程 ${taskNumber.display_number}`;
171
- const spokenReference = taskNumber === undefined
172
- ? `ID ${node.ref.task_id}` : `工程${taskNumber.display_number}`;
169
+ const visibleReference = `工程 ${node.ref.task_id}`;
170
+ const spokenReference = `工程${node.ref.task_id}`;
173
171
  const readyLabel = node.visibility.next_ready ? '。ready frontierの同時dispatch候補' : '';
174
172
  const independenceLabel = independence === null ? '' : `。並列可否は${independence.label}`;
175
173
  const identity = `正規ID ${node.ref.plan_key}/${node.ref.task_id}`;
@@ -235,7 +233,7 @@ function renderTodoSummary(layout, maps) {
235
233
  return { markup: `<g class="todo-summary" aria-label="カテゴリ別ToDo集計表">${markup.join('')}</g>`, height: 96, width: groupX };
236
234
  }
237
235
 
238
- export function renderTodoGanttSvg(layout, options = {}) {
236
+ function renderFlatTodoGanttSvg(layout, options = {}) {
239
237
  if (layout === null || typeof layout !== 'object' || layout.schema !== 'lattice.todo_gantt_layout.v2'
240
238
  || !Array.isArray(layout.nodes) || !Array.isArray(layout.edges)) {
241
239
  throw new TypeError('layout must be lattice.todo_gantt_layout.v2');
@@ -276,3 +274,80 @@ export function renderTodoGanttSvg(layout, options = {}) {
276
274
  .map(([x, y]) => `<circle class="join-contact-marker" cx="${x}" cy="${y}" r="3"></circle>`).join('');
277
275
  return `<svg class="todo-gantt" data-gantt-svg data-svg-width="${width}" data-svg-height="${height}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="group" aria-label="Todo依存工程図"><desc>縦方向は登録済み依存関係による工程段階。構造上の最長依存鎖は各工程を同じ重みとして数え、実時間・工数・資源律速を表さない。</desc>${summary.markup}<g class="edge-layer">${shiftedEdges}<g class="connector-layer">${connectors}</g><g class="junction-layer">${junctions}${contactMarkers}</g></g><g class="node-layer">${nodes}</g></svg>`;
278
276
  }
277
+
278
+ function svgDimensions(markup) {
279
+ const match = markup.match(/data-svg-width="([0-9.]+)" data-svg-height="([0-9.]+)"/u);
280
+ if (match === null) throw new TypeError('nested todo gantt SVG must expose its dimensions');
281
+ return { width: Number(match[1]), height: Number(match[2]) };
282
+ }
283
+
284
+ function resizeSvg(markup, width, height) {
285
+ return markup
286
+ .replace(/data-svg-width="[0-9.]+" data-svg-height="[0-9.]+"/u,
287
+ `data-svg-width="${width}" data-svg-height="${height}"`)
288
+ .replace(/viewBox="0 0 [0-9.]+ [0-9.]+" width="[0-9.]+" height="[0-9.]+"/u,
289
+ `viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"`);
290
+ }
291
+
292
+ function childLayoutOf(child) {
293
+ if (child.level.children.length === 0) return child.level.layout;
294
+ return {
295
+ ...child.level.layout,
296
+ hierarchy: {
297
+ schema: 'lattice.todo_gantt_hierarchy.v1',
298
+ children: child.level.children,
299
+ },
300
+ };
301
+ }
302
+
303
+ function renderNestedTodoGanttSvg(layout, options) {
304
+ let markup = renderFlatTodoGanttSvg(layout, options);
305
+ const base = svgDimensions(markup);
306
+ let width = base.width;
307
+ let height = base.height;
308
+ const panels = [];
309
+ const toggles = [];
310
+ const links = [];
311
+ const nodeByKey = new Map(layout.nodes.map((node) => [nodeKey(node.ref), node]));
312
+ let nextPanelY = 96;
313
+
314
+ for (const child of layout.hierarchy.children) {
315
+ const key = nodeKey(child.parent_ref);
316
+ const parent = nodeByKey.get(key);
317
+ if (parent?.geometry === null || parent === undefined) continue;
318
+ const childMarkup = renderTodoGanttSvg(childLayoutOf(child), options);
319
+ const childSize = svgDimensions(childMarkup);
320
+ // 開いた内部工程図を基底DAGの上へ重ねない。右側へ専用領域を確保し、親カードから
321
+ // 直交線で結ぶ。これなら後続taskも、同じ箱の兄弟taskも隠れない。
322
+ const panelX = base.width + 16;
323
+ const panelY = Math.max(parent.geometry.y + 96, nextPanelY);
324
+ const panelWidth = childSize.width + 24;
325
+ const panelHeight = childSize.height + 44;
326
+ nextPanelY = panelY + panelHeight + 16;
327
+ width = Math.max(width, panelX + panelWidth + 16);
328
+ height = Math.max(height, panelY + panelHeight + 16);
329
+ const embedded = childMarkup.replace('<svg class="todo-gantt"',
330
+ `<svg x="${panelX + 12}" y="${panelY + 32}" class="todo-gantt nested-task-diagram"`);
331
+ const label = `工程 ${child.parent_ref.plan_key}/${child.parent_ref.task_id} の内部工程`;
332
+ panels.push(`<g class="nested-task-panel" data-nested-panel-for="${escapeSvgAttribute(key)}" hidden aria-label="${escapeSvgAttribute(label)}"><rect class="nested-task-surface" x="${panelX}" y="${panelY}" width="${panelWidth}" height="${panelHeight}" rx="8"></rect><text class="nested-task-label" x="${panelX + 12}" y="${panelY + 22}">${escapeSvgText(label)}</text>${embedded}</g>`);
333
+ const parentX = parent.geometry.x + parent.geometry.width;
334
+ const parentY = parent.geometry.y + 96 + parent.geometry.height / 2;
335
+ const elbowX = panelX - 8;
336
+ links.push(`<path class="nested-task-link" data-nested-link-for="${escapeSvgAttribute(key)}" hidden d="M ${parentX} ${parentY} H ${elbowX} V ${panelY + 18} H ${panelX}"></path>`);
337
+ const toggleX = parent.geometry.x + parent.geometry.width - 24;
338
+ const toggleY = parent.geometry.y + 96 + parent.geometry.height - 18;
339
+ toggles.push(`<g class="nested-task-toggle" data-nested-toggle-for="${escapeSvgAttribute(key)}" tabindex="0" role="button" aria-expanded="false" aria-label="${escapeSvgAttribute(`${label}を開く`)}"><rect x="${toggleX - 8}" y="${toggleY - 13}" width="28" height="22" rx="4"></rect><text x="${toggleX + 6}" y="${toggleY + 3}" text-anchor="middle">+</text></g>`);
340
+ }
341
+
342
+ markup = resizeSvg(markup, width, height);
343
+ return markup.replace('</svg>', `<g class="nested-link-layer">${links.join('')}</g><g class="nested-panel-layer">${panels.join('')}</g><g class="nested-toggle-layer">${toggles.join('')}</g></svg>`);
344
+ }
345
+
346
+ export function renderTodoGanttSvg(layout, options = {}) {
347
+ if (layout?.hierarchy === undefined) return renderFlatTodoGanttSvg(layout, options);
348
+ if (layout.hierarchy?.schema !== 'lattice.todo_gantt_hierarchy.v1'
349
+ || !Array.isArray(layout.hierarchy.children)) {
350
+ throw new TypeError('layout hierarchy must be lattice.todo_gantt_hierarchy.v1');
351
+ }
352
+ return renderNestedTodoGanttSvg(layout, options);
353
+ }
@@ -9,18 +9,20 @@ import {
9
9
  import {
10
10
  RUN_REQUEST_CLAIM_MODE,
11
11
  RUN_REQUEST_DECLARATIVE_SCHEMA,
12
+ RUN_REQUEST_PREDICTION_SCHEMA,
12
13
  RUN_REQUEST_SCHEMA,
13
14
  explainRunRequest,
14
15
  selfDigest as runtimeSelfDigest,
15
16
  } from './runtime-contracts.mjs';
16
17
  import { TODO_INDEPENDENCE_GUIDANCE_CODES } from './todo-independence-guidance.mjs';
17
18
 
18
- export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v4';
19
+ export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v5';
19
20
  /**
20
21
  * まだ受理する旧witness set契約。v3以前の厳密なcompile意味を変えず、
21
22
  * 既存宣言を書き換えさせないために読み口を残す。
22
23
  */
23
24
  export const TODO_WITNESS_SET_LEGACY_SCHEMAS = Object.freeze([
25
+ 'lattice.todo_witness_set.v4',
24
26
  'lattice.todo_witness_set.v3',
25
27
  'lattice.todo_witness_set.v2',
26
28
  'lattice.todo_witness_set.v1',
@@ -32,19 +34,28 @@ export const TODO_WITNESS_SET_SCHEMAS = Object.freeze([
32
34
  /** 宣言できる欄はversionごとに違う。どの版から使えるかを1箇所で持つ。 */
33
35
  const CONCERN_ANCHOR_SCHEMAS = Object.freeze([
34
36
  TODO_WITNESS_SET_SCHEMA,
37
+ 'lattice.todo_witness_set.v4',
35
38
  'lattice.todo_witness_set.v3',
36
39
  'lattice.todo_witness_set.v2',
37
40
  ]);
38
- const CREATES_SCHEMAS = Object.freeze([TODO_WITNESS_SET_SCHEMA, 'lattice.todo_witness_set.v3']);
41
+ const CREATES_SCHEMAS = Object.freeze([
42
+ TODO_WITNESS_SET_SCHEMA, 'lattice.todo_witness_set.v4', 'lattice.todo_witness_set.v3',
43
+ ]);
39
44
 
40
45
  /** 1 taskが宣言できるconcern anchorの資源数と、資源あたりのsymbol数の上限。 */
41
46
  export const TODO_CONCERN_ANCHOR_LIMIT = 256;
42
- export const TODO_INDEPENDENCE_SCHEMA = 'lattice.todo_independence.v3';
47
+ export const TODO_INDEPENDENCE_SCHEMA = 'lattice.todo_independence.v5';
43
48
  export const TODO_INDEPENDENCE_PROJECTION_SCHEMA = 'lattice.todo_independence_projection.v2';
44
49
  export const TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA = 'lattice.todo_independence_legacy_marker.v1';
45
50
  export const TODO_INDEPENDENCE_LEGACY_SCHEMAS = Object.freeze([
46
51
  'lattice.todo_independence.v1',
47
52
  'lattice.todo_independence.v2',
53
+ 'lattice.todo_independence.v3',
54
+ // v4は`scope_expanded`を持つが`first_seen_path_count`/`growth_events`が整数固定だった。
55
+ // v5でnull(=subset gapで累計が分からない)を足したので、**同じ版名で形を変えず**版を上げる。
56
+ // 同名変更は「新readerが旧artifactを読める」後方互換しか満たさず、**旧readerが新artifactを
57
+ // 読めない前方互換破壊**になる(2026-08-09のP0と同型・suzune [1183] / kanade [1200])。
58
+ 'lattice.todo_independence.v4',
48
59
  ]);
49
60
 
50
61
  /** boundary compileが一度に扱えるToDo数(runtime front-endのMAX_COLLECTIONと同じ閉じ方)。 */
@@ -56,7 +67,7 @@ export const TODO_INDEPENDENCE_COVERAGE = Object.freeze([
56
67
 
57
68
  /** conflictを生んだresourceの種別。切断可能性の導出はこの種別だけを根拠にする。 */
58
69
  export const TODO_INDEPENDENCE_CONFLICT_KINDS = Object.freeze([
59
- 'symbol', 'path', 'state', 'effect',
70
+ 'symbol', 'path', 'state', 'effect', 'line',
60
71
  ]);
61
72
 
62
73
  /** 切断可能性。code seamで切れるのはsymbol/path起因のconflictだけ(ADR 0128 Decision 2)。 */
@@ -65,7 +76,7 @@ export const TODO_INDEPENDENCE_SEVERABILITY = Object.freeze(['code_seam', 'seria
65
76
  /**
66
77
  * conflict kindから切断可能性を導く。
67
78
  *
68
- * 共有state/effectはcode seamでは切断できない(RC1 boundary compilerの分類規則と同一)。
79
+ * 共有state/effectと意味的なlineはcode seamでは切断できない(RC1 boundary compilerの分類規則と同一)。
69
80
  * read×write交差から実体化される`rw-*`はkind=stateなのでserialへ倒れる。seam候補を
70
81
  * 見逃す方向にしか外れない保守的な誤りであり、既知の限界として受け入れる。
71
82
  */
@@ -96,6 +107,43 @@ export function isTodoIndependenceLegacyArtifactIdentity(value) {
96
107
  && isGitSha(value.base_sha);
97
108
  }
98
109
 
110
+ /**
111
+ * task別の宣言膨張の記録(v4から)。**判定を持たない——事実だけを置く。**
112
+ *
113
+ * 何が膨張の原因か(上流の契約確定に追従したのか、自分の変更の後始末か、元から在った面の
114
+ * 見落としか、思いつきで盛ったのか)は**機械には区別できない**。区別するのはAIであり、
115
+ * 装置が言えるのは「増えた」「何が増減した」「何回目か」「合流点か」までである。
116
+ * ここへ閾値も勧告も置かない(勧告はadvisory側の仕事)。
117
+ *
118
+ * `removed_paths`を持つのは、増加だけを見ると「盛った」と「宣言をやり直した」が同じ顔に
119
+ * なるからである。増減を両方見ないと読み手が誤る。
120
+ *
121
+ * `first_seen_path_count`と`growth_events`が**null**なのは「**このtaskの履歴が分からない**」で、
122
+ * 0とは違う。witnessはwave単位のsubsetで書き出せるので、比較相手のartifactに当該taskが
123
+ * 居ないことがある——その時に今回を初回と置くと、本当の初回と膨張回数が消える。
124
+ * 初回(比較相手が無い)は`compared_witness_digest=null`+数値、
125
+ * subset入替(比較相手はあるがtaskが居ない)は`compared_witness_digest=digest`+nullで区別する。
126
+ */
127
+ function scopeExpansionEntry(value) {
128
+ return plain(value)
129
+ && exactRecord(value, [
130
+ 'task_id', 'compared_witness_digest', 'first_seen_path_count', 'path_count',
131
+ 'added_paths', 'removed_paths', 'growth_events', 'gate_shape',
132
+ ])
133
+ && isTodoIdentifier(value.task_id)
134
+ && (value.compared_witness_digest === null || isTodoDigest(value.compared_witness_digest))
135
+ && (value.first_seen_path_count === null
136
+ || (Number.isSafeInteger(value.first_seen_path_count) && value.first_seen_path_count >= 0))
137
+ && Number.isSafeInteger(value.path_count) && value.path_count >= 0
138
+ && boundedList(value.added_paths, (entry) => typeof entry === 'string' && entry.length > 0)
139
+ && strictlySorted(value.added_paths)
140
+ && boundedList(value.removed_paths, (entry) => typeof entry === 'string' && entry.length > 0)
141
+ && strictlySorted(value.removed_paths)
142
+ && (value.growth_events === null
143
+ || (Number.isSafeInteger(value.growth_events) && value.growth_events >= 0))
144
+ && typeof value.gate_shape === 'boolean';
145
+ }
146
+
99
147
  function boundedList(value, validator, limit = TODO_INDEPENDENCE_LIST_LIMIT) {
100
148
  return Array.isArray(value) && value.length <= limit && value.every(validator);
101
149
  }
@@ -131,7 +179,10 @@ export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId })
131
179
  const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
132
180
  const request = {
133
181
  schema: witnessSet.schema === TODO_WITNESS_SET_SCHEMA
134
- ? RUN_REQUEST_SCHEMA : RUN_REQUEST_DECLARATIVE_SCHEMA,
182
+ ? RUN_REQUEST_SCHEMA
183
+ : witnessSet.schema === 'lattice.todo_witness_set.v4'
184
+ ? RUN_REQUEST_PREDICTION_SCHEMA
185
+ : RUN_REQUEST_DECLARATIVE_SCHEMA,
135
186
  request_id: requestId,
136
187
  repo: { base_sha: baseSha, root_kind: 'git' },
137
188
  capacity: witnessSet.capacity,
@@ -221,6 +272,13 @@ export function explainTodoWitnessSet(value) {
221
272
  if (value.witness_set_digest !== todoSelfDigest(value, 'witness_set_digest')) {
222
273
  return reject('witness_set_digest_mismatch', '/witness_set_digest');
223
274
  }
275
+ for (const taskId of taskIds) {
276
+ const witness = value.manual_witness[taskId];
277
+ if (plain(witness) && Object.hasOwn(witness, 'lines')
278
+ && value.schema !== TODO_WITNESS_SET_SCHEMA) {
279
+ return reject('lines_require_witness_set_v5', `/manual_witness/${taskId}/lines`);
280
+ }
281
+ }
224
282
  const probe = synthesizeWitnessRunRequest(value, {
225
283
  baseSha: PROBE_BASE_SHA, requestId: 'witness-set-probe',
226
284
  });
@@ -342,7 +400,7 @@ export function validateTodoIndependence(value) {
342
400
  if (!exactRecord(value, [
343
401
  'schema', 'project_id', 'plan_key', 'plan_version', 'topology_digest', 'base_sha',
344
402
  'witness_set_digest', 'compiled_at', 'task_ids', 'task_boundaries', 'conflict_resources', 'conflicts',
345
- 'precedences', 'unknowns', 'wave_plan', 'outcome', 'result_digest',
403
+ 'precedences', 'unknowns', 'wave_plan', 'scope_expanded', 'outcome', 'result_digest',
346
404
  ])) return false;
347
405
  if (value.schema !== TODO_INDEPENDENCE_SCHEMA) return false;
348
406
  if (!isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
@@ -362,6 +420,14 @@ export function validateTodoIndependence(value) {
362
420
  || !value.task_boundaries.every((entry, index) => entry.task_id === value.task_ids[index])) {
363
421
  return false;
364
422
  }
423
+ // scope_expandedはtask_idsとちょうど一対一。欠けたtaskがあると「膨張していない」と
424
+ // 「まだ見ていない」が同じ顔になる。
425
+ if (!boundedList(value.scope_expanded, scopeExpansionEntry, TODO_INDEPENDENCE_TASK_LIMIT)
426
+ || value.scope_expanded.length !== value.task_ids.length
427
+ || !strictlySorted(value.scope_expanded, (entry) => entry.task_id)
428
+ || !value.scope_expanded.every((entry, index) => entry.task_id === value.task_ids[index])) {
429
+ return false;
430
+ }
365
431
  if (!boundedList(value.conflict_resources, conflictResourceEntry)
366
432
  || !strictlySorted(value.conflict_resources, (entry) => entry.resource_id)) return false;
367
433
  const conflictResourceIds = new Set(value.conflict_resources.map(({ resource_id: id }) => id));
@@ -156,7 +156,7 @@ const CATALOG = Object.freeze({
156
156
 
157
157
  /** 切断可能性の言い換え。conflictの案内へ添える。 */
158
158
  const SEVERABILITY_HINT = Object.freeze({
159
- code_seam: 'symbol/pathの衝突なので、境界を分けるrefactorで並列化しうる。',
159
+ code_seam: 'symbol/pathの衝突なので、境界を分けるrefactorで並列化しうる。記録済みの競合からseam-proposal compileを検討できる。',
160
160
  serial: '共有stateまたはeffectの衝突なので、分割では切り離せない。',
161
161
  });
162
162
 
@@ -173,6 +173,35 @@ export function todoIndependenceGuidance(code, { severability = null } = {}) {
173
173
  };
174
174
  }
175
175
 
176
+ /**
177
+ * 記録済みの宣言膨張を、AIが分割を検討するための助言へ写す。
178
+ *
179
+ * 判断はしない。膨張回数が既知かつ1回以上という事実だけを入口にし、gate形なら
180
+ * 依存の合流点という構造事実を強い材料として添える。subset gapで累計がnullのtaskや
181
+ * 初回宣言は、回数を主張できないので助言を出さない。
182
+ */
183
+ export function scopeExpansionRecommendations(scopeExpanded) {
184
+ if (!Array.isArray(scopeExpanded)) {
185
+ throw new TypeError('scope_expanded must be an array');
186
+ }
187
+ return scopeExpanded
188
+ .filter((entry) => Number.isSafeInteger(entry?.growth_events)
189
+ && entry.growth_events > 0)
190
+ .map((entry) => ({
191
+ code: 'scope_expanded_consider_split',
192
+ task_id: entry.task_id,
193
+ growth_events: entry.growth_events,
194
+ first_seen_path_count: entry.first_seen_path_count,
195
+ path_count: entry.path_count,
196
+ gate_shape: entry.gate_shape,
197
+ message: `宣言が${entry.growth_events}回膨張している。${entry.gate_shape
198
+ ? 'このtaskは依存の合流点であり、'
199
+ : ''}内側にgateのリストが生えているなら、A1..Anと残余A'への分割を検討できる。`,
200
+ next_action: 'consider_todo_split',
201
+ }))
202
+ .sort((left, right) => left.task_id.localeCompare(right.task_id));
203
+ }
204
+
176
205
  /**
177
206
  * advisoryや投影の状態から、最も行動を要する案内を1つ選ぶ。
178
207
  *