@quolu/lattice 0.47.0 → 0.49.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.
@@ -4,16 +4,26 @@ import {
4
4
  isAuditPendingPhaseStatus,
5
5
  } from './todo-audit-pending.mjs';
6
6
  import {
7
+ TODO_COORDINATION_MODES,
8
+ TODO_LIMITS,
7
9
  exactRecord,
8
10
  isNonNegativeSafeInteger,
11
+ isStrictTodoTimestamp,
9
12
  isTodoDigest,
10
13
  isTodoIdentifier,
11
14
  todoSelfDigest,
12
15
  } from './todo-contracts.mjs';
16
+ import { TODO_INDEPENDENCE_COVERAGE } from './todo-independence-contracts.mjs';
13
17
  import { todoLegacyReconciliationDigest } from './todo-revision.mjs';
14
18
  import { isPhaselessTodoPlanSchema, todoPhaseDefinitions } from './todo-store.mjs';
15
19
 
16
- export const TODO_STATUS_SCHEMA = 'lattice.todo_status_result.v5';
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';
17
27
  export const TODO_DISPATCH_FRONTIER_SCHEMA = 'lattice.todo_dispatch_frontier.v1';
18
28
  export const TODO_STATUS_LIST_LIMIT = 2_000;
19
29
  export const TODO_STATUS_LABEL_LIMIT = 160;
@@ -122,6 +132,104 @@ function auditPendingEntry(value) {
122
132
  && boundedList(value.next_commands, (command) => isTodoStatusBoundedText(command, TODO_STATUS_REASON_LIMIT));
123
133
  }
