@quolu/lattice 0.47.0 → 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.
- package/bin/lattice-dashboard.mjs +2 -2
- package/package.json +1 -1
- package/src/cli-help.mjs +2 -2
- package/src/project-cli.mjs +7 -1
- package/src/todo-cli.mjs +109 -18
- package/src/todo-contracts.mjs +84 -14
- package/src/todo-independence-guidance.mjs +24 -1
- package/src/todo-note-store.mjs +148 -21
- package/src/todo-parallel-candidates.mjs +114 -0
- package/src/todo-status.mjs +175 -8
- package/src/todo-store.mjs +139 -2
package/src/todo-status.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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', '
|
|
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
|
-
|
|
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
|
-
/**
|
|
393
|
-
|
|
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], ['
|
|
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
|
};
|
package/src/todo-store.mjs
CHANGED
|
@@ -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
|
-
|
|
1137
|
-
|
|
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({
|