@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,391 +1,391 @@
1
- /**
2
- * 実行時競合の早期警報(ADR 0143)。
3
- *
4
- * 競合はこれまで、checkpointを撮った瞬間にしか見つからなかった。checkpointに周期は無く、
5
- * 実質workerが完了するまで誰も気づかない。holdで捨てる作業量の正体はこの窓である。
6
- *
7
- * **警報はfindingではない。** findingの契約はcheckpoint digestを必須にしており、それは
8
- * findingが「事後に再読して再導出できる主張」であることを担保している。fs eventは取りこぼすし
9
- * (FSEventsのcoalesce、inotifyのキュー溢れ)、事後再読もできない。よってここが出すのは
10
- * 「早くcheckpointを撮って確かめろ」という引き金だけであり、判定の正本はcheckpointのままである。
11
- *
12
- * この非対称が安全性の根拠になる。警報は**何かを抑制することが無く、早める方向にしか働かない**。
13
- * 取りこぼしても、今日と同じタイミング(完了時・hold時)で必ず捕まる——保証は一切緩まない。
14
- *
15
- * 判定述語はcheckpoint findingと同一(`coveredBy`を共有する)。書き込みイベントのpathから
16
- * worktree rootを剥がせばrepo相対pathになり、誰がやったかはrootが決める。プロセス帰属は要らない。
17
- *
18
- * **ただしそれはworktreeとTODOが1対1の時だけ成り立つ。** 帰属をrootだけに預けているので、
19
- * 複数TODOが同じrootを共有する構成では書き手を特定できない。そこでは監視を張らない
20
- * (`syncSentinelWatches`)——見えないものを見えるふりにしない。
21
- */
22
-
23
- import { watch } from 'node:fs';
24
- import { lstat } from 'node:fs/promises';
25
- import path from 'node:path';
26
-
27
- import { selfDigest } from './runtime-contracts.mjs';
28
- import { coveredBy } from './runtime-diff-observer.mjs';
29
-
30
- /** 警報の種別。findingのkindとは別空間にする——findingへ昇格するのはprobeを通った後だけである。 */
31
- export const IO_WARNING_KINDS = Object.freeze(['io_overlap_warning', 'io_undeclared_write_warning']);
32
-
33
- /**
34
- * 監視から外すrepo相対prefix。
35
- *
36
- * `.git`と`.lattice`は道具自身の書き込みで、作業の成果ではない。`node_modules`は隔離実行が
37
- * 共有mountとして張る場所であり(`seam-apply.mjs`と同じ規律)、worktreeを跨いで同じ絶対pathを
38
- * 指しうるので、pathの一致を競合と読むと必ず誤る。
39
- */
40
- export const DEFAULT_IO_EXCLUDES = Object.freeze(['.git/', '.lattice/', 'node_modules/']);
41
-
42
- const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
43
-
44
- function plainRecord(value) {
45
- return value !== null && typeof value === 'object' && !Array.isArray(value);
46
- }
47
-
48
- /** worktree rootを剥がしてrepo相対pathにする。rootの外を指すものはnull。 */
49
- export function relativeToRoot(root, absolutePath) {
50
- if (typeof root !== 'string' || typeof absolutePath !== 'string') return null;
51
- const relative = path.relative(root, absolutePath);
52
- if (relative.length === 0) return null;
53
- if (path.isAbsolute(relative) || relative.split(path.sep).includes('..')) return null;
54
- return relative.split(path.sep).join('/');
55
- }
56
-
57
- /** 監視対象外か。prefix一致で見る。 */
58
- export function isExcludedPath(relativePath, excludes = DEFAULT_IO_EXCLUDES) {
59
- return excludes.some((prefix) => relativePath === prefix.replace(/\/$/u, '')
60
- || relativePath.startsWith(prefix));
61
- }
62
-
63
- /**
64
- * 1件の書き込み観測を警報へ分類する。純関数。
65
- *
66
- * checkpoint findingの2述語をそのまま1 pathへ適用する:
67
- * - 他のrunning TODOの宣言scopeに入るpathへ書いた → `io_overlap_warning`
68
- * - 自分の宣言scopeの外へ書いた → `io_undeclared_write_warning`
69
- *
70
- * @returns {{warnings: Array<{kind: string, todo_ids: string[], path: string}>}}
71
- */
72
- export function classifyIoObservation(options = {}) {
73
- const { todoId, relativePath, packets, runningTodoIds } = options;
74
- if (typeof todoId !== 'string' || typeof relativePath !== 'string'
75
- || !plainRecord(packets) || !Array.isArray(runningTodoIds)) {
76
- throw new TypeError('classifyIoObservation optionsが不正');
77
- }
78
- const packet = packets[todoId];
79
- // 宣言が無いTODOの観測は判定できない。分からないものを「競合なし」へ丸めない——
80
- // ただし警報は正本ではないので、ここでrunを止めることもしない。
81
- if (!plainRecord(packet) || !plainRecord(packet.scope) || !Array.isArray(packet.scope.writes)) {
82
- return { warnings: [] };
83
- }
84
-
85
- const warnings = [];
86
- if (!coveredBy(packet.scope.writes, relativePath)) {
87
- warnings.push({ kind: 'io_undeclared_write_warning', todo_ids: [todoId], path: relativePath });
88
- }
89
- for (const otherId of [...runningTodoIds].sort(compareText)) {
90
- if (otherId === todoId) continue;
91
- const other = packets[otherId];
92
- if (!plainRecord(other) || !plainRecord(other.scope) || !Array.isArray(other.scope.writes)) continue;
93
- if (coveredBy(other.scope.writes, relativePath)) {
94
- warnings.push({
95
- kind: 'io_overlap_warning',
96
- todo_ids: [todoId, otherId].sort(compareText),
97
- path: relativePath,
98
- });
99
- }
100
- }
101
- return { warnings };
102
- }
103
-
104
- /** 同じ事実を何度も報告しない。1 epochで`(kind, todo集合, path)`ごとに1回に畳む。 */
105
- function warningKey(warning) {
106
- return `${warning.kind}\0${warning.todo_ids.join(',')}\0${warning.path}`;
107
- }
108
-
109
- /**
110
- * running bindingのworktreeを監視し、警報を`onWarning`へ渡す。
111
- *
112
- * `fs.watch(root, {recursive: true})`だけを使う。Node 22の標準機能で、macOSとLinuxの双方で
113
- * 動き、新しいruntime依存を持ち込まない。取りこぼしは仕様である——正本はcheckpointであり、
114
- * ここは早めるためだけに在る。
115
- *
116
- * @param {object} options
117
- * @param {Function} options.onWarning 警報1件ごとに呼ばれる。非同期でよい。
118
- * @param {Function} [options.watchFactory] test用の差し替え口。既定は`fs.watch`。
119
- */
120
- export function createIoSentinel(options = {}) {
121
- const {
122
- packets = {}, onWarning, excludes = DEFAULT_IO_EXCLUDES, watchFactory = watch,
123
- } = options;
124
- if (typeof onWarning !== 'function') throw new TypeError('onWarningが不正');
125
-
126
- /** todo_id -> { root, watcher } */
127
- const watched = new Map();
128
- const reported = new Set();
129
- let closed = false;
130
-
131
- const deliver = async (todoId, relativePath, absolutePath) => {
132
- if (closed) return;
133
- // 実測(macOS)では、監視callbackはdirectoryイベントと、監視対象自身の名前を持つ
134
- // 実在しないentryまで配ってくる。どちらもcheckpoint diffのentryにはならないので、
135
- // そのまま警報にすると「警報は出たがcheckpointでは競合にならない」ずれが生まれる。
136
- // 判定述語をcheckpointと揃えるために、**いま実在する通常file**だけを観測として扱う。
137
- //
138
- // 削除は早期警報の対象から外れる。checkpointは削除をentryとして持つので取り逃しでは
139
- // なく、早く気づけないだけである——警報は早めるためだけに在るという原則どおり。
140
- try {
141
- const stat = await lstat(absolutePath);
142
- if (!stat.isFile()) return;
143
- } catch {
144
- return;
145
- }
146
- const { warnings } = classifyIoObservation({
147
- todoId, relativePath, packets, runningTodoIds: [...watched.keys()],
148
- });
149
- for (const warning of warnings) {
150
- const key = warningKey(warning);
151
- if (reported.has(key)) continue;
152
- reported.add(key);
153
- await onWarning(structuredClone(warning));
154
- }
155
- };
156
-
157
- return {
158
- /** 監視を開始する。既に同じtodoを見ているなら張り替える。 */
159
- watchBinding({ todoId, worktreePath }) {
160
- if (closed) return false;
161
- if (typeof todoId !== 'string' || typeof worktreePath !== 'string') return false;
162
- this.unwatchBinding(todoId);
163
- let watcher;
164
- try {
165
- watcher = watchFactory(worktreePath, { recursive: true }, (_event, filename) => {
166
- if (filename === null || filename === undefined) return;
167
- const absolute = path.resolve(worktreePath, String(filename));
168
- const relative = relativeToRoot(worktreePath, absolute);
169
- if (relative === null || isExcludedPath(relative, excludes)) return;
170
- // 監視callbackは同期契約なので、配送の失敗をここで投げない。
171
- // 警報が落ちてもcheckpointが正本なので、runの判定は壊れない。
172
- void Promise.resolve(deliver(todoId, relative, absolute)).catch(() => {});
173
- });
174
- } catch {
175
- // 監視を張れない環境(platform制約、権限、root不在)でrunを止めない。
176
- return false;
177
- }
178
- if (typeof watcher?.on === 'function') watcher.on('error', () => {});
179
- watched.set(todoId, { root: worktreePath, watcher });
180
- return true;
181
- },
182
-
183
- unwatchBinding(todoId) {
184
- const entry = watched.get(todoId);
185
- if (entry === undefined) return false;
186
- try { entry.watcher.close(); } catch { /* 既に閉じている */ }
187
- watched.delete(todoId);
188
- return true;
189
- },
190
-
191
- /** epochを跨いだら減衰の記憶を捨てる。新しい版では同じpathでも改めて報告する。 */
192
- resetEpoch() {
193
- reported.clear();
194
- },
195
-
196
- watchedTodoIds() {
197
- return [...watched.keys()].sort(compareText);
198
- },
199
-
200
- close() {
201
- closed = true;
202
- for (const todoId of [...watched.keys()]) this.unwatchBinding(todoId);
203
- reported.clear();
204
- },
205
- };
206
- }
207
-
208
- /**
209
- * running中で、かつ**書き手を特定できる**TODOだけを監視するようsentinelを合わせる(ADR 0143)。
210
- *
211
- * 監視rootは`executor_dispatched`の`direct_os_observation_binding.worktree_path`から取る。
212
- * これがTODO→絶対pathの唯一の耐久carrierである。
213
- *
214
- * **rootを共有しているTODOは監視しない。** sentinelの帰属はrootだけで決まり、プロセス帰属を
215
- * 持たない。同じrootで2つ以上が走っている構成では、1件の書き込みが両方のwatcherへ配られ、
216
- * どちらが書いたか観測から言えない——それを警報にすると、無実のTODOへ「他人のscopeへ書いた」
217
- * と主張することになる。管理daemonのscripted構成が実際にこれで、全TODOが同じrepo rootを指す。
218
- *
219
- * 見えないものを見えるふりにしない。共有rootでは早期警報が成立しないというだけであり、
220
- * 競合の判定は従来どおりcheckpointが完全に担う——保証は1つも減らない。
221
- *
222
- * @param {object} options
223
- * @param {object|null} options.sentinel `createRunSentinel`の戻り値
224
- * @param {string[]} options.runningTodoIds いまrunningのTODO
225
- * @param {Function} options.rootOf todo_id -> worktree root(未束縛はundefined)
226
- */
227
- export function syncSentinelWatches({ sentinel, runningTodoIds, rootOf } = {}) {
228
- if (sentinel === null || sentinel === undefined) return;
229
- if (!Array.isArray(runningTodoIds) || typeof rootOf !== 'function') {
230
- throw new TypeError('syncSentinelWatches optionsが不正');
231
- }
232
- const occupants = new Map();
233
- for (const todoId of runningTodoIds) {
234
- const root = rootOf(todoId);
235
- if (typeof root !== 'string' || root.length === 0) continue;
236
- occupants.set(root, (occupants.get(root) ?? 0) + 1);
237
- }
238
- const attributable = runningTodoIds.filter((todoId) => occupants.get(rootOf(todoId)) === 1);
239
- const watched = new Set(sentinel.watchedTodoIds());
240
- for (const todoId of watched) {
241
- if (!attributable.includes(todoId)) sentinel.unwatchBinding(todoId);
242
- }
243
- for (const todoId of attributable) {
244
- // 張り替えは監視を一度落とすので、既に見ているものへは触らない。
245
- if (watched.has(todoId)) continue;
246
- sentinel.watchBinding({ todoId, worktreePath: rootOf(todoId) });
247
- }
248
- }
249
-
250
- /** `LATTICE_IO_SENTINEL`の解釈。既定は警報を出す。`off`で完全に無効。 */
251
- export function ioSentinelMode(env = process.env) {
252
- const raw = String(env.LATTICE_IO_SENTINEL ?? '').trim().toLowerCase();
253
- return ['off', 'warn'].includes(raw) ? raw : 'warn';
254
- }
255
-
256
- /**
257
- * run用のsentinelを作る。無効なら`null`を返す——呼び出し側は分岐を1つ持つだけでよい。
258
- *
259
- * 監視を張れない環境でrunを止めないのと同じ理由で、ここで例外を投げない。sentinelは
260
- * 速さのための付加物であり、これが無くてもrunの判定は今までどおり成立する。
261
- */
262
- export function createRunSentinel({ packets, onWarning, env = process.env } = {}) {
263
- if (ioSentinelMode(env) === 'off') return null;
264
- return createIoSentinel({ packets, onWarning });
265
- }
266
-
267
- /**
268
- * 警報が実在の重なりだったかをcheckpointで確かめる(ADR 0143の二段目)。
269
- *
270
- * 警報だけで止めると、書いて消したtempでも全workerを止めてしまう。かといって警報を
271
- * findingへ昇格させることもできない——findingは事後に再読して再導出できる主張でなければ
272
- * ならず、fs eventはそれを満たさない。
273
- *
274
- * よって間に**probe**を挟む。関与worktreeを無停止でcheckpointし、当該pathがdiffに
275
- * 残っていれば実在、消えていればtransientとする。probeが撮ったcheckpointはgitから読んだ
276
- * 本物のdiffなので、そのままfindingの証拠になる——契約を1つも緩めずに済む。
277
- *
278
- * @param {object} options
279
- * @param {object} options.warning `classifyIoObservation`が返した警報
280
- * @param {object} options.checkpointsByTodo todo_id -> `captureWorktreeDiff`の戻り値
281
- * @returns {{outcome: 'observed'|'transient', writers: string[]}}
282
- */
283
- export function probeIoWarning({ warning, checkpointsByTodo } = {}) {
284
- if (!plainRecord(warning) || typeof warning.path !== 'string'
285
- || !Array.isArray(warning.todo_ids) || !plainRecord(checkpointsByTodo)) {
286
- throw new TypeError('probeIoWarning optionsが不正');
287
- }
288
- const writers = [];
289
- for (const todoId of [...warning.todo_ids].sort(compareText)) {
290
- const entries = checkpointsByTodo[todoId]?.diff?.entries;
291
- if (!Array.isArray(entries)) continue;
292
- if (entries.some((entry) => entry?.path === warning.path)) writers.push(todoId);
293
- }
294
- // 重なりを主張する警報は、当該pathが**実際に変更として残っている**ことを要件にする。
295
- // scope警報は自分1人の話なので、自分のdiffに残っていれば実在である。
296
- const required = warning.kind === 'io_overlap_warning' ? 2 : 1;
297
- return { outcome: writers.length >= required ? 'observed' : 'transient', writers };
298
- }
299
-
300
- /**
301
- * 警報kind → finding kind。**重なりだけがhold経路へ乗る。**
302
- *
303
- * 宣言境界は計画時の**予測**であって、workerを閉じ込める制約ではない。範囲内へ無理に
304
- * 押し込めるとworkerの自由度が落ち、成果の品質が下がる。だから自由に書かせ、**実際の足跡が
305
- * 他の走行中TODOとぶつかった時にだけ**止めて処置する——請求項7(片方を停止し他方を確定して
306
- * 再開する)と請求項8(限定的な変換を施して双方再開する)はそのための構成である。
307
- *
308
- * よって単独のscope警報——誰の領分とも重なっていない宣言外の書き込み——はhold経路へ運ばない。
309
- * それは競合ではなく、**予測が実態より狭かったという情報**であり、止める理由が無い。記録は
310
- * 残る(`io_warning_observed`)ので、再計画の材料としては失われない。
311
- *
312
- * 止めるべきでないものを止めると、処置の当てようが無い停止が生まれる。scope違反に処置が
313
- * 無いのは欠落ではなく、処置すべき事象ではないことの現れである。
314
- */
315
- const WARNING_FINDING_KIND = Object.freeze({
316
- io_overlap_warning: 'observed_write_conflict',
317
- });
318
-
319
- /**
320
- * findingを縛るcheckpointの持ち主を選ぶ。
321
- *
322
- * scope警報は自分1人の話なので当人。重なりは**他人の宣言scopeへ書いた側**——producerの
323
- * 述語がそう定義されているからで、ここで別の選び方をすると再導出が一致しない。
324
- * 双方が同じpathを宣言している場合は両方が資格を持つので、昇順で決めて揺らさない。
325
- */
326
- function selectEscalationAnchor({ warning, writers, packets }) {
327
- const wrote = new Set(writers);
328
- const qualified = [...warning.todo_ids].sort(compareText).filter((todoId) => {
329
- if (!wrote.has(todoId)) return false;
330
- if (warning.kind === 'io_undeclared_write_warning') return true;
331
- return warning.todo_ids.some((otherId) => {
332
- if (otherId === todoId) return false;
333
- const other = packets[otherId];
334
- return plainRecord(other) && plainRecord(other.scope) && Array.isArray(other.scope.writes)
335
- && coveredBy(other.scope.writes, warning.path);
336
- });
337
- });
338
- return qualified[0] ?? null;
339
- }
340
-
341
- /**
342
- * probeを通った警報を、既存のhold経路が受け取れるfinding candidateへ写す(ADR 0143の三段目)。
343
- *
344
- * **新しい判定はここに1つも無い。** 出すのはcandidate——つまり「主張」だけであり、それを
345
- * findingへ昇格させてよいかは既存の`finding_record`が再導出して決める。だから早期警報が
346
- * 短くするのは気づくまでの時間だけで、通す関門は1つも減らない。
347
- *
348
- * findingは1つのcheckpointへ縛られるので、**anchorの選び方が効く**。`detectCheckpointFindings`は
349
- * anchorのdiffと「他TODOの宣言write」からしか重なりを導かないので、anchorは
350
- * **他人の宣言scopeへ書いた側(offender)**でなければならない。警報自身は`todo_ids`を
351
- * 昇順一意で持つ設計上どちらがofferedかを覚えていないので、ここで宣言から選び直す。
352
- * 選び間違えるとproducerが同じfindingを再導出できず、正しい警報が形式の都合で落ちる。
353
- *
354
- * @param {object} options
355
- * @param {object} options.warning `classifyIoObservation`が返した警報
356
- * @param {object} options.probe `probeIoWarning`が返した判定
357
- * @param {object} options.checkpointsByTodo todo_id -> `captureWorktreeDiff`の戻り値
358
- * @param {object} options.packets todo_id -> executor packet(宣言scopeの出所)
359
- * @returns {null|{anchor_todo_id: string, checkpoint_digest: string, writers: string[], candidate: object}}
360
- */
361
- export function buildIoEscalation({ warning, probe, checkpointsByTodo, packets } = {}) {
362
- if (!plainRecord(warning) || !plainRecord(probe) || !plainRecord(checkpointsByTodo)
363
- || !plainRecord(packets) || !Array.isArray(probe.writers)) {
364
- throw new TypeError('buildIoEscalation optionsが不正');
365
- }
366
- if (probe.outcome !== 'observed') return null;
367
- const proposedKind = WARNING_FINDING_KIND[warning.kind];
368
- if (proposedKind === undefined) return null;
369
- const anchorTodoId = selectEscalationAnchor({ warning, writers: probe.writers, packets });
370
- if (anchorTodoId === null) return null;
371
- const checkpoint = checkpointsByTodo[anchorTodoId];
372
- if (!plainRecord(checkpoint) || !/^[0-9a-f]{64}$/u.test(checkpoint.checkpoint_digest ?? '')) {
373
- return null;
374
- }
375
- const candidate = {
376
- schema: 'lattice.runtime_finding_candidate.v1',
377
- proposed_kind: proposedKind,
378
- todo_ids: [...warning.todo_ids].sort(compareText),
379
- path: warning.path,
380
- resource_id: null,
381
- evidence_digests: [checkpoint.checkpoint_digest],
382
- candidate_digest: '',
383
- };
384
- candidate.candidate_digest = selfDigest(candidate, 'candidate_digest');
385
- return {
386
- anchor_todo_id: anchorTodoId,
387
- checkpoint_digest: checkpoint.checkpoint_digest,
388
- writers: [...probe.writers].sort(compareText),
389
- candidate,
390
- };
391
- }
1
+ /**
2
+ * 実行時競合の早期警報(ADR 0143)。
3
+ *
4
+ * 競合はこれまで、checkpointを撮った瞬間にしか見つからなかった。checkpointに周期は無く、
5
+ * 実質workerが完了するまで誰も気づかない。holdで捨てる作業量の正体はこの窓である。
6
+ *
7
+ * **警報はfindingではない。** findingの契約はcheckpoint digestを必須にしており、それは
8
+ * findingが「事後に再読して再導出できる主張」であることを担保している。fs eventは取りこぼすし
9
+ * (FSEventsのcoalesce、inotifyのキュー溢れ)、事後再読もできない。よってここが出すのは
10
+ * 「早くcheckpointを撮って確かめろ」という引き金だけであり、判定の正本はcheckpointのままである。
11
+ *
12
+ * この非対称が安全性の根拠になる。警報は**何かを抑制することが無く、早める方向にしか働かない**。
13
+ * 取りこぼしても、今日と同じタイミング(完了時・hold時)で必ず捕まる——保証は一切緩まない。
14
+ *
15
+ * 判定述語はcheckpoint findingと同一(`coveredBy`を共有する)。書き込みイベントのpathから
16
+ * worktree rootを剥がせばrepo相対pathになり、誰がやったかはrootが決める。プロセス帰属は要らない。
17
+ *
18
+ * **ただしそれはworktreeとTODOが1対1の時だけ成り立つ。** 帰属をrootだけに預けているので、
19
+ * 複数TODOが同じrootを共有する構成では書き手を特定できない。そこでは監視を張らない
20
+ * (`syncSentinelWatches`)——見えないものを見えるふりにしない。
21
+ */
22
+
23
+ import { watch } from 'node:fs';
24
+ import { lstat } from 'node:fs/promises';
25
+ import path from 'node:path';
26
+
27
+ import { selfDigest } from './runtime-contracts.mjs';
28
+ import { coveredBy } from './runtime-diff-observer.mjs';
29
+
30
+ /** 警報の種別。findingのkindとは別空間にする——findingへ昇格するのはprobeを通った後だけである。 */
31
+ export const IO_WARNING_KINDS = Object.freeze(['io_overlap_warning', 'io_undeclared_write_warning']);
32
+
33
+ /**
34
+ * 監視から外すrepo相対prefix。
35
+ *
36
+ * `.git`と`.lattice`は道具自身の書き込みで、作業の成果ではない。`node_modules`は隔離実行が
37
+ * 共有mountとして張る場所であり(`seam-apply.mjs`と同じ規律)、worktreeを跨いで同じ絶対pathを
38
+ * 指しうるので、pathの一致を競合と読むと必ず誤る。
39
+ */
40
+ export const DEFAULT_IO_EXCLUDES = Object.freeze(['.git/', '.lattice/', 'node_modules/']);
41
+
42
+ const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
43
+
44
+ function plainRecord(value) {
45
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
46
+ }
47
+
48
+ /** worktree rootを剥がしてrepo相対pathにする。rootの外を指すものはnull。 */
49
+ export function relativeToRoot(root, absolutePath) {
50
+ if (typeof root !== 'string' || typeof absolutePath !== 'string') return null;
51
+ const relative = path.relative(root, absolutePath);
52
+ if (relative.length === 0) return null;
53
+ if (path.isAbsolute(relative) || relative.split(path.sep).includes('..')) return null;
54
+ return relative.split(path.sep).join('/');
55
+ }
56
+
57
+ /** 監視対象外か。prefix一致で見る。 */
58
+ export function isExcludedPath(relativePath, excludes = DEFAULT_IO_EXCLUDES) {
59
+ return excludes.some((prefix) => relativePath === prefix.replace(/\/$/u, '')
60
+ || relativePath.startsWith(prefix));
61
+ }
62
+
63
+ /**
64
+ * 1件の書き込み観測を警報へ分類する。純関数。
65
+ *
66
+ * checkpoint findingの2述語をそのまま1 pathへ適用する:
67
+ * - 他のrunning TODOの宣言scopeに入るpathへ書いた → `io_overlap_warning`
68
+ * - 自分の宣言scopeの外へ書いた → `io_undeclared_write_warning`
69
+ *
70
+ * @returns {{warnings: Array<{kind: string, todo_ids: string[], path: string}>}}
71
+ */
72
+ export function classifyIoObservation(options = {}) {
73
+ const { todoId, relativePath, packets, runningTodoIds } = options;
74
+ if (typeof todoId !== 'string' || typeof relativePath !== 'string'
75
+ || !plainRecord(packets) || !Array.isArray(runningTodoIds)) {
76
+ throw new TypeError('classifyIoObservation optionsが不正');
77
+ }
78
+ const packet = packets[todoId];
79
+ // 宣言が無いTODOの観測は判定できない。分からないものを「競合なし」へ丸めない——
80
+ // ただし警報は正本ではないので、ここでrunを止めることもしない。
81
+ if (!plainRecord(packet) || !plainRecord(packet.scope) || !Array.isArray(packet.scope.writes)) {
82
+ return { warnings: [] };
83
+ }
84
+
85
+ const warnings = [];
86
+ if (!coveredBy(packet.scope.writes, relativePath)) {
87
+ warnings.push({ kind: 'io_undeclared_write_warning', todo_ids: [todoId], path: relativePath });
88
+ }
89
+ for (const otherId of [...runningTodoIds].sort(compareText)) {
90
+ if (otherId === todoId) continue;
91
+ const other = packets[otherId];
92
+ if (!plainRecord(other) || !plainRecord(other.scope) || !Array.isArray(other.scope.writes)) continue;
93
+ if (coveredBy(other.scope.writes, relativePath)) {
94
+ warnings.push({
95
+ kind: 'io_overlap_warning',
96
+ todo_ids: [todoId, otherId].sort(compareText),
97
+ path: relativePath,
98
+ });
99
+ }
100
+ }
101
+ return { warnings };
102
+ }
103
+
104
+ /** 同じ事実を何度も報告しない。1 epochで`(kind, todo集合, path)`ごとに1回に畳む。 */
105
+ function warningKey(warning) {
106
+ return `${warning.kind}\0${warning.todo_ids.join(',')}\0${warning.path}`;
107
+ }
108
+
109
+ /**
110
+ * running bindingのworktreeを監視し、警報を`onWarning`へ渡す。
111
+ *
112
+ * `fs.watch(root, {recursive: true})`だけを使う。Node 22の標準機能で、macOSとLinuxの双方で
113
+ * 動き、新しいruntime依存を持ち込まない。取りこぼしは仕様である——正本はcheckpointであり、
114
+ * ここは早めるためだけに在る。
115
+ *
116
+ * @param {object} options
117
+ * @param {Function} options.onWarning 警報1件ごとに呼ばれる。非同期でよい。
118
+ * @param {Function} [options.watchFactory] test用の差し替え口。既定は`fs.watch`。
119
+ */
120
+ export function createIoSentinel(options = {}) {
121
+ const {
122
+ packets = {}, onWarning, excludes = DEFAULT_IO_EXCLUDES, watchFactory = watch,
123
+ } = options;
124
+ if (typeof onWarning !== 'function') throw new TypeError('onWarningが不正');
125
+
126
+ /** todo_id -> { root, watcher } */
127
+ const watched = new Map();
128
+ const reported = new Set();
129
+ let closed = false;
130
+
131
+ const deliver = async (todoId, relativePath, absolutePath) => {
132
+ if (closed) return;
133
+ // 実測(macOS)では、監視callbackはdirectoryイベントと、監視対象自身の名前を持つ
134
+ // 実在しないentryまで配ってくる。どちらもcheckpoint diffのentryにはならないので、
135
+ // そのまま警報にすると「警報は出たがcheckpointでは競合にならない」ずれが生まれる。
136
+ // 判定述語をcheckpointと揃えるために、**いま実在する通常file**だけを観測として扱う。
137
+ //
138
+ // 削除は早期警報の対象から外れる。checkpointは削除をentryとして持つので取り逃しでは
139
+ // なく、早く気づけないだけである——警報は早めるためだけに在るという原則どおり。
140
+ try {
141
+ const stat = await lstat(absolutePath);
142
+ if (!stat.isFile()) return;
143
+ } catch {
144
+ return;
145
+ }
146
+ const { warnings } = classifyIoObservation({
147
+ todoId, relativePath, packets, runningTodoIds: [...watched.keys()],
148
+ });
149
+ for (const warning of warnings) {
150
+ const key = warningKey(warning);
151
+ if (reported.has(key)) continue;
152
+ reported.add(key);
153
+ await onWarning(structuredClone(warning));
154
+ }
155
+ };
156
+
157
+ return {
158
+ /** 監視を開始する。既に同じtodoを見ているなら張り替える。 */
159
+ watchBinding({ todoId, worktreePath }) {
160
+ if (closed) return false;
161
+ if (typeof todoId !== 'string' || typeof worktreePath !== 'string') return false;
162
+ this.unwatchBinding(todoId);
163
+ let watcher;
164
+ try {
165
+ watcher = watchFactory(worktreePath, { recursive: true }, (_event, filename) => {
166
+ if (filename === null || filename === undefined) return;
167
+ const absolute = path.resolve(worktreePath, String(filename));
168
+ const relative = relativeToRoot(worktreePath, absolute);
169
+ if (relative === null || isExcludedPath(relative, excludes)) return;
170
+ // 監視callbackは同期契約なので、配送の失敗をここで投げない。
171
+ // 警報が落ちてもcheckpointが正本なので、runの判定は壊れない。
172
+ void Promise.resolve(deliver(todoId, relative, absolute)).catch(() => {});
173
+ });
174
+ } catch {
175
+ // 監視を張れない環境(platform制約、権限、root不在)でrunを止めない。
176
+ return false;
177
+ }
178
+ if (typeof watcher?.on === 'function') watcher.on('error', () => {});
179
+ watched.set(todoId, { root: worktreePath, watcher });
180
+ return true;
181
+ },
182
+
183
+ unwatchBinding(todoId) {
184
+ const entry = watched.get(todoId);
185
+ if (entry === undefined) return false;
186
+ try { entry.watcher.close(); } catch { /* 既に閉じている */ }
187
+ watched.delete(todoId);
188
+ return true;
189
+ },
190
+
191
+ /** epochを跨いだら減衰の記憶を捨てる。新しい版では同じpathでも改めて報告する。 */
192
+ resetEpoch() {
193
+ reported.clear();
194
+ },
195
+
196
+ watchedTodoIds() {
197
+ return [...watched.keys()].sort(compareText);
198
+ },
199
+
200
+ close() {
201
+ closed = true;
202
+ for (const todoId of [...watched.keys()]) this.unwatchBinding(todoId);
203
+ reported.clear();
204
+ },
205
+ };
206
+ }
207
+
208
+ /**
209
+ * running中で、かつ**書き手を特定できる**TODOだけを監視するようsentinelを合わせる(ADR 0143)。
210
+ *
211
+ * 監視rootは`executor_dispatched`の`direct_os_observation_binding.worktree_path`から取る。
212
+ * これがTODO→絶対pathの唯一の耐久carrierである。
213
+ *
214
+ * **rootを共有しているTODOは監視しない。** sentinelの帰属はrootだけで決まり、プロセス帰属を
215
+ * 持たない。同じrootで2つ以上が走っている構成では、1件の書き込みが両方のwatcherへ配られ、
216
+ * どちらが書いたか観測から言えない——それを警報にすると、無実のTODOへ「他人のscopeへ書いた」
217
+ * と主張することになる。管理daemonのscripted構成が実際にこれで、全TODOが同じrepo rootを指す。
218
+ *
219
+ * 見えないものを見えるふりにしない。共有rootでは早期警報が成立しないというだけであり、
220
+ * 競合の判定は従来どおりcheckpointが完全に担う——保証は1つも減らない。
221
+ *
222
+ * @param {object} options
223
+ * @param {object|null} options.sentinel `createRunSentinel`の戻り値
224
+ * @param {string[]} options.runningTodoIds いまrunningのTODO
225
+ * @param {Function} options.rootOf todo_id -> worktree root(未束縛はundefined)
226
+ */
227
+ export function syncSentinelWatches({ sentinel, runningTodoIds, rootOf } = {}) {
228
+ if (sentinel === null || sentinel === undefined) return;
229
+ if (!Array.isArray(runningTodoIds) || typeof rootOf !== 'function') {
230
+ throw new TypeError('syncSentinelWatches optionsが不正');
231
+ }
232
+ const occupants = new Map();
233
+ for (const todoId of runningTodoIds) {
234
+ const root = rootOf(todoId);
235
+ if (typeof root !== 'string' || root.length === 0) continue;
236
+ occupants.set(root, (occupants.get(root) ?? 0) + 1);
237
+ }
238
+ const attributable = runningTodoIds.filter((todoId) => occupants.get(rootOf(todoId)) === 1);
239
+ const watched = new Set(sentinel.watchedTodoIds());
240
+ for (const todoId of watched) {
241
+ if (!attributable.includes(todoId)) sentinel.unwatchBinding(todoId);
242
+ }
243
+ for (const todoId of attributable) {
244
+ // 張り替えは監視を一度落とすので、既に見ているものへは触らない。
245
+ if (watched.has(todoId)) continue;
246
+ sentinel.watchBinding({ todoId, worktreePath: rootOf(todoId) });
247
+ }
248
+ }
249
+
250
+ /** `LATTICE_IO_SENTINEL`の解釈。既定は警報を出す。`off`で完全に無効。 */
251
+ export function ioSentinelMode(env = process.env) {
252
+ const raw = String(env.LATTICE_IO_SENTINEL ?? '').trim().toLowerCase();
253
+ return ['off', 'warn'].includes(raw) ? raw : 'warn';
254
+ }
255
+
256
+ /**
257
+ * run用のsentinelを作る。無効なら`null`を返す——呼び出し側は分岐を1つ持つだけでよい。
258
+ *
259
+ * 監視を張れない環境でrunを止めないのと同じ理由で、ここで例外を投げない。sentinelは
260
+ * 速さのための付加物であり、これが無くてもrunの判定は今までどおり成立する。
261
+ */
262
+ export function createRunSentinel({ packets, onWarning, env = process.env } = {}) {
263
+ if (ioSentinelMode(env) === 'off') return null;
264
+ return createIoSentinel({ packets, onWarning });
265
+ }
266
+
267
+ /**
268
+ * 警報が実在の重なりだったかをcheckpointで確かめる(ADR 0143の二段目)。
269
+ *
270
+ * 警報だけで止めると、書いて消したtempでも全workerを止めてしまう。かといって警報を
271
+ * findingへ昇格させることもできない——findingは事後に再読して再導出できる主張でなければ
272
+ * ならず、fs eventはそれを満たさない。
273
+ *
274
+ * よって間に**probe**を挟む。関与worktreeを無停止でcheckpointし、当該pathがdiffに
275
+ * 残っていれば実在、消えていればtransientとする。probeが撮ったcheckpointはgitから読んだ
276
+ * 本物のdiffなので、そのままfindingの証拠になる——契約を1つも緩めずに済む。
277
+ *
278
+ * @param {object} options
279
+ * @param {object} options.warning `classifyIoObservation`が返した警報
280
+ * @param {object} options.checkpointsByTodo todo_id -> `captureWorktreeDiff`の戻り値
281
+ * @returns {{outcome: 'observed'|'transient', writers: string[]}}
282
+ */
283
+ export function probeIoWarning({ warning, checkpointsByTodo } = {}) {
284
+ if (!plainRecord(warning) || typeof warning.path !== 'string'
285
+ || !Array.isArray(warning.todo_ids) || !plainRecord(checkpointsByTodo)) {
286
+ throw new TypeError('probeIoWarning optionsが不正');
287
+ }
288
+ const writers = [];
289
+ for (const todoId of [...warning.todo_ids].sort(compareText)) {
290
+ const entries = checkpointsByTodo[todoId]?.diff?.entries;
291
+ if (!Array.isArray(entries)) continue;
292
+ if (entries.some((entry) => entry?.path === warning.path)) writers.push(todoId);
293
+ }
294
+ // 重なりを主張する警報は、当該pathが**実際に変更として残っている**ことを要件にする。
295
+ // scope警報は自分1人の話なので、自分のdiffに残っていれば実在である。
296
+ const required = warning.kind === 'io_overlap_warning' ? 2 : 1;
297
+ return { outcome: writers.length >= required ? 'observed' : 'transient', writers };
298
+ }
299
+
300
+ /**
301
+ * 警報kind → finding kind。**重なりだけがhold経路へ乗る。**
302
+ *
303
+ * 宣言境界は計画時の**予測**であって、workerを閉じ込める制約ではない。範囲内へ無理に
304
+ * 押し込めるとworkerの自由度が落ち、成果の品質が下がる。だから自由に書かせ、**実際の足跡が
305
+ * 他の走行中TODOとぶつかった時にだけ**止めて処置する——請求項7(片方を停止し他方を確定して
306
+ * 再開する)と請求項8(限定的な変換を施して双方再開する)はそのための構成である。
307
+ *
308
+ * よって単独のscope警報——誰の領分とも重なっていない宣言外の書き込み——はhold経路へ運ばない。
309
+ * それは競合ではなく、**予測が実態より狭かったという情報**であり、止める理由が無い。記録は
310
+ * 残る(`io_warning_observed`)ので、再計画の材料としては失われない。
311
+ *
312
+ * 止めるべきでないものを止めると、処置の当てようが無い停止が生まれる。scope違反に処置が
313
+ * 無いのは欠落ではなく、処置すべき事象ではないことの現れである。
314
+ */
315
+ const WARNING_FINDING_KIND = Object.freeze({
316
+ io_overlap_warning: 'observed_write_conflict',
317
+ });
318
+
319
+ /**
320
+ * findingを縛るcheckpointの持ち主を選ぶ。
321
+ *
322
+ * scope警報は自分1人の話なので当人。重なりは**他人の宣言scopeへ書いた側**——producerの
323
+ * 述語がそう定義されているからで、ここで別の選び方をすると再導出が一致しない。
324
+ * 双方が同じpathを宣言している場合は両方が資格を持つので、昇順で決めて揺らさない。
325
+ */
326
+ function selectEscalationAnchor({ warning, writers, packets }) {
327
+ const wrote = new Set(writers);
328
+ const qualified = [...warning.todo_ids].sort(compareText).filter((todoId) => {
329
+ if (!wrote.has(todoId)) return false;
330
+ if (warning.kind === 'io_undeclared_write_warning') return true;
331
+ return warning.todo_ids.some((otherId) => {
332
+ if (otherId === todoId) return false;
333
+ const other = packets[otherId];
334
+ return plainRecord(other) && plainRecord(other.scope) && Array.isArray(other.scope.writes)
335
+ && coveredBy(other.scope.writes, warning.path);
336
+ });
337
+ });
338
+ return qualified[0] ?? null;
339
+ }
340
+
341
+ /**
342
+ * probeを通った警報を、既存のhold経路が受け取れるfinding candidateへ写す(ADR 0143の三段目)。
343
+ *
344
+ * **新しい判定はここに1つも無い。** 出すのはcandidate——つまり「主張」だけであり、それを
345
+ * findingへ昇格させてよいかは既存の`finding_record`が再導出して決める。だから早期警報が
346
+ * 短くするのは気づくまでの時間だけで、通す関門は1つも減らない。
347
+ *
348
+ * findingは1つのcheckpointへ縛られるので、**anchorの選び方が効く**。`detectCheckpointFindings`は
349
+ * anchorのdiffと「他TODOの宣言write」からしか重なりを導かないので、anchorは
350
+ * **他人の宣言scopeへ書いた側(offender)**でなければならない。警報自身は`todo_ids`を
351
+ * 昇順一意で持つ設計上どちらがofferedかを覚えていないので、ここで宣言から選び直す。
352
+ * 選び間違えるとproducerが同じfindingを再導出できず、正しい警報が形式の都合で落ちる。
353
+ *
354
+ * @param {object} options
355
+ * @param {object} options.warning `classifyIoObservation`が返した警報
356
+ * @param {object} options.probe `probeIoWarning`が返した判定
357
+ * @param {object} options.checkpointsByTodo todo_id -> `captureWorktreeDiff`の戻り値
358
+ * @param {object} options.packets todo_id -> executor packet(宣言scopeの出所)
359
+ * @returns {null|{anchor_todo_id: string, checkpoint_digest: string, writers: string[], candidate: object}}
360
+ */
361
+ export function buildIoEscalation({ warning, probe, checkpointsByTodo, packets } = {}) {
362
+ if (!plainRecord(warning) || !plainRecord(probe) || !plainRecord(checkpointsByTodo)
363
+ || !plainRecord(packets) || !Array.isArray(probe.writers)) {
364
+ throw new TypeError('buildIoEscalation optionsが不正');
365
+ }
366
+ if (probe.outcome !== 'observed') return null;
367
+ const proposedKind = WARNING_FINDING_KIND[warning.kind];
368
+ if (proposedKind === undefined) return null;
369
+ const anchorTodoId = selectEscalationAnchor({ warning, writers: probe.writers, packets });
370
+ if (anchorTodoId === null) return null;
371
+ const checkpoint = checkpointsByTodo[anchorTodoId];
372
+ if (!plainRecord(checkpoint) || !/^[0-9a-f]{64}$/u.test(checkpoint.checkpoint_digest ?? '')) {
373
+ return null;
374
+ }
375
+ const candidate = {
376
+ schema: 'lattice.runtime_finding_candidate.v1',
377
+ proposed_kind: proposedKind,
378
+ todo_ids: [...warning.todo_ids].sort(compareText),
379
+ path: warning.path,
380
+ resource_id: null,
381
+ evidence_digests: [checkpoint.checkpoint_digest],
382
+ candidate_digest: '',
383
+ };
384
+ candidate.candidate_digest = selfDigest(candidate, 'candidate_digest');
385
+ return {
386
+ anchor_todo_id: anchorTodoId,
387
+ checkpoint_digest: checkpoint.checkpoint_digest,
388
+ writers: [...probe.writers].sort(compareText),
389
+ candidate,
390
+ };
391
+ }