@quolu/lattice 0.12.34 → 0.13.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.
@@ -0,0 +1,380 @@
1
+ import {
2
+ exactRecord,
3
+ isNonNegativeSafeInteger,
4
+ isStrictTodoTimestamp,
5
+ isTodoDigest,
6
+ isTodoIdentifier,
7
+ todoSelfDigest,
8
+ } from './todo-contracts.mjs';
9
+ import {
10
+ RUN_REQUEST_CLAIM_MODE,
11
+ explainRunRequest,
12
+ selfDigest as runtimeSelfDigest,
13
+ } from './runtime-contracts.mjs';
14
+ import { TODO_INDEPENDENCE_GUIDANCE_CODES } from './todo-independence-guidance.mjs';
15
+
16
+ export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v1';
17
+ export const TODO_INDEPENDENCE_SCHEMA = 'lattice.todo_independence.v2';
18
+ export const TODO_INDEPENDENCE_PROJECTION_SCHEMA = 'lattice.todo_independence_projection.v2';
19
+
20
+ /** boundary compileが一度に扱えるToDo数(runtime front-endのMAX_COLLECTIONと同じ閉じ方)。 */
21
+ export const TODO_INDEPENDENCE_TASK_LIMIT = 256;
22
+ export const TODO_INDEPENDENCE_LIST_LIMIT = 4_096;
23
+ export const TODO_INDEPENDENCE_COVERAGE = Object.freeze([
24
+ 'verified', 'stale', 'superseded', 'missing',
25
+ ]);
26
+
27
+ /** conflictを生んだresourceの種別。切断可能性の導出はこの種別だけを根拠にする。 */
28
+ export const TODO_INDEPENDENCE_CONFLICT_KINDS = Object.freeze([
29
+ 'symbol', 'path', 'state', 'effect',
30
+ ]);
31
+
32
+ /** 切断可能性。code seamで切れるのはsymbol/path起因のconflictだけ(ADR 0128 Decision 2)。 */
33
+ export const TODO_INDEPENDENCE_SEVERABILITY = Object.freeze(['code_seam', 'serial']);
34
+
35
+ /**
36
+ * conflict kindから切断可能性を導く。
37
+ *
38
+ * 共有state/effectはcode seamでは切断できない(RC1 boundary compilerの分類規則と同一)。
39
+ * read×write交差から実体化される`rw-*`はkind=stateなのでserialへ倒れる。seam候補を
40
+ * 見逃す方向にしか外れない保守的な誤りであり、既知の限界として受け入れる。
41
+ */
42
+ export function severabilityOfConflictKind(kind) {
43
+ return ['symbol', 'path'].includes(kind) ? 'code_seam' : 'serial';
44
+ }
45
+
46
+ const GIT_SHA = /^[0-9a-f]{40}$/u;
47
+ const PROBE_BASE_SHA = '0'.repeat(40);
48
+
49
+ export const isGitSha = (value) => typeof value === 'string' && GIT_SHA.test(value);
50
+
51
+ function plain(value) {
52
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
53
+ && Object.getPrototypeOf(value) === Object.prototype;
54
+ }
55
+
56
+ function boundedList(value, validator, limit = TODO_INDEPENDENCE_LIST_LIMIT) {
57
+ return Array.isArray(value) && value.length <= limit && value.every(validator);
58
+ }
59
+
60
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
61
+
62
+ function strictlySorted(values, key = (value) => value) {
63
+ return values.every((value, index) => index === 0 || compareText(key(values[index - 1]), key(value)) < 0);
64
+ }
65
+
66
+ function sorted(values, key = (value) => value) {
67
+ return values.every((value, index) => index === 0 || compareText(key(values[index - 1]), key(value)) <= 0);
68
+ }
69
+
70
+ function boundedText(value, maximumBytes = 4_096) {
71
+ return typeof value === 'string' && value.length > 0
72
+ && Buffer.byteLength(value) <= maximumBytes
73
+ && !/[\u0000-\u001f\u007f]/u.test(value);
74
+ }
75
+
76
+ /**
77
+ * witness setを、同じ宣言を持つ`lattice.run_request.v1`へ写す。
78
+ *
79
+ * manual witnessとsensor query setの判定正本は`explainRunRequest`だけが持つ(ADR 0123)。
80
+ * ここで同じ規則を書き直すと契約が二箇所へ分裂するため、検証もcompileもこの合成を通す。
81
+ * `requestId`はplan identityとwitness digestから導出され、run lifecycleへは登録されない。
82
+ */
83
+ export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId }) {
84
+ const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
85
+ const request = {
86
+ schema: 'lattice.run_request.v1',
87
+ request_id: requestId,
88
+ repo: { base_sha: baseSha, root_kind: 'git' },
89
+ capacity: witnessSet.capacity,
90
+ todos: taskIds.map((taskId) => ({ todo_id: taskId })),
91
+ manual_witness: witnessSet.manual_witness,
92
+ sensor_query_set: witnessSet.sensor_query_set,
93
+ executor_capability: { adapters: ['todo-independence'] },
94
+ claim_mode: RUN_REQUEST_CLAIM_MODE,
95
+ request_digest: '',
96
+ };
97
+ request.request_digest = runtimeSelfDigest(request, 'request_digest');
98
+ return request;
99
+ }
100
+
101
+ /**
102
+ * `lattice.todo_witness_set.v1`を検証し、拒否理由とpathを返す。
103
+ *
104
+ * 自分のshapeだけをここで見て、witness本体はprobe requestへ合成して
105
+ * `explainRunRequest`へ委譲する。probeのbase_shaは検証専用の定数であり永続化しない。
106
+ */
107
+ export function explainTodoWitnessSet(value) {
108
+ const reject = (reason, at = '') => ({ valid: false, reason, path: at });
109
+ try {
110
+ if (!exactRecord(value, [
111
+ 'schema', 'project_id', 'plan_key', 'capacity', 'sensor_query_set',
112
+ 'manual_witness', 'witness_set_digest',
113
+ ])) return reject('unexpected_or_missing_top_level_keys');
114
+ if (value.schema !== TODO_WITNESS_SET_SCHEMA) return reject('schema_mismatch', '/schema');
115
+ if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
116
+ if (!isTodoIdentifier(value.plan_key)) return reject('invalid_identifier', '/plan_key');
117
+ if (!plain(value.manual_witness)) return reject('not_an_object', '/manual_witness');
118
+ const taskIds = Object.keys(value.manual_witness);
119
+ if (taskIds.length < 1 || taskIds.length > TODO_INDEPENDENCE_TASK_LIMIT) {
120
+ return reject('bounded_collection_violation', '/manual_witness');
121
+ }
122
+ if (!taskIds.every(isTodoIdentifier)) return reject('invalid_identifier', '/manual_witness');
123
+ if (value.witness_set_digest !== todoSelfDigest(value, 'witness_set_digest')) {
124
+ return reject('witness_set_digest_mismatch', '/witness_set_digest');
125
+ }
126
+ const probe = synthesizeWitnessRunRequest(value, {
127
+ baseSha: PROBE_BASE_SHA, requestId: 'witness-set-probe',
128
+ });
129
+ const explained = explainRunRequest(probe);
130
+ if (!explained.valid) return reject(explained.reason, explained.path);
131
+ return { valid: true };
132
+ } catch {
133
+ return reject('non_canonical_witness_set_bytes');
134
+ }
135
+ }
136
+
137
+ export function validateTodoWitnessSet(value) {
138
+ return explainTodoWitnessSet(value).valid;
139
+ }
140
+
141
+ function conflictEntry(value) {
142
+ return exactRecord(value, ['task_ids', 'resource_id', 'kind'])
143
+ && Array.isArray(value.task_ids) && value.task_ids.length === 2
144
+ && value.task_ids.every(isTodoIdentifier)
145
+ && compareText(value.task_ids[0], value.task_ids[1]) < 0
146
+ && boundedText(value.resource_id)
147
+ && TODO_INDEPENDENCE_CONFLICT_KINDS.includes(value.kind);
148
+ }
149
+
150
+ /**
151
+ * task別の宣言境界。鮮度判定をartifactとgit diffだけで閉じるために持つ(ADR 0128 Decision 4)。
152
+ * witness setを読み直さないので、参照コストは定数のまま保たれる。
153
+ */
154
+ function taskBoundaryEntry(value) {
155
+ return exactRecord(value, ['task_id', 'paths'])
156
+ && isTodoIdentifier(value.task_id)
157
+ && Array.isArray(value.paths) && value.paths.length <= TODO_INDEPENDENCE_LIST_LIMIT
158
+ && value.paths.every((path) => boundedText(path)) && strictlySorted(value.paths);
159
+ }
160
+
161
+ function precedenceEntry(value) {
162
+ return exactRecord(value, ['from_task_id', 'to_task_id', 'reason'])
163
+ && isTodoIdentifier(value.from_task_id) && isTodoIdentifier(value.to_task_id)
164
+ && value.from_task_id !== value.to_task_id && boundedText(value.reason);
165
+ }
166
+
167
+ function unknownEntry(value) {
168
+ return exactRecord(value, ['task_id', 'kind', 'ref'])
169
+ && isTodoIdentifier(value.task_id) && isTodoIdentifier(value.kind) && boundedText(value.ref);
170
+ }
171
+
172
+ function waveEntry(value) {
173
+ return exactRecord(value, ['task_ids'])
174
+ && Array.isArray(value.task_ids) && value.task_ids.length >= 1
175
+ && value.task_ids.length <= TODO_INDEPENDENCE_TASK_LIMIT
176
+ && value.task_ids.every(isTodoIdentifier) && strictlySorted(value.task_ids);
177
+ }
178
+
179
+ function wavePlan(value, taskIds) {
180
+ if (value === null) return true;
181
+ if (!exactRecord(value, ['waves', 'minimum_feasible_waves'])
182
+ || !boundedList(value.waves, waveEntry, TODO_INDEPENDENCE_TASK_LIMIT)
183
+ || !isNonNegativeSafeInteger(value.minimum_feasible_waves)
184
+ || value.waves.length !== value.minimum_feasible_waves) return false;
185
+ const scheduled = value.waves.flatMap((wave) => wave.task_ids).sort(compareText);
186
+ return scheduled.length === taskIds.length
187
+ && scheduled.every((taskId, index) => taskId === taskIds[index]);
188
+ }
189
+
190
+ /**
191
+ * `lattice.todo_independence.v2`。
192
+ *
193
+ * conflict/precedence/unknownはnormalized boundary graphから採る(ADR 0127 Decision 4)。
194
+ * conflictはresource kindを併せて持ち、切断可能性の導出を投影側に許す(ADR 0128 Decision 1)。
195
+ * `task_boundaries`はtask別の宣言境界で、鮮度のdiff交差判定をartifactだけで閉じるために持つ。
196
+ * `wave_plan`はschedulability compileが`compiled`を返した時だけ持ち、unknownが残る間はnullになる。
197
+ * verdictに現れないペアをverified独立と読めるのは、両taskにunknownが無いときだけである。
198
+ */
199
+ export function validateTodoIndependence(value) {
200
+ try {
201
+ if (!exactRecord(value, [
202
+ 'schema', 'project_id', 'plan_key', 'plan_version', 'topology_digest', 'base_sha',
203
+ 'witness_set_digest', 'compiled_at', 'task_ids', 'task_boundaries', 'conflicts',
204
+ 'precedences', 'unknowns', 'wave_plan', 'outcome', 'result_digest',
205
+ ])) return false;
206
+ if (value.schema !== TODO_INDEPENDENCE_SCHEMA) return false;
207
+ if (!isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
208
+ || !isTodoIdentifier(value.plan_version)) return false;
209
+ if (!isTodoDigest(value.topology_digest) || !isGitSha(value.base_sha)
210
+ || !isTodoDigest(value.witness_set_digest)
211
+ || !isStrictTodoTimestamp(value.compiled_at)) return false;
212
+ if (!Array.isArray(value.task_ids) || value.task_ids.length < 1
213
+ || value.task_ids.length > TODO_INDEPENDENCE_TASK_LIMIT
214
+ || !value.task_ids.every(isTodoIdentifier) || !strictlySorted(value.task_ids)) return false;
215
+ const known = new Set(value.task_ids);
216
+ // 宣言境界はcompile対象taskとちょうど一対一で対応する。欠けたtaskがあると、
217
+ // そのtaskだけ交差判定ができないのに全体はverifiedを名乗れてしまう。
218
+ if (!boundedList(value.task_boundaries, taskBoundaryEntry, TODO_INDEPENDENCE_TASK_LIMIT)
219
+ || value.task_boundaries.length !== value.task_ids.length
220
+ || !strictlySorted(value.task_boundaries, (entry) => entry.task_id)
221
+ || !value.task_boundaries.every((entry, index) => entry.task_id === value.task_ids[index])) {
222
+ return false;
223
+ }
224
+ if (!boundedList(value.conflicts, conflictEntry)
225
+ || !value.conflicts.every((entry) => entry.task_ids.every((taskId) => known.has(taskId)))
226
+ || !strictlySorted(value.conflicts, (entry) => (
227
+ `${entry.task_ids[0]}\0${entry.task_ids[1]}\0${entry.resource_id}`))) return false;
228
+ if (!boundedList(value.precedences, precedenceEntry)
229
+ || !value.precedences.every((entry) => known.has(entry.from_task_id) && known.has(entry.to_task_id))
230
+ || !strictlySorted(value.precedences, (entry) => (
231
+ `${entry.from_task_id}\0${entry.to_task_id}\0${entry.reason}`))) return false;
232
+ if (!boundedList(value.unknowns, unknownEntry)
233
+ || !value.unknowns.every((entry) => known.has(entry.task_id))
234
+ || !sorted(value.unknowns, (entry) => (
235
+ `${entry.task_id}\0${entry.kind}\0${entry.ref}`))) return false;
236
+ if (!['compiled', 'unknown'].includes(value.outcome)) return false;
237
+ // unknownが残る限りwave planは主張しない。compiledならunknownは空でなければならない。
238
+ if (value.outcome === 'compiled' && value.unknowns.length > 0) return false;
239
+ if (value.outcome === 'unknown' && value.wave_plan !== null) return false;
240
+ if (!wavePlan(value.wave_plan, value.task_ids)) return false;
241
+ return isTodoDigest(value.result_digest)
242
+ && value.result_digest === todoSelfDigest(value, 'result_digest');
243
+ } catch {
244
+ return false;
245
+ }
246
+ }
247
+
248
+ function groupEntry(value) {
249
+ return exactRecord(value, ['task_ids'])
250
+ && Array.isArray(value.task_ids) && value.task_ids.length >= 1
251
+ && value.task_ids.length <= TODO_INDEPENDENCE_TASK_LIMIT
252
+ && value.task_ids.every(isTodoIdentifier) && strictlySorted(value.task_ids);
253
+ }
254
+
255
+ function pairKindAndSeverability(value) {
256
+ // conflictはkindを持ちseverabilityがそこから導かれる。precedenceは順序制約であって
257
+ // resource起因ではないためkindを持たず、常にserialになる。
258
+ if (value.type === 'conflict') {
259
+ return TODO_INDEPENDENCE_CONFLICT_KINDS.includes(value.kind)
260
+ && value.severability === severabilityOfConflictKind(value.kind);
261
+ }
262
+ return value.kind === null && value.severability === 'serial';
263
+ }
264
+
265
+ function serializeEntry(value) {
266
+ return exactRecord(value, ['task_ids', 'type', 'detail', 'kind', 'severability'])
267
+ && Array.isArray(value.task_ids) && value.task_ids.length === 2
268
+ && value.task_ids.every(isTodoIdentifier)
269
+ && compareText(value.task_ids[0], value.task_ids[1]) < 0
270
+ && ['conflict', 'precedence'].includes(value.type) && boundedText(value.detail)
271
+ && pairKindAndSeverability(value);
272
+ }
273
+
274
+ /**
275
+ * 着手候補と進行中ToDoの競合。
276
+ *
277
+ * v1は両端がready集合のペアだけを採っており、片端がactiveのペアを黙って捨てていた。
278
+ * 着手する瞬間に最も危ないのはこの組み合わせなので、独立した面として持つ(ADR 0128 Decision 3)。
279
+ */
280
+ function activeConflictEntry(value) {
281
+ return exactRecord(value, [
282
+ 'ready_task_id', 'active_task_id', 'type', 'detail', 'kind', 'severability',
283
+ ]) && isTodoIdentifier(value.ready_task_id) && isTodoIdentifier(value.active_task_id)
284
+ && value.ready_task_id !== value.active_task_id
285
+ && ['conflict', 'precedence'].includes(value.type) && boundedText(value.detail)
286
+ && pairKindAndSeverability(value);
287
+ }
288
+
289
+ /**
290
+ * 案内。単一正本のcatalogが返した形をそのまま載せる(ADR 0130 Decision 1・2)。
291
+ * 面ごとに文言を組み立て直さないため、ここではshapeだけを検査する。
292
+ */
293
+ function guidanceEntry(value) {
294
+ return exactRecord(value, ['code', 'message', 'next_action'])
295
+ && TODO_INDEPENDENCE_GUIDANCE_CODES.includes(value.code)
296
+ && boundedText(value.message) && isTodoIdentifier(value.next_action);
297
+ }
298
+
299
+ /**
300
+ * 鮮度の内訳。`coverage`がsha水準の事実を述べるのに対し、こちらは
301
+ * 「そのdiffが宣言境界に触れたか」というtask単位の事実を述べる(ADR 0128 Decision 4)。
302
+ */
303
+ function driftEntry(value) {
304
+ if (value === null) return true;
305
+ return exactRecord(value, ['base_reachable', 'changed_path_count', 'intersecting_task_ids'])
306
+ && typeof value.base_reachable === 'boolean'
307
+ && isNonNegativeSafeInteger(value.changed_path_count)
308
+ && Array.isArray(value.intersecting_task_ids)
309
+ && value.intersecting_task_ids.length <= TODO_INDEPENDENCE_TASK_LIMIT
310
+ && value.intersecting_task_ids.every(isTodoIdentifier)
311
+ && strictlySorted(value.intersecting_task_ids);
312
+ }
313
+
314
+ function unknownTaskEntry(value) {
315
+ return exactRecord(value, ['task_id', 'unknowns'])
316
+ && isTodoIdentifier(value.task_id)
317
+ && Array.isArray(value.unknowns) && value.unknowns.length >= 1
318
+ && value.unknowns.length <= TODO_INDEPENDENCE_LIST_LIMIT
319
+ && value.unknowns.every((entry) => exactRecord(entry, ['kind', 'ref'])
320
+ && isTodoIdentifier(entry.kind) && boundedText(entry.ref));
321
+ }
322
+
323
+ /**
324
+ * `lattice.todo_independence_projection.v2`。
325
+ *
326
+ * ready frontierを「検証済み並列グループ」「直列化すべき組」「未検査」へ分けた読み出し面。
327
+ * v2は進行中ToDoとの競合(`conflicts_with_active`)と鮮度の内訳(`drift`)を加える。
328
+ * `todo_status_result.v4`と`dispatch_frontier`は変更せず、加算の別面として持つ(ADR 0124の規律)。
329
+ */
330
+ export function validateTodoIndependenceProjection(value) {
331
+ try {
332
+ if (!exactRecord(value, [
333
+ 'schema', 'project_id', 'plan_key', 'coverage', 'compiled_base_sha', 'current_base_sha',
334
+ 'plan_version', 'topology_digest', 'active_task_ids', 'uncovered_active_task_ids',
335
+ 'drift', 'guidance', 'frontier', 'result_digest',
336
+ ])) return false;
337
+ if (value.schema !== TODO_INDEPENDENCE_PROJECTION_SCHEMA) return false;
338
+ if (!isTodoIdentifier(value.project_id)) return false;
339
+ if (value.plan_key !== null && !isTodoIdentifier(value.plan_key)) return false;
340
+ if (!TODO_INDEPENDENCE_COVERAGE.includes(value.coverage)) return false;
341
+ if (value.compiled_base_sha !== null && !isGitSha(value.compiled_base_sha)) return false;
342
+ if (!isGitSha(value.current_base_sha)) return false;
343
+ if (value.plan_version !== null && !isTodoIdentifier(value.plan_version)) return false;
344
+ if (value.topology_digest !== null && !isTodoDigest(value.topology_digest)) return false;
345
+ // 記録が無い状態でcompile済みidentityを名乗らない。
346
+ if (value.coverage === 'missing'
347
+ && (value.compiled_base_sha !== null || value.topology_digest !== null)) return false;
348
+ if (value.coverage !== 'missing' && value.compiled_base_sha === null) return false;
349
+ for (const key of ['active_task_ids', 'uncovered_active_task_ids']) {
350
+ if (!Array.isArray(value[key]) || value[key].length > TODO_INDEPENDENCE_TASK_LIMIT
351
+ || !value[key].every(isTodoIdentifier) || !strictlySorted(value[key])) return false;
352
+ }
353
+ // 宣言のないactiveは考慮済みactiveの部分集合でなければならない。
354
+ const activeSet = new Set(value.active_task_ids);
355
+ if (!value.uncovered_active_task_ids.every((taskId) => activeSet.has(taskId))) return false;
356
+ if (!driftEntry(value.drift)) return false;
357
+ if (!guidanceEntry(value.guidance)) return false;
358
+ // driftはstale時の内訳。それ以外で語ると鮮度の事実を二重に主張することになる。
359
+ if (value.coverage !== 'stale' && value.drift !== null) return false;
360
+ if (!exactRecord(value.frontier, [
361
+ 'parallel_groups', 'serialize_pairs', 'conflicts_with_active', 'unknown',
362
+ ])) return false;
363
+ if (!boundedList(value.frontier.parallel_groups, groupEntry, TODO_INDEPENDENCE_TASK_LIMIT)
364
+ || !strictlySorted(value.frontier.parallel_groups, (entry) => entry.task_ids[0])) return false;
365
+ if (!boundedList(value.frontier.serialize_pairs, serializeEntry)
366
+ || !strictlySorted(value.frontier.serialize_pairs, (entry) => (
367
+ `${entry.task_ids[0]}\0${entry.task_ids[1]}\0${entry.type}\0${entry.detail}`))) return false;
368
+ if (!boundedList(value.frontier.conflicts_with_active, activeConflictEntry)
369
+ || !strictlySorted(value.frontier.conflicts_with_active, (entry) => (
370
+ `${entry.ready_task_id}\0${entry.active_task_id}\0${entry.type}\0${entry.detail}`))) {
371
+ return false;
372
+ }
373
+ if (!boundedList(value.frontier.unknown, unknownTaskEntry, TODO_INDEPENDENCE_TASK_LIMIT)
374
+ || !strictlySorted(value.frontier.unknown, (entry) => entry.task_id)) return false;
375
+ return isTodoDigest(value.result_digest)
376
+ && value.result_digest === todoSelfDigest(value, 'result_digest');
377
+ } catch {
378
+ return false;
379
+ }
380
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 並列可否の案内(ADR 0130)。
3
+ *
4
+ * 状況から`{code, message, next_action}`を引く単一正本。advisory・投影・typed error・helpは
5
+ * すべてここから引く。面ごとに文言を書けば必ずずれ、同じ状況に別の説明が付く。
6
+ *
7
+ * 文は事実と次の一歩だけを述べ、指示しない。dispatchの意思決定はhostが所有する
8
+ * (ADR 0063 Decision 5)。命令形にするとLatticeがagentを統制する面へ滑る。
9
+ */
10
+
11
+ export const TODO_INDEPENDENCE_GUIDANCE_CODES = Object.freeze([
12
+ 'independence_unrecorded',
13
+ 'independence_task_undeclared',
14
+ 'independence_superseded',
15
+ 'independence_stale_for_task',
16
+ 'independence_conflict_with_active',
17
+ 'independence_conflict_between_ready',
18
+ 'independence_verified',
19
+ ]);
20
+
21
+ const CATALOG = Object.freeze({
22
+ independence_unrecorded: Object.freeze({
23
+ message: 'このplanの並列可否はまだ判定していない。競合が無いのではなく、記録が存在しない。',
24
+ next_action: 'declare_witness_set_then_compile',
25
+ }),
26
+ independence_task_undeclared: Object.freeze({
27
+ message: 'この工程はwitness setで宣言されていないため、記録には含まれていない。',
28
+ next_action: 'add_task_to_witness_set_then_compile',
29
+ }),
30
+ independence_superseded: Object.freeze({
31
+ message: 'planが改訂され、記録は別のtopologyについての判定になっている。',
32
+ next_action: 'migrate_witness_set_then_compile',
33
+ }),
34
+ independence_stale_for_task: Object.freeze({
35
+ message: '記録後にこの工程の宣言境界が変更されたため、記録時点の判定は現在のcodeを指していない。',
36
+ next_action: 'recompile_independence',
37
+ }),
38
+ independence_conflict_with_active: Object.freeze({
39
+ message: '作業中の工程と同じ資源を書く記録がある。並行すると衝突する。',
40
+ next_action: 'serialize_or_split_boundary',
41
+ }),
42
+ independence_conflict_between_ready: Object.freeze({
43
+ message: '他のready工程と同じ資源を書く記録がある。同時に着手すると衝突する。',
44
+ next_action: 'serialize_or_split_boundary',
45
+ }),
46
+ independence_verified: Object.freeze({
47
+ message: '記録時点の宣言境界では、他のready工程と干渉しない。',
48
+ next_action: 'none',
49
+ }),
50
+ });
51
+
52
+ /** 切断可能性の言い換え。conflictの案内へ添える。 */
53
+ const SEVERABILITY_HINT = Object.freeze({
54
+ code_seam: 'symbol/pathの衝突なので、境界を分けるrefactorで並列化しうる。',
55
+ serial: '共有stateまたはeffectの衝突なので、分割では切り離せない。',
56
+ });
57
+
58
+ export function todoIndependenceGuidance(code, { severability = null } = {}) {
59
+ const entry = CATALOG[code];
60
+ if (entry === undefined) {
61
+ throw new TypeError(`unknown independence guidance code: ${code}`);
62
+ }
63
+ const hint = severability === null ? null : SEVERABILITY_HINT[severability] ?? null;
64
+ return {
65
+ code,
66
+ message: hint === null ? entry.message : `${entry.message}${hint}`,
67
+ next_action: entry.next_action,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * advisoryや投影の状態から、最も行動を要する案内を1つ選ぶ。
73
+ *
74
+ * 複数の状況が重なることはあるが、案内を並べると読み手はどれから手を付けるか決められない。
75
+ * 「記録が無い」より「記録が古い」より「衝突している」を上に置く——後者ほど
76
+ * 次の一歩が具体的だからである。
77
+ */
78
+ export function selectIndependenceGuidance({
79
+ coverage, taskDeclared, taskStale, conflictWithActive = null, conflictBetweenReady = null,
80
+ }) {
81
+ if (conflictWithActive !== null) {
82
+ return todoIndependenceGuidance('independence_conflict_with_active', {
83
+ severability: conflictWithActive,
84
+ });
85
+ }
86
+ if (conflictBetweenReady !== null) {
87
+ return todoIndependenceGuidance('independence_conflict_between_ready', {
88
+ severability: conflictBetweenReady,
89
+ });
90
+ }
91
+ if (coverage === 'missing') return todoIndependenceGuidance('independence_unrecorded');
92
+ if (coverage === 'superseded') return todoIndependenceGuidance('independence_superseded');
93
+ if (!taskDeclared) return todoIndependenceGuidance('independence_task_undeclared');
94
+ if (taskStale) return todoIndependenceGuidance('independence_stale_for_task');
95
+ return todoIndependenceGuidance('independence_verified');
96
+ }
97
+
98
+ /**
99
+ * 宣言からcompileを経て読むまでの順序。helpとMCP instructionsが同じ手順を語るための正本。
100
+ * 面ごとに手順を書き直すと、片方だけが古くなる。
101
+ */
102
+ export const TODO_INDEPENDENCE_WORKFLOW = Object.freeze([
103
+ '1. 宣言する: .lattice/todo/witness/<plan_key>.json へ、ToDoごとのowns/reads/writes/affected_testsを書く',
104
+ '2. 判定する: lattice todo independence compile --plan <key> --input <ref>(実sensorを引き、clean worktreeが要る)',
105
+ '3. 読む: lattice todo independence --plan <key> --json(sensorを引かず、記録とHEAD照合だけで返る)',
106
+ '4. 追従する: plan改訂後は lattice todo independence witness migrate --plan <key> で宣言を写してから再compileする',
107
+ ]);