@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.
- package/bin/lattice-dashboard.mjs +2 -2
- package/package.json +1 -1
- package/src/artifact-contracts.mjs +0 -3
- package/src/cli-help.mjs +2 -2
- package/src/project-cli.mjs +19 -3
- package/src/rc3-scripted-campaign.mjs +15 -16
- package/src/runtime-cli.mjs +230 -146
- package/src/runtime-contracts.mjs +9 -5
- package/src/runtime-decision-verifier.mjs +26 -5
- package/src/runtime-engine.mjs +48 -5
- package/src/runtime-front-end.mjs +34 -15
- package/src/runtime-hold-recompile.mjs +11 -5
- package/src/runtime-managed-supervisor.mjs +54 -15
- package/src/runtime-scripted-adapter-controller.mjs +43 -19
- package/src/runtime-seam-resolve.mjs +4 -2
- package/src/seam-apply.mjs +3 -3
- package/src/seam-verification.mjs +38 -2
- package/src/todo-audit-pending.mjs +91 -0
- package/src/todo-cli.mjs +111 -18
- package/src/todo-contracts.mjs +84 -14
- package/src/todo-dashboard-registry.mjs +2 -2
- package/src/todo-gantt-html-style.mjs +6 -2
- package/src/todo-gantt-html.mjs +30 -2
- package/src/todo-gantt-scope.mjs +9 -5
- package/src/todo-independence-contracts.mjs +10 -6
- package/src/todo-independence-guidance.mjs +26 -29
- package/src/todo-note-store.mjs +148 -21
- package/src/todo-parallel-candidates.mjs +114 -0
- package/src/todo-status.mjs +254 -11
- package/src/todo-store.mjs +148 -2
- package/src/witness-scaffold.mjs +13 -32
|
@@ -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
|
+
}
|
package/src/todo-status.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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', '
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
321
|
-
|
|
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],
|
|
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
|
-
*
|
|
390
|
-
*
|
|
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'
|
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 };
|
|
@@ -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
|
-
|
|
1128
|
-
|
|
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({
|