@quolu/lattice 0.46.2 → 0.48.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,114 @@
1
+ /**
2
+ * status面の`parallel_candidates`欄の素材(ob05・オーナー裁定C③)。
3
+ *
4
+ * **判定は1つも行わない。** ここに載るのは`projectIndependenceFrontier`が既に出している結果を
5
+ * 候補の視点で並べ直したものだけである。並列できそうな組を選ぶのはAIの仕事で、機械が持つのは
6
+ * 「まだ判定していないreadyはこれ」「判定済みの結果はこれ」だけ——推定・判断をLatticeの中へ
7
+ * 実装しない(所有境界)。
8
+ *
9
+ * `independenceForGantt`と違い、**記録が無いplanを飛ばさない**。artifactがnullの状態は
10
+ * 「まだ誰も判定していない」であって、この欄が最も要る場面である。飛ばすと沈黙が不在に見える。
11
+ *
12
+ * `todo status`と`lattice status`の両方が使うので、どちらのCLIにも属さない場所へ置く。
13
+ * gitの読みは呼び出し側から渡す(`gitHead`/`changedPathsSince`)——このmoduleがgitの
14
+ * 呼び方を決めると、2つのCLIが持つ既存の作法を上書きすることになる。
15
+ */
16
+
17
+ import { projectIndependenceFrontier } from './todo-independence.mjs';
18
+ import { readTodoIndependenceArtifact } from './todo-store.mjs';
19
+ import { TODO_STATUS_DISPATCH_ONLY, computeReadyFrontier, projectTodoStatus } from './todo-status.mjs';
20
+
21
+ /** 記録が無いplanでは鮮度を見ないので、HEADの代わりに使う。project-cliと同じ作法。 */
22
+ const PLACEHOLDER_SHA = '0'.repeat(40);
23
+
24
+ const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
25
+
26
+ export async function readTodoParallelCandidatesForStatus(options = {}) {
27
+ const { repoRoot, store, gitHead, changedPathsSince = () => null } = options;
28
+ const frontier = computeReadyFrontier(store);
29
+ const status = projectTodoStatus(store, TODO_STATUS_DISPATCH_ONLY);
30
+ let currentBaseSha = null;
31
+ const candidates = [];
32
+ for (const member of store.members) {
33
+ const planKey = member.plan.plan_key;
34
+ const readyTaskIds = frontier.filter((task) => task.plan_key === planKey)
35
+ .map(({ task_id: taskId }) => taskId);
36
+ let artifact = null;
37
+ let unreadableReason = null;
38
+ try {
39
+ artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
40
+ } catch (error) {
41
+ // 読めない記録を「記録なし」へ丸めない(ADR 0131)。丸めると「壊れている」が
42
+ // 「まだ判定していない」と同じ形になり、沈黙が不在に見える。理由を載せて先へ進む
43
+ // ——1 planの壊れでstatus面ごと落とすと、他planの候補まで見えなくなる。
44
+ // `summarizeIndependence`(project-cli)と同じ答え方に揃える。
45
+ unreadableReason = error?.code
46
+ ? `${error.code}:${error.detail?.reason ?? error.message}` : 'independence_unreadable';
47
+ }
48
+ // ready taskが1つも無く記録も無いplanは、判定する対象そのものが無い。entryごと出さない。
49
+ if (readyTaskIds.length === 0 && artifact === null && unreadableReason === null) continue;
50
+ // HEADが要るのは鮮度の判定だけである。記録が1つも無いplanでHEADを引くと、commitの無い
51
+ // repo(初期化直後・test fixture)で`git_head_unresolved`に落ちる——判定していない
52
+ // planを見るために、判定に使わない値の解決を要求してはいけない。
53
+ if (artifact !== null && currentBaseSha === null) {
54
+ try {
55
+ currentBaseSha = gitHead(repoRoot);
56
+ } catch (error) {
57
+ // HEADが解決できない(commitが1つも無いrepo等)なら鮮度を判定できない。
58
+ // placeholderで代用すると「判定済みだが古い」と断言することになる——
59
+ // 知らないことを知っていると言わない。1 planの事情で面ごと落とさないのも同じ理由。
60
+ unreadableReason = error?.code
61
+ ? `${error.code}:${error.detail?.reason ?? error.message}` : 'independence_base_unresolved';
62
+ }
63
+ }
64
+ if (unreadableReason !== null) {
65
+ candidates.push({
66
+ plan_key: planKey,
67
+ // 壊れた記録から判定は読めない。coverageは名乗らず、理由を名乗る。
68
+ coverage: null,
69
+ unreadable_reason: unreadableReason,
70
+ unjudged_task_ids: readyTaskIds,
71
+ verified_parallel_groups: [],
72
+ serialize_pairs: [],
73
+ next_commands: [`lattice todo independence compile --plan ${planKey} --input <file>`],
74
+ });
75
+ continue;
76
+ }
77
+ const baseSha = currentBaseSha ?? PLACEHOLDER_SHA;
78
+ const changedPaths = artifact !== null && artifact.base_sha !== null
79
+ && artifact.base_sha !== baseSha
80
+ ? changedPathsSince(repoRoot, artifact.base_sha) : null;
81
+ const projected = projectIndependenceFrontier({
82
+ artifact,
83
+ readyTaskIds,
84
+ activeTaskIds: status.active_set.filter((task) => task.plan_key === planKey)
85
+ .map(({ task_id: taskId }) => taskId),
86
+ plan: member.plan,
87
+ currentBaseSha: baseSha,
88
+ changedPaths,
89
+ });
90
+ const unjudged = projected.frontier.unknown.map(({ task_id: taskId }) => taskId);
91
+ // 1件だけの「並列group」は並列の情報を持たない(1つのtaskは常に自分と並列である)。
92
+ const groups = projected.frontier.parallel_groups
93
+ .filter((group) => group.task_ids.length > 1)
94
+ .map(({ task_ids: taskIds }) => ({ task_ids: taskIds }));
95
+ const pairs = projected.frontier.serialize_pairs.map((pair) => ({
96
+ task_ids: pair.task_ids, type: pair.type, detail: pair.detail,
97
+ }));
98
+ if (unjudged.length === 0 && groups.length === 0 && pairs.length === 0) continue;
99
+ candidates.push({
100
+ plan_key: planKey,
101
+ coverage: projected.coverage,
102
+ unreadable_reason: null,
103
+ unjudged_task_ids: unjudged,
104
+ verified_parallel_groups: groups,
105
+ serialize_pairs: pairs,
106
+ // 欄だけ置いて閉じない。未判定が残るなら宣言してcompile、済んでいるなら読み出し。
107
+ next_commands: unjudged.length > 0
108
+ ? [`lattice todo independence compile --plan ${planKey} --input <file>`]
109
+ : [`lattice todo independence --plan ${planKey} --json`],
110
+ });
111
+ }
112
+ candidates.sort((left, right) => compareText(left.plan_key, right.plan_key));
113
+ return candidates;
114
+ }
@@ -1,13 +1,29 @@
1
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,
2
9
  exactRecord,