124
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
+
125
233
  function memberHead(value) {
126
234
  return exactRecord(value, [
127
235
  'plan_key', 'plan_version', 'through_sequence', 'journal_head_digest',
@@ -175,16 +283,20 @@ function dispatchFrontierEntry(value, projectId, nextReady) {
175
283
  && value.frontier_digest === expected.frontier_digest;
176
284
  }
177
285
 
178
- /** todo status v5 wire shapeを検証し、digestも再計算する。 */
286
+ /** todo status v6 wire shapeを検証し、digestも再計算する。 */
179
287
  export function validateTodoStatusResult(value) {
180
288
  try {
181
289
  return exactRecord(value, [
182
290
  'schema', 'project_id', 'active_set', 'next_ready', 'dispatch_frontier',
183
- 'blocked', 'audit_pending', 'member_heads', 'result_digest',
291
+ 'blocked', 'audit_pending', 'plan_notes', 'coordination', 'parallel_candidates',
292
+ 'member_heads', 'result_digest',
184
293
  ]) && value.schema === TODO_STATUS_SCHEMA && isTodoIdentifier(value.project_id)
185
294
  && boundedList(value.active_set, activeTaskEntry) && boundedList(value.next_ready, taskEntry)
186
295
  && dispatchFrontierEntry(value.dispatch_frontier, value.project_id, value.next_ready)
187
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)
188
300
  && boundedList(value.member_heads, memberHead)
189
301
  && isTodoDigest(value.result_digest)
190
302
  && value.result_digest === todoSelfDigest(value, 'result_digest');
@@ -249,6 +361,7 @@ function buildTodoGraph(readModel) {
249
361
  const incoming = new Map();
250
362
  const memberHeads = [];
251
363
  const auditPending = [];
364
+ const coordination = [];
252
365
  // snapshot artifactの形式(v1にはphasesキーが無い)には縛られない導出ビューを読む
253
366
  // (readTodoStoreが常にmember.phasesとして埋める。ADR 0147)。
254
367
  const phaseStatuses = new Map(readModel.members.flatMap((member) => (
@@ -296,6 +409,17 @@ function buildTodoGraph(readModel) {
296
409
  // snapshot artifactの形式には縛られない導出ビュー(member.phases)を読む(ADR 0147)。
297
410
  const phases = new Map((member.phases ?? []).map((state) => [state.phase_id, state]));
298
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
+ }
299
423
  for (const task of member.plan.tasks) {
300
424
  const state = states.get(task.task_id);
301
425
  if (!plain(state) || !['pending', 'in-progress', 'blocked', 'done'].includes(state.status)) {
@@ -355,7 +479,10 @@ function buildTodoGraph(readModel) {
355
479
  left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1
356
480
  : left.phase_id < right.phase_id ? -1 : left.phase_id > right.phase_id ? 1 : 0));
357
481
 
358
- return { nodes, incoming, phaseAcceptIncoming, phaseStatuses, memberHeads, auditPending };
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 };
359
486
  }
360
487
 
361
488
  /** 先行完了とphase gateを満たしたpending taskだけがreadyになる。 */
@@ -389,10 +516,39 @@ export function computeReadyFrontier(readModel) {
389
516
  return ready;
390
517
  }
391
518
 
392
- /** Canonical todo read modelからSessionStart向け現在地をread-only投影する。 */
393
- 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
+ }
394
548
  const graph = buildTodoGraph(readModel);
395
- const { nodes, incoming, memberHeads, auditPending } = graph;
549
+ const { nodes, incoming, memberHeads, auditPending, coordination } = graph;
550
+ const planNotes = [...options.planNotes];
551
+ const parallelCandidates = [...options.parallelCandidates];
396
552
 
397
553
  const activeSet = [];
398
554
  const nextReady = [];
@@ -425,7 +581,9 @@ export function projectTodoStatus(readModel) {
425
581
  memberHeads.sort((left, right) => left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0);
426
582
  for (const [name, value] of [
427
583
  ['active_set', activeSet], ['next_ready', nextReady], ['blocked', blocked],
428
- ['audit_pending', auditPending], ['member_heads', memberHeads],
584
+ ['audit_pending', auditPending], ['plan_notes', planNotes],
585
+ ['coordination', coordination], ['parallel_candidates', parallelCandidates],
586
+ ['member_heads', memberHeads],
429
587
  ]) enforceListLimit(name, value);
430
588
 
431
589
  const result = {
@@ -438,6 +596,15 @@ export function projectTodoStatus(readModel) {
438
596
  // 監査待ちは`member.phases`だけから作った別の列で、dispatch(next_ready/dispatch_frontier)へは
439
597
  // 影響しない。監査が進んでもfrontier_digestは動かない。
440
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,
441
608
  member_heads: memberHeads,
442
609
  result_digest: '',
443
610
  };
@@ -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 };
@@ -287,6 +340,29 @@ export function todoPhaseDefinitions(plan) {
287
340
  return phasesOf(plan);
288
341
  }
289
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
+
290
366
  function derivedPhaseStatus(plan, taskStates, phaseStates, phaseId) {
291
367
  const state = phaseStates.get(phaseId);
292
368
  // ADR 0148: closed_unauditedも他の終端状態と同じく確定済みとして扱う。ここへ足さないと、
@@ -433,6 +509,10 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
433
509
  }
434
510
  continue;
435
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;
436
516
  if (event.kind.startsWith('phase_')) {
437
517
  // phaseStatesはphasesOf(plan)から作られる(v4/v5なら実Phase、それ以外なら暗黙の
438
518
  // terminal-audit Phaseだけ)。`has`判定だけで両方の場合を賄えるので、schemaでの
@@ -1133,8 +1213,13 @@ export async function readTodoStore(options = {}) {
1133
1213
  // phase無しplanのどちらでも同じ形(暗黙のterminal-audit Phase込み)で常に埋める。
1134
1214
  // 消費者はここを読み、snapshot.phases(v1には存在しない)を直接読まない。
1135
1215
  const phases = projectPhaseStates(plan, journal.events, new Map(tasks.map((task) => [task.task_id, task])));
1136
- loaded.push({ descriptor, plan, revision, journal, snapshot: snapshotStale ? expectedSnapshot : snapshot,
1137
- 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 });
1138
1223
  }
1139
1224
  validateMergedGraph(loaded);
1140
1225
  return {
@@ -1352,6 +1437,15 @@ export async function appendTodoEvent(options = {}) {
1352
1437
  const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now });
1353
1438
  const member = store.members.find(({ descriptor }) => descriptor.plan_key === options.planKey);
1354
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
+ }
1355
1449
  const input = resolveTargetedEvent({
1356
1450
  ...options.event,
1357
1451
  task_id: resolveCanonicalTaskId(member.plan, options.event.task_id),
@@ -1402,6 +1496,49 @@ export async function appendTodoEvent(options = {}) {
1402
1496
  });
1403
1497
  }
1404
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
+
1405
1542
  export function buildTodoPlan(input) {
1406
1543
  const plan = { ...input, topology_digest: '', plan_digest: '' };
1407
1544
  plan.topology_digest = digestTodoArtifact({