@quolu/lattice 0.52.3 → 0.52.4

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 (134) hide show
  1. package/package.json +1 -1
  2. package/src/artifact-contracts-v2.mjs +325 -325
  3. package/src/artifact-contracts.mjs +895 -895
  4. package/src/boundary-compiler.mjs +712 -712
  5. package/src/boundary-observation-compiler-v2.mjs +344 -344
  6. package/src/bounded-seam.mjs +230 -230
  7. package/src/bridge-address.mjs +107 -107
  8. package/src/bridge-cli.mjs +297 -297
  9. package/src/bridge-config.mjs +346 -346
  10. package/src/bridge-daemon.mjs +378 -378
  11. package/src/bridge-hub-protocol.mjs +198 -0
  12. package/src/bridge-launch-agent.mjs +323 -323
  13. package/src/bridge-registrar.mjs +102 -102
  14. package/src/bridge-server.mjs +376 -376
  15. package/src/cli-help.mjs +308 -308
  16. package/src/cli-stdio.mjs +40 -40
  17. package/src/control-compiler.mjs +532 -532
  18. package/src/dag-chain.mjs +261 -261
  19. package/src/factory-diagnostics.mjs +188 -188
  20. package/src/git-process.mjs +72 -0
  21. package/src/hash-chain.mjs +76 -76
  22. package/src/hooks-cli.mjs +1057 -1057
  23. package/src/isolation-runner.mjs +409 -409
  24. package/src/node-version-guard.mjs +44 -44
  25. package/src/project-cli.mjs +3 -3
  26. package/src/project-identity.mjs +130 -130
  27. package/src/rc1-black-box-oracle.mjs +906 -906
  28. package/src/rc1-comparison.mjs +154 -154
  29. package/src/rc1-evidence-bundle.mjs +456 -456
  30. package/src/rc1-v4-campaign.mjs +708 -708
  31. package/src/rc1-v4-transform.mjs +467 -467
  32. package/src/rc1-v5-artifact-set.mjs +855 -855
  33. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  34. package/src/rc1-v5-campaign.mjs +797 -797
  35. package/src/rc1-v5-transform.mjs +421 -421
  36. package/src/rc1-v6-artifact-set.mjs +807 -807
  37. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  38. package/src/rc1-v6-campaign.mjs +623 -623
  39. package/src/rc1-v6-causal-binding.mjs +473 -473
  40. package/src/rc1-v6-measurement.mjs +313 -313
  41. package/src/rc2-artifact-set.mjs +1584 -1584
  42. package/src/rc2-campaign.mjs +1506 -1506
  43. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  44. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  45. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  46. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  47. package/src/rc3-actual-dogfood.mjs +652 -652
  48. package/src/rc3-dogfood-scaffold.mjs +315 -315
  49. package/src/rc3-scripted-campaign.mjs +1383 -1383
  50. package/src/rc4-stage1-dogfood.mjs +673 -673
  51. package/src/runtime-adapter-registry.mjs +524 -524
  52. package/src/runtime-cli.mjs +4588 -4588
  53. package/src/runtime-contracts.mjs +824 -824
  54. package/src/runtime-control-store.mjs +604 -604
  55. package/src/runtime-controller-protocol.mjs +587 -587
  56. package/src/runtime-decision-verifier.mjs +701 -701
  57. package/src/runtime-diff-observer.mjs +361 -361
  58. package/src/runtime-direct-os-observer.mjs +301 -301
  59. package/src/runtime-driver-state.mjs +166 -166
  60. package/src/runtime-engine.mjs +779 -779
  61. package/src/runtime-errors.mjs +356 -356
  62. package/src/runtime-event-store.mjs +189 -189
  63. package/src/runtime-front-end.mjs +925 -925
  64. package/src/runtime-gate-store.mjs +481 -481
  65. package/src/runtime-hold-recompile.mjs +916 -916
  66. package/src/runtime-io-sentinel.mjs +391 -391
  67. package/src/runtime-lifecycle-lock.mjs +294 -294
  68. package/src/runtime-managed-supervisor.mjs +1490 -1490
  69. package/src/runtime-multi-epoch-store.mjs +838 -838
  70. package/src/runtime-projection.mjs +269 -269
  71. package/src/runtime-pull-intake.mjs +1192 -1192
  72. package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
  73. package/src/runtime-scripted-executor.mjs +163 -163
  74. package/src/runtime-scripted-worktree.mjs +104 -104
  75. package/src/runtime-seam-resolve.mjs +428 -428
  76. package/src/runtime-seam-treatment.mjs +173 -173
  77. package/src/runtime-socket-owner.mjs +125 -125
  78. package/src/runtime-work-order-contracts.mjs +91 -91
  79. package/src/runtime-work-order-controller.mjs +1171 -1171
  80. package/src/runtime-worktree-executor.mjs +199 -199
  81. package/src/schedulability-compiler-v2.mjs +303 -303
  82. package/src/schedulability-verifier-v2.mjs +317 -317
  83. package/src/seam-apply.mjs +549 -549
  84. package/src/seam-commit-shared.mjs +22 -22
  85. package/src/seam-commit-transform.mjs +81 -81
  86. package/src/seam-commit.mjs +18 -18
  87. package/src/seam-cost.mjs +322 -322
  88. package/src/seam-derivation.mjs +188 -188
  89. package/src/seam-gate.mjs +146 -146
  90. package/src/seam-proposal-contracts.mjs +446 -446
  91. package/src/seam-proposal-queries.mjs +521 -521
  92. package/src/seam-proposal.mjs +2011 -2011
  93. package/src/seam-ref.mjs +33 -33
  94. package/src/seam-rewrite.mjs +286 -286
  95. package/src/seam-transform.mjs +554 -554
  96. package/src/seam-verification.mjs +260 -260
  97. package/src/sensor-adapter.mjs +432 -432
  98. package/src/sensor-cli.mjs +139 -139
  99. package/src/sensor-diff.mjs +661 -661
  100. package/src/sensor-node-runtime.mjs +53 -53
  101. package/src/sensor-runtime.mjs +52 -52
  102. package/src/timestamp-contract.mjs +8 -8
  103. package/src/todo-audit-pending.mjs +91 -91
  104. package/src/todo-chain.mjs +178 -178
  105. package/src/todo-cli.mjs +8 -8
  106. package/src/todo-contracts.mjs +728 -728
  107. package/src/todo-dashboard-registry.mjs +573 -573
  108. package/src/todo-dispatch-shape.mjs +190 -190
  109. package/src/todo-gantt-html-independence.mjs +239 -239
  110. package/src/todo-gantt-html-shared.mjs +226 -226
  111. package/src/todo-gantt-html-style.mjs +131 -131
  112. package/src/todo-gantt-html.mjs +248 -248
  113. package/src/todo-gantt-layout.mjs +974 -974
  114. package/src/todo-gantt-live.mjs +361 -361
  115. package/src/todo-gantt-nested.mjs +263 -263
  116. package/src/todo-gantt-presentation.mjs +217 -217
  117. package/src/todo-gantt-scope.mjs +123 -123
  118. package/src/todo-gantt-svg.mjs +353 -353
  119. package/src/todo-independence-contracts.mjs +595 -595
  120. package/src/todo-independence-guidance.mjs +322 -322
  121. package/src/todo-independence.mjs +640 -640
  122. package/src/todo-markdown-renderer.mjs +260 -260
  123. package/src/todo-migration.mjs +448 -448
  124. package/src/todo-narrative-anchor.mjs +130 -130
  125. package/src/todo-note-store.mjs +629 -629
  126. package/src/todo-parallel-candidates.mjs +114 -114
  127. package/src/todo-revision.mjs +995 -995
  128. package/src/todo-split.mjs +472 -472
  129. package/src/todo-status.mjs +690 -690
  130. package/src/todo-store-git-transaction.mjs +418 -418
  131. package/src/todo-store.mjs +42 -21
  132. package/src/treatment-compiler.mjs +728 -728
  133. package/src/treatment-runner.mjs +656 -656
  134. package/src/witness-scaffold.mjs +180 -180
