@quolu/lattice 0.52.3 → 0.53.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 (188) hide show
  1. package/LICENSE +147 -147
  2. package/README.ja.md +355 -355
  3. package/README.md +258 -258
  4. package/bin/lattice-bridge.mjs +25 -0
  5. package/bin/lattice-hub.mjs +67 -0
  6. package/bin/lattice-mcp.mjs +0 -0
  7. package/bin/lattice-scripted-adapter.mjs +0 -0
  8. package/bin/lattice-scripted-worker.mjs +0 -0
  9. package/bin/lattice-work-order-adapter.mjs +0 -0
  10. package/bin/lattice.mjs +0 -0
  11. package/docs/bridge-setup.md +132 -132
  12. package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
  13. package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
  14. package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
  15. package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
  16. package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
  17. package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
  18. package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
  19. package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
  20. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
  21. package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
  22. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
  23. package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
  24. package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
  25. package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
  26. package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
  27. package/package.json +103 -103
  28. package/sensor/LICENSE +21 -21
  29. package/sensor/NOTICE +19 -19
  30. package/sensor/dist/bin/lattice-sensor.js +9 -9
  31. package/sensor/dist/db/index.js +24 -24
  32. package/sensor/dist/db/migrations.js +41 -41
  33. package/sensor/dist/db/queries.js +164 -164
  34. package/sensor/dist/db/schema.sql +205 -205
  35. package/sensor/dist/directory.js +5 -5
  36. package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  37. package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  38. package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  39. package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  40. package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  41. package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  42. package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
  43. package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
  44. package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
  45. package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  46. package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  47. package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
  48. package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
  49. package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
  50. package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  51. package/sensor/dist/mcp/liveness-watchdog.js +53 -53
  52. package/sensor/dist/mcp/server-instructions.js +95 -95
  53. package/sensor/package.json +56 -56
  54. package/src/artifact-contracts-v2.mjs +325 -325
  55. package/src/artifact-contracts.mjs +895 -895
  56. package/src/boundary-compiler.mjs +712 -712
  57. package/src/boundary-observation-compiler-v2.mjs +344 -344
  58. package/src/bounded-seam.mjs +230 -230
  59. package/src/bridge-address.mjs +107 -107
  60. package/src/bridge-cli.mjs +303 -297
  61. package/src/bridge-config.mjs +382 -346
  62. package/src/bridge-daemon.mjs +378 -378
  63. package/src/bridge-hub-heartbeat.mjs +170 -0
  64. package/src/bridge-hub-protocol.mjs +198 -0
  65. package/src/bridge-hub-server.mjs +544 -0
  66. package/src/bridge-launch-agent.mjs +323 -323
  67. package/src/bridge-registrar.mjs +102 -102
  68. package/src/bridge-server.mjs +376 -376
  69. package/src/cli-help.mjs +308 -308
  70. package/src/cli-stdio.mjs +40 -40
  71. package/src/control-compiler.mjs +532 -532
  72. package/src/dag-chain.mjs +261 -261
  73. package/src/factory-diagnostics.mjs +188 -188
  74. package/src/git-process.mjs +72 -0
  75. package/src/hash-chain.mjs +76 -76
  76. package/src/hooks-cli.mjs +1057 -1057
  77. package/src/isolation-runner.mjs +409 -409
  78. package/src/node-version-guard.mjs +44 -44
  79. package/src/project-cli.mjs +3 -3
  80. package/src/project-identity.mjs +130 -130
  81. package/src/rc1-black-box-oracle.mjs +906 -906
  82. package/src/rc1-comparison.mjs +154 -154
  83. package/src/rc1-evidence-bundle.mjs +456 -456
  84. package/src/rc1-v4-campaign.mjs +708 -708
  85. package/src/rc1-v4-transform.mjs +467 -467
  86. package/src/rc1-v5-artifact-set.mjs +855 -855
  87. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  88. package/src/rc1-v5-campaign.mjs +797 -797
  89. package/src/rc1-v5-transform.mjs +421 -421
  90. package/src/rc1-v6-artifact-set.mjs +807 -807
  91. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  92. package/src/rc1-v6-campaign.mjs +623 -623
  93. package/src/rc1-v6-causal-binding.mjs +473 -473
  94. package/src/rc1-v6-measurement.mjs +313 -313
  95. package/src/rc2-artifact-set.mjs +1584 -1584
  96. package/src/rc2-campaign.mjs +1506 -1506
  97. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  98. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  99. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  100. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  101. package/src/rc3-actual-dogfood.mjs +652 -652
  102. package/src/rc3-dogfood-scaffold.mjs +315 -315
  103. package/src/rc3-scripted-campaign.mjs +1383 -1383
  104. package/src/rc4-stage1-dogfood.mjs +673 -673
  105. package/src/runtime-adapter-registry.mjs +524 -524
  106. package/src/runtime-cli.mjs +4588 -4588
  107. package/src/runtime-contracts.mjs +824 -824
  108. package/src/runtime-control-store.mjs +604 -604
  109. package/src/runtime-controller-protocol.mjs +587 -587
  110. package/src/runtime-decision-verifier.mjs +701 -701
  111. package/src/runtime-diff-observer.mjs +361 -361
  112. package/src/runtime-direct-os-observer.mjs +301 -301
  113. package/src/runtime-driver-state.mjs +166 -166
  114. package/src/runtime-engine.mjs +779 -779
  115. package/src/runtime-errors.mjs +356 -356
  116. package/src/runtime-event-store.mjs +189 -189
  117. package/src/runtime-front-end.mjs +925 -925
  118. package/src/runtime-gate-store.mjs +481 -481
  119. package/src/runtime-hold-recompile.mjs +916 -916
  120. package/src/runtime-io-sentinel.mjs +391 -391
  121. package/src/runtime-lifecycle-lock.mjs +294 -294
  122. package/src/runtime-managed-supervisor.mjs +1490 -1490
  123. package/src/runtime-multi-epoch-store.mjs +838 -838
  124. package/src/runtime-projection.mjs +269 -269
  125. package/src/runtime-pull-intake.mjs +1192 -1192
  126. package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
  127. package/src/runtime-scripted-executor.mjs +163 -163
  128. package/src/runtime-scripted-worktree.mjs +104 -104
  129. package/src/runtime-seam-resolve.mjs +428 -428
  130. package/src/runtime-seam-treatment.mjs +173 -173
  131. package/src/runtime-socket-owner.mjs +125 -125
  132. package/src/runtime-work-order-contracts.mjs +91 -91
  133. package/src/runtime-work-order-controller.mjs +1171 -1171
  134. package/src/runtime-worktree-executor.mjs +199 -199
  135. package/src/schedulability-compiler-v2.mjs +303 -303
  136. package/src/schedulability-verifier-v2.mjs +317 -317
  137. package/src/seam-apply.mjs +549 -549
  138. package/src/seam-commit-shared.mjs +22 -22
  139. package/src/seam-commit-transform.mjs +81 -81
  140. package/src/seam-commit.mjs +18 -18
  141. package/src/seam-cost.mjs +322 -322
  142. package/src/seam-derivation.mjs +188 -188
  143. package/src/seam-gate.mjs +146 -146
  144. package/src/seam-proposal-contracts.mjs +446 -446
  145. package/src/seam-proposal-queries.mjs +521 -521
  146. package/src/seam-proposal.mjs +2011 -2011
  147. package/src/seam-ref.mjs +33 -33
  148. package/src/seam-rewrite.mjs +286 -286
  149. package/src/seam-transform.mjs +554 -554
  150. package/src/seam-verification.mjs +260 -260
  151. package/src/sensor-adapter.mjs +432 -432
  152. package/src/sensor-cli.mjs +139 -139
  153. package/src/sensor-diff.mjs +661 -661
  154. package/src/sensor-node-runtime.mjs +53 -53
  155. package/src/sensor-runtime.mjs +52 -52
  156. package/src/timestamp-contract.mjs +8 -8
  157. package/src/todo-audit-pending.mjs +91 -91
  158. package/src/todo-chain.mjs +178 -178
  159. package/src/todo-cli.mjs +8 -8
  160. package/src/todo-contracts.mjs +728 -728
  161. package/src/todo-dashboard-registry.mjs +573 -573
  162. package/src/todo-dispatch-shape.mjs +190 -190
  163. package/src/todo-gantt-html-independence.mjs +239 -239
  164. package/src/todo-gantt-html-shared.mjs +226 -226
  165. package/src/todo-gantt-html-style.mjs +131 -131
  166. package/src/todo-gantt-html.mjs +248 -248
  167. package/src/todo-gantt-layout.mjs +974 -974
  168. package/src/todo-gantt-live.mjs +361 -361
  169. package/src/todo-gantt-nested.mjs +263 -263
  170. package/src/todo-gantt-presentation.mjs +217 -217
  171. package/src/todo-gantt-scope.mjs +123 -123
  172. package/src/todo-gantt-svg.mjs +353 -353
  173. package/src/todo-independence-contracts.mjs +595 -595
  174. package/src/todo-independence-guidance.mjs +322 -322
  175. package/src/todo-independence.mjs +640 -640
  176. package/src/todo-markdown-renderer.mjs +260 -260
  177. package/src/todo-migration.mjs +448 -448
  178. package/src/todo-narrative-anchor.mjs +130 -130
  179. package/src/todo-note-store.mjs +629 -629
  180. package/src/todo-parallel-candidates.mjs +114 -114
  181. package/src/todo-revision.mjs +995 -995
  182. package/src/todo-split.mjs +472 -472
  183. package/src/todo-status.mjs +690 -690
  184. package/src/todo-store-git-transaction.mjs +418 -418
  185. package/src/todo-store.mjs +42 -21
  186. package/src/treatment-compiler.mjs +728 -728
  187. package/src/treatment-runner.mjs +656 -656
  188. 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
+ }