@quolu/lattice 0.52.0 → 0.52.2

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