3
10
  isNonNegativeSafeInteger,
11
+ isStrictTodoTimestamp,
4
12
  isTodoDigest,
5
13
  isTodoIdentifier,
6
14
  todoSelfDigest,
7
15
  } from './todo-contracts.mjs';
16
+ import { TODO_INDEPENDENCE_COVERAGE } from './todo-independence-contracts.mjs';
8
17
  import { todoLegacyReconciliationDigest } from './todo-revision.mjs';
18
+ import { isPhaselessTodoPlanSchema, todoPhaseDefinitions } from './todo-store.mjs';
9
19
 
10
- export const TODO_STATUS_SCHEMA = 'lattice.todo_status_result.v4';
20
+ /**
21
+ * v6で`plan_notes`を足す。ADR 0054・0063の前例どおり既存versionへのin-place追加はしない。
22
+ *
23
+ * plan単位noteは工程に属する義務で、taskへ着手した人のcontextには届くが、**まだ誰も
24
+ * 着手していない工程の義務は、この欄が無いとどこにも出ない**。
25
+ */
26
+ export const TODO_STATUS_SCHEMA = 'lattice.todo_status_result.v6';
11
27
  export const TODO_DISPATCH_FRONTIER_SCHEMA = 'lattice.todo_dispatch_frontier.v1';
12
28
  export const TODO_STATUS_LIST_LIMIT = 2_000;
13
29
  export const TODO_STATUS_LABEL_LIMIT = 160;
@@ -97,6 +113,123 @@ function blockedEntry(value) {
97
113
  && isTodoStatusBoundedText(value.reason, TODO_STATUS_REASON_LIMIT);
98
114
  }
99
115
 
