@quolu/lattice 0.52.0 → 0.52.2

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 (182) hide show
  1. package/LICENSE +147 -147
  2. package/README.ja.md +355 -355
  3. package/README.md +258 -258
  4. package/bin/lattice-mcp.mjs +0 -0
  5. package/bin/lattice-scripted-adapter.mjs +0 -0
  6. package/bin/lattice-scripted-worker.mjs +0 -0
  7. package/bin/lattice-work-order-adapter.mjs +0 -0
  8. package/bin/lattice.mjs +0 -0
  9. package/docs/bridge-setup.md +132 -132
  10. package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
  11. package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
  12. package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
  13. package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
  14. package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
  15. package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
  16. package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
  17. package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
  18. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
  19. package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
  20. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
  21. package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
  22. package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
  23. package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
  24. package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
  25. package/package.json +103 -103
  26. package/sensor/LICENSE +21 -21
  27. package/sensor/NOTICE +19 -19
  28. package/sensor/dist/bin/lattice-sensor.js +9 -9
  29. package/sensor/dist/db/index.js +24 -24
  30. package/sensor/dist/db/migrations.js +41 -41
  31. package/sensor/dist/db/queries.js +164 -164
  32. package/sensor/dist/db/schema.sql +205 -205
  33. package/sensor/dist/directory.js +5 -5
  34. package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  35. package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  36. package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  37. package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  38. package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  39. package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  40. package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
  41. package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
  42. package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
  43. package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  44. package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  45. package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
  46. package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
  47. package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
  48. package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  49. package/sensor/dist/mcp/liveness-watchdog.js +53 -53
  50. package/sensor/dist/mcp/server-instructions.js +95 -95
  51. package/sensor/package.json +56 -56
  52. package/src/artifact-contracts-v2.mjs +325 -325
  53. package/src/artifact-contracts.mjs +895 -895
  54. package/src/boundary-compiler.mjs +712 -712
  55. package/src/boundary-observation-compiler-v2.mjs +344 -344
  56. package/src/bounded-seam.mjs +230 -230
  57. package/src/bridge-address.mjs +107 -107
  58. package/src/bridge-cli.mjs +297 -297
  59. package/src/bridge-config.mjs +346 -346
  60. package/src/bridge-daemon.mjs +378 -378
  61. package/src/bridge-launch-agent.mjs +323 -323
  62. package/src/bridge-registrar.mjs +102 -102
  63. package/src/bridge-server.mjs +376 -376
  64. package/src/cli-help.mjs +308 -308
  65. package/src/cli-stdio.mjs +40 -40
  66. package/src/control-compiler.mjs +532 -532
  67. package/src/dag-chain.mjs +261 -261
  68. package/src/factory-diagnostics.mjs +188 -188
  69. package/src/hash-chain.mjs +76 -76
  70. package/src/hooks-cli.mjs +1057 -1053
  71. package/src/isolation-runner.mjs +409 -409
  72. package/src/node-version-guard.mjs +44 -44
  73. package/src/project-cli.mjs +697 -697
  74. package/src/project-identity.mjs +130 -130
  75. package/src/rc1-black-box-oracle.mjs +906 -906
  76. package/src/rc1-comparison.mjs +154 -154
  77. package/src/rc1-evidence-bundle.mjs +456 -456
  78. package/src/rc1-v4-campaign.mjs +708 -708
  79. package/src/rc1-v4-transform.mjs +467 -467
  80. package/src/rc1-v5-artifact-set.mjs +855 -855
  81. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  82. package/src/rc1-v5-campaign.mjs +797 -797
  83. package/src/rc1-v5-transform.mjs +421 -421
  84. package/src/rc1-v6-artifact-set.mjs +807 -807
  85. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  86. package/src/rc1-v6-campaign.mjs +623 -623
  87. package/src/rc1-v6-causal-binding.mjs +473 -473
  88. package/src/rc1-v6-measurement.mjs +313 -313
  89. package/src/rc2-artifact-set.mjs +1584 -1584
  90. package/src/rc2-campaign.mjs +1506 -1502
  91. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  92. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  93. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  94. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  95. package/src/rc3-actual-dogfood.mjs +652 -652
  96. package/src/rc3-dogfood-scaffold.mjs +315 -315
  97. package/src/rc3-scripted-campaign.mjs +1383 -1383
  98. package/src/rc4-stage1-dogfood.mjs +673 -673
  99. package/src/runtime-adapter-registry.mjs +524 -520
  100. package/src/runtime-cli.mjs +4588 -4584
  101. package/src/runtime-contracts.mjs +824 -824
  102. package/src/runtime-control-store.mjs +604 -600
  103. package/src/runtime-controller-protocol.mjs +587 -587
  104. package/src/runtime-decision-verifier.mjs +701 -701
  105. package/src/runtime-diff-observer.mjs +361 -361
  106. package/src/runtime-direct-os-observer.mjs +301 -301
  107. package/src/runtime-driver-state.mjs +166 -162
  108. package/src/runtime-engine.mjs +779 -779
  109. package/src/runtime-errors.mjs +356 -356
  110. package/src/runtime-event-store.mjs +189 -189
  111. package/src/runtime-front-end.mjs +925 -925
  112. package/src/runtime-gate-store.mjs +481 -477
  113. package/src/runtime-hold-recompile.mjs +916 -916
  114. package/src/runtime-io-sentinel.mjs +391 -391
  115. package/src/runtime-lifecycle-lock.mjs +294 -290
  116. package/src/runtime-managed-supervisor.mjs +1490 -1490
  117. package/src/runtime-multi-epoch-store.mjs +838 -834
  118. package/src/runtime-projection.mjs +269 -269
  119. package/src/runtime-pull-intake.mjs +1192 -1188
  120. package/src/runtime-scripted-adapter-controller.mjs +1160 -1156
  121. package/src/runtime-scripted-executor.mjs +163 -163
  122. package/src/runtime-scripted-worktree.mjs +104 -104
  123. package/src/runtime-seam-resolve.mjs +428 -428
  124. package/src/runtime-seam-treatment.mjs +173 -173
  125. package/src/runtime-socket-owner.mjs +125 -125
  126. package/src/runtime-work-order-contracts.mjs +91 -91
  127. package/src/runtime-work-order-controller.mjs +1171 -1167
  128. package/src/runtime-worktree-executor.mjs +199 -199
  129. package/src/schedulability-compiler-v2.mjs +303 -303
  130. package/src/schedulability-verifier-v2.mjs +317 -317
  131. package/src/seam-apply.mjs +549 -549
  132. package/src/seam-commit-shared.mjs +22 -22
  133. package/src/seam-commit-transform.mjs +81 -81
  134. package/src/seam-commit.mjs +18 -18
  135. package/src/seam-cost.mjs +322 -322
  136. package/src/seam-derivation.mjs +188 -188
  137. package/src/seam-gate.mjs +146 -146
  138. package/src/seam-proposal-contracts.mjs +446 -446
  139. package/src/seam-proposal-queries.mjs +521 -521
  140. package/src/seam-proposal.mjs +2011 -2011
  141. package/src/seam-ref.mjs +33 -33
  142. package/src/seam-rewrite.mjs +286 -286
  143. package/src/seam-transform.mjs +554 -554
  144. package/src/seam-verification.mjs +260 -260
  145. package/src/sensor-adapter.mjs +432 -432
  146. package/src/sensor-cli.mjs +139 -139
  147. package/src/sensor-diff.mjs +661 -661
  148. package/src/sensor-node-runtime.mjs +53 -53
  149. package/src/sensor-runtime.mjs +52 -52
  150. package/src/timestamp-contract.mjs +8 -8
  151. package/src/todo-audit-pending.mjs +91 -91
  152. package/src/todo-chain.mjs +178 -178
  153. package/src/todo-cli.mjs +3141 -3126
  154. package/src/todo-contracts.mjs +728 -728
  155. package/src/todo-dashboard-registry.mjs +573 -573
  156. package/src/todo-dispatch-shape.mjs +190 -190
  157. package/src/todo-gantt-html-independence.mjs +239 -239
  158. package/src/todo-gantt-html-shared.mjs +226 -226
  159. package/src/todo-gantt-html-style.mjs +131 -131
  160. package/src/todo-gantt-html.mjs +248 -248
  161. package/src/todo-gantt-layout.mjs +974 -974
  162. package/src/todo-gantt-live.mjs +361 -361
  163. package/src/todo-gantt-nested.mjs +263 -263
  164. package/src/todo-gantt-presentation.mjs +217 -217
  165. package/src/todo-gantt-scope.mjs +123 -123
  166. package/src/todo-gantt-svg.mjs +353 -353
  167. package/src/todo-independence-contracts.mjs +595 -595
  168. package/src/todo-independence-guidance.mjs +322 -322
  169. package/src/todo-independence.mjs +640 -640
  170. package/src/todo-markdown-renderer.mjs +260 -260
  171. package/src/todo-migration.mjs +448 -448
  172. package/src/todo-narrative-anchor.mjs +130 -130
  173. package/src/todo-note-store.mjs +629 -625
  174. package/src/todo-parallel-candidates.mjs +114 -114
  175. package/src/todo-revision.mjs +995 -995
  176. package/src/todo-split.mjs +472 -472
  177. package/src/todo-status.mjs +690 -690
  178. package/src/todo-store-git-transaction.mjs +418 -418
  179. package/src/todo-store.mjs +4322 -4314
  180. package/src/treatment-compiler.mjs +728 -728
  181. package/src/treatment-runner.mjs +656 -656
  182. package/src/witness-scaffold.mjs +180 -180