@@ -1,690 +1,690 @@
1
- import {
2
- AUDIT_PENDING_PHASE_STATUSES,
3
- auditPendingNextCommands,
4
- isAuditPendingPhaseStatus,
5
- } from './todo-audit-pending.mjs';
6
- import {
7
- TODO_COORDINATION_MODES,
8
- TODO_LIMITS,
9
- exactRecord,
10
- isNonNegativeSafeInteger,
11
- isStrictTodoTimestamp,
12
- isTodoDigest,
13
- isTodoIdentifier,
14
- todoSelfDigest,
15
- } from './todo-contracts.mjs';
16
- import { TODO_INDEPENDENCE_COVERAGE } from './todo-independence-contracts.mjs';
17
- import { todoLegacyReconciliationDigest } from './todo-revision.mjs';
18
- import {
19
- isPhaselessTodoPlanSchema,
20
- projectTodoCrossPlanDependencies,
21
- todoPhaseDefinitions,
22
- } from './todo-store.mjs';
23
-
24
- /**
25
- * v6で`plan_notes`を足す。ADR 0054・0063の前例どおり既存versionへのin-place追加はしない。
26
- *
27
- * plan単位noteは工程に属する義務で、taskへ着手した人のcontextには届くが、**まだ誰も
28
- * 着手していない工程の義務は、この欄が無いとどこにも出ない**。
29
- */
30
- export const TODO_STATUS_SCHEMA = 'lattice.todo_status_result.v6';
31
- export const TODO_DISPATCH_FRONTIER_SCHEMA = 'lattice.todo_dispatch_frontier.v1';
32
- export const TODO_STATUS_LIST_LIMIT = 2_000;
33
- export const TODO_STATUS_LABEL_LIMIT = 160;
34
- export const TODO_STATUS_REASON_LIMIT = 512;
35
- export const TODO_STATUS_CAPTURE_LIMIT = 64 * 1_024;
36
-
37
- export class TodoStatusProjectionError extends Error {
38
- constructor(code, reason, detail = {}) {
39
- super(reason);
40
- this.name = 'TodoStatusProjectionError';
41
- this.code = code;
42
- this.detail = { reason, ...detail };
43
- }
44
- }
45
-
46
- function fail(code, reason, detail) {
47
- throw new TodoStatusProjectionError(code, reason, detail);
48
- }
49
-
50
- function plain(value) {
51
- return value !== null && typeof value === 'object' && !Array.isArray(value)
52
- && Object.getPrototypeOf(value) === Object.prototype;
53
- }
54
-
55
- /** Python consumerのlen()/ord()と同じUnicode code point単位のbounded_text。 */
56
- export function isTodoStatusBoundedText(value, limit = TODO_STATUS_REASON_LIMIT) {
57
- if (typeof value !== 'string') return false;
58
- const points = [...value];
59
- return points.length > 0 && points.length <= limit
60
- && points.every((character) => character.codePointAt(0) >= 0x20 && character !== '\x7f');
61
- }
62
-
63
- function displayText(value, fallback, limit) {
64
- const source = typeof value === 'string' && value.length > 0 ? value : fallback;
65
- const points = [...source].slice(0, limit).map((character) => {
66
- const codePoint = character.codePointAt(0);
67
- return codePoint >= 0x20 && codePoint !== 0x7f ? character : '\uFFFD';
68
- });
69
- const result = points.join('');
70
- return result.length > 0 ? result : fallback;
71
- }
72
-
73
- function taskKey(projectId, planKey, taskId) {
74
- return `${projectId}\0${planKey}\0${taskId}`;
75
- }
76
-
77
- function refKey(ref) {
78
- return taskKey(ref.project_id, ref.plan_key, ref.task_id);
79
- }
80
-
81
- function compareTaskEntries(left, right) {
82
- return left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1
83
- : left.task_id < right.task_id ? -1 : left.task_id > right.task_id ? 1 : 0;
84
- }
85
-
86
- function enforceListLimit(name, value) {
87
- if (value.length > TODO_STATUS_LIST_LIMIT) {
88
- fail('TODO_SCALE_EXCEEDED', 'todo_status_list_limit_exceeded', {
89
- list: name,
90
- count: value.length,
91
- limit: TODO_STATUS_LIST_LIMIT,
92
- });
93
- }
94
- }
95
-
96
- function taskEntry(value) {
97
- return exactRecord(value, ['plan_key', 'task_id', 'label'])
98
- && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.task_id)
99
- && isTodoStatusBoundedText(value.label, TODO_STATUS_LABEL_LIMIT);
100
- }
101
-
102
- function taskRefEntry(value) {
103
- return exactRecord(value, ['plan_key', 'task_id'])
104
- && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.task_id);
105
- }
106
-
107
- function activeTaskEntry(value) {
108
- return exactRecord(value, ['plan_key', 'task_id', 'label', 'unmet_dependencies'])
109
- && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.task_id)
110
- && isTodoStatusBoundedText(value.label, TODO_STATUS_LABEL_LIMIT)
111
- && boundedList(value.unmet_dependencies, taskRefEntry);
112
- }
113
-
114
- function blockedEntry(value) {
115
- return exactRecord(value, ['plan_key', 'task_id', 'reason'])
116
- && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.task_id)
117
- && isTodoStatusBoundedText(value.reason, TODO_STATUS_REASON_LIMIT);
118
- }
119
-
120
- /**
121
- * 監査待ちPhase 1件。
122
- *
123
- * task entryと紛れないよう`status`ではなく`phase_status`にする。状態集合は
124
- * `todo-audit-pending.mjs`の定義をそのまま使う(ここで書き直さない)。
125
- * `next_commands`を非空必須にしたのは、監査待ちなのに次の一手が空なら次アクション面として
126
- * 無意味だからである(状態集合が閉じている以上、空になる枝は無い)。
127
- */
128
- function auditPendingEntry(value) {
129
- return exactRecord(value, [
130
- 'plan_key', 'phase_id', 'phase_status', 'implicit', 'required_evidence_slots', 'next_commands',
131
- ]) && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.phase_id)
132
- && AUDIT_PENDING_PHASE_STATUSES.has(value.phase_status)
133
- && typeof value.implicit === 'boolean'
134
- && boundedList(value.required_evidence_slots, isTodoIdentifier)
135
- && Array.isArray(value.next_commands) && value.next_commands.length > 0
136
- && boundedList(value.next_commands, (command) => isTodoStatusBoundedText(command, TODO_STATUS_REASON_LIMIT));
137
- }
138
-
139
- function planNoteLatestEntry(value) {
140
- return exactRecord(value, ['event_digest', 'actor_agent', 'recorded_at'])
141
- && isTodoDigest(value.event_digest) && isTodoIdentifier(value.actor_agent)
142
- && isStrictTodoTimestamp(value.recorded_at);
143
- }
144
-
145
- /**
146
- * plan単位noteの要約1件。
147
- *
148
- * **本文を持たない。** 自由記述のMarkdownをここへinlineすると、noteを書くほど
149
- * `TODO_STATUS_CAPTURE_LIMIT`へ近づき、健全なstoreが`TODO_SCALE_EXCEEDED`で落ちる
150
- * ——「記録すると壊れる」面を作らない。載せるのは件数・帰属・次の一手までで、
151
- * 中身は`next_commands`が指す`note list`が持つ。
152
- *
153
- * `plan_note_head_digest`は`note_context.note_head_digest`(task chain)と**別のchainのhead**なので、
154
- * 名前で区別する。同名にすると型が同じdigestなので、取り違えてもexact validatorを通ってしまう。
155
- *
156
- * `count`は1以上(0件のplanはentryごと出さない)。`next_commands`が非空必須なのは
157
- * `audit_pending`と同じ理由で、欄に出るだけで次の一手が無いなら次アクション面として無意味である。
158
- */
159
- function planNoteEntry(value) {
160
- return exactRecord(value, ['plan_key', 'plan_note_head_digest', 'count', 'latest', 'next_commands'])
161
- && isTodoIdentifier(value.plan_key) && isTodoDigest(value.plan_note_head_digest)
162
- && isNonNegativeSafeInteger(value.count) && value.count > 0
163
- && Array.isArray(value.latest) && value.latest.length > 0
164
- && value.latest.length <= Math.min(value.count, TODO_LIMITS.statusPlanNoteLatest)
165
- && value.latest.every(planNoteLatestEntry)
166
- && value.latest[0].event_digest === value.plan_note_head_digest
167
- && Array.isArray(value.next_commands) && value.next_commands.length > 0
168
- && boundedList(value.next_commands,
169
- (command) => isTodoStatusBoundedText(command, TODO_STATUS_REASON_LIMIT));
170
- }
171
-
172
- /**
173
- * 調整方式を宣言したplan 1件(ob03・オーナー裁定C①)。
174
- *
175
- * **宣言済みのplanだけを列挙する。** 未宣言を`mode: null`で全plan出すと、plan数ぶん常に
176
- * 埋まって読み飛ばされる列になる——前campaignで`audit_pending`の設計時に避けた形と同じ。
177
- * 未宣言は「`member_heads`に居て`coordination`に居ない」で引ける。
178
- *
179
- * `declared_by`が本体である。witnessが全planの暗黙義務だった時に無かったのが帰属で、
180
- * ここを落とすとこの欄は「もう1つの督促」に戻る。
181
- */
182
- function coordinationEntry(value) {
183
- return exactRecord(value, ['plan_key', 'mode', 'declared_by', 'declared_at', 'reason'])
184
- && isTodoIdentifier(value.plan_key)
185
- && TODO_COORDINATION_MODES.includes(value.mode)
186
- && exactRecord(value.declared_by, ['host', 'session', 'agent'])
187
- && ['host', 'session', 'agent'].every((key) => isTodoStatusBoundedText(value.declared_by[key], TODO_STATUS_LABEL_LIMIT))
188
- && isStrictTodoTimestamp(value.declared_at)
189
- && isTodoStatusBoundedText(value.reason, TODO_STATUS_REASON_LIMIT);
190
- }
191
-
192
- /**
193
- * 並列候補1 plan(ob05・オーナー裁定C③)。
194
- *
195
- * **新しい判定は1つも行わない。** ここに載るのは`projectIndependenceFrontier`が既に出している
196
- * 結果を、候補の視点で並べ直したものだけである。並列できそうな組を選ぶのはAIの仕事で、
197
- * 機械が持つのは「まだ判定していないreadyはこれ」「判定済みの結果はこれ」だけ
198
- * ——推定・判断をLatticeの中へ実装しない(所有境界)。
199
- *
200
- * **ready taskを1つも持たないplanはentryごと出さない。** 全plan常に1行にすると、plan数ぶん
201
- * 埋まって読み飛ばされる列になる(前campaignのwitness `coverage: missing`が実際にそうなった)。
202
- *
203
- * 逆に`coverage: 'missing'`のplanは**出す**。「まだ誰も判定していない」はこの欄が最も要る状態で、
204
- * そこを飛ばすと沈黙が不在に見える。
205
- */
206
- function parallelCandidateEntry(value) {
207
- return exactRecord(value, [
208
- 'plan_key', 'coverage', 'unreadable_reason', 'unjudged_task_ids',
209
- 'verified_parallel_groups', 'serialize_pairs', 'next_commands',
210
- ]) && isTodoIdentifier(value.plan_key)
211
- // 読めない記録は`coverage`を名乗らず理由を名乗る(ADR 0131・`summarizeIndependence`と同じ答え方)。
212
- // 「壊れている」を「まだ判定していない」へ丸めない。
213
- && (value.unreadable_reason === null
214
- ? TODO_INDEPENDENCE_COVERAGE.includes(value.coverage)
215
- : value.coverage === null
216
- && isTodoStatusBoundedText(value.unreadable_reason, TODO_STATUS_REASON_LIMIT))
217
- && boundedList(value.unjudged_task_ids, isTodoIdentifier)
218
- // 1件の組は並列の情報を持たない(taskは常に自分と並列である)。生産側が落としている以上、
219
- // 契約側でも受けない——受けると消費者が「2件以上」を前提にできなくなる。
220
- && boundedList(value.verified_parallel_groups, (group) => exactRecord(group, ['task_ids'])
221
- && Array.isArray(group.task_ids) && group.task_ids.length > 1
222
- && group.task_ids.every(isTodoIdentifier))
223
- && boundedList(value.serialize_pairs, (pair) => exactRecord(pair, ['task_ids', 'type', 'detail'])
224
- && Array.isArray(pair.task_ids) && pair.task_ids.length === 2
225
- && pair.task_ids.every(isTodoIdentifier)
226
- && isTodoStatusBoundedText(pair.type, TODO_STATUS_LABEL_LIMIT)
227
- && isTodoStatusBoundedText(pair.detail, TODO_STATUS_REASON_LIMIT))
228
- // 候補が在るのに次の一手が無い欄は、この工程が直している「欄だけ置いて閉じる」形になる。
229
- && Array.isArray(value.next_commands) && value.next_commands.length > 0
230
- && boundedList(value.next_commands,
231
- (command) => isTodoStatusBoundedText(command, TODO_STATUS_REASON_LIMIT))
232
- // 何も無いplanは出さない。空entryは「判定する対象が無い」と「判定が済んだ」を混ぜる。
233
- && (value.unjudged_task_ids.length > 0 || value.verified_parallel_groups.length > 0
234
- || value.serialize_pairs.length > 0);
235
- }
236
-
237
- function memberHead(value) {
238
- return exactRecord(value, [
239
- 'plan_key', 'plan_version', 'through_sequence', 'journal_head_digest',
240
- 'reconciliation_state', 'revision_digest', 'reconciliation_digest',
241
- ]) && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.plan_version)
242
- && isNonNegativeSafeInteger(value.through_sequence) && isTodoDigest(value.journal_head_digest)
243
- && ['registered_unreconciled', 'reconciled'].includes(value.reconciliation_state)
244
- && (value.revision_digest === null || isTodoDigest(value.revision_digest))
245
- && isTodoDigest(value.reconciliation_digest)
246
- && ((value.reconciliation_state === 'registered_unreconciled' && value.revision_digest === null)
247
- || (value.reconciliation_state === 'reconciled' && isTodoDigest(value.revision_digest)));
248
- }
249
-
250
- function boundedList(value, validator) {
251
- return Array.isArray(value) && value.length <= TODO_STATUS_LIST_LIMIT && value.every(validator);
252
- }
253
-
254
- function dispatchFrontier(projectId, nextReady) {
255
- const preimage = {
256
- schema: TODO_DISPATCH_FRONTIER_SCHEMA,
257
- project_id: projectId,
258
- tasks: nextReady.map(({ plan_key: planKey, task_id: taskId }) => ({
259
- plan_key: planKey,
260
- task_id: taskId,
261
- })),
262
- frontier_digest: '',
263
- };
264
- return {
265
- schema: TODO_DISPATCH_FRONTIER_SCHEMA,
266
- selection_source: 'next_ready',
267
- policy: 'all_ready_parallel_by_default',
268
- recommended_parallelism: nextReady.length,
269
- subset_requires_reason: nextReady.length > 1,
270
- parallel_start_flag: '--parallel-frontier',
271
- frontier_digest: todoSelfDigest(preimage, 'frontier_digest'),
272
- };
273
- }
274
-
275
- function dispatchFrontierEntry(value, projectId, nextReady) {
276
- const expected = dispatchFrontier(projectId, nextReady);
277
- return exactRecord(value, [
278
- 'schema', 'selection_source', 'policy', 'recommended_parallelism',
279
- 'subset_requires_reason', 'parallel_start_flag', 'frontier_digest',
280
- ]) && value.schema === TODO_DISPATCH_FRONTIER_SCHEMA
281
- && value.selection_source === 'next_ready'
282
- && value.policy === 'all_ready_parallel_by_default'
283
- && isNonNegativeSafeInteger(value.recommended_parallelism)
284
- && value.recommended_parallelism === nextReady.length
285
- && value.subset_requires_reason === (nextReady.length > 1)
286
- && value.parallel_start_flag === '--parallel-frontier'
287
- && value.frontier_digest === expected.frontier_digest;
288
- }
289
-
290
- /** todo status v6 wire shapeを検証し、digestも再計算する。 */
291
- export function validateTodoStatusResult(value) {
292
- try {
293
- return exactRecord(value, [
294
- 'schema', 'project_id', 'active_set', 'next_ready', 'dispatch_frontier',
295
- 'blocked', 'audit_pending', 'plan_notes', 'coordination', 'parallel_candidates',
296
- 'member_heads', 'result_digest',
297
- ]) && value.schema === TODO_STATUS_SCHEMA && isTodoIdentifier(value.project_id)
298
- && boundedList(value.active_set, activeTaskEntry) && boundedList(value.next_ready, taskEntry)
299
- && dispatchFrontierEntry(value.dispatch_frontier, value.project_id, value.next_ready)
300
- && boundedList(value.blocked, blockedEntry) && boundedList(value.audit_pending, auditPendingEntry)
301
- && boundedList(value.plan_notes, planNoteEntry)
302
- && boundedList(value.coordination, coordinationEntry)
303
- && boundedList(value.parallel_candidates, parallelCandidateEntry)
304
- && boundedList(value.member_heads, memberHead)
305
- && isTodoDigest(value.result_digest)
306
- && value.result_digest === todoSelfDigest(value, 'result_digest');
307
- } catch {
308
- return false;
309
- }
310
- }
311
-
312
- /**
313
- * memberの監査待ちPhaseを`audit_pending` entryへ起こす。
314
- *
315
- * 「監査待ちか」の判定は`todo-audit-pending.mjs`、Phase定義(slots)は`todo-store.mjs`の
316
- * `todoPhaseDefinitions`が正本である。`plan.phases`を直接読むとphase無しplan(v1/v2/v3)と
317
- * synthetic read modelで落ちるので読まない。
318
- *
319
- * dispatchへは一切流さない——ここで作るのは`member.phases`だけを見る別の列であり、
320
- * nodes/incomingへは入れない(ADR 0062・ADR 0147裁定5)。
321
- */
322
- function collectAuditPending(member, sink) {
323
- const declared = todoPhaseDefinitions(member.plan);
324
- // Phase plan(v4/v5/v7)なのに`plan.phases`が配列でない場合はここでtypedに落とす
325
- // (素のTypeErrorで抜けさせない)。phase無しplanは常に暗黙Phase 1件が返るので該当しない。
326
- if (!Array.isArray(declared)) {
327
- fail('TODO_STATUS_INVALID_INPUT', 'todo_status_plan_phases_invalid', { plan_key: member.plan.plan_key });
328
- }
329
- const definitions = new Map(declared.map((entry) => [entry.phase_id, entry]));
330
- const implicit = isPhaselessTodoPlanSchema(member.plan.schema);
331
- for (const phase of member.phases ?? []) {
332
- if (!isAuditPendingPhaseStatus(phase?.status)) continue;
333
- const definition = definitions.get(phase.phase_id);
334
- // 導出ビューに在ってplanに定義が無いPhaseは、状態とplanがずれている証拠である。
335
- // slotsを空へ丸めるとgateが何を要求するか嘘を吐くので、fail closedにする。
336
- if (definition === undefined) {
337
- fail('TODO_STATUS_INVALID_INPUT', 'todo_status_phase_definition_missing', {
338
- plan_key: member.plan.plan_key, phase_id: phase.phase_id,
339
- });
340
- }
341
- sink.push({
342
- plan_key: member.plan.plan_key,
343
- phase_id: phase.phase_id,
344
- phase_status: phase.status,
345
- implicit,
346
- required_evidence_slots: [...definition.required_evidence_slots],
347
- next_commands: auditPendingNextCommands(member.plan.plan_key, phase.phase_id, phase.status),
348
- });
349
- }
350
- }
351
-
352
- /**
353
- * read modelからtask node・依存辺・phase gateを組み立てる。
354
- *
355
- * ready判定の唯一の計算元。`projectTodoStatus`と`computeReadyFrontier`が同じ結果を
356
- * 出すために共有する(同じ規則を二箇所へ持たない)。
357
- */
358
- function buildTodoGraph(readModel) {
359
- if (!plain(readModel) || readModel.schema !== 'lattice.todo_store_read.v1'
360
- || !isTodoIdentifier(readModel.project_id) || !Array.isArray(readModel.members)) {
361
- fail('TODO_STATUS_INVALID_INPUT', 'todo_status_read_model_invalid');
362
- }
363
-
364
- const nodes = new Map();
365
- const incoming = new Map();
366
- const memberHeads = [];
367
- const auditPending = [];
368
- const coordination = [];
369
- // snapshot artifactの形式(v1にはphasesキーが無い)には縛られない導出ビューを読む
370
- // (readTodoStoreが常にmember.phasesとして埋める。ADR 0147)。
371
- const phaseStatuses = new Map(readModel.members.flatMap((member) => (
372
- (member.phases ?? []).map((phase) => [
373
- `${member.plan.project_id}\0${member.plan.plan_key}\0${phase.phase_id}`, phase.status,
374
- ])
375
- )));
376
- const members = [...readModel.members].sort((left, right) => {
377
- const leftKey = left?.descriptor?.plan_key ?? '';
378
- const rightKey = right?.descriptor?.plan_key ?? '';
379
- return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
380
- });
381
-
382
- for (const member of members) {
383
- if (!plain(member) || !plain(member.plan) || !plain(member.descriptor)
384
- || !Array.isArray(member.plan.tasks) || !Array.isArray(member.plan.hard_dependencies)
385
- || !Array.isArray(member.plan.joins) || !Array.isArray(member.tasks)
386
- || !plain(member.journal) || !Array.isArray(member.journal.events)) {
387
- fail('TODO_STATUS_INVALID_INPUT', 'todo_status_member_invalid');
388
- }
389
- const head = member.journal.events.at(-1);
390
- const genesis = member.journal.events[0];
391
- if (!plain(head) || !isNonNegativeSafeInteger(head.sequence) || !isTodoDigest(head.event_digest)) {
392
- fail('TODO_STATUS_INVALID_INPUT', 'todo_status_member_head_invalid');
393
- }
394
- const reconciled = ['lattice.todo_event.v2', 'lattice.todo_event.v4'].includes(genesis.schema);
395
- const phaseV3 = genesis.schema === 'lattice.todo_event.v4'
396
- && member.revision?.schema === 'lattice.phase_todo_revision.v3';
397
- memberHeads.push({
398
- plan_key: member.plan.plan_key,
399
- plan_version: member.plan.plan_version,
400
- through_sequence: head.sequence,
401
- journal_head_digest: head.event_digest,
402
- reconciliation_state: reconciled ? 'reconciled' : 'registered_unreconciled',
403
- revision_digest: reconciled ? genesis.revision_digest : null,
404
- reconciliation_digest: reconciled
405
- ? phaseV3 ? member.revision.reconciliation.reconciliation_digest
406
- : genesis.schema === 'lattice.todo_event.v4' ? genesis.revision_digest
407
- : genesis.reconciliation_digest
408
- : todoLegacyReconciliationDigest({
409
- planDigest: member.plan.plan_digest, journalHeadDigest: head.event_digest,
410
- }),
411
- });
412
- const states = new Map(member.tasks.map((state) => [state.task_id, state]));
413
- // snapshot artifactの形式には縛られない導出ビュー(member.phases)を読む(ADR 0147)。
414
- const phases = new Map((member.phases ?? []).map((state) => [state.phase_id, state]));
415
- collectAuditPending(member, auditPending);
416
- // 調整方式の宣言(ob03)。member.coordinationはstoreがplan-scoped chainから投影した
417
- // 導出ビューで、未宣言はnull。宣言済みだけをここへ載せる。
418
- if (member.coordination !== null && member.coordination !== undefined) {
419
- coordination.push({
420
- plan_key: member.plan.plan_key,
421
- mode: member.coordination.mode,
422
- declared_by: member.coordination.declared_by,
423
- declared_at: member.coordination.declared_at,
424
- reason: displayText(member.coordination.reason, member.coordination.mode, TODO_STATUS_REASON_LIMIT),
425
- });
426
- }
427
- for (const task of member.plan.tasks) {
428
- const state = states.get(task.task_id);
429
- if (!plain(state) || !['pending', 'in-progress', 'blocked', 'done'].includes(state.status)) {
430
- fail('TODO_STATUS_INVALID_INPUT', 'todo_status_task_state_missing', {
431
- plan_key: member.plan.plan_key,
432
- task_id: task.task_id,
433
- });
434
- }
435
- const key = taskKey(member.plan.project_id, member.plan.plan_key, task.task_id);
436
- if (nodes.has(key)) fail('TODO_STATUS_INVALID_INPUT', 'todo_status_task_duplicate');
437
- nodes.set(key, {
438
- key,
439
- project_id: member.plan.project_id,
440
- plan_key: member.plan.plan_key,
441
- task_id: task.task_id,
442
- label: displayText(task.title, task.task_id, TODO_STATUS_LABEL_LIMIT),
443
- status: state.status,
444
- blocked_reason: state.blocked_reason,
445
- plan_schema: member.plan.schema,
446
- phase_id: ['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(member.plan.schema)
447
- ? task.phase_id : null,
448
- phase_status: ['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(member.plan.schema)
449
- ? phases.get(task.phase_id)?.status : null,
450
- phase_ready: member.plan.schema !== 'lattice.todo_plan.v4'
451
- || phases.get(task.phase_id)?.status === 'active',
452
- });
453
- incoming.set(key, new Set());
454
- }
455
- }
456
-
457
- const addPredecessor = (from, to) => {
458
- const fromKey = refKey(from);
459
- const toKey = refKey(to);
460
- if (!nodes.has(fromKey) || !nodes.has(toKey)) {
461
- fail('TODO_STATUS_INVALID_INPUT', 'todo_status_dependency_dangling');
462
- }
463
- incoming.get(toKey).add(fromKey);
464
- };
465
- const phaseAcceptIncoming = new Map([...nodes.keys()].map((key) => [key, new Set()]));
466
- for (const member of members) {
467
- for (const edge of member.plan.hard_dependencies) addPredecessor(edge.from, edge.to);
468
- for (const join of member.plan.joins) {
469
- for (const after of join.after) addPredecessor(after, join.before);
470
- }
471
- if (['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(member.plan.schema)) {
472
- for (const edge of member.plan.phase_accept_dependencies) {
473
- const target = refKey(edge.to);
474
- if (!nodes.has(target)) fail('TODO_STATUS_INVALID_INPUT', 'todo_status_dependency_dangling');
475
- phaseAcceptIncoming.get(target).add(
476
- `${edge.from.project_id}\0${edge.from.plan_key}\0${edge.from.phase_id}`,
477
- );
478
- }
479
- }
480
- }
481
- // active plan topologyは追記改変しない。開発中に接続したplan-scoped edgeを同じincomingへ
482
- // 合成することで、projectTodoStatusとcomputeReadyFrontierが同じ待機判定を即座に読む。
483
- for (const dependency of projectTodoCrossPlanDependencies(members)) {
484
- addPredecessor(dependency.from, dependency.to);
485
- }
486
-
487
- auditPending.sort((left, right) => (
488
- left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1
489
- : left.phase_id < right.phase_id ? -1 : left.phase_id > right.phase_id ? 1 : 0));
490
-
491
- coordination.sort((left, right) => (
492
- left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0));
493
-
494
- return { nodes, incoming, phaseAcceptIncoming, phaseStatuses, memberHeads, auditPending, coordination };
495
- }
496
-
497
- /** 先行完了とphase gateを満たしたpending taskだけがreadyになる。 */
498
- function isNodeReady(node, { nodes, incoming, phaseAcceptIncoming, phaseStatuses }) {
499
- return node.status === 'pending' && node.phase_ready
500
- && [...incoming.get(node.key)].every((key) => {
501
- const predecessor = nodes.get(key);
502
- return predecessor.status === 'done'
503
- && (predecessor.plan_schema !== 'lattice.todo_plan.v4'
504
- || predecessor.phase_id === null
505
- || (predecessor.plan_key === node.plan_key && predecessor.phase_id === node.phase_id)
506
- || predecessor.phase_status === 'accepted');
507
- })
508
- && [...phaseAcceptIncoming.get(node.key)]
509
- .every((key) => phaseStatuses.get(key) === 'accepted');
510
- }
511
-
512
- /**
513
- * ready frontierだけをread-only投影する。
514
- *
515
- * `todo status`の`next_ready`と同じ計算・同じ順序を返す。independence投影が
516
- * ready集合を別実装で持たないための共有入口である。
517
- */
518
- export function computeReadyFrontier(readModel) {
519
- const graph = buildTodoGraph(readModel);
520
- const ready = [...graph.nodes.values()]
521
- .filter((node) => isNodeReady(node, graph))
522
- .map((node) => ({ plan_key: node.plan_key, task_id: node.task_id, label: node.label }));
523
- ready.sort(compareTaskEntries);
524
- enforceListLimit('next_ready', ready);
525
- return ready;
526
- }
527
-
528
- /**
529
- * dispatch面(`next_ready`/`active_set`/`dispatch_frontier`)だけを見る内部呼び出しが渡す明示の空。
530
- *
531
- * `todo start`のready判定・gantt・dashboardの生存判定は、statusのresultを**外へ出さない**。
532
- * そこでnote chainを読むと、noteの破損がdispatch判定やdashboardの可視性を巻き添えに落とす
533
- * ——ganttのnote破損は既に警告として表出する契約があり(`notesForGantt`)、二重に落とさない。
534
- * この定数を使った結果をwireとして出力しないこと。`plan_notes`が常に空になる。
535
- */
536
- export const TODO_STATUS_DISPATCH_ONLY = Object.freeze({
537
- planNotes: Object.freeze([]), parallelCandidates: Object.freeze([]),
538
- });
539
-
540
- /**
541
- * Canonical todo read modelからSessionStart向け現在地をread-only投影する。
542
- *
543
- * `planNotes`は**必須**である。plan単位noteはstore read modelに入っていない別chainなので、
544
- * 読むのは呼び出し側の責務になる。省略を空配列へ丸めると「noteが無い」と「読まなかった」が
545
- * 同じ形になり、配線を1箇所忘れただけで義務が静かに消える——それはこの欄が塞いでいる穴そのものである。
546
- * 素材は`readTodoPlanNotesForStatus`(`src/todo-note-store.mjs`)が作り、resultを出力する
547
- * 呼び出し元だけがそれを渡す。dispatch面しか見ない内部呼び出しは`TODO_STATUS_DISPATCH_ONLY`を使う。
548
- */
549
- export function projectTodoStatus(readModel, options = undefined) {
550
- if (!exactRecord(options, ['planNotes', 'parallelCandidates'])
551
- || !Array.isArray(options.planNotes)) {
552
- fail('TODO_STATUS_INVALID_INPUT', 'todo_status_plan_notes_missing');
553
- }
554
- if (!Array.isArray(options.parallelCandidates)) {
555
- fail('TODO_STATUS_INVALID_INPUT', 'todo_status_parallel_candidates_missing');
556
- }
557
- const graph = buildTodoGraph(readModel);
558
- const { nodes, incoming, memberHeads, auditPending, coordination } = graph;
559
- const planNotes = [...options.planNotes];
560
- const parallelCandidates = [...options.parallelCandidates];
561
-
562
- const activeSet = [];
563
- const nextReady = [];
564
- const blocked = [];
565
- for (const node of nodes.values()) {
566
- const task = { plan_key: node.plan_key, task_id: node.task_id, label: node.label };
567
- if (node.status === 'in-progress') {
568
- const unmetDependencies = [...incoming.get(node.key)]
569
- .filter((key) => nodes.get(key).status !== 'done')
570
- .map((key) => {
571
- const predecessor = nodes.get(key);
572
- return { plan_key: predecessor.plan_key, task_id: predecessor.task_id };
573
- })
574
- .sort(compareTaskEntries);
575
- enforceListLimit('active_set.unmet_dependencies', unmetDependencies);
576
- activeSet.push({ ...task, unmet_dependencies: unmetDependencies });
577
- }
578
- if (isNodeReady(node, graph)) nextReady.push(task);
579
- if (node.status === 'blocked') {
580
- blocked.push({
581
- plan_key: node.plan_key,
582
- task_id: node.task_id,
583
- reason: displayText(node.blocked_reason, 'blocked', TODO_STATUS_REASON_LIMIT),
584
- });
585
- }
586
- }
587
- activeSet.sort(compareTaskEntries);
588
- nextReady.sort(compareTaskEntries);
589
- blocked.sort(compareTaskEntries);
590
- memberHeads.sort((left, right) => left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0);
591
- for (const [name, value] of [
592
- ['active_set', activeSet], ['next_ready', nextReady], ['blocked', blocked],
593
- ['audit_pending', auditPending], ['plan_notes', planNotes],
594
- ['coordination', coordination], ['parallel_candidates', parallelCandidates],
595
- ['member_heads', memberHeads],
596
- ]) enforceListLimit(name, value);
597
-
598
- const result = {
599
- schema: TODO_STATUS_SCHEMA,
600
- project_id: readModel.project_id,
601
- active_set: activeSet,
602
- next_ready: nextReady,
603
- dispatch_frontier: dispatchFrontier(readModel.project_id, nextReady),
604
- blocked,
605
- // 監査待ちは`member.phases`だけから作った別の列で、dispatch(next_ready/dispatch_frontier)へは
606
- // 影響しない。監査が進んでもfrontier_digestは動かない。
607
- audit_pending: auditPending,
608
- // plan単位noteも同じく別の列である。noteの有無・件数はdispatchへ影響しないので、
609
- // next_ready・dispatch_frontier・frontier_digestはnoteを書いても1バイトも動かない。
610
- plan_notes: planNotes,
611
- // 調整方式の宣言も同じく別の列である。宣言はdispatchを変えない——未宣言でもready
612
- // frontierは通常どおり出る(ADR 0160・ob04のProtected behavior)。
613
- coordination,
614
- // 並列候補も別の列である。判定が進んでもdispatchは動かない——next_ready・
615
- // dispatch_frontier・frontier_digestは判定状態を1バイトも含まない(ADR 0063・ob04)。
616
- parallel_candidates: parallelCandidates,
617
- member_heads: memberHeads,
618
- result_digest: '',
619
- };
620
- result.result_digest = todoSelfDigest(result, 'result_digest');
621
- if (!validateTodoStatusResult(result)) fail('TODO_STATUS_INVALID_RESULT', 'todo_status_result_invalid');
622
- const resultBytes = Buffer.byteLength(`${JSON.stringify(result)}\n`);
623
- if (resultBytes > TODO_STATUS_CAPTURE_LIMIT) {
624
- fail('TODO_SCALE_EXCEEDED', 'todo_status_result_size_limit_exceeded', {
625
- result_bytes: resultBytes,
626
- result_limit: TODO_STATUS_CAPTURE_LIMIT,
627
- });
628
- }
629
- return result;
630
- }
631
-
632
- export const TODO_BINDING_PROJECTION_SCHEMA = 'lattice.todo_binding_projection.v1';
633
-
634
- /**
635
- * `compile_binding`が設定されたTaskだけを、TODO正本のidentityつきで投影する(ADR 0124)。
636
- *
637
- * これはTODO工程storeとruntime実行を結ぶ唯一の公開読み取り面である。host は
638
- * `compiled_plan_digest`で`runtime_plan.v1`を、`base_sha`でrun requestのbaseを照合し、
639
- * plan→`executor_packet.v1`→`executor_receipt.v1`(`packet_digest`帰属)まで辿れる。
640
- *
641
- * binding投影は`todo_status_result`とは独立した加算の別面である(ADR 0124)。status側の
642
- * version bump(v4→v5)はこの面の形を動かさない。
643
- */
644
- export function projectTodoBindings(readModel, { requestedPlanKey = null } = {}) {
645
- if (!plain(readModel) || readModel.schema !== 'lattice.todo_store_read.v1'
646
- || !isTodoIdentifier(readModel.project_id) || !Array.isArray(readModel.members)) {
647
- fail('TODO_STATUS_INVALID_INPUT', 'todo_status_read_model_invalid');
648
- }
649
- if (requestedPlanKey !== null && !isTodoIdentifier(requestedPlanKey)) {
650
- fail('TODO_STATUS_INVALID_INPUT', 'todo_binding_plan_key_invalid');
651
- }
652
- const members = [...readModel.members].sort((left, right) => {
653
- const leftKey = left?.plan?.plan_key ?? '';
654
- const rightKey = right?.plan?.plan_key ?? '';
655
- return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
656
- });
657
- const bindings = [];
658
- let matchedPlan = requestedPlanKey === null;
659
- for (const member of members) {
660
- const plan = member?.plan;
661
- if (!plain(plan) || !Array.isArray(plan.tasks)) continue;
662
- if (requestedPlanKey !== null && plan.plan_key !== requestedPlanKey) continue;
663
- if (requestedPlanKey !== null) matchedPlan = true;
664
- for (const task of plan.tasks) {
665
- if (!plain(task) || task.compile_binding === null || task.compile_binding === undefined) continue;
666
- bindings.push({
667
- project_id: plan.project_id,
668
- plan_key: plan.plan_key,
669
- plan_version: plan.plan_version,
670
- task_id: task.task_id,
671
- compile_binding: task.compile_binding,
672
- });
673
- if (bindings.length > TODO_STATUS_LIST_LIMIT) {
674
- fail('TODO_SCALE_EXCEEDED', 'todo_binding_projection_limit_exceeded', {
675
- binding_limit: TODO_STATUS_LIST_LIMIT,
676
- });
677
- }
678
- }
679
- }
680
- if (!matchedPlan) fail('TODO_STATUS_INVALID_INPUT', 'todo_binding_plan_not_found');
681
- const result = {
682
- schema: TODO_BINDING_PROJECTION_SCHEMA,
683
- project_id: readModel.project_id,
684
- plan_key: requestedPlanKey,
685
- bindings,
686
- result_digest: '',
687
- };
688
- result.result_digest = todoSelfDigest(result, 'result_digest');
689
- return result;
690
- }
1
+ import {
2
+ AUDIT_PENDING_PHASE_STATUSES,
3
+ auditPendingNextCommands,
4
+ isAuditPendingPhaseStatus,
5
+ } from './todo-audit-pending.mjs';
6
+ import {
7
+ TODO_COORDINATION_MODES,
8
+ TODO_LIMITS,
9
+ exactRecord,
10
+ isNonNegativeSafeInteger,
11
+ isStrictTodoTimestamp,
12
+ isTodoDigest,
13
+ isTodoIdentifier,
14
+ todoSelfDigest,
15
+ } from './todo-contracts.mjs';
16
+ import { TODO_INDEPENDENCE_COVERAGE } from './todo-independence-contracts.mjs';
17
+ import { todoLegacyReconciliationDigest } from './todo-revision.mjs';
18
+ import {
19
+ isPhaselessTodoPlanSchema,
20
+ projectTodoCrossPlanDependencies,
21
+ todoPhaseDefinitions,
22
+ } from './todo-store.mjs';
23
+
24
+ /**
25
+ * v6で`plan_notes`を足す。ADR 0054・0063の前例どおり既存versionへのin-place追加はしない。
26
+ *
27
+ * plan単位noteは工程に属する義務で、taskへ着手した人のcontextには届くが、**まだ誰も
28
+ * 着手していない工程の義務は、この欄が無いとどこにも出ない**。
29
+ */
30
+ export const TODO_STATUS_SCHEMA = 'lattice.todo_status_result.v6';
31
+ export const TODO_DISPATCH_FRONTIER_SCHEMA = 'lattice.todo_dispatch_frontier.v1';
32
+ export const TODO_STATUS_LIST_LIMIT = 2_000;
33
+ export const TODO_STATUS_LABEL_LIMIT = 160;
34
+ export const TODO_STATUS_REASON_LIMIT = 512;
35
+ export const TODO_STATUS_CAPTURE_LIMIT = 64 * 1_024;
36
+
37
+ export class TodoStatusProjectionError extends Error {
38
+ constructor(code, reason, detail = {}) {
39
+ super(reason);
40
+ this.name = 'TodoStatusProjectionError';
41
+ this.code = code;
42
+ this.detail = { reason, ...detail };
43
+ }
44
+ }
45
+
46
+ function fail(code, reason, detail) {
47
+ throw new TodoStatusProjectionError(code, reason, detail);
48
+ }
49
+
50
+ function plain(value) {
51
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
52
+ && Object.getPrototypeOf(value) === Object.prototype;
53
+ }
54
+
55
+ /** Python consumerのlen()/ord()と同じUnicode code point単位のbounded_text。 */
56
+ export function isTodoStatusBoundedText(value, limit = TODO_STATUS_REASON_LIMIT) {
57
+ if (typeof value !== 'string') return false;
58
+ const points = [...value];
59
+ return points.length > 0 && points.length <= limit
60
+ && points.every((character) => character.codePointAt(0) >= 0x20 && character !== '\x7f');
61
+ }
62
+
63
+ function displayText(value, fallback, limit) {
64
+ const source = typeof value === 'string' && value.length > 0 ? value : fallback;
65
+ const points = [...source].slice(0, limit).map((character) => {
66
+ const codePoint = character.codePointAt(0);
67
+ return codePoint >= 0x20 && codePoint !== 0x7f ? character : '\uFFFD';
68
+ });
69
+ const result = points.join('');
70
+ return result.length > 0 ? result : fallback;
71
+ }
72
+
73
+ function taskKey(projectId, planKey, taskId) {
74
+ return `${projectId}\0${planKey}\0${taskId}`;
75
+ }
76
+
77
+ function refKey(ref) {
78
+ return taskKey(ref.project_id, ref.plan_key, ref.task_id);
79
+ }
80
+
81
+ function compareTaskEntries(left, right) {
82
+ return left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1
83
+ : left.task_id < right.task_id ? -1 : left.task_id > right.task_id ? 1 : 0;
84
+ }
85
+
86
+ function enforceListLimit(name, value) {
87
+ if (value.length > TODO_STATUS_LIST_LIMIT) {
88
+ fail('TODO_SCALE_EXCEEDED', 'todo_status_list_limit_exceeded', {
89
+ list: name,
90
+ count: value.length,
91
+ limit: TODO_STATUS_LIST_LIMIT,
92
+ });
93
+ }
94
+ }
95
+
96
+ function taskEntry(value) {
97
+ return exactRecord(value, ['plan_key', 'task_id', 'label'])
98
+ && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.task_id)
99
+ && isTodoStatusBoundedText(value.label, TODO_STATUS_LABEL_LIMIT);
100
+ }
101
+
102
+ function taskRefEntry(value) {
103
+ return exactRecord(value, ['plan_key', 'task_id'])
104
+ && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.task_id);
105
+ }
106
+
107
+ function activeTaskEntry(value) {
108
+ return exactRecord(value, ['plan_key', 'task_id', 'label', 'unmet_dependencies'])
109
+ && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.task_id)
110
+ && isTodoStatusBoundedText(value.label, TODO_STATUS_LABEL_LIMIT)
111
+ && boundedList(value.unmet_dependencies, taskRefEntry);
112
+ }
113
+
114
+ function blockedEntry(value) {
115
+ return exactRecord(value, ['plan_key', 'task_id', 'reason'])
116
+ && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.task_id)
117
+ && isTodoStatusBoundedText(value.reason, TODO_STATUS_REASON_LIMIT);
118
+ }
119
+
120
+ /**
121
+ * 監査待ちPhase 1件。
122
+ *
123
+ * task entryと紛れないよう`status`ではなく`phase_status`にする。状態集合は
124
+ * `todo-audit-pending.mjs`の定義をそのまま使う(ここで書き直さない)。
125
+ * `next_commands`を非空必須にしたのは、監査待ちなのに次の一手が空なら次アクション面として
126
+ * 無意味だからである(状態集合が閉じている以上、空になる枝は無い)。
127
+ */
128
+ function auditPendingEntry(value) {
129
+ return exactRecord(value, [
130
+ 'plan_key', 'phase_id', 'phase_status', 'implicit', 'required_evidence_slots', 'next_commands',
131
+ ]) && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.phase_id)
132
+ && AUDIT_PENDING_PHASE_STATUSES.has(value.phase_status)
133
+ && typeof value.implicit === 'boolean'
134
+ && boundedList(value.required_evidence_slots, isTodoIdentifier)
135
+ && Array.isArray(value.next_commands) && value.next_commands.length > 0
136
+ && boundedList(value.next_commands, (command) => isTodoStatusBoundedText(command, TODO_STATUS_REASON_LIMIT));
137
+ }
138
+
139
+ function planNoteLatestEntry(value) {
140
+ return exactRecord(value, ['event_digest', 'actor_agent', 'recorded_at'])
141
+ && isTodoDigest(value.event_digest) && isTodoIdentifier(value.actor_agent)
142
+ && isStrictTodoTimestamp(value.recorded_at);
143
+ }
144
+
145
+ /**
146
+ * plan単位noteの要約1件。
147
+ *
148
+ * **本文を持たない。** 自由記述のMarkdownをここへinlineすると、noteを書くほど
149
+ * `TODO_STATUS_CAPTURE_LIMIT`へ近づき、健全なstoreが`TODO_SCALE_EXCEEDED`で落ちる
150
+ * ——「記録すると壊れる」面を作らない。載せるのは件数・帰属・次の一手までで、
151
+ * 中身は`next_commands`が指す`note list`が持つ。
152
+ *
153
+ * `plan_note_head_digest`は`note_context.note_head_digest`(task chain)と**別のchainのhead**なので、
154
+ * 名前で区別する。同名にすると型が同じdigestなので、取り違えてもexact validatorを通ってしまう。
155
+ *
156
+ * `count`は1以上(0件のplanはentryごと出さない)。`next_commands`が非空必須なのは
157
+ * `audit_pending`と同じ理由で、欄に出るだけで次の一手が無いなら次アクション面として無意味である。
158
+ */
159
+ function planNoteEntry(value) {
160
+ return exactRecord(value, ['plan_key', 'plan_note_head_digest', 'count', 'latest', 'next_commands'])
161
+ && isTodoIdentifier(value.plan_key) && isTodoDigest(value.plan_note_head_digest)
162
+ && isNonNegativeSafeInteger(value.count) && value.count > 0
163
+ && Array.isArray(value.latest) && value.latest.length > 0
164
+ && value.latest.length <= Math.min(value.count, TODO_LIMITS.statusPlanNoteLatest)
165
+ && value.latest.every(planNoteLatestEntry)
166
+ && value.latest[0].event_digest === value.plan_note_head_digest
167
+ && Array.isArray(value.next_commands) && value.next_commands.length > 0
168
+ && boundedList(value.next_commands,
169
+ (command) => isTodoStatusBoundedText(command, TODO_STATUS_REASON_LIMIT));
170
+ }
171
+
172
+ /**
173
+ * 調整方式を宣言したplan 1件(ob03・オーナー裁定C①)。
174
+ *
175
+ * **宣言済みのplanだけを列挙する。** 未宣言を`mode: null`で全plan出すと、plan数ぶん常に
176
+ * 埋まって読み飛ばされる列になる——前campaignで`audit_pending`の設計時に避けた形と同じ。
177
+ * 未宣言は「`member_heads`に居て`coordination`に居ない」で引ける。
178
+ *
179
+ * `declared_by`が本体である。witnessが全planの暗黙義務だった時に無かったのが帰属で、
180
+ * ここを落とすとこの欄は「もう1つの督促」に戻る。
181
+ */
182
+ function coordinationEntry(value) {
183
+ return exactRecord(value, ['plan_key', 'mode', 'declared_by', 'declared_at', 'reason'])
184
+ && isTodoIdentifier(value.plan_key)
185
+ && TODO_COORDINATION_MODES.includes(value.mode)
186
+ && exactRecord(value.declared_by, ['host', 'session', 'agent'])
187
+ && ['host', 'session', 'agent'].every((key) => isTodoStatusBoundedText(value.declared_by[key], TODO_STATUS_LABEL_LIMIT))
188
+ && isStrictTodoTimestamp(value.declared_at)
189
+ && isTodoStatusBoundedText(value.reason, TODO_STATUS_REASON_LIMIT);
190
+ }
191
+
192
+ /**
193
+ * 並列候補1 plan(ob05・オーナー裁定C③)。
194
+ *
195
+ * **新しい判定は1つも行わない。** ここに載るのは`projectIndependenceFrontier`が既に出している
196
+ * 結果を、候補の視点で並べ直したものだけである。並列できそうな組を選ぶのはAIの仕事で、
197
+ * 機械が持つのは「まだ判定していないreadyはこれ」「判定済みの結果はこれ」だけ
198
+ * ——推定・判断をLatticeの中へ実装しない(所有境界)。
199
+ *
200
+ * **ready taskを1つも持たないplanはentryごと出さない。** 全plan常に1行にすると、plan数ぶん
201
+ * 埋まって読み飛ばされる列になる(前campaignのwitness `coverage: missing`が実際にそうなった)。
202
+ *
203
+ * 逆に`coverage: 'missing'`のplanは**出す**。「まだ誰も判定していない」はこの欄が最も要る状態で、
204
+ * そこを飛ばすと沈黙が不在に見える。
205
+ */
206
+ function parallelCandidateEntry(value) {
207
+ return exactRecord(value, [
208
+ 'plan_key', 'coverage', 'unreadable_reason', 'unjudged_task_ids',
209
+ 'verified_parallel_groups', 'serialize_pairs', 'next_commands',
210
+ ]) && isTodoIdentifier(value.plan_key)
211
+ // 読めない記録は`coverage`を名乗らず理由を名乗る(ADR 0131・`summarizeIndependence`と同じ答え方)。
212
+ // 「壊れている」を「まだ判定していない」へ丸めない。
213
+ && (value.unreadable_reason === null
214
+ ? TODO_INDEPENDENCE_COVERAGE.includes(value.coverage)
215
+ : value.coverage === null
216
+ && isTodoStatusBoundedText(value.unreadable_reason, TODO_STATUS_REASON_LIMIT))
217
+ && boundedList(value.unjudged_task_ids, isTodoIdentifier)
218
+ // 1件の組は並列の情報を持たない(taskは常に自分と並列である)。生産側が落としている以上、
219
+ // 契約側でも受けない——受けると消費者が「2件以上」を前提にできなくなる。
220
+ && boundedList(value.verified_parallel_groups, (group) => exactRecord(group, ['task_ids'])
221
+ && Array.isArray(group.task_ids) && group.task_ids.length > 1
222
+ && group.task_ids.every(isTodoIdentifier))
223
+ && boundedList(value.serialize_pairs, (pair) => exactRecord(pair, ['task_ids', 'type', 'detail'])
224
+ && Array.isArray(pair.task_ids) && pair.task_ids.length === 2
225
+ && pair.task_ids.every(isTodoIdentifier)
226
+ && isTodoStatusBoundedText(pair.type, TODO_STATUS_LABEL_LIMIT)
227
+ && isTodoStatusBoundedText(pair.detail, TODO_STATUS_REASON_LIMIT))
228
+ // 候補が在るのに次の一手が無い欄は、この工程が直している「欄だけ置いて閉じる」形になる。
229
+ && Array.isArray(value.next_commands) && value.next_commands.length > 0
230
+ && boundedList(value.next_commands,
231
+ (command) => isTodoStatusBoundedText(command, TODO_STATUS_REASON_LIMIT))
232
+ // 何も無いplanは出さない。空entryは「判定する対象が無い」と「判定が済んだ」を混ぜる。
233
+ && (value.unjudged_task_ids.length > 0 || value.verified_parallel_groups.length > 0
234
+ || value.serialize_pairs.length > 0);
235
+ }
236
+
237
+ function memberHead(value) {
238
+ return exactRecord(value, [
239
+ 'plan_key', 'plan_version', 'through_sequence', 'journal_head_digest',
240
+ 'reconciliation_state', 'revision_digest', 'reconciliation_digest',
241
+ ]) && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.plan_version)
242
+ && isNonNegativeSafeInteger(value.through_sequence) && isTodoDigest(value.journal_head_digest)
243
+ && ['registered_unreconciled', 'reconciled'].includes(value.reconciliation_state)
244
+ && (value.revision_digest === null || isTodoDigest(value.revision_digest))
245
+ && isTodoDigest(value.reconciliation_digest)
246
+ && ((value.reconciliation_state === 'registered_unreconciled' && value.revision_digest === null)
247
+ || (value.reconciliation_state === 'reconciled' && isTodoDigest(value.revision_digest)));
248
+ }
249
+
250
+ function boundedList(value, validator) {
251
+ return Array.isArray(value) && value.length <= TODO_STATUS_LIST_LIMIT && value.every(validator);
252
+ }
253
+
254
+ function dispatchFrontier(projectId, nextReady) {
255
+ const preimage = {
256
+ schema: TODO_DISPATCH_FRONTIER_SCHEMA,
257
+ project_id: projectId,
258
+ tasks: nextReady.map(({ plan_key: planKey, task_id: taskId }) => ({
259
+ plan_key: planKey,
260
+ task_id: taskId,
261
+ })),
262
+ frontier_digest: '',
263
+ };
264
+ return {
265
+ schema: TODO_DISPATCH_FRONTIER_SCHEMA,
266
+ selection_source: 'next_ready',
267
+ policy: 'all_ready_parallel_by_default',
268
+ recommended_parallelism: nextReady.length,
269
+ subset_requires_reason: nextReady.length > 1,
270
+ parallel_start_flag: '--parallel-frontier',
271
+ frontier_digest: todoSelfDigest(preimage, 'frontier_digest'),
272
+ };
273
+ }
274
+
275
+ function dispatchFrontierEntry(value, projectId, nextReady) {
276
+ const expected = dispatchFrontier(projectId, nextReady);
277
+ return exactRecord(value, [
278
+ 'schema', 'selection_source', 'policy', 'recommended_parallelism',
279
+ 'subset_requires_reason', 'parallel_start_flag', 'frontier_digest',
280
+ ]) && value.schema === TODO_DISPATCH_FRONTIER_SCHEMA
281
+ && value.selection_source === 'next_ready'
282
+ && value.policy === 'all_ready_parallel_by_default'
283
+ && isNonNegativeSafeInteger(value.recommended_parallelism)
284
+ && value.recommended_parallelism === nextReady.length
285
+ && value.subset_requires_reason === (nextReady.length > 1)
286
+ && value.parallel_start_flag === '--parallel-frontier'
287
+ && value.frontier_digest === expected.frontier_digest;
288
+ }
289
+
290
+ /** todo status v6 wire shapeを検証し、digestも再計算する。 */
291
+ export function validateTodoStatusResult(value) {
292
+ try {
293
+ return exactRecord(value, [
294
+ 'schema', 'project_id', 'active_set', 'next_ready', 'dispatch_frontier',
295
+ 'blocked', 'audit_pending', 'plan_notes', 'coordination', 'parallel_candidates',
296
+ 'member_heads', 'result_digest',
297
+ ]) && value.schema === TODO_STATUS_SCHEMA && isTodoIdentifier(value.project_id)
298
+ && boundedList(value.active_set, activeTaskEntry) && boundedList(value.next_ready, taskEntry)
299
+ && dispatchFrontierEntry(value.dispatch_frontier, value.project_id, value.next_ready)
300
+ && boundedList(value.blocked, blockedEntry) && boundedList(value.audit_pending, auditPendingEntry)
301
+ && boundedList(value.plan_notes, planNoteEntry)
302
+ && boundedList(value.coordination, coordinationEntry)
303
+ && boundedList(value.parallel_candidates, parallelCandidateEntry)
304
+ && boundedList(value.member_heads, memberHead)
305
+ && isTodoDigest(value.result_digest)
306
+ && value.result_digest === todoSelfDigest(value, 'result_digest');
307
+ } catch {
308
+ return false;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * memberの監査待ちPhaseを`audit_pending` entryへ起こす。
314
+ *
315
+ * 「監査待ちか」の判定は`todo-audit-pending.mjs`、Phase定義(slots)は`todo-store.mjs`の
316
+ * `todoPhaseDefinitions`が正本である。`plan.phases`を直接読むとphase無しplan(v1/v2/v3)と
317
+ * synthetic read modelで落ちるので読まない。
318
+ *
319
+ * dispatchへは一切流さない——ここで作るのは`member.phases`だけを見る別の列であり、
320
+ * nodes/incomingへは入れない(ADR 0062・ADR 0147裁定5)。
321
+ */
322
+ function collectAuditPending(member, sink) {
323
+ const declared = todoPhaseDefinitions(member.plan);
324
+ // Phase plan(v4/v5/v7)なのに`plan.phases`が配列でない場合はここでtypedに落とす
325
+ // (素のTypeErrorで抜けさせない)。phase無しplanは常に暗黙Phase 1件が返るので該当しない。
326
+ if (!Array.isArray(declared)) {
327
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_plan_phases_invalid', { plan_key: member.plan.plan_key });
328
+ }
329
+ const definitions = new Map(declared.map((entry) => [entry.phase_id, entry]));
330
+ const implicit = isPhaselessTodoPlanSchema(member.plan.schema);
331
+ for (const phase of member.phases ?? []) {
332
+ if (!isAuditPendingPhaseStatus(phase?.status)) continue;
333
+ const definition = definitions.get(phase.phase_id);
334
+ // 導出ビューに在ってplanに定義が無いPhaseは、状態とplanがずれている証拠である。
335
+ // slotsを空へ丸めるとgateが何を要求するか嘘を吐くので、fail closedにする。
336
+ if (definition === undefined) {
337
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_phase_definition_missing', {
338
+ plan_key: member.plan.plan_key, phase_id: phase.phase_id,
339
+ });
340
+ }
341
+ sink.push({
342
+ plan_key: member.plan.plan_key,
343
+ phase_id: phase.phase_id,
344
+ phase_status: phase.status,
345
+ implicit,
346
+ required_evidence_slots: [...definition.required_evidence_slots],
347
+ next_commands: auditPendingNextCommands(member.plan.plan_key, phase.phase_id, phase.status),
348
+ });
349
+ }
350
+ }
351
+
352
+ /**
353
+ * read modelからtask node・依存辺・phase gateを組み立てる。
354
+ *
355
+ * ready判定の唯一の計算元。`projectTodoStatus`と`computeReadyFrontier`が同じ結果を
356
+ * 出すために共有する(同じ規則を二箇所へ持たない)。
357
+ */
358
+ function buildTodoGraph(readModel) {
359
+ if (!plain(readModel) || readModel.schema !== 'lattice.todo_store_read.v1'
360
+ || !isTodoIdentifier(readModel.project_id) || !Array.isArray(readModel.members)) {
361
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_read_model_invalid');
362
+ }
363
+
364
+ const nodes = new Map();
365
+ const incoming = new Map();
366
+ const memberHeads = [];
367
+ const auditPending = [];
368
+ const coordination = [];
369
+ // snapshot artifactの形式(v1にはphasesキーが無い)には縛られない導出ビューを読む
370
+ // (readTodoStoreが常にmember.phasesとして埋める。ADR 0147)。
371
+ const phaseStatuses = new Map(readModel.members.flatMap((member) => (
372
+ (member.phases ?? []).map((phase) => [
373
+ `${member.plan.project_id}\0${member.plan.plan_key}\0${phase.phase_id}`, phase.status,
374
+ ])
375
+ )));
376
+ const members = [...readModel.members].sort((left, right) => {
377
+ const leftKey = left?.descriptor?.plan_key ?? '';
378
+ const rightKey = right?.descriptor?.plan_key ?? '';
379
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
380
+ });
381
+
382
+ for (const member of members) {
383
+ if (!plain(member) || !plain(member.plan) || !plain(member.descriptor)
384
+ || !Array.isArray(member.plan.tasks) || !Array.isArray(member.plan.hard_dependencies)
385
+ || !Array.isArray(member.plan.joins) || !Array.isArray(member.tasks)
386
+ || !plain(member.journal) || !Array.isArray(member.journal.events)) {
387
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_member_invalid');
388
+ }
389
+ const head = member.journal.events.at(-1);
390
+ const genesis = member.journal.events[0];
391
+ if (!plain(head) || !isNonNegativeSafeInteger(head.sequence) || !isTodoDigest(head.event_digest)) {
392
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_member_head_invalid');
393
+ }
394
+ const reconciled = ['lattice.todo_event.v2', 'lattice.todo_event.v4'].includes(genesis.schema);
395
+ const phaseV3 = genesis.schema === 'lattice.todo_event.v4'
396
+ && member.revision?.schema === 'lattice.phase_todo_revision.v3';
397
+ memberHeads.push({
398
+ plan_key: member.plan.plan_key,
399
+ plan_version: member.plan.plan_version,
400
+ through_sequence: head.sequence,
401
+ journal_head_digest: head.event_digest,
402
+ reconciliation_state: reconciled ? 'reconciled' : 'registered_unreconciled',
403
+ revision_digest: reconciled ? genesis.revision_digest : null,
404
+ reconciliation_digest: reconciled
405
+ ? phaseV3 ? member.revision.reconciliation.reconciliation_digest
406
+ : genesis.schema === 'lattice.todo_event.v4' ? genesis.revision_digest
407
+ : genesis.reconciliation_digest
408
+ : todoLegacyReconciliationDigest({
409
+ planDigest: member.plan.plan_digest, journalHeadDigest: head.event_digest,
410
+ }),
411
+ });
412
+ const states = new Map(member.tasks.map((state) => [state.task_id, state]));
413
+ // snapshot artifactの形式には縛られない導出ビュー(member.phases)を読む(ADR 0147)。
414
+ const phases = new Map((member.phases ?? []).map((state) => [state.phase_id, state]));
415
+ collectAuditPending(member, auditPending);
416
+ // 調整方式の宣言(ob03)。member.coordinationはstoreがplan-scoped chainから投影した
417
+ // 導出ビューで、未宣言はnull。宣言済みだけをここへ載せる。
418
+ if (member.coordination !== null && member.coordination !== undefined) {
419
+ coordination.push({
420
+ plan_key: member.plan.plan_key,
421
+ mode: member.coordination.mode,
422
+ declared_by: member.coordination.declared_by,
423
+ declared_at: member.coordination.declared_at,
424
+ reason: displayText(member.coordination.reason, member.coordination.mode, TODO_STATUS_REASON_LIMIT),
425
+ });
426
+ }
427
+ for (const task of member.plan.tasks) {
428
+ const state = states.get(task.task_id);
429
+ if (!plain(state) || !['pending', 'in-progress', 'blocked', 'done'].includes(state.status)) {
430
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_task_state_missing', {
431
+ plan_key: member.plan.plan_key,
432
+ task_id: task.task_id,
433
+ });
434
+ }
435
+ const key = taskKey(member.plan.project_id, member.plan.plan_key, task.task_id);
436
+ if (nodes.has(key)) fail('TODO_STATUS_INVALID_INPUT', 'todo_status_task_duplicate');
437
+ nodes.set(key, {
438
+ key,
439
+ project_id: member.plan.project_id,
440
+ plan_key: member.plan.plan_key,
441
+ task_id: task.task_id,
442
+ label: displayText(task.title, task.task_id, TODO_STATUS_LABEL_LIMIT),
443
+ status: state.status,
444
+ blocked_reason: state.blocked_reason,
445
+ plan_schema: member.plan.schema,
446
+ phase_id: ['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(member.plan.schema)
447
+ ? task.phase_id : null,
448
+ phase_status: ['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(member.plan.schema)
449
+ ? phases.get(task.phase_id)?.status : null,
450
+ phase_ready: member.plan.schema !== 'lattice.todo_plan.v4'
451
+ || phases.get(task.phase_id)?.status === 'active',
452
+ });
453
+ incoming.set(key, new Set());
454
+ }
455
+ }
456
+
457
+ const addPredecessor = (from, to) => {
458
+ const fromKey = refKey(from);
459
+ const toKey = refKey(to);
460
+ if (!nodes.has(fromKey) || !nodes.has(toKey)) {
461
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_dependency_dangling');
462
+ }
463
+ incoming.get(toKey).add(fromKey);
464
+ };
465
+ const phaseAcceptIncoming = new Map([...nodes.keys()].map((key) => [key, new Set()]));
466
+ for (const member of members) {
467
+ for (const edge of member.plan.hard_dependencies) addPredecessor(edge.from, edge.to);
468
+ for (const join of member.plan.joins) {
469
+ for (const after of join.after) addPredecessor(after, join.before);
470
+ }
471
+ if (['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(member.plan.schema)) {
472
+ for (const edge of member.plan.phase_accept_dependencies) {
473
+ const target = refKey(edge.to);
474
+ if (!nodes.has(target)) fail('TODO_STATUS_INVALID_INPUT', 'todo_status_dependency_dangling');
475
+ phaseAcceptIncoming.get(target).add(
476
+ `${edge.from.project_id}\0${edge.from.plan_key}\0${edge.from.phase_id}`,
477
+ );
478
+ }
479
+ }
480
+ }
481
+ // active plan topologyは追記改変しない。開発中に接続したplan-scoped edgeを同じincomingへ
482
+ // 合成することで、projectTodoStatusとcomputeReadyFrontierが同じ待機判定を即座に読む。
483
+ for (const dependency of projectTodoCrossPlanDependencies(members)) {
484
+ addPredecessor(dependency.from, dependency.to);
485
+ }
486
+
487
+ auditPending.sort((left, right) => (
488
+ left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1
489
+ : left.phase_id < right.phase_id ? -1 : left.phase_id > right.phase_id ? 1 : 0));
490
+
491
+ coordination.sort((left, right) => (
492
+ left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0));
493
+
494
+ return { nodes, incoming, phaseAcceptIncoming, phaseStatuses, memberHeads, auditPending, coordination };
495
+ }
496
+
497
+ /** 先行完了とphase gateを満たしたpending taskだけがreadyになる。 */
498
+ function isNodeReady(node, { nodes, incoming, phaseAcceptIncoming, phaseStatuses }) {
499
+ return node.status === 'pending' && node.phase_ready
500
+ && [...incoming.get(node.key)].every((key) => {
501
+ const predecessor = nodes.get(key);
502
+ return predecessor.status === 'done'
503
+ && (predecessor.plan_schema !== 'lattice.todo_plan.v4'
504
+ || predecessor.phase_id === null
505
+ || (predecessor.plan_key === node.plan_key && predecessor.phase_id === node.phase_id)
506
+ || predecessor.phase_status === 'accepted');
507
+ })
508
+ && [...phaseAcceptIncoming.get(node.key)]
509
+ .every((key) => phaseStatuses.get(key) === 'accepted');
510
+ }
511
+
512
+ /**
513
+ * ready frontierだけをread-only投影する。
514
+ *
515
+ * `todo status`の`next_ready`と同じ計算・同じ順序を返す。independence投影が
516
+ * ready集合を別実装で持たないための共有入口である。
517
+ */
518
+ export function computeReadyFrontier(readModel) {
519
+ const graph = buildTodoGraph(readModel);
520
+ const ready = [...graph.nodes.values()]
521
+ .filter((node) => isNodeReady(node, graph))
522
+ .map((node) => ({ plan_key: node.plan_key, task_id: node.task_id, label: node.label }));
523
+ ready.sort(compareTaskEntries);
524
+ enforceListLimit('next_ready', ready);
525
+ return ready;
526
+ }
527
+
528
+ /**
529
+ * dispatch面(`next_ready`/`active_set`/`dispatch_frontier`)だけを見る内部呼び出しが渡す明示の空。
530
+ *
531
+ * `todo start`のready判定・gantt・dashboardの生存判定は、statusのresultを**外へ出さない**。
532
+ * そこでnote chainを読むと、noteの破損がdispatch判定やdashboardの可視性を巻き添えに落とす
533
+ * ——ganttのnote破損は既に警告として表出する契約があり(`notesForGantt`)、二重に落とさない。
534
+ * この定数を使った結果をwireとして出力しないこと。`plan_notes`が常に空になる。
535
+ */
536
+ export const TODO_STATUS_DISPATCH_ONLY = Object.freeze({
537
+ planNotes: Object.freeze([]), parallelCandidates: Object.freeze([]),
538
+ });
539
+
540
+ /**
541
+ * Canonical todo read modelからSessionStart向け現在地をread-only投影する。
542
+ *
543
+ * `planNotes`は**必須**である。plan単位noteはstore read modelに入っていない別chainなので、
544
+ * 読むのは呼び出し側の責務になる。省略を空配列へ丸めると「noteが無い」と「読まなかった」が
545
+ * 同じ形になり、配線を1箇所忘れただけで義務が静かに消える——それはこの欄が塞いでいる穴そのものである。
546
+ * 素材は`readTodoPlanNotesForStatus`(`src/todo-note-store.mjs`)が作り、resultを出力する
547
+ * 呼び出し元だけがそれを渡す。dispatch面しか見ない内部呼び出しは`TODO_STATUS_DISPATCH_ONLY`を使う。
548
+ */
549
+ export function projectTodoStatus(readModel, options = undefined) {
550
+ if (!exactRecord(options, ['planNotes', 'parallelCandidates'])
551
+ || !Array.isArray(options.planNotes)) {
552
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_plan_notes_missing');
553
+ }
554
+ if (!Array.isArray(options.parallelCandidates)) {
555
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_parallel_candidates_missing');
556
+ }
557
+ const graph = buildTodoGraph(readModel);
558
+ const { nodes, incoming, memberHeads, auditPending, coordination } = graph;
559
+ const planNotes = [...options.planNotes];
560
+ const parallelCandidates = [...options.parallelCandidates];
561
+
562
+ const activeSet = [];
563
+ const nextReady = [];
564
+ const blocked = [];
565
+ for (const node of nodes.values()) {
566
+ const task = { plan_key: node.plan_key, task_id: node.task_id, label: node.label };
567
+ if (node.status === 'in-progress') {
568
+ const unmetDependencies = [...incoming.get(node.key)]
569
+ .filter((key) => nodes.get(key).status !== 'done')
570
+ .map((key) => {
571
+ const predecessor = nodes.get(key);
572
+ return { plan_key: predecessor.plan_key, task_id: predecessor.task_id };
573
+ })
574
+ .sort(compareTaskEntries);
575
+ enforceListLimit('active_set.unmet_dependencies', unmetDependencies);
576
+ activeSet.push({ ...task, unmet_dependencies: unmetDependencies });
577
+ }
578
+ if (isNodeReady(node, graph)) nextReady.push(task);
579
+ if (node.status === 'blocked') {
580
+ blocked.push({
581
+ plan_key: node.plan_key,
582
+ task_id: node.task_id,
583
+ reason: displayText(node.blocked_reason, 'blocked', TODO_STATUS_REASON_LIMIT),
584
+ });
585
+ }
586
+ }
587
+ activeSet.sort(compareTaskEntries);
588
+ nextReady.sort(compareTaskEntries);
589
+ blocked.sort(compareTaskEntries);
590
+ memberHeads.sort((left, right) => left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0);
591
+ for (const [name, value] of [
592
+ ['active_set', activeSet], ['next_ready', nextReady], ['blocked', blocked],
593
+ ['audit_pending', auditPending], ['plan_notes', planNotes],
594
+ ['coordination', coordination], ['parallel_candidates', parallelCandidates],
595
+ ['member_heads', memberHeads],
596
+ ]) enforceListLimit(name, value);
597
+
598
+ const result = {
599
+ schema: TODO_STATUS_SCHEMA,
600
+ project_id: readModel.project_id,
601
+ active_set: activeSet,
602
+ next_ready: nextReady,
603
+ dispatch_frontier: dispatchFrontier(readModel.project_id, nextReady),
604
+ blocked,
605
+ // 監査待ちは`member.phases`だけから作った別の列で、dispatch(next_ready/dispatch_frontier)へは
606
+ // 影響しない。監査が進んでもfrontier_digestは動かない。
607
+ audit_pending: auditPending,
608
+ // plan単位noteも同じく別の列である。noteの有無・件数はdispatchへ影響しないので、
609
+ // next_ready・dispatch_frontier・frontier_digestはnoteを書いても1バイトも動かない。
610
+ plan_notes: planNotes,
611
+ // 調整方式の宣言も同じく別の列である。宣言はdispatchを変えない——未宣言でもready
612
+ // frontierは通常どおり出る(ADR 0160・ob04のProtected behavior)。
613
+ coordination,
614
+ // 並列候補も別の列である。判定が進んでもdispatchは動かない——next_ready・
615
+ // dispatch_frontier・frontier_digestは判定状態を1バイトも含まない(ADR 0063・ob04)。
616
+ parallel_candidates: parallelCandidates,
617
+ member_heads: memberHeads,
618
+ result_digest: '',
619
+ };
620
+ result.result_digest = todoSelfDigest(result, 'result_digest');
621
+ if (!validateTodoStatusResult(result)) fail('TODO_STATUS_INVALID_RESULT', 'todo_status_result_invalid');
622
+ const resultBytes = Buffer.byteLength(`${JSON.stringify(result)}\n`);
623
+ if (resultBytes > TODO_STATUS_CAPTURE_LIMIT) {
624
+ fail('TODO_SCALE_EXCEEDED', 'todo_status_result_size_limit_exceeded', {
625
+ result_bytes: resultBytes,
626
+ result_limit: TODO_STATUS_CAPTURE_LIMIT,
627
+ });
628
+ }
629
+ return result;
630
+ }
631
+
632
+ export const TODO_BINDING_PROJECTION_SCHEMA = 'lattice.todo_binding_projection.v1';
633
+
634
+ /**
635
+ * `compile_binding`が設定されたTaskだけを、TODO正本のidentityつきで投影する(ADR 0124)。
636
+ *
637
+ * これはTODO工程storeとruntime実行を結ぶ唯一の公開読み取り面である。host は
638
+ * `compiled_plan_digest`で`runtime_plan.v1`を、`base_sha`でrun requestのbaseを照合し、
639
+ * plan→`executor_packet.v1`→`executor_receipt.v1`(`packet_digest`帰属)まで辿れる。
640
+ *
641
+ * binding投影は`todo_status_result`とは独立した加算の別面である(ADR 0124)。status側の
642
+ * version bump(v4→v5)はこの面の形を動かさない。
643
+ */
644
+ export function projectTodoBindings(readModel, { requestedPlanKey = null } = {}) {
645
+ if (!plain(readModel) || readModel.schema !== 'lattice.todo_store_read.v1'
646
+ || !isTodoIdentifier(readModel.project_id) || !Array.isArray(readModel.members)) {
647
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_read_model_invalid');
648
+ }
649
+ if (requestedPlanKey !== null && !isTodoIdentifier(requestedPlanKey)) {
650
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_binding_plan_key_invalid');
651
+ }
652
+ const members = [...readModel.members].sort((left, right) => {
653
+ const leftKey = left?.plan?.plan_key ?? '';
654
+ const rightKey = right?.plan?.plan_key ?? '';
655
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
656
+ });
657
+ const bindings = [];
658
+ let matchedPlan = requestedPlanKey === null;
659
+ for (const member of members) {
660
+ const plan = member?.plan;
661
+ if (!plain(plan) || !Array.isArray(plan.tasks)) continue;
662
+ if (requestedPlanKey !== null && plan.plan_key !== requestedPlanKey) continue;
663
+ if (requestedPlanKey !== null) matchedPlan = true;
664
+ for (const task of plan.tasks) {
665
+ if (!plain(task) || task.compile_binding === null || task.compile_binding === undefined) continue;
666
+ bindings.push({
667
+ project_id: plan.project_id,
668
+ plan_key: plan.plan_key,
669
+ plan_version: plan.plan_version,
670
+ task_id: task.task_id,
671
+ compile_binding: task.compile_binding,
672
+ });
673
+ if (bindings.length > TODO_STATUS_LIST_LIMIT) {
674
+ fail('TODO_SCALE_EXCEEDED', 'todo_binding_projection_limit_exceeded', {
675
+ binding_limit: TODO_STATUS_LIST_LIMIT,
676
+ });
677
+ }
678
+ }
679
+ }
680
+ if (!matchedPlan) fail('TODO_STATUS_INVALID_INPUT', 'todo_binding_plan_not_found');
681
+ const result = {
682
+ schema: TODO_BINDING_PROJECTION_SCHEMA,
683
+ project_id: readModel.project_id,
684
+ plan_key: requestedPlanKey,
685
+ bindings,
686
+ result_digest: '',
687
+ };
688
+ result.result_digest = todoSelfDigest(result, 'result_digest');
689
+ return result;
690
+ }