@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
package/src/todo-gantt-html.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
|
|
3
|
+
import { auditPendingPhasesOf } from './todo-audit-pending.mjs';
|
|
3
4
|
import { serializeJsonForScript } from './todo-markdown-renderer.mjs';
|
|
4
5
|
import { renderTodoGanttSvg, TODO_GANTT_STATUS_PRESENTATION } from './todo-gantt-svg.mjs';
|
|
5
6
|
import { renderDiagramLegend, renderRightPane } from './todo-gantt-html-independence.mjs';
|
|
6
7
|
import { escapeHtmlAttribute, escapeHtmlText, refKey } from './todo-gantt-html-shared.mjs';
|
|
7
8
|
import { CSS } from './todo-gantt-html-style.mjs';
|
|
8
9
|
|
|
9
|
-
export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.
|
|
10
|
+
export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v19';
|
|
10
11
|
export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
|
|
11
12
|
export const TODO_GANTT_HTML_MAX_BYTES = 24 * 1024 * 1024;
|
|
12
13
|
|
|
@@ -17,6 +18,33 @@ function projectDisplayName(readModel, metadata) {
|
|
|
17
18
|
return readModel.project_id;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* 監査待ちPhaseをヘッダへ出す(ADR 0147/0148)。
|
|
23
|
+
*
|
|
24
|
+
* CLIのstdoutは1行のversioned JSONというADR 0049の公理があり、人が読むテキスト面が無い。
|
|
25
|
+
* 人が実際に見るのはこの図とdashboard(同じHTMLを配信する)なので、監査待ちはここへ出す。
|
|
26
|
+
* 全taskがdoneでも監査が済んでいなければ工程は閉じていない——図が「全部緑」に見えるのに
|
|
27
|
+
* ヘッダが無言なら、読み手は完了したと読む。
|
|
28
|
+
*
|
|
29
|
+
* 判断の着いたPhase(accepted/closed_unaudited)しか無ければ、この札は出ない。
|
|
30
|
+
*
|
|
31
|
+
* **出す文字列を有界にする。** ツールバーはpane幅で制約されていないので、長い札は縮まずに
|
|
32
|
+
* 行を押し広げ、ズーム操作を画面外へ追い出す(2026-08-08に実測)。CSSの`text-overflow`は
|
|
33
|
+
* ここでは発動しないため、頼れない。全plan名を並べる代わりに先頭1件+残件数だけを出し、
|
|
34
|
+
* 全文は`title`に残す。件数(`監査待ち N件`)は常に見える——省略するのは内訳であって、
|
|
35
|
+
* 監査待ちの存在そのものではない。
|
|
36
|
+
*/
|
|
37
|
+
function renderAuditPendingChip(readModel) {
|
|
38
|
+
const pending = auditPendingPhasesOf(readModel);
|
|
39
|
+
if (pending.length === 0) return '';
|
|
40
|
+
const label = ({ plan_key: planKey, phase_id: phaseId, status }) => `${planKey}/${phaseId} (${status})`;
|
|
41
|
+
// 残件数は書かない——先頭の`N件`が既に全体を数えており、「ほかM件」はその引き算でしかない。
|
|
42
|
+
// 幅は有限なので、同じことを二度言う分だけplan名が削れる。
|
|
43
|
+
const text = `監査待ち ${pending.length}件: ${label(pending[0])}`;
|
|
44
|
+
const full = `監査待ち ${pending.length}件: ${pending.map(label).join(' · ')}`;
|
|
45
|
+
return `<span class="audit-pending-chip" title="${escapeHtmlAttribute(full)}">${escapeHtmlText(text)}</span>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
export class TodoGanttRenderError extends Error {
|
|
21
49
|
constructor(code, message, detail = {}) {
|
|
22
50
|
super(message);
|
|
@@ -196,7 +224,7 @@ export function renderTodoGanttHtml({
|
|
|
196
224
|
metadata,
|
|
197
225
|
presentation,
|
|
198
226
|
});
|
|
199
|
-
const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong
|
|
227
|
+
const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong>${renderAuditPendingChip(readModel)}<button type="button" data-zoom-action="out" aria-label="縮小">−</button><button type="button" data-zoom-action="reset">等倍</button><button type="button" data-zoom-action="in" aria-label="拡大">+</button><button type="button" data-zoom-action="fit">全体表示</button><output class="zoom-readout" data-zoom-output aria-live="polite">100%</output><span class="diagram-note">縦=依存段階(時間ではない)</span></div>${renderDiagramLegend(presentation, layout, expandedSvg !== '')}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${diagrams}</div></section><div class="pane-divider" data-pane-divider aria-hidden="true"></div><aside class="narrative-pane" aria-label="選択工程の詳細と全工程一覧">${rightPane}</aside></main><script type="application/json" id="todo-gantt-data">${staticData}</script><script>${CONTROLLER}</script></body></html>`;
|
|
200
228
|
const htmlBytes = Buffer.byteLength(html, 'utf8');
|
|
201
229
|
if (htmlBytes > TODO_GANTT_HTML_MAX_BYTES) {
|
|
202
230
|
throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt HTML limit exceeded', {
|
package/src/todo-gantt-scope.mjs
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
* the real plan, and measuring them on a narrowed graph would make them lie.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { isAuditPendingPhaseStatus } from './todo-audit-pending.mjs';
|
|
30
|
+
|
|
29
31
|
export const TODO_GANTT_SCOPES = Object.freeze(['live', 'all']);
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -50,14 +52,16 @@ function compareText(left, right) {
|
|
|
50
52
|
* phase無しplanのtaskにはphase_idフィールド自体が無いため、そこでは暗黙Phaseの状態を
|
|
51
53
|
* 埋める。フィールドが無い/nullの入力(既存test・素のnode)は従来どおり対象外(false)になる。
|
|
52
54
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* 他のtaskが図に残っている限りplanが未完了だと分かるので対象にしない——ここまで
|
|
55
|
+
* 監査待ちの状態集合そのものは`todo-audit-pending.mjs`が持つ(gate_ready・reviewing・rejected)。
|
|
56
|
+
* `active`(一部taskがまだpending)が入っていないことにここも依存している——ここまで
|
|
56
57
|
* 広げると、完走していない枝の通常foldまで止めてしまい既存挙動を変える。
|
|
58
|
+
*
|
|
59
|
+
* 下のv4/v5除外は共有moduleへ移さない。あれは「監査待ちとは何か」の定義ではなく、
|
|
60
|
+
* ganttがどこまでfoldを止めるかという本moduleの方針である。移すと、宣言Phase planの
|
|
61
|
+
* fold挙動を他の消費者の都合で変えてしまう。
|
|
57
62
|
*/
|
|
58
|
-
const AUDIT_PENDING_PHASE_STATUSES = new Set(['gate_ready', 'reviewing', 'rejected']);
|
|
59
63
|
function auditPending(node) {
|
|
60
|
-
if (!
|
|
64
|
+
if (!isAuditPendingPhaseStatus(node.phase_status)) return false;
|
|
61
65
|
const schema = node.plan_schema ?? null;
|
|
62
66
|
return !['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(schema);
|
|
63
67
|
}
|
|
@@ -8,18 +8,20 @@ import {
|
|
|
8
8
|
} from './todo-contracts.mjs';
|
|
9
9
|
import {
|
|
10
10
|
RUN_REQUEST_CLAIM_MODE,
|
|
11
|
+
RUN_REQUEST_DECLARATIVE_SCHEMA,
|
|
11
12
|
RUN_REQUEST_SCHEMA,
|
|
12
13
|
explainRunRequest,
|
|
13
14
|
selfDigest as runtimeSelfDigest,
|
|
14
15
|
} from './runtime-contracts.mjs';
|
|
15
16
|
import { TODO_INDEPENDENCE_GUIDANCE_CODES } from './todo-independence-guidance.mjs';
|
|
16
17
|
|
|
17
|
-
export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.
|
|
18
|
+
export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v4';
|
|
18
19
|
/**
|
|
19
|
-
* まだ受理する旧witness set契約。
|
|
20
|
-
*
|
|
20
|
+
* まだ受理する旧witness set契約。v3以前の厳密なcompile意味を変えず、
|
|
21
|
+
* 既存宣言を書き換えさせないために読み口を残す。
|
|
21
22
|
*/
|
|
22
23
|
export const TODO_WITNESS_SET_LEGACY_SCHEMAS = Object.freeze([
|
|
24
|
+
'lattice.todo_witness_set.v3',
|
|
23
25
|
'lattice.todo_witness_set.v2',
|
|
24
26
|
'lattice.todo_witness_set.v1',
|
|
25
27
|
]);
|
|
@@ -30,9 +32,10 @@ export const TODO_WITNESS_SET_SCHEMAS = Object.freeze([
|
|
|
30
32
|
/** 宣言できる欄はversionごとに違う。どの版から使えるかを1箇所で持つ。 */
|
|
31
33
|
const CONCERN_ANCHOR_SCHEMAS = Object.freeze([
|
|
32
34
|
TODO_WITNESS_SET_SCHEMA,
|
|
35
|
+
'lattice.todo_witness_set.v3',
|
|
33
36
|
'lattice.todo_witness_set.v2',
|
|
34
37
|
]);
|
|
35
|
-
const CREATES_SCHEMAS = Object.freeze([TODO_WITNESS_SET_SCHEMA]);
|
|
38
|
+
const CREATES_SCHEMAS = Object.freeze([TODO_WITNESS_SET_SCHEMA, 'lattice.todo_witness_set.v3']);
|
|
36
39
|
|
|
37
40
|
/** 1 taskが宣言できるconcern anchorの資源数と、資源あたりのsymbol数の上限。 */
|
|
38
41
|
export const TODO_CONCERN_ANCHOR_LIMIT = 256;
|
|
@@ -127,7 +130,8 @@ function boundedText(value, maximumBytes = 4_096) {
|
|
|
127
130
|
export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId }) {
|
|
128
131
|
const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
|
|
129
132
|
const request = {
|
|
130
|
-
schema:
|
|
133
|
+
schema: witnessSet.schema === TODO_WITNESS_SET_SCHEMA
|
|
134
|
+
? RUN_REQUEST_SCHEMA : RUN_REQUEST_DECLARATIVE_SCHEMA,
|
|
131
135
|
request_id: requestId,
|
|
132
136
|
repo: { base_sha: baseSha, root_kind: 'git' },
|
|
133
137
|
capacity: witnessSet.capacity,
|
|
@@ -468,7 +472,7 @@ function unknownTaskEntry(value) {
|
|
|
468
472
|
*
|
|
469
473
|
* ready frontierを「検証済み並列グループ」「直列化すべき組」「未検査」へ分けた読み出し面。
|
|
470
474
|
* v2は進行中ToDoとの競合(`conflicts_with_active`)と鮮度の内訳(`drift`)を加える。
|
|
471
|
-
* `todo_status_result
|
|
475
|
+
* `todo_status_result`と`dispatch_frontier`は変更せず、加算の別面として持つ(ADR 0124の規律)。
|
|
472
476
|
*/
|
|
473
477
|
export function validateTodoIndependenceProjection(value) {
|
|
474
478
|
try {
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
export const TODO_INDEPENDENCE_GUIDANCE_CODES = Object.freeze([
|
|
12
12
|
'independence_no_ready_frontier',
|
|
13
|
+
// ob03: 調整方式の宣言に関する案内。witnessが全planの暗黙義務だった時、未compileの督促は
|
|
14
|
+
// 誰の受入条件でもない作業を指しており、正確なまま素通りされた。まず「どちらで行くか」を
|
|
15
|
+
// 選ばせ、選択の後にだけ督促する。
|
|
16
|
+
'coordination_mode_undeclared',
|
|
17
|
+
'coordination_conversation',
|
|
13
18
|
'independence_unrecorded',
|
|
14
19
|
'independence_task_undeclared',
|
|
15
20
|
'independence_contract_superseded',
|
|
@@ -63,6 +68,14 @@ const CATALOG = Object.freeze({
|
|
|
63
68
|
message: '着手候補が無いため、並列可否を述べる対象が無い。',
|
|
64
69
|
next_action: 'none',
|
|
65
70
|
}),
|
|
71
|
+
coordination_mode_undeclared: Object.freeze({
|
|
72
|
+
message: 'このplanは調整方式をまだ選んでいない。witness検証で並列するか、会話で調整するかが決まっていない。',
|
|
73
|
+
next_action: 'declare_coordination_mode',
|
|
74
|
+
}),
|
|
75
|
+
coordination_conversation: Object.freeze({
|
|
76
|
+
message: 'このplanは会話調整を選んでいる。並列可否は宣言と判定ではなく、卓の合意が持つ。',
|
|
77
|
+
next_action: 'none',
|
|
78
|
+
}),
|
|
66
79
|
independence_unrecorded: Object.freeze({
|
|
67
80
|
message: 'このplanの並列可否はまだ判定していない。競合が無いのではなく、記録が存在しない。',
|
|
68
81
|
next_action: 'declare_witness_set_then_compile',
|
|
@@ -170,6 +183,7 @@ export function todoIndependenceGuidance(code, { severability = null } = {}) {
|
|
|
170
183
|
export function selectIndependenceGuidance({
|
|
171
184
|
coverage, taskDeclared, taskStale, conflictWithActive = null, conflictBetweenReady = null,
|
|
172
185
|
contractSuperseded = false, readyCount = null, verdictsAbsent = false,
|
|
186
|
+
coordinationMode = 'witness',
|
|
173
187
|
}) {
|
|
174
188
|
// 着手候補が無いなら述べる対象が無い。ここを通さないと、readyが空のとき
|
|
175
189
|
// 「未検査taskが1件も無い」が空虚に真になり、記録が古くても検証済みへ倒れる。
|
|
@@ -187,7 +201,16 @@ export function selectIndependenceGuidance({
|
|
|
187
201
|
if (contractSuperseded) {
|
|
188
202
|
return todoIndependenceGuidance('independence_contract_superseded');
|
|
189
203
|
}
|
|
190
|
-
|
|
204
|
+
// 記録が無い時に何を言うかは、planがどちらの方式を選んだかで変わる(ob03・裁定C①)。
|
|
205
|
+
// 会話調整を選んだplanへ未compileを督促するのは、選択を尊重しないことになる。未宣言の
|
|
206
|
+
// planへ督促するのは、誰の受入条件でもない作業を指すことになる——それが8件で素通りされた
|
|
207
|
+
// 当のものである。督促が一級で出るのはwitnessを選んだplanだけとする。
|
|
208
|
+
// 既定を`witness`にしてあるのは、宣言を渡さない既存の呼び出し側の挙動を変えないためである。
|
|
209
|
+
if (coverage === 'missing') {
|
|
210
|
+
if (coordinationMode === 'conversation') return todoIndependenceGuidance('coordination_conversation');
|
|
211
|
+
if (coordinationMode === null) return todoIndependenceGuidance('coordination_mode_undeclared');
|
|
212
|
+
return todoIndependenceGuidance('independence_unrecorded');
|
|
213
|
+
}
|
|
191
214
|
if (coverage === 'superseded') return todoIndependenceGuidance('independence_superseded');
|
|
192
215
|
if (!taskDeclared) return todoIndependenceGuidance('independence_task_undeclared');
|
|
193
216
|
if (taskStale) return todoIndependenceGuidance('independence_stale_for_task');
|
|
@@ -241,36 +264,12 @@ export const TODO_INDEPENDENCE_WORKFLOW = Object.freeze([
|
|
|
241
264
|
* のは、ADR 0130が禁じたものそのものである。
|
|
242
265
|
*/
|
|
243
266
|
const SCAFFOLD_CATALOG = Object.freeze({
|
|
244
|
-
path_absent_declare_creates: {
|
|
245
|
-
message: '宣言したpathは不在と観測できている。そのToDoが作るなら、owns entryを{ path, creates: true }(下書きv2)にして再実行する。作らないなら、宣言しているpathが正しいか確かめる。',
|
|
246
|
-
next_action: 'declare_creates_then_retry',
|
|
247
|
-
},
|
|
248
|
-
creates_path_present: {
|
|
249
|
-
message: '創作を宣言したpathは既に存在する。作るのでなく変更するなら、creates宣言を外してpath名だけにする。',
|
|
250
|
-
next_action: 'drop_creates_then_retry',
|
|
251
|
-
},
|
|
252
|
-
creates_unverified: {
|
|
253
|
-
message: '創作宣言の裏付けが取れない。sensorの観測が「対象1件・依存test無し」の形になっていない。sensor syncで索引を取り直してから再実行する。',
|
|
254
|
-
next_action: 'sync_sensor_then_retry',
|
|
255
|
-
},
|
|
256
|
-
affected_tests_unobserved: {
|
|
257
|
-
message: '所有pathのaffected観測が取れていない。sensor syncで索引へ収載してから再実行する。',
|
|
258
|
-
next_action: 'sync_sensor_then_retry',
|
|
259
|
-
},
|
|
260
|
-
multiple_owned_paths_unsupported: {
|
|
261
|
-
message: 'affected_testsは宣言とfresh観測をbinding単位でexact比較するため、1 ToDoが複数pathを所有する宣言は今の契約で表現できない。ToDoを分けるか、所有を1 pathに絞る。',
|
|
262
|
-
next_action: 'split_todo_or_narrow_owns',
|
|
263
|
-
},
|
|
264
267
|
anchor_outside_owned: {
|
|
265
268
|
message: 'concern_anchorのwithinが、自分の所有pathを指していない。所有していない資源の内側に担当を主張できない。',
|
|
266
269
|
next_action: 'align_anchor_with_owns',
|
|
267
270
|
},
|
|
268
|
-
owns_empty: {
|
|
269
|
-
message: 'ownsが空のToDoがある。何を所有するかは判定の起点なので、道具が補わない。',
|
|
270
|
-
next_action: 'declare_owns_then_retry',
|
|
271
|
-
},
|
|
272
271
|
draft_invalid: {
|
|
273
|
-
message: '下書きがlattice.todo_witness_draft契約を満たしていない。
|
|
272
|
+
message: '下書きがlattice.todo_witness_draft契約を満たしていない。',
|
|
274
273
|
next_action: 'fix_draft_schema_then_retry',
|
|
275
274
|
},
|
|
276
275
|
});
|
|
@@ -284,9 +283,7 @@ const SCAFFOLD_CATALOG = Object.freeze({
|
|
|
284
283
|
export function selectWitnessScaffoldGuidance(reasons = []) {
|
|
285
284
|
const codes = reasons.map((reason) => String(reason).split(':')[0]);
|
|
286
285
|
const order = [
|
|
287
|
-
'draft_invalid', '
|
|
288
|
-
'path_absent_declare_creates', 'creates_path_present', 'creates_unverified',
|
|
289
|
-
'affected_tests_unobserved',
|
|
286
|
+
'draft_invalid', 'anchor_outside_owned',
|
|
290
287
|
];
|
|
291
288
|
const code = order.find((candidate) => codes.includes(candidate));
|
|
292
289
|
if (code === undefined) {
|
package/src/todo-note-store.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
TODO_LIMITS,
|
|
9
9
|
TODO_NOTE_CONTEXT_SCHEMA,
|
|
10
10
|
TODO_NOTE_EVENT_SCHEMA,
|
|
11
|
+
TODO_NOTE_EVENT_V2_SCHEMA,
|
|
11
12
|
canonicalizeTodoArtifact,
|
|
12
13
|
exactRecord,
|
|
13
14
|
isTodoDigest,
|
|
@@ -45,10 +46,37 @@ function canonicalLine(value) {
|
|
|
45
46
|
return Buffer.from(`${canonicalizeTodoArtifact(value)}\n`, 'utf8');
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* task noteとplan noteは**別のchain file**へ積む。
|
|
51
|
+
*
|
|
52
|
+
* 混ぜると、plan note(`todo_note_event.v2`)を1件書いた時点で、旧CLIにとってその planの
|
|
53
|
+
* chain全体が壊れたものになる——`parseCanonicalSegment`は1 eventずつbyte完全一致で検証し、
|
|
54
|
+
* 1件でも通らなければchainごと`NOTE_LOG_CORRUPT`で落とすためである。noteの読みは
|
|
55
|
+
* `todo start`の前提条件(fail closed)なので、**旧CLIでstartが通らなくなる**。しかも
|
|
56
|
+
* store��書いたものは戻せないので、rollbackで復旧できない。
|
|
57
|
+
*
|
|
58
|
+
* 旧readerが読むのは`active.jsonl`と`sealed/*`だけで、plan直下を列挙しない。別名のfileへ
|
|
59
|
+
* 積めば存在に気づかず、task noteの読み書きは1バイトも変わらない。旧CLIからplan noteは
|
|
60
|
+
* 見えないままだが、**旧CLIではplan noteを書けない以上、読めなくても行動が変わらない**。
|
|
61
|
+
*/
|
|
48
62
|
function notePaths(repoRoot, planKey) {
|
|
49
63
|
if (!isTodoIdentifier(planKey)) throw new TypeError('planKey must be a todo identifier');
|
|
50
64
|
const root = path.resolve(repoRoot, NOTE_ROOT_REF, planKey);
|
|
51
|
-
return {
|
|
65
|
+
return {
|
|
66
|
+
root,
|
|
67
|
+
active: path.join(root, 'active.jsonl'),
|
|
68
|
+
sealed: path.join(root, 'sealed'),
|
|
69
|
+
planActive: path.join(root, 'plan-active.jsonl'),
|
|
70
|
+
planSealed: path.join(root, 'plan-sealed'),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** chainごとのpathと、そのchainが受けるevent schema。 */
|
|
75
|
+
function chainRefs(repoRoot, planKey, scope) {
|
|
76
|
+
const refs = notePaths(repoRoot, planKey);
|
|
77
|
+
return scope === 'plan'
|
|
78
|
+
? { active: refs.planActive, sealed: refs.planSealed, schema: TODO_NOTE_EVENT_V2_SCHEMA }
|
|
79
|
+
: { active: refs.active, sealed: refs.sealed, schema: TODO_NOTE_EVENT_SCHEMA };
|
|
52
80
|
}
|
|
53
81
|
|
|
54
82
|
async function readOptionalBounded(ref, { missing = false } = {}) {
|
|
@@ -110,10 +138,15 @@ function validateEventChain(events, { projectId, planKey }) {
|
|
|
110
138
|
}
|
|
111
139
|
}
|
|
112
140
|
|
|
113
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* planに属する独立note chainをbyte-levelで検証して読む。missingだけは空chainである。
|
|
143
|
+
* `scope`で読むchainを選ぶ(既定はtask chain=旧CLIと同じ経路・同じbyte)。
|
|
144
|
+
*/
|
|
114
145
|
export async function readTodoNoteEvents(options = {}) {
|
|
115
146
|
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
116
|
-
const
|
|
147
|
+
const scope = options.scope ?? 'task';
|
|
148
|
+
if (!['plan', 'task'].includes(scope)) throw new TypeError('note chain scope must be plan or task');
|
|
149
|
+
const { active, sealed, schema } = chainRefs(repoRoot, options.planKey, scope);
|
|
117
150
|
const names = await sealedFiles(sealed);
|
|
118
151
|
const events = [];
|
|
119
152
|
let previousSegmentDigest = ZERO_DIGEST;
|
|
@@ -132,6 +165,11 @@ export async function readTodoNoteEvents(options = {}) {
|
|
|
132
165
|
}
|
|
133
166
|
const activeBytes = await readOptionalBounded(active, { missing: true });
|
|
134
167
|
if (activeBytes !== null) events.push(...parseCanonicalSegment(activeBytes, active));
|
|
168
|
+
// chainの分離は「そのfileへ何が積まれるか」で守る。混ざったchainは分離の意味を失うので
|
|
169
|
+
// 黙って受けず、読んだ時点でtypedに落とす。
|
|
170
|
+
if (events.some((event) => event.schema !== schema)) {
|
|
171
|
+
fail('NOTE_LOG_CORRUPT', 'note_chain_scope_mixed', { plan_key: options.planKey, scope });
|
|
172
|
+
}
|
|
135
173
|
if (events.length > 0) {
|
|
136
174
|
validateEventChain(events, { projectId: events[0].project_id, planKey: options.planKey });
|
|
137
175
|
}
|
|
@@ -186,22 +224,32 @@ export async function appendTodoNote(options = {}) {
|
|
|
186
224
|
if (!exactRecord(options, keys)
|
|
187
225
|
|| !Array.isArray(options.eligibleSupersedes)
|
|
188
226
|
|| !options.eligibleSupersedes.every(isTodoDigest)
|
|
189
|
-
|| ![options.projectId, options.planKey, options.planVersion
|
|
190
|
-
|
|
227
|
+
|| ![options.projectId, options.planKey, options.planVersion].every(isTodoIdentifier)
|
|
228
|
+
// taskId nullがplan単位noteの指定。scopeはeventのschemaが持つ。
|
|
229
|
+
|| !(options.taskId === null || isTodoIdentifier(options.taskId))) {
|
|
230
|
+
throw new TypeError('todo note append options invalid');
|
|
231
|
+
}
|
|
232
|
+
const planScoped = options.taskId === null;
|
|
233
|
+
const scope = planScoped ? 'plan' : 'task';
|
|
191
234
|
const repoRoot = path.resolve(options.repoRoot);
|
|
192
235
|
return withNoteLock(repoRoot, async () => {
|
|
193
|
-
|
|
236
|
+
// sequenceとprevious_digestはchainごとに独立。note系の起点は1(journal系の0ではない)。
|
|
237
|
+
const chain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey, scope });
|
|
194
238
|
if (options.supersedes !== null) {
|
|
195
239
|
const target = chain.events.find(({ event_digest: digest }) => digest === options.supersedes);
|
|
196
240
|
if (target === undefined || !options.eligibleSupersedes.includes(options.supersedes)) {
|
|
197
|
-
|
|
241
|
+
// plan noteはplan noteだけを、task noteは同じtaskのnoteだけを訂正できる。
|
|
242
|
+
// scopeを跨ぐ訂正を許すと、届く先が違うものを同じ履歴として畳むことになる。
|
|
243
|
+
fail('NOTE_SUPERSEDES_INVALID', planScoped
|
|
244
|
+
? 'superseded_note_not_plan_scoped' : 'superseded_note_not_in_same_task', {
|
|
198
245
|
plan_key: options.planKey, task_id: options.taskId,
|
|
199
246
|
});
|
|
200
247
|
}
|
|
201
248
|
}
|
|
202
249
|
const previous = chain.events.at(-1) ?? null;
|
|
203
250
|
const event = {
|
|
204
|
-
schema: TODO_NOTE_EVENT_SCHEMA,
|
|
251
|
+
schema: planScoped ? TODO_NOTE_EVENT_V2_SCHEMA : TODO_NOTE_EVENT_SCHEMA,
|
|
252
|
+
...(planScoped ? { scope: 'plan' } : {}),
|
|
205
253
|
project_id: options.projectId,
|
|
206
254
|
plan_key: options.planKey,
|
|
207
255
|
task_id: options.taskId,
|
|
@@ -217,7 +265,7 @@ export async function appendTodoNote(options = {}) {
|
|
|
217
265
|
event.event_digest = todoSelfDigest(event, 'event_digest');
|
|
218
266
|
if (!validateTodoNoteEvent(event)) throw new TypeError('todo note event input invalid');
|
|
219
267
|
|
|
220
|
-
const paths =
|
|
268
|
+
const paths = chainRefs(repoRoot, options.planKey, scope);
|
|
221
269
|
const eventBytes = canonicalLine(event);
|
|
222
270
|
if (chain.active_bytes.length > 0
|
|
223
271
|
&& chain.active_bytes.length + eventBytes.length > TODO_LIMITS.journalSegmentBytes) {
|
|
@@ -235,10 +283,12 @@ export async function appendTodoNote(options = {}) {
|
|
|
235
283
|
});
|
|
236
284
|
}
|
|
237
285
|
|
|
286
|
+
/** v1(task note)とv2(plan note)を、明示`scope`を持つ1つの形へ正規化する。 */
|
|
238
287
|
function noteProjectionEntry(event, supersededBy) {
|
|
239
288
|
return {
|
|
240
289
|
event_digest: event.event_digest,
|
|
241
290
|
origin_plan_version: event.plan_version,
|
|
291
|
+
scope: event.schema === TODO_NOTE_EVENT_V2_SCHEMA ? 'plan' : 'task',
|
|
242
292
|
origin_task_id: event.task_id,
|
|
243
293
|
actor: event.actor,
|
|
244
294
|
recorded_at: event.recorded_at,
|
|
@@ -302,37 +352,52 @@ function resolveNoteTarget(event, { currentPlanVersion, currentTaskIds, migratio
|
|
|
302
352
|
export function projectTodoNoteContext(options = {}) {
|
|
303
353
|
if (!exactRecord(options, [
|
|
304
354
|
'projectId', 'planKey', 'currentPlanVersion', 'currentTaskId',
|
|
305
|
-
'currentTaskIds', 'events', 'migrations',
|
|
355
|
+
'currentTaskIds', 'events', 'planEvents', 'migrations',
|
|
306
356
|
]) || ![options.projectId, options.planKey, options.currentPlanVersion, options.currentTaskId]
|
|
307
357
|
.every(isTodoIdentifier) || !Array.isArray(options.currentTaskIds)
|
|
308
358
|
|| !options.currentTaskIds.every(isTodoIdentifier)
|
|
309
359
|
|| !options.currentTaskIds.includes(options.currentTaskId)
|
|
310
|
-
|| !Array.isArray(options.events) || !options.events.every(validateTodoNoteEvent)
|
|
360
|
+
|| !Array.isArray(options.events) || !options.events.every(validateTodoNoteEvent)
|
|
361
|
+
|| !Array.isArray(options.planEvents) || !options.planEvents.every(validateTodoNoteEvent)) {
|
|
311
362
|
throw new TypeError('todo note projection options invalid');
|
|
312
363
|
}
|
|
313
364
|
const currentTaskIds = new Set(options.currentTaskIds);
|
|
314
365
|
const migrations = migrationIndex(options.migrations);
|
|
366
|
+
// 訂正はscopeを跨げないので、supersedeの追跡もchainごとに閉じる。
|
|
315
367
|
const supersededBy = new Map();
|
|
316
|
-
for (const event of options.events) {
|
|
368
|
+
for (const event of [...options.events, ...options.planEvents]) {
|
|
317
369
|
if (event.project_id !== options.projectId || event.plan_key !== options.planKey) {
|
|
318
370
|
fail('NOTE_LOG_CORRUPT', 'note_identity_mismatch');
|
|
319
371
|
}
|
|
320
372
|
if (event.supersedes !== null) supersededBy.set(event.supersedes, event.event_digest);
|
|
321
373
|
}
|
|
322
374
|
|
|
323
|
-
const
|
|
375
|
+
const taskCurrent = [];
|
|
324
376
|
const archived = [];
|
|
325
|
-
const
|
|
377
|
+
const sequenceOf = (events) => new Map(events.map((event) => [event.event_digest, event.sequence]));
|
|
378
|
+
const taskSequence = sequenceOf(options.events);
|
|
379
|
+
const planSequence = sequenceOf(options.planEvents);
|
|
326
380
|
for (const event of options.events) {
|
|
381
|
+
const entry = noteProjectionEntry(event, supersededBy.get(event.event_digest));
|
|
327
382
|
const target = resolveNoteTarget(event, {
|
|
328
383
|
currentPlanVersion: options.currentPlanVersion, currentTaskIds, migrations,
|
|
329
384
|
});
|
|
330
|
-
const entry = noteProjectionEntry(event, supersededBy.get(event.event_digest));
|
|
331
385
|
if (target.kind === 'archived') archived.push(entry);
|
|
332
|
-
else if (target.taskId === options.currentTaskId)
|
|
386
|
+
else if (target.taskId === options.currentTaskId) taskCurrent.push(entry);
|
|
333
387
|
}
|
|
334
|
-
|
|
335
|
-
|
|
388
|
+
// plan noteは特定のtaskに属さないので、task migrationで宛先を失うことがない。全taskの
|
|
389
|
+
// contextへ載せる——工程レベルの義務は「次に着手する誰か」へ届くべきもので、誰が着手するかは
|
|
390
|
+
// 書いた時点で分からない。
|
|
391
|
+
const planCurrent = options.planEvents
|
|
392
|
+
.map((event) => noteProjectionEntry(event, supersededBy.get(event.event_digest)));
|
|
393
|
+
taskCurrent.sort((left, right) => taskSequence.get(right.event_digest)
|
|
394
|
+
- taskSequence.get(left.event_digest));
|
|
395
|
+
planCurrent.sort((left, right) => planSequence.get(right.event_digest)
|
|
396
|
+
- planSequence.get(left.event_digest));
|
|
397
|
+
// 2本のsequenceは独立なので、混ぜた全順序は時刻にもsequenceにも作れない(時刻は
|
|
398
|
+
// future_clock_skewがある以上信用できない)。決定的な規則で並べる: plan単位の申し送りは
|
|
399
|
+
// task固有のものより文脈が広いので先に読ませる。
|
|
400
|
+
const current = [...planCurrent, ...taskCurrent];
|
|
336
401
|
archived.reverse();
|
|
337
402
|
|
|
338
403
|
const notes = [];
|
|
@@ -349,10 +414,14 @@ export function projectTodoNoteContext(options = {}) {
|
|
|
349
414
|
plan_key: options.planKey,
|
|
350
415
|
task_id: options.currentTaskId,
|
|
351
416
|
notes,
|
|
352
|
-
|
|
417
|
+
// headはchainごとに言う。合成すると「どちらのheadか」と連結順を定義する必要が生まれ、
|
|
418
|
+
// 順序が決まっていなければ同じ状態から違うdigestが出る。
|
|
419
|
+
note_head_digest: taskCurrent[0]?.event_digest ?? null,
|
|
420
|
+
plan_note_head_digest: planCurrent[0]?.event_digest ?? null,
|
|
421
|
+
// overflowはcontext全体の予算の話でchainの性質ではないので、ここは合成でよい。
|
|
353
422
|
overflow_count: current.length - notes.length,
|
|
354
|
-
|
|
355
|
-
|
|
423
|
+
// plan noteを載せる以上、案内はplan全体を返す形でなければ「full」ではない。
|
|
424
|
+
full_history_command: `lattice todo note list --plan ${options.planKey} --json`,
|
|
356
425
|
context_digest: '',
|
|
357
426
|
};
|
|
358
427
|
context.context_digest = todoSelfDigest(context, 'context_digest');
|
|
@@ -450,6 +519,7 @@ export async function readTodoNoteContext(options = {}) {
|
|
|
450
519
|
plan_key: options.planKey, task_id: options.taskId,
|
|
451
520
|
});
|
|
452
521
|
const chain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey });
|
|
522
|
+
const planChain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey, scope: 'plan' });
|
|
453
523
|
const migrations = await readNoteMigrations(repoRoot, options.planKey,
|
|
454
524
|
new Set(chain.events.map(({ plan_version: version }) => version)));
|
|
455
525
|
return projectTodoNoteContext({
|
|
@@ -459,10 +529,65 @@ export async function readTodoNoteContext(options = {}) {
|
|
|
459
529
|
currentTaskId: task.task_id,
|
|
460
530
|
currentTaskIds: member.plan.tasks.map(({ task_id: taskId }) => taskId),
|
|
461
531
|
events: chain.events,
|
|
532
|
+
planEvents: planChain.events,
|
|
462
533
|
migrations,
|
|
463
534
|
});
|
|
464
535
|
}
|
|
465
536
|
|
|
537
|
+
/**
|
|
538
|
+
* status面の`plan_notes`欄の素材を、store内の全planぶん一度に読む。
|
|
539
|
+
*
|
|
540
|
+
* plan単位noteは「工程に属する義務」で、着手する誰かのcontextへは届くが、**まだ誰も
|
|
541
|
+
* 着手していない工程の義務はどこにも出ない**。statusはそれを出す唯一の面なので、
|
|
542
|
+
* ここでは本文を一切運ばない——載せるのは件数・帰属・次の一手だけで、中身は
|
|
543
|
+
* `note list`が持つ(`audit_pending`が330字のproseを落としたのと同じ判断・ADR 0159)。
|
|
544
|
+
*
|
|
545
|
+
* noteを持たないplanはentryごと出さない。「全plan常に1行」は、前campaignの
|
|
546
|
+
* witness `coverage: missing`と同じ「満杯で始まるので読み飛ばされる欄」になる。
|
|
547
|
+
*
|
|
548
|
+
* chainが壊れているplanが1つでもあれば`readTodoNoteEvents`のtyped errorがそのまま出る
|
|
549
|
+
* (fail closed)。壊れた義務記録を「義務なし」と同じ空へ丸めない。
|
|
550
|
+
*/
|
|
551
|
+
export async function readTodoPlanNotesForStatus(options = {}) {
|
|
552
|
+
if (!exactRecord(options, ['repoRoot', 'store'])
|
|
553
|
+
|| options.store === null || typeof options.store !== 'object'
|
|
554
|
+
|| !Array.isArray(options.store.members)) {
|
|
555
|
+
throw new TypeError('todo plan notes read options invalid');
|
|
556
|
+
}
|
|
557
|
+
const repoRoot = path.resolve(options.repoRoot);
|
|
558
|
+
const summaries = [];
|
|
559
|
+
for (const member of options.store.members) {
|
|
560
|
+
const planKey = member.plan.plan_key;
|
|
561
|
+
// plan noteは専用chainに在る。schemaでの選別は要らない——読む先そのものが分かれている。
|
|
562
|
+
const chain = await readTodoNoteEvents({ repoRoot, planKey, scope: 'plan' });
|
|
563
|
+
const superseded = new Set(chain.events
|
|
564
|
+
.filter(({ supersedes }) => supersedes !== null)
|
|
565
|
+
.map(({ supersedes }) => supersedes));
|
|
566
|
+
// 訂正されたnoteは数えない。数えると訂正するほど件数が増える。
|
|
567
|
+
const current = chain.events
|
|
568
|
+
.filter((event) => !superseded.has(event.event_digest))
|
|
569
|
+
.sort((left, right) => right.sequence - left.sequence);
|
|
570
|
+
if (current.length === 0) continue;
|
|
571
|
+
summaries.push({
|
|
572
|
+
plan_key: planKey,
|
|
573
|
+
// `note_context.note_head_digest`はtask chainのheadを指す。同名で別のchainを指すと
|
|
574
|
+
// 型が同じdigestなので取り違えてもvalidatorを通る——名前で区別する(room [211])。
|
|
575
|
+
plan_note_head_digest: current[0].event_digest,
|
|
576
|
+
count: current.length,
|
|
577
|
+
latest: current.slice(0, TODO_LIMITS.statusPlanNoteLatest).map((event) => ({
|
|
578
|
+
event_digest: event.event_digest,
|
|
579
|
+
actor_agent: event.actor.agent,
|
|
580
|
+
recorded_at: event.recorded_at,
|
|
581
|
+
})),
|
|
582
|
+
// 欄だけでは読まれない。届いた先で次に何を打てばいいかを名指しする。
|
|
583
|
+
next_commands: [`lattice todo note list --plan ${planKey} --json`],
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
summaries.sort((left, right) => (
|
|
587
|
+
left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0));
|
|
588
|
+
return summaries;
|
|
589
|
+
}
|
|
590
|
+
|
|
466
591
|
/** Gantt用に1 planのchain/historyを一度だけ読み、全task contextへ投影する。 */
|
|
467
592
|
export async function readTodoNoteContextsForPlan(options = {}) {
|
|
468
593
|
if (!exactRecord(options, ['repoRoot', 'store', 'planKey'])
|
|
@@ -478,6 +603,7 @@ export async function readTodoNoteContextsForPlan(options = {}) {
|
|
|
478
603
|
plan_key: options.planKey,
|
|
479
604
|
});
|
|
480
605
|
const chain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey });
|
|
606
|
+
const planChain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey, scope: 'plan' });
|
|
481
607
|
const migrations = await readNoteMigrations(repoRoot, options.planKey,
|
|
482
608
|
new Set(chain.events.map(({ plan_version: version }) => version)));
|
|
483
609
|
const currentTaskIds = member.plan.tasks.map(({ task_id: taskId }) => taskId);
|
|
@@ -488,6 +614,7 @@ export async function readTodoNoteContextsForPlan(options = {}) {
|
|
|
488
614
|
currentTaskId: task.task_id,
|
|
489
615
|
currentTaskIds,
|
|
490
616
|
events: chain.events,
|
|
617
|
+
planEvents: planChain.events,
|
|
491
618
|
migrations,
|
|
492
619
|
}));
|
|
493
620
|
return {
|