@@ -1,640 +1,640 @@
1
- import { digestTodoArtifact, isTodoIdentifier, todoSelfDigest } from './todo-contracts.mjs';
2
- import {
3
- TODO_INDEPENDENCE_SCHEMA,
4
- isGitSha,
5
- isTodoIndependenceLegacyMarker,
6
- severabilityOfConflictKind,
7
- synthesizeWitnessRunRequest,
8
- validateTodoIndependence,
9
- validateTodoWitnessSet,
10
- } from './todo-independence-contracts.mjs';
11
- import { compileRuntimePlanV1, evidenceFromCollectedOutcomes } from './runtime-front-end.mjs';
12
- import { collectSensorEvidence } from './sensor-adapter.mjs';
13
-
14
- export class TodoIndependenceError extends Error {
15
- constructor(code, reason, detail = {}) {
16
- super(reason);
17
- this.name = 'TodoIndependenceError';
18
- this.code = code;
19
- this.detail = { reason, ...detail };
20
- }
21
- }
22
-
23
- function fail(code, reason, detail) {
24
- throw new TodoIndependenceError(code, reason, detail);
25
- }
26
-
27
- const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
28
-
29
- /**
30
- * compile一回を識別するid。plan identityとwitness digestだけから決まるので、
31
- * 同じ入力からは同じrequest bytesが出る。run lifecycleへは登録しない。
32
- */
33
- export function witnessRequestId(witnessSet, planVersion) {
34
- const digest = digestTodoArtifact({
35
- schema: 'lattice.todo_independence_request_id.v1',
36
- project_id: witnessSet.project_id,
37
- plan_key: witnessSet.plan_key,
38
- plan_version: planVersion,
39
- witness_set_digest: witnessSet.witness_set_digest,
40
- });
41
- return `independence-${digest.slice(0, 24)}`;
42
- }
43
-
44
- /** 実sensorを引いてfront-end evidence契約へ整形する。失敗をunknownへ丸めない。 */
45
- export async function collectWitnessSensorEvidence({ cwd, witnessSet, execute = undefined }) {
46
- const querySet = witnessSet.sensor_query_set;
47
- const collected = await collectSensorEvidence({
48
- cwd, querySet, ...(execute === undefined ? {} : { execute }),
49
- });
50
- return evidenceFromCollectedOutcomes({ querySet, collected });
51
- }
52
-
53
- /**
54
- * conflictから参照されるnormalized resourceを正規化してartifactへ載せる。
55
- *
56
- * `graph.conflicts`はresource_idしか持たず、宣言由来のstate resource idは任意文字列なので
57
- * prefixからkindを復元できない。normalized resourceを引けない場合は、切断可能性を
58
- * 不明のまま記録するのでなくtyped failで止める。
59
- */
60
- function conflictsFrom(verdicts, resources) {
61
- const resourceById = new Map();
62
- for (const resource of Array.isArray(resources) ? resources : []) {
63
- const resourceId = resource?.resource_id;
64
- if (resourceById.has(resourceId)) {
65
- const previous = resourceById.get(resourceId);
66
- if (previous.kind !== resource?.kind) {
67
- fail('INDEPENDENCE_RESOURCE_KIND_MISMATCH', 'conflict_resource_kind_mismatch', {
68
- resource_id: resourceId ?? null,
69
- observed_kinds: [previous.kind ?? null, resource?.kind ?? null],
70
- });
71
- }
72
- if (previous.target !== resource?.target) {
73
- fail('INDEPENDENCE_RESOURCE_TARGET_MISMATCH', 'conflict_resource_target_mismatch', {
74
- resource_id: resourceId ?? null,
75
- observed_targets: [previous.target ?? null, resource?.target ?? null],
76
- });
77
- }
78
- fail('INDEPENDENCE_RESOURCE_DUPLICATE', 'conflict_resource_duplicate', {
79
- resource_id: resourceId ?? null,
80
- });
81
- }
82
- resourceById.set(resourceId, { kind: resource?.kind, target: resource?.target });
83
- }
84
- const conflicts = verdicts
85
- .filter((verdict) => verdict.type === 'conflict')
86
- .map((verdict) => {
87
- const resource = resourceById.get(verdict.resource_id);
88
- const kind = resource?.kind;
89
- if (!['symbol', 'path', 'state', 'effect', 'line'].includes(kind)) {
90
- fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
91
- resource_id: verdict.resource_id, observed_kind: kind ?? null,
92
- });
93
- }
94
- if (typeof resource.target !== 'string' || resource.target.length === 0) {
95
- fail('INDEPENDENCE_RESOURCE_TARGET_UNRESOLVED', 'conflict_resource_target_unresolved', {
96
- resource_id: verdict.resource_id, observed_target: resource.target ?? null,
97
- });
98
- }
99
- return {
100
- task_ids: [...verdict.todo_ids].sort(compareText),
101
- resource_id: verdict.resource_id,
102
- };
103
- })
104
- .sort((left, right) => compareText(
105
- `${left.task_ids[0]}\0${left.task_ids[1]}\0${left.resource_id}`,
106
- `${right.task_ids[0]}\0${right.task_ids[1]}\0${right.resource_id}`,
107
- ));
108
- const used = new Set(conflicts.map(({ resource_id: resourceId }) => resourceId));
109
- const conflictResources = [...used]
110
- .map((resourceId) => ({ resource_id: resourceId, ...resourceById.get(resourceId) }))
111
- .sort((left, right) => compareText(left.resource_id, right.resource_id));
112
- return { conflicts, conflictResources };
113
- }
114
-
115
- /**
116
- * witnessが宣言した境界pathを集める。
117
- *
118
- * 鮮度のdiff交差判定に使うので、observationへ効くpathを漏れなく採る——所有path、
119
- * 書き込み、読み取り、affected test、sensor queryのexpect path。symbol owns自体は
120
- * pathを持たないが、それを裏取りするqueryのexpect pathが同じ効果を持つ。
121
- */
122
- function boundaryPathsOf(witness) {
123
- const paths = new Set();
124
- for (const own of witness.owns) {
125
- if (own.kind === 'path') paths.add(own.target);
126
- }
127
- for (const path of witness.writes) paths.add(path);
128
- for (const path of witness.reads) paths.add(path);
129
- for (const path of witness.affected_tests) paths.add(path);
130
- for (const entry of witness.sensor_provenance.queries) paths.add(entry.expect.path);
131
- for (const line of witness.lines ?? []) {
132
- for (const anchor of line.anchors) paths.add(anchor.path);
133
- }
134
- return [...paths].sort(compareText);
135
- }
136
-
137
- function precedencesFrom(verdicts) {
138
- return verdicts
139
- .filter((verdict) => verdict.type === 'precedence')
140
- .map((verdict) => ({
141
- from_task_id: verdict.from_todo_id,
142
- to_task_id: verdict.to_todo_id,
143
- reason: verdict.reason,
144
- }))
145
- .sort((left, right) => compareText(
146
- `${left.from_task_id}\0${left.to_task_id}\0${left.reason}`,
147
- `${right.from_task_id}\0${right.to_task_id}\0${right.reason}`,
148
- ));
149
- }
150
-
151
- function unknownsFrom(detail) {
152
- const entries = [
153
- ...(Array.isArray(detail?.unknowns) ? detail.unknowns : []),
154
- ...(Array.isArray(detail?.unresolved_witnesses) ? detail.unresolved_witnesses : []),
155
- ]
156
- .filter((entry) => entry !== null && typeof entry === 'object'
157
- && typeof entry.todo_id === 'string' && typeof entry.kind === 'string'
158
- && typeof entry.ref === 'string')
159
- .map((entry) => ({
160
- task_id: entry.todo_id,
161
- kind: entry.kind,
162
- ref: entry.ref,
163
- }));
164
- const seen = new Set();
165
- return entries
166
- .filter((entry) => {
167
- const key = `${entry.task_id}\0${entry.kind}\0${entry.ref}`;
168
- if (seen.has(key)) return false;
169
- seen.add(key);
170
- return true;
171
- })
172
- .sort((left, right) => compareText(
173
- `${left.task_id}\0${left.kind}\0${left.ref}`,
174
- `${right.task_id}\0${right.kind}\0${right.ref}`,
175
- ));
176
- }
177
-
178
- /**
179
- * witness setとsensor evidenceから`lattice.todo_independence.v5`を作る。
180
- *
181
- * compileは宣言済みtaskの部分集合へ閉じる(ADR 0127 Decision 4)。未宣言taskを混ぜると、
182
- * unknownが1件でも出た時点で宣言済みtask同士の判定まで失われるためである。
183
- * `dispatchable`ならpairwise verdictとwave planを写し、`BOUNDARY_UNKNOWN`なら
184
- * unknownをtask単位で記録してwave planを持たない。それ以外のnon-dispatchableは
185
- * 契約違反または観測不成立であり、artifactへ丸めずtyped errorで止める。
186
- */
187
- /**
188
- * 前回artifactと今回の宣言を突き合わせて、task別の膨張を事実だけ記録する(s1)。
189
- *
190
- * **判定しない。** 上流の契約確定に追従した膨張・自分の変更の後始末・元から在った面の見落とし・
191
- * 思いつきで盛った分は、**機械には区別できない**(2026-08-09の実測: 1つのtaskへ前3種が同時に出た)。
192
- * 装置が言えるのは「増えた」「何が増減した」「何回目か」「合流点か」までで、仕分けはAIがやる。
193
- *
194
- * 前回が無い(初回・plan version違い・legacy版)時は、比較相手なしとして今回の宣言を初回として置く。
195
- * **「前回が読めなかった」を「膨張ゼロ」に化かさない**ため、compared_witness_digestで区別できる。
196
- */
197
- function scopeExpansionFrom({ taskIds, boundaries, previousArtifact, plan }) {
198
- const comparable = previousArtifact !== null && previousArtifact !== undefined
199
- && previousArtifact.schema === TODO_INDEPENDENCE_SCHEMA
200
- && previousArtifact.plan_key === plan.plan_key
201
- && previousArtifact.plan_version === plan.plan_version
202
- && previousArtifact.topology_digest === plan.topology_digest;
203
- const previousBoundaries = new Map(comparable
204
- ? previousArtifact.task_boundaries.map((entry) => [entry.task_id, entry.paths])
205
- : []);
206
- const previousExpansion = new Map(comparable
207
- ? previousArtifact.scope_expanded.map((entry) => [entry.task_id, entry])
208
- : []);
209
- // 合流点(gate形)はplanのtopologyが持つ事実で、宣言とは独立に決まる。
210
- const incoming = new Map(taskIds.map((taskId) => [taskId, 0]));
211
- for (const edge of plan.hard_dependencies ?? []) {
212
- const to = edge?.to?.task_id ?? edge?.to_task_id;
213
- if (incoming.has(to)) incoming.set(to, incoming.get(to) + 1);
214
- }
215
- return taskIds.map((taskId) => {
216
- const current = boundaries.get(taskId) ?? [];
217
- const previous = previousBoundaries.get(taskId) ?? null;
218
- const prior = previousExpansion.get(taskId) ?? null;
219
- // **subset入替を「初回」へ偽装しない。** witnessはwave単位のsubsetで書き出せるので、
220
- // A→B→Aと入れ替わると直前artifactにAが居ない。そこで今回を初回と置くと、
221
- // **本当の初回宣言数と膨張回数が消える**(suzune の監査で実測・room [1152])。
222
- // 比較相手はあるのにこのtaskが居ない時は、**数を主張せずnullで「分からない」と言う。**
223
- if (comparable && previous === null) {
224
- return {
225
- task_id: taskId,
226
- compared_witness_digest: previousArtifact.witness_set_digest,
227
- first_seen_path_count: null,
228
- path_count: current.length,
229
- added_paths: [],
230
- removed_paths: [],
231
- growth_events: null,
232
- gate_shape: (incoming.get(taskId) ?? 0) >= 2,
233
- };
234
- }
235
- const currentSet = new Set(current);
236
- const previousSet = new Set(previous ?? []);
237
- const added = previous === null ? [] : current.filter((path) => !previousSet.has(path));
238
- const removed = previous === null ? [] : previous.filter((path) => !currentSet.has(path));
239
- return {
240
- task_id: taskId,
241
- compared_witness_digest: comparable ? previousArtifact.witness_set_digest : null,
242
- // **unknown は後続へ伝播する。** 一度 subset gap で累計が分からなくなった task は、
243
- // 次の compile で `(null ?? 0) + 1` のように既知へ戻してはいけない——
244
- // **欠落前の累計が分からないことは、その後も分からないまま**である(suzune [1173])。
245
- first_seen_path_count: prior === null
246
- ? current.length
247
- : prior.first_seen_path_count,
248
- path_count: current.length,
249
- added_paths: [...added].sort(compareText),
250
- removed_paths: [...removed].sort(compareText),
251
- growth_events: prior !== null && prior.growth_events === null
252
- ? null
253
- : (prior?.growth_events ?? 0) + (added.length > 0 ? 1 : 0),
254
- gate_shape: (incoming.get(taskId) ?? 0) >= 2,
255
- };
256
- });
257
- }
258
-
259
- export function compileTodoIndependence(options = {}) {
260
- const { witnessSet, plan, baseSha, compiledAt, sensorEvidence, previousArtifact = null } = options;
261
- if (!validateTodoWitnessSet(witnessSet)) {
262
- fail('INDEPENDENCE_WITNESS_INVALID', 'witness_set_invalid');
263
- }
264
- if (!isGitSha(baseSha)) fail('INDEPENDENCE_BASE_INVALID', 'base_sha_invalid');
265
- if (witnessSet.project_id !== plan.project_id || witnessSet.plan_key !== plan.plan_key) {
266
- fail('INDEPENDENCE_PLAN_MISMATCH', 'witness_set_plan_mismatch', {
267
- witness_plan_key: witnessSet.plan_key, plan_key: plan.plan_key,
268
- });
269
- }
270
-
271
- const planTaskIds = new Set(plan.tasks.map(({ task_id: taskId }) => taskId));
272
- const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
273
- const absent = taskIds.filter((taskId) => !planTaskIds.has(taskId));
274
- if (absent.length > 0) {
275
- fail('INDEPENDENCE_TASK_ABSENT', 'witness_task_absent_from_plan', { task_ids: absent });
276
- }
277
-
278
- const request = synthesizeWitnessRunRequest(witnessSet, {
279
- baseSha, requestId: witnessRequestId(witnessSet, plan.plan_version),
280
- });
281
- const compiled = compileRuntimePlanV1({
282
- request,
283
- sensorEvidence,
284
- planRef: witnessRequestId(witnessSet, plan.plan_version),
285
- planEpoch: 0,
286
- predecessorRefs: [],
287
- });
288
-
289
- if (compiled.outcome !== 'dispatchable'
290
- && !(compiled.outcome === 'non_dispatchable' && compiled.code === 'BOUNDARY_UNKNOWN')) {
291
- fail('INDEPENDENCE_COMPILE_FAILED', 'boundary_compile_not_dispatchable', {
292
- code: compiled.code ?? null, compile_detail: compiled.detail ?? null,
293
- });
294
- }
295
-
296
- const dispatchable = compiled.outcome === 'dispatchable';
297
- const conflictProjection = dispatchable
298
- ? conflictsFrom(compiled.pairwise_verdicts, compiled.resources)
299
- : { conflicts: [], conflictResources: [] };
300
- const boundaryEntries = taskIds.map((taskId) => ({
301
- task_id: taskId,
302
- paths: boundaryPathsOf(witnessSet.manual_witness[taskId]),
303
- }));
304
- const artifact = {
305
- schema: TODO_INDEPENDENCE_SCHEMA,
306
- project_id: plan.project_id,
307
- plan_key: plan.plan_key,
308
- plan_version: plan.plan_version,
309
- topology_digest: plan.topology_digest,
310
- base_sha: baseSha,
311
- witness_set_digest: witnessSet.witness_set_digest,
312
- compiled_at: compiledAt,
313
- task_ids: taskIds,
314
- task_boundaries: boundaryEntries,
315
- conflict_resources: conflictProjection.conflictResources,
316
- conflicts: conflictProjection.conflicts,
317
- precedences: dispatchable ? precedencesFrom(compiled.pairwise_verdicts) : [],
318
- unknowns: dispatchable ? [] : unknownsFrom(compiled.detail),
319
- wave_plan: dispatchable
320
- ? {
321
- waves: compiled.schedule.waves.map((wave) => ({
322
- task_ids: [...wave.todo_ids].sort(compareText),
323
- })),
324
- minimum_feasible_waves: compiled.schedule.minimum_feasible_waves,
325
- }
326
- : null,
327
- scope_expanded: scopeExpansionFrom({
328
- taskIds,
329
- boundaries: new Map(boundaryEntries.map((entry) => [entry.task_id, entry.paths])),
330
- previousArtifact,
331
- plan,
332
- }),
333
- outcome: dispatchable ? 'compiled' : 'unknown',
334
- result_digest: '',
335
- };
336
- artifact.result_digest = todoSelfDigest(artifact, 'result_digest');
337
-
338
- // unknownを1件も持たないのに非dispatchableという状態は、原因を失った成功扱いになる。
339
- if (!dispatchable && artifact.unknowns.length === 0) {
340
- fail('INDEPENDENCE_COMPILE_FAILED', 'boundary_unknown_without_cause', {
341
- compile_detail: compiled.detail ?? null,
342
- });
343
- }
344
- if (!validateTodoIndependence(artifact)) {
345
- fail('INDEPENDENCE_ARTIFACT_INVALID', 'independence_artifact_invalid');
346
- }
347
- return artifact;
348
- }
349
-
350
- function pairKey(left, right) {
351
- return left < right ? `${left}\0${right}` : `${right}\0${left}`;
352
- }
353
-
354
- /**
355
- * 変更pathが宣言境界に触れたかを判定する。
356
- *
357
- * 完全一致か、宣言側が`dir/`形式のprefixで変更pathを覆う場合に交差とみなす
358
- * (runtime diff observerのscope判定と同一規則)。
359
- */
360
- function boundaryTouches(declaredPaths, changedPath) {
361
- return declaredPaths.some((declared) => declared === changedPath
362
- || (declared.endsWith('/') && changedPath.startsWith(declared)));
363
- }
364
-
365
- /**
366
- * ready frontierを、記録済みartifactだけを根拠に
367
- * 「検証済み独立」「直列化すべき組」「進行中との競合」「未検査」へ分けて投影する。
368
- *
369
- * sensorは引かない。artifactが無い/planが進んだ場合はcoverageで示す。
370
- * HEADが動いた場合は`changedPaths`と宣言境界の交差を見て、交差したtaskだけを未検査へ落とす
371
- * (ADR 0128 Decision 4)。宣言境界に触れないdiffは観測を変えないため、
372
- * 交差しなかったtaskのverified独立は維持する。
373
- *
374
- * @param {object} options
375
- * @param {object|null} options.artifact 記録済みindependence artifact(v2)
376
- * @param {string[]} options.readyTaskIds 同一plan内のready task
377
- * @param {string[]} options.activeTaskIds 同一plan内のin-progress task
378
- * @param {object} options.plan plan_versionとtopology_digestを持つactive plan
379
- * @param {string} options.currentBaseSha 現在のHEAD
380
- * @param {string[]|null} options.changedPaths base_sha..HEADの変更path。
381
- * nullはdiffを確定できなかったこと(base到達不能)を意味し、全taskを交差扱いにする。
382
- */
383
- export function projectIndependenceFrontier({
384
- artifact, readyTaskIds, activeTaskIds = [], plan, currentBaseSha, changedPaths = null,
385
- }) {
386
- const ready = [...readyTaskIds].sort(compareText);
387
- const active = [...activeTaskIds].sort(compareText);
388
- if (!isGitSha(currentBaseSha)) fail('INDEPENDENCE_BASE_INVALID', 'current_base_sha_invalid');
389
-
390
- const emptyFrontier = () => ({
391
- parallel_groups: [],
392
- serialize_pairs: [],
393
- conflicts_with_active: [],
394
- unknown: ready.map((taskId) => ({
395
- task_id: taskId,
396
- unknowns: [{ kind: 'witness_missing', ref: 'no_independence_record' }],
397
- })),
398
- });
399
-
400
- if (artifact === null) {
401
- return {
402
- coverage: 'missing',
403
- drift: null,
404
- active_task_ids: active,
405
- uncovered_active_task_ids: active,
406
- frontier: emptyFrontier(),
407
- };
408
- }
409
-
410
- if (isTodoIndependenceLegacyMarker(artifact)) {
411
- return {
412
- coverage: 'superseded',
413
- drift: null,
414
- active_task_ids: active,
415
- uncovered_active_task_ids: active,
416
- frontier: {
417
- parallel_groups: [],
418
- serialize_pairs: [],
419
- conflicts_with_active: [],
420
- unknown: ready.map((taskId) => ({
421
- task_id: taskId,
422
- unknowns: [{ kind: 'record_superseded', ref: artifact.legacy_schema }],
423
- })),
424
- },
425
- };
426
- }
427
-
428
- const superseded = artifact.plan_version !== plan.plan_version
429
- || artifact.topology_digest !== plan.topology_digest;
430
- const coverage = superseded ? 'superseded'
431
- : artifact.base_sha === currentBaseSha ? 'verified' : 'stale';
432
-
433
- const covered = new Set(artifact.task_ids);
434
- const boundariesByTask = new Map(artifact.task_boundaries
435
- .map((entry) => [entry.task_id, entry.paths]));
436
-
437
- // 宣言境界とdiffの交差。coverage==='stale'のときだけ意味を持つ。
438
- let drift = null;
439
- const intersecting = new Set();
440
- if (coverage === 'stale') {
441
- const baseReachable = changedPaths !== null;
442
- if (!baseReachable) {
443
- // 差分を確定できないなら、記録が今も有効だと主張する根拠が無い。
444
- for (const taskId of artifact.task_ids) intersecting.add(taskId);
445
- } else {
446
- for (const taskId of artifact.task_ids) {
447
- const declared = boundariesByTask.get(taskId) ?? [];
448
- if (changedPaths.some((changed) => boundaryTouches(declared, changed))) {
449
- intersecting.add(taskId);
450
- }
451
- }
452
- }
453
- drift = {
454
- base_reachable: baseReachable,
455
- changed_path_count: baseReachable ? changedPaths.length : 0,
456
- intersecting_task_ids: [...intersecting].sort(compareText),
457
- };
458
- }
459
-
460
- const unknownByTask = new Map();
461
- const noteUnknown = (taskId, kind, ref) => {
462
- if (!unknownByTask.has(taskId)) unknownByTask.set(taskId, []);
463
- unknownByTask.get(taskId).push({ kind, ref });
464
- };
465
- for (const taskId of ready) {
466
- if (!covered.has(taskId)) noteUnknown(taskId, 'witness_missing', 'task_not_in_witness_set');
467
- }
468
- for (const entry of artifact.unknowns) {
469
- if (ready.includes(entry.task_id)) noteUnknown(entry.task_id, entry.kind, entry.ref);
470
- }
471
- // 記録全体がverdictを持たない場合、どのpairについても判定は存在しない。
472
- //
473
- // compileがBOUNDARY_UNKNOWNで止まると、front endはpairwise verdictを1つも返さないので
474
- // artifact.conflictsは空になる。この空をそのまま読むと、自分にunknownが無いreadyどうしが
475
- // 「ぶつかる記録が無い=独立」として並列グループへ入る。実際には**誰についても判定していない**。
476
- // 新規fileを1つ宣言したToDoが混ざるだけで、無関係なToDoの実competitionが消えて
477
- // 検証済み並列に見える——不在を証拠に読み替える、最も危険な向きの誤りである。
478
- if (artifact.outcome !== 'compiled') {
479
- for (const taskId of ready) {
480
- if (covered.has(taskId)) noteUnknown(taskId, 'plan_verdicts_absent', artifact.outcome);
481
- }
482
- }
483
- // planが進んだ記録はtask単位に救えない(topology自体が別物)。
484
- // HEADだけが進んだ場合は、宣言境界に触れたtaskだけを落とす。
485
- if (coverage === 'superseded') {
486
- for (const taskId of ready) {
487
- if (covered.has(taskId)) noteUnknown(taskId, 'record_superseded', artifact.base_sha);
488
- }
489
- } else if (coverage === 'stale') {
490
- for (const taskId of ready) {
491
- if (covered.has(taskId) && intersecting.has(taskId)) {
492
- noteUnknown(taskId, 'record_stale', artifact.base_sha);
493
- }
494
- }
495
- }
496
-
497
- // 記録が今のtaskへ有効か。無効な相手との競合は「競合なし」でなく「判定不能」であり、
498
- // conflicts_with_activeではなくuncovered_activeとして示す。
499
- const usable = (taskId) => covered.has(taskId)
500
- && !(coverage === 'superseded')
501
- && !(coverage === 'stale' && intersecting.has(taskId));
502
- const uncoveredActive = active.filter((taskId) => !usable(taskId));
503
-
504
- const readySet = new Set(ready);
505
- const activeSet = new Set(active);
506
- const serializePairs = [];
507
- const activeConflicts = [];
508
- const blocked = new Set();
509
-
510
- const notePair = (left, right, type, detail, kind) => {
511
- const severability = type === 'conflict' ? severabilityOfConflictKind(kind) : 'serial';
512
- const pairKind = type === 'conflict' ? kind : null;
513
- if (readySet.has(left) && readySet.has(right)) {
514
- const pair = [left, right].sort(compareText);
515
- serializePairs.push({
516
- task_ids: pair, type, detail, kind: pairKind, severability,
517
- });
518
- blocked.add(pairKey(left, right));
519
- return;
520
- }
521
- // 片端がactiveなら着手時の警告面へ回す。v1はここで黙って捨てていた。
522
- const readyId = readySet.has(left) ? left : readySet.has(right) ? right : null;
523
- const activeId = activeSet.has(left) ? left : activeSet.has(right) ? right : null;
524
- if (readyId === null || activeId === null || readyId === activeId) return;
525
- if (!usable(readyId) || !usable(activeId)) return;
526
- activeConflicts.push({
527
- ready_task_id: readyId, active_task_id: activeId, type, detail,
528
- kind: pairKind, severability,
529
- });
530
- };
531
-
532
- const conflictResourceById = new Map(artifact.conflict_resources
533
- .map((resource) => [resource.resource_id, resource]));
534
- for (const conflict of artifact.conflicts) {
535
- const resource = conflictResourceById.get(conflict.resource_id);
536
- if (resource === undefined) {
537
- fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
538
- resource_id: conflict.resource_id, observed_kind: null,
539
- });
540
- }
541
- notePair(conflict.task_ids[0], conflict.task_ids[1], 'conflict', conflict.resource_id,
542
- resource.kind);
543
- }
544
- for (const precedence of artifact.precedences) {
545
- notePair(precedence.from_task_id, precedence.to_task_id, 'precedence', precedence.reason, null);
546
- }
547
- serializePairs.sort((left, right) => compareText(
548
- `${left.task_ids[0]}\0${left.task_ids[1]}\0${left.type}\0${left.detail}`,
549
- `${right.task_ids[0]}\0${right.task_ids[1]}\0${right.type}\0${right.detail}`,
550
- ));
551
- activeConflicts.sort((left, right) => compareText(
552
- `${left.ready_task_id}\0${left.active_task_id}\0${left.type}\0${left.detail}`,
553
- `${right.ready_task_id}\0${right.active_task_id}\0${right.type}\0${right.detail}`,
554
- ));
555
-
556
- // 未検査を並列グループへ入れない。verdictの不在を独立の証拠にできるのは、
557
- // 両taskがcompile対象であり、どちらにもunknownが無いときだけである。
558
- const eligible = ready.filter((taskId) => !unknownByTask.has(taskId));
559
- const groups = [];
560
- for (const taskId of eligible) {
561
- const target = groups.find((group) => group.every((member) => !blocked.has(pairKey(member, taskId))));
562
- if (target === undefined) groups.push([taskId]); else target.push(taskId);
563
- }
564
-
565
- return {
566
- coverage,
567
- drift,
568
- active_task_ids: active,
569
- uncovered_active_task_ids: uncoveredActive,
570
- frontier: {
571
- parallel_groups: groups.map((task_ids) => ({ task_ids: [...task_ids].sort(compareText) }))
572
- .sort((left, right) => compareText(left.task_ids[0], right.task_ids[0])),
573
- serialize_pairs: serializePairs,
574
- conflicts_with_active: activeConflicts,
575
- unknown: [...unknownByTask.entries()]
576
- .map(([taskId, unknowns]) => ({ task_id: taskId, unknowns }))
577
- .sort((left, right) => compareText(left.task_id, right.task_id)),
578
- },
579
- };
580
- }
581
-
582
- export function isIndependenceIdentifier(value) {
583
- return isTodoIdentifier(value);
584
- }
585
-
586
- /**
587
- * revisionのtask migrationでwitness宣言のtask_idを写す(ADR 0128 Decision 6)。
588
- *
589
- * 写像だけを担い、宣言内容が改訂後も意味的に妥当かは主張しない——それは機械には判定できない。
590
- * 解決できないIDはfail closedにする。既に新IDになっている宣言はそのまま通す(冪等)。
591
- *
592
- * @param {object} options
593
- * @param {object} options.witnessSet 現在の宣言
594
- * @param {Array<{from_task_id: string, to_task_id: string}>} options.taskMigration
595
- * @param {string[]} options.planTaskIds 移行後planのtask_id集合
596
- */
597
- export function migrateWitnessSetTaskIds({ witnessSet, taskMigration, planTaskIds }) {
598
- const mapping = new Map(taskMigration.map((entry) => [entry.from_task_id, entry.to_task_id]));
599
- const current = new Set(planTaskIds);
600
- const migrated = {};
601
- const unresolved = [];
602
- let migratedCount = 0;
603
- let removedCount = 0;
604
- let unchangedCount = 0;
605
-
606
- for (const taskId of Object.keys(witnessSet.manual_witness).sort(compareText)) {
607
- const witness = witnessSet.manual_witness[taskId];
608
- if (mapping.has(taskId)) {
609
- const target = mapping.get(taskId);
610
- if (target === 'removed') { removedCount += 1; continue; }
611
- migrated[target] = witness;
612
- if (target === taskId) unchangedCount += 1; else migratedCount += 1;
613
- continue;
614
- }
615
- // 移行表に無くても、既に現planのtaskなら再実行とみなして通す。
616
- if (current.has(taskId)) { migrated[taskId] = witness; unchangedCount += 1; continue; }
617
- unresolved.push(taskId);
618
- }
619
- if (unresolved.length > 0) {
620
- fail('WITNESS_MIGRATION_UNRESOLVED', 'witness_task_id_unresolved', {
621
- task_ids: unresolved.sort(compareText),
622
- next_action: 'update_witness_set_manually',
623
- });
624
- }
625
- if (Object.keys(migrated).length === 0) {
626
- fail('WITNESS_MIGRATION_EMPTY', 'witness_set_would_be_empty');
627
- }
628
-
629
- const next = { ...witnessSet, manual_witness: migrated, witness_set_digest: '' };
630
- next.witness_set_digest = todoSelfDigest(next, 'witness_set_digest');
631
- if (!validateTodoWitnessSet(next)) {
632
- fail('INVALID_TODO_WITNESS_SET', 'migrated_witness_set_invalid');
633
- }
634
- return {
635
- witnessSet: next,
636
- migrated_count: migratedCount,
637
- removed_count: removedCount,
638
- unchanged_count: unchangedCount,
639
- };
640
- }
1
+ import { digestTodoArtifact, isTodoIdentifier, todoSelfDigest } from './todo-contracts.mjs';
2
+ import {
3
+ TODO_INDEPENDENCE_SCHEMA,
4
+ isGitSha,
5
+ isTodoIndependenceLegacyMarker,
6
+ severabilityOfConflictKind,
7
+ synthesizeWitnessRunRequest,
8
+ validateTodoIndependence,
9
+ validateTodoWitnessSet,
10
+ } from './todo-independence-contracts.mjs';
11
+ import { compileRuntimePlanV1, evidenceFromCollectedOutcomes } from './runtime-front-end.mjs';
12
+ import { collectSensorEvidence } from './sensor-adapter.mjs';
13
+
14
+ export class TodoIndependenceError extends Error {
15
+ constructor(code, reason, detail = {}) {
16
+ super(reason);
17
+ this.name = 'TodoIndependenceError';
18
+ this.code = code;
19
+ this.detail = { reason, ...detail };
20
+ }
21
+ }
22
+
23
+ function fail(code, reason, detail) {
24
+ throw new TodoIndependenceError(code, reason, detail);
25
+ }
26
+
27
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
28
+
29
+ /**
30
+ * compile一回を識別するid。plan identityとwitness digestだけから決まるので、
31
+ * 同じ入力からは同じrequest bytesが出る。run lifecycleへは登録しない。
32
+ */
33
+ export function witnessRequestId(witnessSet, planVersion) {
34
+ const digest = digestTodoArtifact({
35
+ schema: 'lattice.todo_independence_request_id.v1',
36
+ project_id: witnessSet.project_id,
37
+ plan_key: witnessSet.plan_key,
38
+ plan_version: planVersion,
39
+ witness_set_digest: witnessSet.witness_set_digest,
40
+ });
41
+ return `independence-${digest.slice(0, 24)}`;
42
+ }
43
+
44
+ /** 実sensorを引いてfront-end evidence契約へ整形する。失敗をunknownへ丸めない。 */
45
+ export async function collectWitnessSensorEvidence({ cwd, witnessSet, execute = undefined }) {
46
+ const querySet = witnessSet.sensor_query_set;
47
+ const collected = await collectSensorEvidence({
48
+ cwd, querySet, ...(execute === undefined ? {} : { execute }),
49
+ });
50
+ return evidenceFromCollectedOutcomes({ querySet, collected });
51
+ }
52
+
53
+ /**
54
+ * conflictから参照されるnormalized resourceを正規化してartifactへ載せる。
55
+ *
56
+ * `graph.conflicts`はresource_idしか持たず、宣言由来のstate resource idは任意文字列なので
57
+ * prefixからkindを復元できない。normalized resourceを引けない場合は、切断可能性を
58
+ * 不明のまま記録するのでなくtyped failで止める。
59
+ */
60
+ function conflictsFrom(verdicts, resources) {
61
+ const resourceById = new Map();
62
+ for (const resource of Array.isArray(resources) ? resources : []) {
63
+ const resourceId = resource?.resource_id;
64
+ if (resourceById.has(resourceId)) {
65
+ const previous = resourceById.get(resourceId);
66
+ if (previous.kind !== resource?.kind) {
67
+ fail('INDEPENDENCE_RESOURCE_KIND_MISMATCH', 'conflict_resource_kind_mismatch', {
68
+ resource_id: resourceId ?? null,
69
+ observed_kinds: [previous.kind ?? null, resource?.kind ?? null],
70
+ });
71
+ }
72
+ if (previous.target !== resource?.target) {
73
+ fail('INDEPENDENCE_RESOURCE_TARGET_MISMATCH', 'conflict_resource_target_mismatch', {
74
+ resource_id: resourceId ?? null,
75
+ observed_targets: [previous.target ?? null, resource?.target ?? null],
76
+ });
77
+ }
78
+ fail('INDEPENDENCE_RESOURCE_DUPLICATE', 'conflict_resource_duplicate', {
79
+ resource_id: resourceId ?? null,
80
+ });
81
+ }
82
+ resourceById.set(resourceId, { kind: resource?.kind, target: resource?.target });
83
+ }
84
+ const conflicts = verdicts
85
+ .filter((verdict) => verdict.type === 'conflict')
86
+ .map((verdict) => {
87
+ const resource = resourceById.get(verdict.resource_id);
88
+ const kind = resource?.kind;
89
+ if (!['symbol', 'path', 'state', 'effect', 'line'].includes(kind)) {
90
+ fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
91
+ resource_id: verdict.resource_id, observed_kind: kind ?? null,
92
+ });
93
+ }
94
+ if (typeof resource.target !== 'string' || resource.target.length === 0) {
95
+ fail('INDEPENDENCE_RESOURCE_TARGET_UNRESOLVED', 'conflict_resource_target_unresolved', {
96
+ resource_id: verdict.resource_id, observed_target: resource.target ?? null,
97
+ });
98
+ }
99
+ return {
100
+ task_ids: [...verdict.todo_ids].sort(compareText),
101
+ resource_id: verdict.resource_id,
102
+ };
103
+ })
104
+ .sort((left, right) => compareText(
105
+ `${left.task_ids[0]}\0${left.task_ids[1]}\0${left.resource_id}`,
106
+ `${right.task_ids[0]}\0${right.task_ids[1]}\0${right.resource_id}`,
107
+ ));
108
+ const used = new Set(conflicts.map(({ resource_id: resourceId }) => resourceId));
109
+ const conflictResources = [...used]
110
+ .map((resourceId) => ({ resource_id: resourceId, ...resourceById.get(resourceId) }))
111
+ .sort((left, right) => compareText(left.resource_id, right.resource_id));
112
+ return { conflicts, conflictResources };
113
+ }
114
+
115
+ /**
116
+ * witnessが宣言した境界pathを集める。
117
+ *
118
+ * 鮮度のdiff交差判定に使うので、observationへ効くpathを漏れなく採る——所有path、
119
+ * 書き込み、読み取り、affected test、sensor queryのexpect path。symbol owns自体は
120
+ * pathを持たないが、それを裏取りするqueryのexpect pathが同じ効果を持つ。
121
+ */
122
+ function boundaryPathsOf(witness) {
123
+ const paths = new Set();
124
+ for (const own of witness.owns) {
125
+ if (own.kind === 'path') paths.add(own.target);
126
+ }
127
+ for (const path of witness.writes) paths.add(path);
128
+ for (const path of witness.reads) paths.add(path);
129
+ for (const path of witness.affected_tests) paths.add(path);
130
+ for (const entry of witness.sensor_provenance.queries) paths.add(entry.expect.path);
131
+ for (const line of witness.lines ?? []) {
132
+ for (const anchor of line.anchors) paths.add(anchor.path);
133
+ }
134
+ return [...paths].sort(compareText);
135
+ }
136
+
137
+ function precedencesFrom(verdicts) {
138
+ return verdicts
139
+ .filter((verdict) => verdict.type === 'precedence')
140
+ .map((verdict) => ({
141
+ from_task_id: verdict.from_todo_id,
142
+ to_task_id: verdict.to_todo_id,
143
+ reason: verdict.reason,
144
+ }))
145
+ .sort((left, right) => compareText(
146
+ `${left.from_task_id}\0${left.to_task_id}\0${left.reason}`,
147
+ `${right.from_task_id}\0${right.to_task_id}\0${right.reason}`,
148
+ ));
149
+ }
150
+
151
+ function unknownsFrom(detail) {
152
+ const entries = [
153
+ ...(Array.isArray(detail?.unknowns) ? detail.unknowns : []),
154
+ ...(Array.isArray(detail?.unresolved_witnesses) ? detail.unresolved_witnesses : []),
155
+ ]
156
+ .filter((entry) => entry !== null && typeof entry === 'object'
157
+ && typeof entry.todo_id === 'string' && typeof entry.kind === 'string'
158
+ && typeof entry.ref === 'string')
159
+ .map((entry) => ({
160
+ task_id: entry.todo_id,
161
+ kind: entry.kind,
162
+ ref: entry.ref,
163
+ }));
164
+ const seen = new Set();
165
+ return entries
166
+ .filter((entry) => {
167
+ const key = `${entry.task_id}\0${entry.kind}\0${entry.ref}`;
168
+ if (seen.has(key)) return false;
169
+ seen.add(key);
170
+ return true;
171
+ })
172
+ .sort((left, right) => compareText(
173
+ `${left.task_id}\0${left.kind}\0${left.ref}`,
174
+ `${right.task_id}\0${right.kind}\0${right.ref}`,
175
+ ));
176
+ }
177
+
178
+ /**
179
+ * witness setとsensor evidenceから`lattice.todo_independence.v5`を作る。
180
+ *
181
+ * compileは宣言済みtaskの部分集合へ閉じる(ADR 0127 Decision 4)。未宣言taskを混ぜると、
182
+ * unknownが1件でも出た時点で宣言済みtask同士の判定まで失われるためである。
183
+ * `dispatchable`ならpairwise verdictとwave planを写し、`BOUNDARY_UNKNOWN`なら
184
+ * unknownをtask単位で記録してwave planを持たない。それ以外のnon-dispatchableは
185
+ * 契約違反または観測不成立であり、artifactへ丸めずtyped errorで止める。
186
+ */
187
+ /**
188
+ * 前回artifactと今回の宣言を突き合わせて、task別の膨張を事実だけ記録する(s1)。
189
+ *
190
+ * **判定しない。** 上流の契約確定に追従した膨張・自分の変更の後始末・元から在った面の見落とし・
191
+ * 思いつきで盛った分は、**機械には区別できない**(2026-08-09の実測: 1つのtaskへ前3種が同時に出た)。
192
+ * 装置が言えるのは「増えた」「何が増減した」「何回目か」「合流点か」までで、仕分けはAIがやる。
193
+ *
194
+ * 前回が無い(初回・plan version違い・legacy版)時は、比較相手なしとして今回の宣言を初回として置く。
195
+ * **「前回が読めなかった」を「膨張ゼロ」に化かさない**ため、compared_witness_digestで区別できる。
196
+ */
197
+ function scopeExpansionFrom({ taskIds, boundaries, previousArtifact, plan }) {
198
+ const comparable = previousArtifact !== null && previousArtifact !== undefined
199
+ && previousArtifact.schema === TODO_INDEPENDENCE_SCHEMA
200
+ && previousArtifact.plan_key === plan.plan_key
201
+ && previousArtifact.plan_version === plan.plan_version
202
+ && previousArtifact.topology_digest === plan.topology_digest;
203
+ const previousBoundaries = new Map(comparable
204
+ ? previousArtifact.task_boundaries.map((entry) => [entry.task_id, entry.paths])
205
+ : []);
206
+ const previousExpansion = new Map(comparable
207
+ ? previousArtifact.scope_expanded.map((entry) => [entry.task_id, entry])
208
+ : []);
209
+ // 合流点(gate形)はplanのtopologyが持つ事実で、宣言とは独立に決まる。
210
+ const incoming = new Map(taskIds.map((taskId) => [taskId, 0]));
211
+ for (const edge of plan.hard_dependencies ?? []) {
212
+ const to = edge?.to?.task_id ?? edge?.to_task_id;
213
+ if (incoming.has(to)) incoming.set(to, incoming.get(to) + 1);
214
+ }
215
+ return taskIds.map((taskId) => {
216
+ const current = boundaries.get(taskId) ?? [];
217
+ const previous = previousBoundaries.get(taskId) ?? null;
218
+ const prior = previousExpansion.get(taskId) ?? null;
219
+ // **subset入替を「初回」へ偽装しない。** witnessはwave単位のsubsetで書き出せるので、
220
+ // A→B→Aと入れ替わると直前artifactにAが居ない。そこで今回を初回と置くと、
221
+ // **本当の初回宣言数と膨張回数が消える**(suzune の監査で実測・room [1152])。
222
+ // 比較相手はあるのにこのtaskが居ない時は、**数を主張せずnullで「分からない」と言う。**
223
+ if (comparable && previous === null) {
224
+ return {
225
+ task_id: taskId,
226
+ compared_witness_digest: previousArtifact.witness_set_digest,
227
+ first_seen_path_count: null,
228
+ path_count: current.length,
229
+ added_paths: [],
230
+ removed_paths: [],
231
+ growth_events: null,
232
+ gate_shape: (incoming.get(taskId) ?? 0) >= 2,
233
+ };
234
+ }
235
+ const currentSet = new Set(current);
236
+ const previousSet = new Set(previous ?? []);
237
+ const added = previous === null ? [] : current.filter((path) => !previousSet.has(path));
238
+ const removed = previous === null ? [] : previous.filter((path) => !currentSet.has(path));
239
+ return {
240
+ task_id: taskId,
241
+ compared_witness_digest: comparable ? previousArtifact.witness_set_digest : null,
242
+ // **unknown は後続へ伝播する。** 一度 subset gap で累計が分からなくなった task は、
243
+ // 次の compile で `(null ?? 0) + 1` のように既知へ戻してはいけない——
244
+ // **欠落前の累計が分からないことは、その後も分からないまま**である(suzune [1173])。
245
+ first_seen_path_count: prior === null
246
+ ? current.length
247
+ : prior.first_seen_path_count,
248
+ path_count: current.length,
249
+ added_paths: [...added].sort(compareText),
250
+ removed_paths: [...removed].sort(compareText),
251
+ growth_events: prior !== null && prior.growth_events === null
252
+ ? null
253
+ : (prior?.growth_events ?? 0) + (added.length > 0 ? 1 : 0),
254
+ gate_shape: (incoming.get(taskId) ?? 0) >= 2,
255
+ };
256
+ });
257
+ }
258
+
259
+ export function compileTodoIndependence(options = {}) {
260
+ const { witnessSet, plan, baseSha, compiledAt, sensorEvidence, previousArtifact = null } = options;
261
+ if (!validateTodoWitnessSet(witnessSet)) {
262
+ fail('INDEPENDENCE_WITNESS_INVALID', 'witness_set_invalid');
263
+ }
264
+ if (!isGitSha(baseSha)) fail('INDEPENDENCE_BASE_INVALID', 'base_sha_invalid');
265
+ if (witnessSet.project_id !== plan.project_id || witnessSet.plan_key !== plan.plan_key) {
266
+ fail('INDEPENDENCE_PLAN_MISMATCH', 'witness_set_plan_mismatch', {
267
+ witness_plan_key: witnessSet.plan_key, plan_key: plan.plan_key,
268
+ });
269
+ }
270
+
271
+ const planTaskIds = new Set(plan.tasks.map(({ task_id: taskId }) => taskId));
272
+ const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
273
+ const absent = taskIds.filter((taskId) => !planTaskIds.has(taskId));
274
+ if (absent.length > 0) {
275
+ fail('INDEPENDENCE_TASK_ABSENT', 'witness_task_absent_from_plan', { task_ids: absent });
276
+ }
277
+
278
+ const request = synthesizeWitnessRunRequest(witnessSet, {
279
+ baseSha, requestId: witnessRequestId(witnessSet, plan.plan_version),
280
+ });
281
+ const compiled = compileRuntimePlanV1({
282
+ request,
283
+ sensorEvidence,
284
+ planRef: witnessRequestId(witnessSet, plan.plan_version),
285
+ planEpoch: 0,
286
+ predecessorRefs: [],
287
+ });
288
+
289
+ if (compiled.outcome !== 'dispatchable'
290
+ && !(compiled.outcome === 'non_dispatchable' && compiled.code === 'BOUNDARY_UNKNOWN')) {
291
+ fail('INDEPENDENCE_COMPILE_FAILED', 'boundary_compile_not_dispatchable', {
292
+ code: compiled.code ?? null, compile_detail: compiled.detail ?? null,
293
+ });
294
+ }
295
+
296
+ const dispatchable = compiled.outcome === 'dispatchable';
297
+ const conflictProjection = dispatchable
298
+ ? conflictsFrom(compiled.pairwise_verdicts, compiled.resources)
299
+ : { conflicts: [], conflictResources: [] };
300
+ const boundaryEntries = taskIds.map((taskId) => ({
301
+ task_id: taskId,
302
+ paths: boundaryPathsOf(witnessSet.manual_witness[taskId]),
303
+ }));
304
+ const artifact = {
305
+ schema: TODO_INDEPENDENCE_SCHEMA,
306
+ project_id: plan.project_id,
307
+ plan_key: plan.plan_key,
308
+ plan_version: plan.plan_version,
309
+ topology_digest: plan.topology_digest,
310
+ base_sha: baseSha,
311
+ witness_set_digest: witnessSet.witness_set_digest,
312
+ compiled_at: compiledAt,
313
+ task_ids: taskIds,
314
+ task_boundaries: boundaryEntries,
315
+ conflict_resources: conflictProjection.conflictResources,
316
+ conflicts: conflictProjection.conflicts,
317
+ precedences: dispatchable ? precedencesFrom(compiled.pairwise_verdicts) : [],
318
+ unknowns: dispatchable ? [] : unknownsFrom(compiled.detail),
319
+ wave_plan: dispatchable
320
+ ? {
321
+ waves: compiled.schedule.waves.map((wave) => ({
322
+ task_ids: [...wave.todo_ids].sort(compareText),
323
+ })),
324
+ minimum_feasible_waves: compiled.schedule.minimum_feasible_waves,
325
+ }
326
+ : null,
327
+ scope_expanded: scopeExpansionFrom({
328
+ taskIds,
329
+ boundaries: new Map(boundaryEntries.map((entry) => [entry.task_id, entry.paths])),
330
+ previousArtifact,
331
+ plan,
332
+ }),
333
+ outcome: dispatchable ? 'compiled' : 'unknown',
334
+ result_digest: '',
335
+ };
336
+ artifact.result_digest = todoSelfDigest(artifact, 'result_digest');
337
+
338
+ // unknownを1件も持たないのに非dispatchableという状態は、原因を失った成功扱いになる。
339
+ if (!dispatchable && artifact.unknowns.length === 0) {
340
+ fail('INDEPENDENCE_COMPILE_FAILED', 'boundary_unknown_without_cause', {
341
+ compile_detail: compiled.detail ?? null,
342
+ });
343
+ }
344
+ if (!validateTodoIndependence(artifact)) {
345
+ fail('INDEPENDENCE_ARTIFACT_INVALID', 'independence_artifact_invalid');
346
+ }
347
+ return artifact;
348
+ }
349
+
350
+ function pairKey(left, right) {
351
+ return left < right ? `${left}\0${right}` : `${right}\0${left}`;
352
+ }
353
+
354
+ /**
355
+ * 変更pathが宣言境界に触れたかを判定する。
356
+ *
357
+ * 完全一致か、宣言側が`dir/`形式のprefixで変更pathを覆う場合に交差とみなす
358
+ * (runtime diff observerのscope判定と同一規則)。
359
+ */
360
+ function boundaryTouches(declaredPaths, changedPath) {
361
+ return declaredPaths.some((declared) => declared === changedPath
362
+ || (declared.endsWith('/') && changedPath.startsWith(declared)));
363
+ }
364
+
365
+ /**
366
+ * ready frontierを、記録済みartifactだけを根拠に
367
+ * 「検証済み独立」「直列化すべき組」「進行中との競合」「未検査」へ分けて投影する。
368
+ *
369
+ * sensorは引かない。artifactが無い/planが進んだ場合はcoverageで示す。
370
+ * HEADが動いた場合は`changedPaths`と宣言境界の交差を見て、交差したtaskだけを未検査へ落とす
371
+ * (ADR 0128 Decision 4)。宣言境界に触れないdiffは観測を変えないため、
372
+ * 交差しなかったtaskのverified独立は維持する。
373
+ *
374
+ * @param {object} options
375
+ * @param {object|null} options.artifact 記録済みindependence artifact(v2)
376
+ * @param {string[]} options.readyTaskIds 同一plan内のready task
377
+ * @param {string[]} options.activeTaskIds 同一plan内のin-progress task
378
+ * @param {object} options.plan plan_versionとtopology_digestを持つactive plan
379
+ * @param {string} options.currentBaseSha 現在のHEAD
380
+ * @param {string[]|null} options.changedPaths base_sha..HEADの変更path。
381
+ * nullはdiffを確定できなかったこと(base到達不能)を意味し、全taskを交差扱いにする。
382
+ */
383
+ export function projectIndependenceFrontier({
384
+ artifact, readyTaskIds, activeTaskIds = [], plan, currentBaseSha, changedPaths = null,
385
+ }) {
386
+ const ready = [...readyTaskIds].sort(compareText);
387
+ const active = [...activeTaskIds].sort(compareText);
388
+ if (!isGitSha(currentBaseSha)) fail('INDEPENDENCE_BASE_INVALID', 'current_base_sha_invalid');
389
+
390
+ const emptyFrontier = () => ({
391
+ parallel_groups: [],
392
+ serialize_pairs: [],
393
+ conflicts_with_active: [],
394
+ unknown: ready.map((taskId) => ({
395
+ task_id: taskId,
396
+ unknowns: [{ kind: 'witness_missing', ref: 'no_independence_record' }],
397
+ })),
398
+ });
399
+
400
+ if (artifact === null) {
401
+ return {
402
+ coverage: 'missing',
403
+ drift: null,
404
+ active_task_ids: active,
405
+ uncovered_active_task_ids: active,
406
+ frontier: emptyFrontier(),
407
+ };
408
+ }
409
+
410
+ if (isTodoIndependenceLegacyMarker(artifact)) {
411
+ return {
412
+ coverage: 'superseded',
413
+ drift: null,
414
+ active_task_ids: active,
415
+ uncovered_active_task_ids: active,
416
+ frontier: {
417
+ parallel_groups: [],
418
+ serialize_pairs: [],
419
+ conflicts_with_active: [],
420
+ unknown: ready.map((taskId) => ({
421
+ task_id: taskId,
422
+ unknowns: [{ kind: 'record_superseded', ref: artifact.legacy_schema }],
423
+ })),
424
+ },
425
+ };
426
+ }
427
+
428
+ const superseded = artifact.plan_version !== plan.plan_version
429
+ || artifact.topology_digest !== plan.topology_digest;
430
+ const coverage = superseded ? 'superseded'
431
+ : artifact.base_sha === currentBaseSha ? 'verified' : 'stale';
432
+
433
+ const covered = new Set(artifact.task_ids);
434
+ const boundariesByTask = new Map(artifact.task_boundaries
435
+ .map((entry) => [entry.task_id, entry.paths]));
436
+
437
+ // 宣言境界とdiffの交差。coverage==='stale'のときだけ意味を持つ。
438
+ let drift = null;
439
+ const intersecting = new Set();
440
+ if (coverage === 'stale') {
441
+ const baseReachable = changedPaths !== null;
442
+ if (!baseReachable) {
443
+ // 差分を確定できないなら、記録が今も有効だと主張する根拠が無い。
444
+ for (const taskId of artifact.task_ids) intersecting.add(taskId);
445
+ } else {
446
+ for (const taskId of artifact.task_ids) {
447
+ const declared = boundariesByTask.get(taskId) ?? [];
448
+ if (changedPaths.some((changed) => boundaryTouches(declared, changed))) {
449
+ intersecting.add(taskId);
450
+ }
451
+ }
452
+ }
453
+ drift = {
454
+ base_reachable: baseReachable,
455
+ changed_path_count: baseReachable ? changedPaths.length : 0,
456
+ intersecting_task_ids: [...intersecting].sort(compareText),
457
+ };
458
+ }
459
+
460
+ const unknownByTask = new Map();
461
+ const noteUnknown = (taskId, kind, ref) => {
462
+ if (!unknownByTask.has(taskId)) unknownByTask.set(taskId, []);
463
+ unknownByTask.get(taskId).push({ kind, ref });
464
+ };
465
+ for (const taskId of ready) {
466
+ if (!covered.has(taskId)) noteUnknown(taskId, 'witness_missing', 'task_not_in_witness_set');
467
+ }
468
+ for (const entry of artifact.unknowns) {
469
+ if (ready.includes(entry.task_id)) noteUnknown(entry.task_id, entry.kind, entry.ref);
470
+ }
471
+ // 記録全体がverdictを持たない場合、どのpairについても判定は存在しない。
472
+ //
473
+ // compileがBOUNDARY_UNKNOWNで止まると、front endはpairwise verdictを1つも返さないので
474
+ // artifact.conflictsは空になる。この空をそのまま読むと、自分にunknownが無いreadyどうしが
475
+ // 「ぶつかる記録が無い=独立」として並列グループへ入る。実際には**誰についても判定していない**。
476
+ // 新規fileを1つ宣言したToDoが混ざるだけで、無関係なToDoの実competitionが消えて
477
+ // 検証済み並列に見える——不在を証拠に読み替える、最も危険な向きの誤りである。
478
+ if (artifact.outcome !== 'compiled') {
479
+ for (const taskId of ready) {
480
+ if (covered.has(taskId)) noteUnknown(taskId, 'plan_verdicts_absent', artifact.outcome);
481
+ }
482
+ }
483
+ // planが進んだ記録はtask単位に救えない(topology自体が別物)。
484
+ // HEADだけが進んだ場合は、宣言境界に触れたtaskだけを落とす。
485
+ if (coverage === 'superseded') {
486
+ for (const taskId of ready) {
487
+ if (covered.has(taskId)) noteUnknown(taskId, 'record_superseded', artifact.base_sha);
488
+ }
489
+ } else if (coverage === 'stale') {
490
+ for (const taskId of ready) {
491
+ if (covered.has(taskId) && intersecting.has(taskId)) {
492
+ noteUnknown(taskId, 'record_stale', artifact.base_sha);
493
+ }
494
+ }
495
+ }
496
+
497
+ // 記録が今のtaskへ有効か。無効な相手との競合は「競合なし」でなく「判定不能」であり、
498
+ // conflicts_with_activeではなくuncovered_activeとして示す。
499
+ const usable = (taskId) => covered.has(taskId)
500
+ && !(coverage === 'superseded')
501
+ && !(coverage === 'stale' && intersecting.has(taskId));
502
+ const uncoveredActive = active.filter((taskId) => !usable(taskId));
503
+
504
+ const readySet = new Set(ready);
505
+ const activeSet = new Set(active);
506
+ const serializePairs = [];
507
+ const activeConflicts = [];
508
+ const blocked = new Set();
509
+
510
+ const notePair = (left, right, type, detail, kind) => {
511
+ const severability = type === 'conflict' ? severabilityOfConflictKind(kind) : 'serial';
512
+ const pairKind = type === 'conflict' ? kind : null;
513
+ if (readySet.has(left) && readySet.has(right)) {
514
+ const pair = [left, right].sort(compareText);
515
+ serializePairs.push({
516
+ task_ids: pair, type, detail, kind: pairKind, severability,
517
+ });
518
+ blocked.add(pairKey(left, right));
519
+ return;
520
+ }
521
+ // 片端がactiveなら着手時の警告面へ回す。v1はここで黙って捨てていた。
522
+ const readyId = readySet.has(left) ? left : readySet.has(right) ? right : null;
523
+ const activeId = activeSet.has(left) ? left : activeSet.has(right) ? right : null;
524
+ if (readyId === null || activeId === null || readyId === activeId) return;
525
+ if (!usable(readyId) || !usable(activeId)) return;
526
+ activeConflicts.push({
527
+ ready_task_id: readyId, active_task_id: activeId, type, detail,
528
+ kind: pairKind, severability,
529
+ });
530
+ };
531
+
532
+ const conflictResourceById = new Map(artifact.conflict_resources
533
+ .map((resource) => [resource.resource_id, resource]));
534
+ for (const conflict of artifact.conflicts) {
535
+ const resource = conflictResourceById.get(conflict.resource_id);
536
+ if (resource === undefined) {
537
+ fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
538
+ resource_id: conflict.resource_id, observed_kind: null,
539
+ });
540
+ }
541
+ notePair(conflict.task_ids[0], conflict.task_ids[1], 'conflict', conflict.resource_id,
542
+ resource.kind);
543
+ }
544
+ for (const precedence of artifact.precedences) {
545
+ notePair(precedence.from_task_id, precedence.to_task_id, 'precedence', precedence.reason, null);
546
+ }
547
+ serializePairs.sort((left, right) => compareText(
548
+ `${left.task_ids[0]}\0${left.task_ids[1]}\0${left.type}\0${left.detail}`,
549
+ `${right.task_ids[0]}\0${right.task_ids[1]}\0${right.type}\0${right.detail}`,
550
+ ));
551
+ activeConflicts.sort((left, right) => compareText(
552
+ `${left.ready_task_id}\0${left.active_task_id}\0${left.type}\0${left.detail}`,
553
+ `${right.ready_task_id}\0${right.active_task_id}\0${right.type}\0${right.detail}`,
554
+ ));
555
+
556
+ // 未検査を並列グループへ入れない。verdictの不在を独立の証拠にできるのは、
557
+ // 両taskがcompile対象であり、どちらにもunknownが無いときだけである。
558
+ const eligible = ready.filter((taskId) => !unknownByTask.has(taskId));
559
+ const groups = [];
560
+ for (const taskId of eligible) {
561
+ const target = groups.find((group) => group.every((member) => !blocked.has(pairKey(member, taskId))));
562
+ if (target === undefined) groups.push([taskId]); else target.push(taskId);
563
+ }
564
+
565
+ return {
566
+ coverage,
567
+ drift,
568
+ active_task_ids: active,
569
+ uncovered_active_task_ids: uncoveredActive,
570
+ frontier: {
571
+ parallel_groups: groups.map((task_ids) => ({ task_ids: [...task_ids].sort(compareText) }))
572
+ .sort((left, right) => compareText(left.task_ids[0], right.task_ids[0])),
573
+ serialize_pairs: serializePairs,
574
+ conflicts_with_active: activeConflicts,
575
+ unknown: [...unknownByTask.entries()]
576
+ .map(([taskId, unknowns]) => ({ task_id: taskId, unknowns }))
577
+ .sort((left, right) => compareText(left.task_id, right.task_id)),
578
+ },
579
+ };
580
+ }
581
+
582
+ export function isIndependenceIdentifier(value) {
583
+ return isTodoIdentifier(value);
584
+ }
585
+
586
+ /**
587
+ * revisionのtask migrationでwitness宣言のtask_idを写す(ADR 0128 Decision 6)。
588
+ *
589
+ * 写像だけを担い、宣言内容が改訂後も意味的に妥当かは主張しない——それは機械には判定できない。
590
+ * 解決できないIDはfail closedにする。既に新IDになっている宣言はそのまま通す(冪等)。
591
+ *
592
+ * @param {object} options
593
+ * @param {object} options.witnessSet 現在の宣言
594
+ * @param {Array<{from_task_id: string, to_task_id: string}>} options.taskMigration
595
+ * @param {string[]} options.planTaskIds 移行後planのtask_id集合
596
+ */
597
+ export function migrateWitnessSetTaskIds({ witnessSet, taskMigration, planTaskIds }) {
598
+ const mapping = new Map(taskMigration.map((entry) => [entry.from_task_id, entry.to_task_id]));
599
+ const current = new Set(planTaskIds);
600
+ const migrated = {};
601
+ const unresolved = [];
602
+ let migratedCount = 0;
603
+ let removedCount = 0;
604
+ let unchangedCount = 0;
605
+
606
+ for (const taskId of Object.keys(witnessSet.manual_witness).sort(compareText)) {
607
+ const witness = witnessSet.manual_witness[taskId];
608
+ if (mapping.has(taskId)) {
609
+ const target = mapping.get(taskId);
610
+ if (target === 'removed') { removedCount += 1; continue; }
611
+ migrated[target] = witness;
612
+ if (target === taskId) unchangedCount += 1; else migratedCount += 1;
613
+ continue;
614
+ }
615
+ // 移行表に無くても、既に現planのtaskなら再実行とみなして通す。
616
+ if (current.has(taskId)) { migrated[taskId] = witness; unchangedCount += 1; continue; }
617
+ unresolved.push(taskId);
618
+ }
619
+ if (unresolved.length > 0) {
620
+ fail('WITNESS_MIGRATION_UNRESOLVED', 'witness_task_id_unresolved', {
621
+ task_ids: unresolved.sort(compareText),
622
+ next_action: 'update_witness_set_manually',
623
+ });
624
+ }
625
+ if (Object.keys(migrated).length === 0) {
626
+ fail('WITNESS_MIGRATION_EMPTY', 'witness_set_would_be_empty');
627
+ }
628
+
629
+ const next = { ...witnessSet, manual_witness: migrated, witness_set_digest: '' };
630
+ next.witness_set_digest = todoSelfDigest(next, 'witness_set_digest');
631
+ if (!validateTodoWitnessSet(next)) {
632
+ fail('INVALID_TODO_WITNESS_SET', 'migrated_witness_set_invalid');
633
+ }
634
+ return {
635
+ witnessSet: next,
636
+ migrated_count: migratedCount,
637
+ removed_count: removedCount,
638
+ unchanged_count: unchangedCount,
639
+ };
640
+ }