116
+ /**
117
+ * 監査待ちPhase 1件。
118
+ *
119
+ * task entryと紛れないよう`status`ではなく`phase_status`にする。状態集合は
120
+ * `todo-audit-pending.mjs`の定義をそのまま使う(ここで書き直さない)。
121
+ * `next_commands`を非空必須にしたのは、監査待ちなのに次の一手が空なら次アクション面として
122
+ * 無意味だからである(状態集合が閉じている以上、空になる枝は無い)。
123
+ */
124
+ function auditPendingEntry(value) {
125
+ return exactRecord(value, [
126
+ 'plan_key', 'phase_id', 'phase_status', 'implicit', 'required_evidence_slots', 'next_commands',
127
+ ]) && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.phase_id)
128
+ && AUDIT_PENDING_PHASE_STATUSES.has(value.phase_status)
129
+ && typeof value.implicit === 'boolean'
130
+ && boundedList(value.required_evidence_slots, isTodoIdentifier)
131
+ && Array.isArray(value.next_commands) && value.next_commands.length > 0
132
+ && boundedList(value.next_commands, (command) => isTodoStatusBoundedText(command, TODO_STATUS_REASON_LIMIT));
133
+ }
134
+
135
+ function planNoteLatestEntry(value) {
136
+ return exactRecord(value, ['event_digest', 'actor_agent', 'recorded_at'])
137
+ && isTodoDigest(value.event_digest) && isTodoIdentifier(value.actor_agent)
138
+ && isStrictTodoTimestamp(value.recorded_at);
139
+ }
140
+
141
+ /**
142
+ * plan単位noteの要約1件。
143
+ *
144
+ * **本文を持たない。** 自由記述のMarkdownをここへinlineすると、noteを書くほど
145
+ * `TODO_STATUS_CAPTURE_LIMIT`へ近づき、健全なstoreが`TODO_SCALE_EXCEEDED`で落ちる
146
+ * ——「記録すると壊れる」面を作らない。載せるのは件数・帰属・次の一手までで、
147
+ * 中身は`next_commands`が指す`note list`が持つ。
148
+ *
149
+ * `plan_note_head_digest`は`note_context.note_head_digest`(task chain)と**別のchainのhead**なので、
150
+ * 名前で区別する。同名にすると型が同じdigestなので、取り違えてもexact validatorを通ってしまう。
151
+ *
152
+ * `count`は1以上(0件のplanはentryごと出さない)。`next_commands`が非空必須なのは
153
+ * `audit_pending`と同じ理由で、欄に出るだけで次の一手が無いなら次アクション面として無意味である。
154
+ */
155
+ function planNoteEntry(value) {
156
+ return exactRecord(value, ['plan_key', 'plan_note_head_digest', 'count', 'latest', 'next_commands'])
157
+ && isTodoIdentifier(value.plan_key) && isTodoDigest(value.plan_note_head_digest)
158
+ && isNonNegativeSafeInteger(value.count) && value.count > 0
159
+ && Array.isArray(value.latest) && value.latest.length > 0
160
+ && value.latest.length <= Math.min(value.count, TODO_LIMITS.statusPlanNoteLatest)
161
+ && value.latest.every(planNoteLatestEntry)
162
+ && value.latest[0].event_digest === value.plan_note_head_digest
163
+ && Array.isArray(value.next_commands) && value.next_commands.length > 0
164
+ && boundedList(value.next_commands,
165
+ (command) => isTodoStatusBoundedText(command, TODO_STATUS_REASON_LIMIT));
166
+ }
167
+
168
+ /**
169
+ * 調整方式を宣言したplan 1件(ob03・オーナー裁定C①)。
170
+ *
171
+ * **宣言済みのplanだけを列挙する。** 未宣言を`mode: null`で全plan出すと、plan数ぶん常に
172
+ * 埋まって読み飛ばされる列になる——前campaignで`audit_pending`の設計時に避けた形と同じ。
173
+ * 未宣言は「`member_heads`に居て`coordination`に居ない」で引ける。
174
+ *
175
+ * `declared_by`が本体である。witnessが全planの暗黙義務だった時に無かったのが帰属で、
176
+ * ここを落とすとこの欄は「もう1つの督促」に戻る。
177
+ */
178
+ function coordinationEntry(value) {
179
+ return exactRecord(value, ['plan_key', 'mode', 'declared_by', 'declared_at', 'reason'])
180
+ && isTodoIdentifier(value.plan_key)
181
+ && TODO_COORDINATION_MODES.includes(value.mode)
182
+ && exactRecord(value.declared_by, ['host', 'session', 'agent'])
183
+ && ['host', 'session', 'agent'].every((key) => isTodoStatusBoundedText(value.declared_by[key], TODO_STATUS_LABEL_LIMIT))
184
+ && isStrictTodoTimestamp(value.declared_at)
185
+ && isTodoStatusBoundedText(value.reason, TODO_STATUS_REASON_LIMIT);
186
+ }
187
+
188
+ /**
189
+ * 並列候補1 plan(ob05・オーナー裁定C③)。
190
+ *
191
+ * **新しい判定は1つも行わない。** ここに載るのは`projectIndependenceFrontier`が既に出している
192
+ * 結果を、候補の視点で並べ直したものだけである。並列できそうな組を選ぶのはAIの仕事で、
193
+ * 機械が持つのは「まだ判定していないreadyはこれ」「判定済みの結果はこれ」だけ
194
+ * ——推定・判断をLatticeの中へ実装しない(所有境界)。
195
+ *
196
+ * **ready taskを1つも持たないplanはentryごと出さない。** 全plan常に1行にすると、plan数ぶん
197
+ * 埋まって読み飛ばされる列になる(前campaignのwitness `coverage: missing`が実際にそうなった)。
198
+ *
199
+ * 逆に`coverage: 'missing'`のplanは**出す**。「まだ誰も判定していない」はこの欄が最も要る状態で、
200
+ * そこを飛ばすと沈黙が不在に見える。
201
+ */
202
+ function parallelCandidateEntry(value) {
203
+ return exactRecord(value, [
204
+ 'plan_key', 'coverage', 'unreadable_reason', 'unjudged_task_ids',
205
+ 'verified_parallel_groups', 'serialize_pairs', 'next_commands',
206
+ ]) && isTodoIdentifier(value.plan_key)
207
+ // 読めない記録は`coverage`を名乗らず理由を名乗る(ADR 0131・`summarizeIndependence`と同じ答え方)。
208
+ // 「壊れている」を「まだ判定していない」へ丸めない。
209
+ && (value.unreadable_reason === null
210
+ ? TODO_INDEPENDENCE_COVERAGE.includes(value.coverage)
211
+ : value.coverage === null
212
+ && isTodoStatusBoundedText(value.unreadable_reason, TODO_STATUS_REASON_LIMIT))
213
+ && boundedList(value.unjudged_task_ids, isTodoIdentifier)
214
+ // 1件の組は並列の情報を持たない(taskは常に自分と並列である)。生産側が落としている以上、
215
+ // 契約側でも受けない——受けると消費者が「2件以上」を前提にできなくなる。
216
+ && boundedList(value.verified_parallel_groups, (group) => exactRecord(group, ['task_ids'])
217
+ && Array.isArray(group.task_ids) && group.task_ids.length > 1
218
+ && group.task_ids.every(isTodoIdentifier))
219
+ && boundedList(value.serialize_pairs, (pair) => exactRecord(pair, ['task_ids', 'type', 'detail'])
220
+ && Array.isArray(pair.task_ids) && pair.task_ids.length === 2
221
+ && pair.task_ids.every(isTodoIdentifier)
222
+ && isTodoStatusBoundedText(pair.type, TODO_STATUS_LABEL_LIMIT)
223
+ && isTodoStatusBoundedText(pair.detail, TODO_STATUS_REASON_LIMIT))
224
+ // 候補が在るのに次の一手が無い欄は、この工程が直している「欄だけ置いて閉じる」形になる。
225
+ && Array.isArray(value.next_commands) && value.next_commands.length > 0
226
+ && boundedList(value.next_commands,
227
+ (command) => isTodoStatusBoundedText(command, TODO_STATUS_REASON_LIMIT))
228
+ // 何も無いplanは出さない。空entryは「判定する対象が無い」と「判定が済んだ」を混ぜる。
229
+ && (value.unjudged_task_ids.length > 0 || value.verified_parallel_groups.length > 0
230
+ || value.serialize_pairs.length > 0);
231
+ }
232
+
100
233
  function memberHead(value) {
101
234
  return exactRecord(value, [
102
235
  'plan_key', 'plan_version', 'through_sequence', 'journal_head_digest',
@@ -150,16 +283,21 @@ function dispatchFrontierEntry(value, projectId, nextReady) {
150
283
  && value.frontier_digest === expected.frontier_digest;
151
284
  }
152
285
 
153
- /** todo status v4 wire shapeを検証し、digestも再計算する。 */
286
+ /** todo status v6 wire shapeを検証し、digestも再計算する。 */
154
287
  export function validateTodoStatusResult(value) {
155
288
  try {
156
289
  return exactRecord(value, [
157
290
  'schema', 'project_id', 'active_set', 'next_ready', 'dispatch_frontier',
158
- 'blocked', 'member_heads', 'result_digest',
291
+ 'blocked', 'audit_pending', 'plan_notes', 'coordination', 'parallel_candidates',
292
+ 'member_heads', 'result_digest',
159
293
  ]) && value.schema === TODO_STATUS_SCHEMA && isTodoIdentifier(value.project_id)
160
294
  && boundedList(value.active_set, activeTaskEntry) && boundedList(value.next_ready, taskEntry)
161
295
  && dispatchFrontierEntry(value.dispatch_frontier, value.project_id, value.next_ready)
162
- && boundedList(value.blocked, blockedEntry) && boundedList(value.member_heads, memberHead)
296
+ && boundedList(value.blocked, blockedEntry) && boundedList(value.audit_pending, auditPendingEntry)
297
+ && boundedList(value.plan_notes, planNoteEntry)
298
+ && boundedList(value.coordination, coordinationEntry)
299
+ && boundedList(value.parallel_candidates, parallelCandidateEntry)
300
+ && boundedList(value.member_heads, memberHead)
163
301
  && isTodoDigest(value.result_digest)
164
302
  && value.result_digest === todoSelfDigest(value, 'result_digest');
165
303
  } catch {
@@ -167,6 +305,46 @@ export function validateTodoStatusResult(value) {
167
305
  }
168
306
  }
169
307
 
308
+ /**
309
+ * memberの監査待ちPhaseを`audit_pending` entryへ起こす。
310
+ *
311
+ * 「監査待ちか」の判定は`todo-audit-pending.mjs`、Phase定義(slots)は`todo-store.mjs`の
312
+ * `todoPhaseDefinitions`が正本である。`plan.phases`を直接読むとphase無しplan(v1/v2/v3)と
313
+ * synthetic read modelで落ちるので読まない。
314
+ *
315
+ * dispatchへは一切流さない——ここで作るのは`member.phases`だけを見る別の列であり、
316
+ * nodes/incomingへは入れない(ADR 0062・ADR 0147裁定5)。
317
+ */
318
+ function collectAuditPending(member, sink) {
319
+ const declared = todoPhaseDefinitions(member.plan);
320
+ // Phase plan(v4/v5/v7)なのに`plan.phases`が配列でない場合はここでtypedに落とす
321
+ // (素のTypeErrorで抜けさせない)。phase無しplanは常に暗黙Phase 1件が返るので該当しない。
322
+ if (!Array.isArray(declared)) {
323
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_plan_phases_invalid', { plan_key: member.plan.plan_key });
324
+ }
325
+ const definitions = new Map(declared.map((entry) => [entry.phase_id, entry]));
326
+ const implicit = isPhaselessTodoPlanSchema(member.plan.schema);
327
+ for (const phase of member.phases ?? []) {
328
+ if (!isAuditPendingPhaseStatus(phase?.status)) continue;
329
+ const definition = definitions.get(phase.phase_id);
330
+ // 導出ビューに在ってplanに定義が無いPhaseは、状態とplanがずれている証拠である。
331
+ // slotsを空へ丸めるとgateが何を要求するか嘘を吐くので、fail closedにする。
332
+ if (definition === undefined) {
333
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_phase_definition_missing', {
334
+ plan_key: member.plan.plan_key, phase_id: phase.phase_id,
335
+ });
336
+ }
337
+ sink.push({
338
+ plan_key: member.plan.plan_key,
339
+ phase_id: phase.phase_id,
340
+ phase_status: phase.status,
341
+ implicit,
342
+ required_evidence_slots: [...definition.required_evidence_slots],
343
+ next_commands: auditPendingNextCommands(member.plan.plan_key, phase.phase_id, phase.status),
344
+ });
345
+ }
346
+ }
347
+
170
348
  /**
171
349
  * read modelからtask node・依存辺・phase gateを組み立てる。
172
350
  *
@@ -182,6 +360,8 @@ function buildTodoGraph(readModel) {
182
360
  const nodes = new Map();
183
361
  const incoming = new Map();
184
362
  const memberHeads = [];
363
+ const auditPending = [];
364
+ const coordination = [];
185
365
  // snapshot artifactの形式(v1にはphasesキーが無い)には縛られない導出ビューを読む
186
366
  // (readTodoStoreが常にmember.phasesとして埋める。ADR 0147)。
187
367
  const phaseStatuses = new Map(readModel.members.flatMap((member) => (
@@ -228,6 +408,18 @@ function buildTodoGraph(readModel) {
228
408
  const states = new Map(member.tasks.map((state) => [state.task_id, state]));
229
409
  // snapshot artifactの形式には縛られない導出ビュー(member.phases)を読む(ADR 0147)。
230
410
  const phases = new Map((member.phases ?? []).map((state) => [state.phase_id, state]));
411
+ collectAuditPending(member, auditPending);
412
+ // 調整方式の宣言(ob03)。member.coordinationはstoreがplan-scoped chainから投影した
413
+ // 導出ビューで、未宣言はnull。宣言済みだけをここへ載せる。
414
+ if (member.coordination !== null && member.coordination !== undefined) {
415
+ coordination.push({
416
+ plan_key: member.plan.plan_key,
417
+ mode: member.coordination.mode,
418
+ declared_by: member.coordination.declared_by,
419
+ declared_at: member.coordination.declared_at,
420
+ reason: displayText(member.coordination.reason, member.coordination.mode, TODO_STATUS_REASON_LIMIT),
421
+ });
422
+ }
231
423
  for (const task of member.plan.tasks) {
232
424
  const state = states.get(task.task_id);
233
425
  if (!plain(state) || !['pending', 'in-progress', 'blocked', 'done'].includes(state.status)) {
@@ -283,7 +475,14 @@ function buildTodoGraph(readModel) {
283
475
  }
284
476
  }
285
477
 
286
- return { nodes, incoming, phaseAcceptIncoming, phaseStatuses, memberHeads };
478
+ auditPending.sort((left, right) => (
479
+ left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1
480
+ : left.phase_id < right.phase_id ? -1 : left.phase_id > right.phase_id ? 1 : 0));
481
+
482
+ coordination.sort((left, right) => (
483
+ left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0));
484
+
485
+ return { nodes, incoming, phaseAcceptIncoming, phaseStatuses, memberHeads, auditPending, coordination };
287
486
  }
288
487
 
289
488
  /** 先行完了とphase gateを満たしたpending taskだけがreadyになる。 */
@@ -317,10 +516,39 @@ export function computeReadyFrontier(readModel) {
317
516
  return ready;
318
517
  }
319
518
 
320
- /** Canonical todo read modelからSessionStart向け現在地をread-only投影する。 */
321
- export function projectTodoStatus(readModel) {
519
+ /**
520
+ * dispatch面(`next_ready`/`active_set`/`dispatch_frontier`)だけを見る内部呼び出しが渡す明示の空。
521
+ *
522
+ * `todo start`のready判定・gantt・dashboardの生存判定は、statusのresultを**外へ出さない**。
523
+ * そこでnote chainを読むと、noteの破損がdispatch判定やdashboardの可視性を巻き添えに落とす
524
+ * ——ganttのnote破損は既に警告として表出する契約があり(`notesForGantt`)、二重に落とさない。
525
+ * この定数を使った結果をwireとして出力しないこと。`plan_notes`が常に空になる。
526
+ */
527
+ export const TODO_STATUS_DISPATCH_ONLY = Object.freeze({
528
+ planNotes: Object.freeze([]), parallelCandidates: Object.freeze([]),
529
+ });
530
+
531
+ /**
532
+ * Canonical todo read modelからSessionStart向け現在地をread-only投影する。
533
+ *
534
+ * `planNotes`は**必須**である。plan単位noteはstore read modelに入っていない別chainなので、
535
+ * 読むのは呼び出し側の責務になる。省略を空配列へ丸めると「noteが無い」と「読まなかった」が
536
+ * 同じ形になり、配線を1箇所忘れただけで義務が静かに消える——それはこの欄が塞いでいる穴そのものである。
537
+ * 素材は`readTodoPlanNotesForStatus`(`src/todo-note-store.mjs`)が作り、resultを出力する
538
+ * 呼び出し元だけがそれを渡す。dispatch面しか見ない内部呼び出しは`TODO_STATUS_DISPATCH_ONLY`を使う。
539
+ */
540
+ export function projectTodoStatus(readModel, options = undefined) {
541
+ if (!exactRecord(options, ['planNotes', 'parallelCandidates'])
542
+ || !Array.isArray(options.planNotes)) {
543
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_plan_notes_missing');
544
+ }
545
+ if (!Array.isArray(options.parallelCandidates)) {
546
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_parallel_candidates_missing');
547
+ }
322
548
  const graph = buildTodoGraph(readModel);
323
- const { nodes, incoming, memberHeads } = graph;
549
+ const { nodes, incoming, memberHeads, auditPending, coordination } = graph;
550
+ const planNotes = [...options.planNotes];
551
+ const parallelCandidates = [...options.parallelCandidates];
324
552
 
325
553
  const activeSet = [];
326
554
  const nextReady = [];
@@ -352,7 +580,10 @@ export function projectTodoStatus(readModel) {
352
580
  blocked.sort(compareTaskEntries);
353
581
  memberHeads.sort((left, right) => left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0);
354
582
  for (const [name, value] of [
355
- ['active_set', activeSet], ['next_ready', nextReady], ['blocked', blocked], ['member_heads', memberHeads],
583
+ ['active_set', activeSet], ['next_ready', nextReady], ['blocked', blocked],
584
+ ['audit_pending', auditPending], ['plan_notes', planNotes],
585
+ ['coordination', coordination], ['parallel_candidates', parallelCandidates],
586
+ ['member_heads', memberHeads],
356
587
  ]) enforceListLimit(name, value);
357
588
 
358
589
  const result = {
@@ -362,6 +593,18 @@ export function projectTodoStatus(readModel) {
362
593
  next_ready: nextReady,
363
594
  dispatch_frontier: dispatchFrontier(readModel.project_id, nextReady),
364
595
  blocked,
596
+ // 監査待ちは`member.phases`だけから作った別の列で、dispatch(next_ready/dispatch_frontier)へは
597
+ // 影響しない。監査が進んでもfrontier_digestは動かない。
598
+ audit_pending: auditPending,
599
+ // plan単位noteも同じく別の列である。noteの有無・件数はdispatchへ影響しないので、
600
+ // next_ready・dispatch_frontier・frontier_digestはnoteを書いても1バイトも動かない。
601
+ plan_notes: planNotes,
602
+ // 調整方式の宣言も同じく別の列である。宣言はdispatchを変えない——未宣言でもready
603
+ // frontierは通常どおり出る(ADR 0160・ob04のProtected behavior)。
604
+ coordination,
605
+ // 並列候補も別の列である。判定が進んでもdispatchは動かない——next_ready・
606
+ // dispatch_frontier・frontier_digestは判定状態を1バイトも含まない(ADR 0063・ob04)。
607
+ parallel_candidates: parallelCandidates,
365
608
  member_heads: memberHeads,
366
609
  result_digest: '',
367
610
  };
@@ -386,8 +629,8 @@ export const TODO_BINDING_PROJECTION_SCHEMA = 'lattice.todo_binding_projection.v
386
629
  * `compiled_plan_digest`で`runtime_plan.v1`を、`base_sha`でrun requestのbaseを照合し、
387
630
  * plan→`executor_packet.v1`→`executor_receipt.v1`(`packet_digest`帰属)まで辿れる。
388
631
  *
389
- * `todo_status_result.v4`は変更しない。binding投影は加算の別面とし、v4を受理する
390
- * 既存hostを壊さない。
632
+ * binding投影は`todo_status_result`とは独立した加算の別面である(ADR 0124)。status側の
633
+ * version bump(v4→v5)はこの面の形を動かさない。
391
634
  */
392
635
  export function projectTodoBindings(readModel, { requestedPlanKey = null } = {}) {
393
636
  if (!plain(readModel) || readModel.schema !== 'lattice.todo_store_read.v1'
@@ -5,7 +5,9 @@ import {
5
5
  } from 'node:fs/promises';
6
6
  import path from 'node:path';
7
7
  import {
8
+ TODO_COORDINATION_MODES,
8
9
  TODO_LIMITS,
10
+ TODO_PLAN_SCOPED_EVENT_KINDS,
9
11
  canonicalizeTodoArtifact,
10
12
  digestTodoArtifact,
11
13
  exactRecord,
@@ -235,6 +237,57 @@ async function readJournal(repoRoot, journalRef) {
235
237
  return { segments, events, activeBytes };
236
238
  }
237
239
 
240
+ /**
241
+ * planへ帰属するeventを積む、lifecycle journalとは別のchain(ob03)。
242
+ *
243
+ * 同じ`journal/active.jsonl`へ混ぜない。旧CLIの`validateTodoEvent`は`TODO_EVENT_KINDS`を
244
+ * exactで見るので、未知kindが1件混ざるとその**plan全体**が`STORE_CORRUPT`になる。journalは
245
+ * `todo status`の正本なので、混ぜると旧CLIから工程が読めなくなり、版を戻してもstoreは戻らない。
246
+ *
247
+ * 旧CLIの`readJournal`は`journal/active.jsonl`と`journal/sealed/*`を名指しで開くだけで、
248
+ * `journal/`自体をreaddirしない。version dir配下のfile一覧を厳密検査する経路も無い
249
+ * (`independence.json`が同じ性質で既に出荷されている・ADR 0127 Decision 1)。したがって
250
+ * 兄弟fileを置いても旧CLIは存在に気づかず、互換もrollbackも保たれる。
251
+ */
252
+ function planScopedJournalRef(journalRef) {
253
+ return path.posix.join(path.posix.dirname(journalRef), 'plan-scoped.jsonl');
254
+ }
255
+
256
+ /**
257
+ * plan-scoped chainを読む。記録が無ければ空(「まだ何も宣言していない」)。
258
+ *
259
+ * lifecycle journalと違いgenesisを持たない——planの存在はjournalが証明しており、この
260
+ * chainはそこへ後から積まれる宣言だけを持つ。壊れている記録はnullや空へ丸めずtyped failにする。
261
+ */
262
+ async function readPlanScopedJournal(repoRoot, journalRef) {
263
+ const ref = planScopedJournalRef(journalRef);
264
+ const absolute = path.resolve(repoRoot, ref);
265
+ let bytes;
266
+ try {
267
+ const state = await lstat(absolute);
268
+ if (state.isSymbolicLink() || !state.isFile()) fail('STORE_CORRUPT', 'plan_scoped_journal_unsafe');
269
+ bytes = await readFile(absolute);
270
+ } catch (error) {
271
+ if (error instanceof TodoStoreError) throw error;
272
+ if (error?.code === 'ENOENT') return { ref, events: [], activeBytes: Buffer.alloc(0) };
273
+ fail('STORE_CORRUPT', 'plan_scoped_journal_read_failed');
274
+ }
275
+ const events = parseJournalSegment(bytes);
276
+ if (!events.every(({ kind }) => TODO_PLAN_SCOPED_EVENT_KINDS.includes(kind))) {
277
+ fail('STORE_CORRUPT', 'plan_scoped_journal_kind_invalid');
278
+ }
279
+ const failures = verifyLinearHashChain({
280
+ entries: events,
281
+ canonicalize: canonicalizeTodoArtifact,
282
+ digestField: 'event_digest',
283
+ genesisPrevious: null,
284
+ });
285
+ if (failures.size > 0) {
286
+ fail('STORE_CORRUPT', [...failures].sort()[0], { failed_conditions: [...failures].sort() });
287
+ }
288
+ return { ref, events, activeBytes: bytes };
289
+ }
290
+
238
291
  function taskState(taskId) {
239
292
  return { task_id: taskId, status: 'pending', started_at: null, done_at: null, blocked_reason: null,
240
293
  evidence: null, evidence_unverified: false, imported: false };
@@ -278,6 +331,38 @@ function phasesOf(plan) {
278
331
  return isPhaselessTodoPlanSchema(plan.schema) ? [terminalAuditPhase()] : plan.phases;
279
332
  }
280
333
 
334
+ // 終端監査Phaseの定義(required_evidence_slotsを含む)はterminalAuditPhase()の中だけに在り、
335
+ // store外からは読めなかった。todo-status側で再導出すると「終端監査が何を要求するか」の定義が
336
+ // 二重化するので、phasesOfをそのまま公開して定義の正本を1つに保つ。
337
+ // 返るのは呼び手が渡したplan自身のphases(またはその場で作る暗黙Phase)であり、新たな内部状態は
338
+ // 露出しない。
339
+ export function todoPhaseDefinitions(plan) {
340
+ return phasesOf(plan);
341
+ }
342
+
343
+ /**
344
+ * planの調整方式の宣言を投影する(ob03・オーナー裁定C①)。
345
+ *
346
+ * 最後の`coordination_mode` eventが現在の宣言で、1件も無ければ未宣言(null)である。
347
+ * 未宣言は「witnessで行くと決めた」でも「会話で行くと決めた」でもなく、**まだ選んでいない**。
348
+ * 「誰が選んだか」はeventのactorが持つ——witnessが全planの暗黙義務だった時に帰属が無く、
349
+ * 正確な案内が素通りされたことへの是正なので、帰属を落とすとこの機構の意味が消える。
350
+ *
351
+ * 宣言はdispatchを変えない。未宣言でもready frontierは通常どおり出る(ADR 0160・ob04)。
352
+ */
353
+ export function projectTodoCoordination(events) {
354
+ const declaration = [...events].reverse()
355
+ .find((event) => event.kind === 'coordination_mode');
356
+ if (declaration === undefined) return null;
357
+ return {
358
+ mode: declaration.payload.mode,
359
+ reason: declaration.payload.reason,
360
+ declared_by: declaration.actor,
361
+ declared_at: declaration.recorded_at,
362
+ event_digest: declaration.event_digest,
363
+ };
364
+ }
365
+
281
366
  function derivedPhaseStatus(plan, taskStates, phaseStates, phaseId) {
282
367
  const state = phaseStates.get(phaseId);
283
368
  // ADR 0148: closed_unauditedも他の終端状態と同じく確定済みとして扱う。ここへ足さないと、
@@ -424,6 +509,10 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
424
509
  }
425
510
  continue;
426
511
  }
512
+ // planへ帰属するkindは別chain(plan-scoped.jsonl)へ積むので、通常はここへ来ない。
513
+ // 来た場合(旧形式のstoreや将来の移行)でもtask状態へ触れさせない——下の
514
+ // states.get(event.task_id)がnullを引いてevent_task_missingで落ちるのを防ぐ。
515
+ if (TODO_PLAN_SCOPED_EVENT_KINDS.includes(event.kind)) continue;
427
516
  if (event.kind.startsWith('phase_')) {
428
517
  // phaseStatesはphasesOf(plan)から作られる(v4/v5なら実Phase、それ以外なら暗黙の
429
518
  // terminal-audit Phaseだけ)。`has`判定だけで両方の場合を賄えるので、schemaでの
@@ -1124,8 +1213,13 @@ export async function readTodoStore(options = {}) {
1124
1213
  // phase無しplanのどちらでも同じ形(暗黙のterminal-audit Phase込み)で常に埋める。
1125
1214
  // 消費者はここを読み、snapshot.phases(v1には存在しない)を直接読まない。
1126
1215
  const phases = projectPhaseStates(plan, journal.events, new Map(tasks.map((task) => [task.task_id, task])));
1127
- loaded.push({ descriptor, plan, revision, journal, snapshot: snapshotStale ? expectedSnapshot : snapshot,
1128
- tasks, phases, snapshot_stale: snapshotStale });
1216
+ // 調整方式の宣言(ob03)。lifecycle journalとは別chainから読む。phasesと同じく
1217
+ // snapshot artifactの形式には縛られない導出ビューで、未宣言はnull(「まだ選んでいない」)。
1218
+ const planScoped = await readPlanScopedJournal(repoRoot, descriptor.journal_ref);
1219
+ const coordination = projectTodoCoordination(planScoped.events);
1220
+ loaded.push({ descriptor, plan, revision, journal, plan_scoped: planScoped,
1221
+ snapshot: snapshotStale ? expectedSnapshot : snapshot,
1222
+ tasks, phases, coordination, snapshot_stale: snapshotStale });
1129
1223
  }
1130
1224
  validateMergedGraph(loaded);
1131
1225
  return {
@@ -1343,6 +1437,15 @@ export async function appendTodoEvent(options = {}) {
1343
1437
  const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now });
1344
1438
  const member = store.members.find(({ descriptor }) => descriptor.plan_key === options.planKey);
1345
1439
  if (!member) fail('STORE_INCONSISTENT', 'plan_not_active');
1440
+ // planへ帰属するeventは別chainへ積む(ob03)。task状態もPhase状態も動かさないので、
1441
+ // replay・snapshot・manifestへは触れない——lifecycle journalのheadを進めるのは
1442
+ // 「作業が進んだ」の意味であり、方式を選んだだけでそこを動かすと意味がずれる。
1443
+ if (TODO_PLAN_SCOPED_EVENT_KINDS.includes(options.event.kind)) {
1444
+ return appendPlanScopedEvent({
1445
+ repoRoot, member,
1446
+ input: { ...options.event, recorded_at: options.event.recorded_at ?? new Date().toISOString() },
1447
+ });
1448
+ }
1346
1449
  const input = resolveTargetedEvent({
1347
1450
  ...options.event,
1348
1451
  task_id: resolveCanonicalTaskId(member.plan, options.event.task_id),
@@ -1393,6 +1496,49 @@ export async function appendTodoEvent(options = {}) {
1393
1496
  });
1394
1497
  }
1395
1498
 
1499
+ /**
1500
+ * plan-scoped chainへ1件積む(ob03)。
1501
+ *
1502
+ * lifecycle journalとは独立したsequenceとhash chainを持つ。`member_heads`の
1503
+ * `through_sequence`/`journal_head_digest`は**task chainだけ**を指し続ける——
1504
+ * 「この planの lifecycleがどこまで進んだか」という意味を、方式の宣言で動かさない
1505
+ * (合成すると型も値域も同じまま意味だけずれ、消費者はexact検証を通してしまう)。
1506
+ */
1507
+ async function appendPlanScopedEvent({ repoRoot, member, input }) {
1508
+ const previous = member.plan_scoped.events.at(-1) ?? null;
1509
+ const event = {
1510
+ schema: 'lattice.todo_event.v3',
1511
+ project_id: member.plan.project_id,
1512
+ plan_key: member.plan.plan_key, plan_version: member.plan.plan_version,
1513
+ // chainの先頭は sequence 0(`verifyLinearHashChain`は sequence 0 ⟺ previous_digest null を
1514
+ // genesis束縛として検証する)。lifecycle journalのsequenceとは独立に数える。
1515
+ sequence: previous === null ? 0 : previous.sequence + 1,
1516
+ previous_digest: previous?.event_digest ?? null,
1517
+ kind: input.kind, task_id: null, phase_id: null,
1518
+ actor: input.actor, recorded_at: input.recorded_at,
1519
+ provenance: input.provenance ?? null, payload: input.payload, event_digest: '',
1520
+ };
1521
+ event.event_digest = todoSelfDigest(event, 'event_digest');
1522
+ if (!validateTodoEvent(event)) throw new TypeError('todo event input violates its declared schema');
1523
+
1524
+ const bytes = canonicalLine(event);
1525
+ if (member.plan_scoped.activeBytes.length + bytes.length > TODO_LIMITS.journalSegmentBytes) {
1526
+ // 宣言は1 planあたり数件の想定なので封緘機構は持たない。上限へ達したら黙って捨てず、
1527
+ // typedに止めて「この設計では足りない」ことを表に出す。
1528
+ fail('STORE_WRITE_REFUSED', 'plan_scoped_journal_segment_limit_exceeded', {
1529
+ ref: member.plan_scoped.ref, limit: TODO_LIMITS.journalSegmentBytes,
1530
+ });
1531
+ }
1532
+ await atomicWrite(path.resolve(repoRoot, member.plan_scoped.ref),
1533
+ Buffer.concat([member.plan_scoped.activeBytes, bytes]));
1534
+ const events = [...member.plan_scoped.events, event];
1535
+ return {
1536
+ event, plan: member.plan, snapshot: member.snapshot,
1537
+ plan_scoped_head_digest: event.event_digest,
1538
+ coordination: projectTodoCoordination(events),
1539
+ };
1540
+ }
1541
+
1396
1542
  export function buildTodoPlan(input) {
1397
1543
  const plan = { ...input, topology_digest: '', plan_digest: '' };
1398
1544
  plan.topology_digest = digestTodoArtifact({