@quolu/lattice 0.37.0 → 0.38.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/README.ja.md +15 -0
- package/README.md +16 -0
- package/bin/lattice-dashboard.mjs +2 -2
- package/package.json +1 -1
- package/src/cli-help.mjs +7 -0
- package/src/todo-cli.mjs +241 -8
- package/src/todo-contracts.mjs +66 -0
- package/src/todo-gantt-html-independence.mjs +29 -3
- package/src/todo-gantt-html-style.mjs +6 -0
- package/src/todo-gantt-html.mjs +28 -6
- package/src/todo-note-store.mjs +492 -0
package/README.ja.md
CHANGED
|
@@ -43,6 +43,21 @@ planを再コンパイルします。共有面が共有でなくなるので、
|
|
|
43
43
|
|
|
44
44
|
人が手で切ったのではありません。**工程を並列化するために製品が切りました。**
|
|
45
45
|
|
|
46
|
+
### 作業記憶はToDoと一緒に渡る
|
|
47
|
+
|
|
48
|
+
AIは、次の担当が作業を続けるために必要な方針、棄却案、調査結果、注意、未解決事項をToDoへ追記できます。
|
|
49
|
+
通常の`lattice todo show`と、成功するすべての`lattice todo start`は、元version/元task、訂正状態、
|
|
50
|
+
note chain head、overflow、全履歴コマンドを含む最新のbounded `note_context`を自動で返します。
|
|
51
|
+
次のAIが別のnote読取コマンドを知っている必要はありません。
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
lattice todo note --plan <key> --task <id> --message "既存parserを使い、fallbackは追加しない"
|
|
55
|
+
lattice todo show --plan <key> --task <id> --json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
ローカルGanttでは、選択したToDoの右ペインへ同じ作業記録を表示します。公開Gantt/dashboardは契約として
|
|
59
|
+
note本文を含めません。
|
|
60
|
+
|
|
46
61
|
### 変換の受入五条件
|
|
47
62
|
|
|
48
63
|
**5つすべて**を満たしたときだけ採用します。1つでも欠ければ棄却です。
|
package/README.md
CHANGED
|
@@ -58,6 +58,22 @@ the same parallel group.
|
|
|
58
58
|
|
|
59
59
|
Nobody hand-refactored that file. The product cut it so the work could parallelize.
|
|
60
60
|
|
|
61
|
+
### Task memory travels with the task
|
|
62
|
+
|
|
63
|
+
An agent can append the decisions, rejected approaches, findings, cautions, and open questions needed
|
|
64
|
+
to continue a ToDo. A normal `lattice todo show` and every successful `lattice todo start` return the
|
|
65
|
+
latest bounded `note_context` automatically, including origin, correction state, chain head, overflow,
|
|
66
|
+
and the full-history command. The next agent does not need to discover or remember a separate note
|
|
67
|
+
lookup command.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
lattice todo note --plan <key> --task <id> --message "Use the existing parser; do not add a fallback"
|
|
71
|
+
lattice todo show --plan <key> --task <id> --json
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The same context appears in the selected ToDo's detail pane in a local Gantt. Public Gantt and
|
|
75
|
+
dashboard rendering exclude note bodies by contract.
|
|
76
|
+
|
|
61
77
|
### The five acceptance conditions
|
|
62
78
|
|
|
63
79
|
A transform is adopted only when **all five** hold. One missing condition rejects it:
|
|
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
|
|
6
6
|
import { readTodoStoreStable } from '../src/todo-store.mjs';
|
|
7
7
|
import { projectTodoStatus } from '../src/todo-status.mjs';
|
|
8
|
-
import { ganttLiveHeadDigest,
|
|
8
|
+
import { ganttLiveHeadDigest, renderPublicTodoGanttForProject } from '../src/todo-cli.mjs';
|
|
9
9
|
import {
|
|
10
10
|
readVisibleTodoDashboardProjects,
|
|
11
11
|
writeTodoDashboardDaemonDescriptor,
|
|
@@ -87,7 +87,7 @@ async function synchronize() {
|
|
|
87
87
|
displayName: entry.display_name,
|
|
88
88
|
render: async ({ displayName }) => {
|
|
89
89
|
const store = await readCachedStore(entry.repo_root);
|
|
90
|
-
const result = await
|
|
90
|
+
const result = await renderPublicTodoGanttForProject({ repoRoot: entry.repo_root,
|
|
91
91
|
stable: true, displayName, readModel: store });
|
|
92
92
|
return {
|
|
93
93
|
html: result.rendered.html,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Quo / クオ at kitepon.dev",
|
package/src/cli-help.mjs
CHANGED
|
@@ -61,6 +61,8 @@ Commands:
|
|
|
61
61
|
|
|
62
62
|
Read commands:
|
|
63
63
|
status [--json]
|
|
64
|
+
show --plan <key> --task <id> --json # 個別ToDoと最新bounded note contextを追加操作なしで返す
|
|
65
|
+
note list --plan <key> [--task <id>] --json # note全履歴の診断面(通常read/startでは不要)
|
|
64
66
|
bindings [--plan <key>] [--json] # compile_binding付きTaskをTODO identityつきで投影する
|
|
65
67
|
independence [--plan <key>] [--json] # readyを検証済み並列・要直列・未検査へ分けて投影する
|
|
66
68
|
seam-profile --plan <key> --file <path> [--json] # 係争fileの切断コスト内訳を投影する(read-only)
|
|
@@ -73,6 +75,8 @@ Read commands:
|
|
|
73
75
|
phase status --plan <key>
|
|
74
76
|
|
|
75
77
|
Write commands:
|
|
78
|
+
note --plan <key> --task <id> (--message <text>|--input <file>)
|
|
79
|
+
# ToDoへ作業継続に必要な方針・調査結果・注意をappend-onlyで追記する
|
|
76
80
|
migrate --input <extraction.json> [--serialization-reviewed]
|
|
77
81
|
# 既存storeへplanを追加する(plan createは空store初期化専用)。
|
|
78
82
|
# 依存グラフがほぼ一直線なら一度突き返し、再考後の --serialization-reviewed で通す
|
|
@@ -168,6 +172,9 @@ const SUBCOMMAND_USAGE = Object.freeze({
|
|
|
168
172
|
'run seam resolve': 'run seam resolve --run .lattice/runs/<id> --finding <digest> --input <seam-request.json>',
|
|
169
173
|
'event verify': 'event verify --run .lattice/runs/<id>',
|
|
170
174
|
'todo status': 'todo status [--json]',
|
|
175
|
+
'todo show': 'todo show --plan <key> --task <id> --json',
|
|
176
|
+
'todo note': 'todo note --plan <key> --task <id> (--message <text>|--input <file>) | list --plan <key> [--task <id>] --json',
|
|
177
|
+
'todo note list': 'todo note list --plan <key> [--task <id>] --json',
|
|
171
178
|
'todo bindings': 'todo bindings [--plan <key>] [--json]',
|
|
172
179
|
'todo independence': 'todo independence [--plan <key>] [--json] | compile --plan <key> --input <file> | witness migrate --plan <key>',
|
|
173
180
|
'todo seam-profile': 'todo seam-profile --plan <key> --file <path> [--json]',
|
package/src/todo-cli.mjs
CHANGED
|
@@ -104,6 +104,12 @@ import {
|
|
|
104
104
|
parseTodoSourceRef, todoLegacyReconciliationDigest, validatePhaseTodoRevision,
|
|
105
105
|
validateTodoRevision, validateTodoRevisionSet,
|
|
106
106
|
} from './todo-revision.mjs';
|
|
107
|
+
import {
|
|
108
|
+
appendTodoNote,
|
|
109
|
+
readTodoNoteContext,
|
|
110
|
+
readTodoNoteContextsForPlan,
|
|
111
|
+
readTodoNoteEvents,
|
|
112
|
+
} from './todo-note-store.mjs';
|
|
107
113
|
|
|
108
114
|
const CLI_ERROR_SCHEMA = 'lattice.cli_error.v2';
|
|
109
115
|
const DEFAULT_GANTT_REF = '.lattice/generated/gantt.html';
|
|
@@ -111,6 +117,7 @@ const GANTT_DESCRIPTOR_SUFFIX = '.status.json';
|
|
|
111
117
|
const MAX_GANTT_DESCRIPTOR_BYTES = 65_536;
|
|
112
118
|
const DEFAULT_GANTT_SCOPE = 'live';
|
|
113
119
|
const MAX_MIGRATION_INPUT_BYTES = 8_388_608;
|
|
120
|
+
const MAX_NOTE_INPUT_BYTES = 16_384;
|
|
114
121
|
const ACTOR_ENV_KEYS = Object.freeze([
|
|
115
122
|
'LATTICE_TODO_ACTOR_HOST',
|
|
116
123
|
'LATTICE_TODO_ACTOR_SESSION',
|
|
@@ -183,6 +190,43 @@ function selectMembers(store, requestedPlanKey) {
|
|
|
183
190
|
return [member];
|
|
184
191
|
}
|
|
185
192
|
|
|
193
|
+
function selectNoteTask(member, requestedTaskId) {
|
|
194
|
+
const exact = member.plan.tasks.find(({ task_id: taskId }) => taskId === requestedTaskId);
|
|
195
|
+
if (exact !== undefined) return exact;
|
|
196
|
+
const folded = requestedTaskId.toLowerCase();
|
|
197
|
+
const matches = member.plan.tasks.filter(({ task_id: taskId }) => taskId.toLowerCase() === folded);
|
|
198
|
+
if (matches.length !== 1) {
|
|
199
|
+
throw new TodoStoreError('NOTE_TASK_NOT_FOUND', 'note_task_not_active', undefined, {
|
|
200
|
+
plan_key: member.plan.plan_key, task_id: requestedTaskId,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return matches[0];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function readNoteTextInput(repoRoot, inputRef) {
|
|
207
|
+
if (!isTodoRef(inputRef)) throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo');
|
|
208
|
+
const canonicalRoot = await realpath(repoRoot);
|
|
209
|
+
const absolute = path.resolve(canonicalRoot, inputRef);
|
|
210
|
+
if (!within(canonicalRoot, absolute) || absolute === canonicalRoot) {
|
|
211
|
+
throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo');
|
|
212
|
+
}
|
|
213
|
+
let metadata;
|
|
214
|
+
try { metadata = await lstat(absolute); } catch {
|
|
215
|
+
throw new TodoStoreError('INPUT_UNREADABLE', 'input_missing');
|
|
216
|
+
}
|
|
217
|
+
if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_NOTE_INPUT_BYTES) {
|
|
218
|
+
throw new TodoStoreError('INPUT_UNREADABLE', 'unsafe_or_oversized_note_input');
|
|
219
|
+
}
|
|
220
|
+
const resolved = await realpath(absolute);
|
|
221
|
+
if (resolved !== absolute || !within(canonicalRoot, resolved)) {
|
|
222
|
+
throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_alias_or_escape');
|
|
223
|
+
}
|
|
224
|
+
const bytes = await readFile(resolved);
|
|
225
|
+
if (bytes.length > MAX_NOTE_INPUT_BYTES) throw new TodoStoreError('INPUT_TOO_LARGE', 'note_input_too_large');
|
|
226
|
+
try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
|
|
227
|
+
catch { throw new TodoStoreError('INPUT_UNREADABLE', 'note_input_invalid_utf8'); }
|
|
228
|
+
}
|
|
229
|
+
|
|
186
230
|
function taskRef(plan, taskId) {
|
|
187
231
|
return { project_id: plan.project_id, plan_key: plan.plan_key, task_id: taskId };
|
|
188
232
|
}
|
|
@@ -455,6 +499,7 @@ function terminalAuditDoneAdvisory(plan, phases) {
|
|
|
455
499
|
|
|
456
500
|
async function mutate({
|
|
457
501
|
repoRoot, env, planKey, taskId, kind, payload, evidenceRef, advisory = null,
|
|
502
|
+
noteContext = null,
|
|
458
503
|
}) {
|
|
459
504
|
const actor = mutationActor(env);
|
|
460
505
|
const evidence = evidenceRef === null ? null : await readEvidenceInput(repoRoot, evidenceRef);
|
|
@@ -475,8 +520,12 @@ async function mutate({
|
|
|
475
520
|
// Phase状態はsnapshot(v1にはphasesキーが無い)でなく、appendTodoEventが別途返す
|
|
476
521
|
// 導出ビュー`phases`から読む。
|
|
477
522
|
const resolvedAdvisory = advisory ?? (kind === 'done' ? terminalAuditDoneAdvisory(plan, phases) : null);
|
|
523
|
+
const includesNoteContext = kind === 'start';
|
|
524
|
+
if (includesNoteContext && noteContext === null) {
|
|
525
|
+
throw new TypeError('start mutation requires note context');
|
|
526
|
+
}
|
|
478
527
|
const result = {
|
|
479
|
-
schema: 'lattice.todo_mutation_result.v2',
|
|
528
|
+
schema: includesNoteContext ? 'lattice.todo_mutation_result.v3' : 'lattice.todo_mutation_result.v2',
|
|
480
529
|
project_id: event.project_id,
|
|
481
530
|
plan_key: event.plan_key,
|
|
482
531
|
plan_version: event.plan_version,
|
|
@@ -488,6 +537,7 @@ async function mutate({
|
|
|
488
537
|
snapshot_digest: snapshot.snapshot_digest,
|
|
489
538
|
status: task.status,
|
|
490
539
|
advisory: resolvedAdvisory,
|
|
540
|
+
...(includesNoteContext ? { note_context: noteContext } : {}),
|
|
491
541
|
result_digest: '',
|
|
492
542
|
};
|
|
493
543
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
@@ -655,13 +705,33 @@ async function startTask({
|
|
|
655
705
|
});
|
|
656
706
|
}
|
|
657
707
|
}
|
|
658
|
-
const
|
|
708
|
+
const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
|
|
709
|
+
if (member === undefined) throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active');
|
|
710
|
+
const taskMatches = member.plan.tasks.filter(({ task_id: candidate }) => (
|
|
711
|
+
candidate.toLowerCase() === taskId.toLowerCase()
|
|
712
|
+
));
|
|
713
|
+
if (taskMatches.length === 0) {
|
|
714
|
+
throw new TodoStoreError('TASK_NOT_FOUND', 'task_not_found', undefined, {
|
|
715
|
+
requested_task_id: taskId,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
if (taskMatches.length > 1) {
|
|
719
|
+
throw new TodoStoreError('TASK_ID_AMBIGUOUS', 'task_id_case_ambiguous', undefined, {
|
|
720
|
+
requested_task_id: taskId,
|
|
721
|
+
matching_task_ids: taskMatches.map(({ task_id: candidate }) => candidate).sort(),
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
const resolvedTaskId = readyTask?.task_id ?? taskMatches[0].task_id;
|
|
725
|
+
// noteはjournal appendより前に読む。読めなければstart自体を止め、部分進行を作らない。
|
|
726
|
+
const { context: noteContext } = await readTodoNoteContext({
|
|
727
|
+
repoRoot, store, planKey, taskId: resolvedTaskId,
|
|
728
|
+
});
|
|
659
729
|
// 助言はjournalへ書く前に確定させる。計算できないならstart自体を止める。
|
|
660
730
|
const advisory = await startAdvisory({
|
|
661
731
|
repoRoot, store, projection, planKey, taskId: resolvedTaskId,
|
|
662
732
|
});
|
|
663
733
|
return mutate({ repoRoot, env, planKey, taskId: resolvedTaskId, kind: 'start',
|
|
664
|
-
payload: { override_reason: overrideReason }, evidenceRef: null, advisory });
|
|
734
|
+
payload: { override_reason: overrideReason }, evidenceRef: null, advisory, noteContext });
|
|
665
735
|
}
|
|
666
736
|
|
|
667
737
|
function validatePhaseDecisionInput(value, outcome) {
|
|
@@ -928,6 +998,93 @@ async function status({ repoRoot }) {
|
|
|
928
998
|
return projectTodoStatus(await readTodoStore({ repoRoot }));
|
|
929
999
|
}
|
|
930
1000
|
|
|
1001
|
+
async function todoDetail({ repoRoot, planKey, taskId }) {
|
|
1002
|
+
const store = await readTodoStore({ repoRoot });
|
|
1003
|
+
const [member] = selectMembers(store, planKey);
|
|
1004
|
+
const task = selectNoteTask(member, taskId);
|
|
1005
|
+
const state = member.tasks.find(({ task_id: current }) => current === task.task_id);
|
|
1006
|
+
if (state === undefined) throw new TodoStoreError('STORE_INCONSISTENT', 'task_state_missing');
|
|
1007
|
+
const { context } = await readTodoNoteContext({
|
|
1008
|
+
repoRoot, store, planKey, taskId: task.task_id,
|
|
1009
|
+
});
|
|
1010
|
+
const result = {
|
|
1011
|
+
schema: 'lattice.todo_detail_result.v1',
|
|
1012
|
+
project_id: store.project_id,
|
|
1013
|
+
plan_key: planKey,
|
|
1014
|
+
plan_version: member.plan.plan_version,
|
|
1015
|
+
task,
|
|
1016
|
+
state,
|
|
1017
|
+
note_context: context,
|
|
1018
|
+
result_digest: '',
|
|
1019
|
+
};
|
|
1020
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
1021
|
+
return result;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
async function appendNote({ repoRoot, env, planKey, taskId, message, inputRef, supersedes }) {
|
|
1025
|
+
const store = await readTodoStore({ repoRoot });
|
|
1026
|
+
const [member] = selectMembers(store, planKey);
|
|
1027
|
+
const task = selectNoteTask(member, taskId);
|
|
1028
|
+
const body = inputRef === null ? message : await readNoteTextInput(repoRoot, inputRef);
|
|
1029
|
+
const projectedBeforeAppend = await readTodoNoteContext({
|
|
1030
|
+
repoRoot, store, planKey, taskId: task.task_id,
|
|
1031
|
+
});
|
|
1032
|
+
const event = await appendTodoNote({
|
|
1033
|
+
repoRoot,
|
|
1034
|
+
projectId: store.project_id,
|
|
1035
|
+
planKey,
|
|
1036
|
+
planVersion: member.plan.plan_version,
|
|
1037
|
+
taskId: task.task_id,
|
|
1038
|
+
actor: mutationActor(env),
|
|
1039
|
+
recordedAt: new Date().toISOString(),
|
|
1040
|
+
body,
|
|
1041
|
+
supersedes,
|
|
1042
|
+
eligibleSupersedes: projectedBeforeAppend.history.map(({ event_digest: digest }) => digest),
|
|
1043
|
+
});
|
|
1044
|
+
const { context } = await readTodoNoteContext({ repoRoot, store, planKey, taskId: task.task_id });
|
|
1045
|
+
const result = {
|
|
1046
|
+
schema: 'lattice.todo_note_append_result.v1',
|
|
1047
|
+
project_id: store.project_id,
|
|
1048
|
+
plan_key: planKey,
|
|
1049
|
+
task_id: task.task_id,
|
|
1050
|
+
event,
|
|
1051
|
+
note_context: context,
|
|
1052
|
+
result_digest: '',
|
|
1053
|
+
};
|
|
1054
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
1055
|
+
return result;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async function listNotes({ repoRoot, planKey, taskId }) {
|
|
1059
|
+
const store = await readTodoStore({ repoRoot });
|
|
1060
|
+
const [member] = selectMembers(store, planKey);
|
|
1061
|
+
const chain = await readTodoNoteEvents({ repoRoot, planKey });
|
|
1062
|
+
let notes = chain.events;
|
|
1063
|
+
let archived = [];
|
|
1064
|
+
let resolvedTaskId = null;
|
|
1065
|
+
if (taskId !== null) {
|
|
1066
|
+
const task = selectNoteTask(member, taskId);
|
|
1067
|
+
resolvedTaskId = task.task_id;
|
|
1068
|
+
const projected = await readTodoNoteContext({
|
|
1069
|
+
repoRoot, store, planKey, taskId: task.task_id,
|
|
1070
|
+
});
|
|
1071
|
+
notes = projected.history;
|
|
1072
|
+
archived = projected.archived;
|
|
1073
|
+
}
|
|
1074
|
+
const result = {
|
|
1075
|
+
schema: 'lattice.todo_note_list_result.v1',
|
|
1076
|
+
project_id: store.project_id,
|
|
1077
|
+
plan_key: planKey,
|
|
1078
|
+
requested_task_id: resolvedTaskId,
|
|
1079
|
+
notes,
|
|
1080
|
+
archived,
|
|
1081
|
+
note_head_digest: chain.head_digest,
|
|
1082
|
+
result_digest: '',
|
|
1083
|
+
};
|
|
1084
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
1085
|
+
return result;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
931
1088
|
async function bindings({ repoRoot, requestedPlanKey }) {
|
|
932
1089
|
return projectTodoBindings(await readTodoStore({ repoRoot }), { requestedPlanKey });
|
|
933
1090
|
}
|
|
@@ -1721,12 +1878,13 @@ function parseGanttDescriptor(bytes, descriptorRef) {
|
|
|
1721
1878
|
* 一つの関数だけが組む。
|
|
1722
1879
|
*/
|
|
1723
1880
|
export async function ganttLiveHeadDigest({ repoRoot, store }) {
|
|
1724
|
-
const [independence, seamProposals] = await Promise.all([
|
|
1881
|
+
const [independence, seamProposals, noteHeads] = await Promise.all([
|
|
1725
1882
|
independenceForGantt({ repoRoot, store }),
|
|
1726
1883
|
seamProposalsForGantt({ repoRoot, store }),
|
|
1884
|
+
noteHeadsForGantt({ repoRoot, store }),
|
|
1727
1885
|
]);
|
|
1728
1886
|
return digestTodoArtifact({
|
|
1729
|
-
schema: 'lattice.todo_gantt_live_head.
|
|
1887
|
+
schema: 'lattice.todo_gantt_live_head.v2',
|
|
1730
1888
|
manifest_digest: store.manifest.manifest_digest,
|
|
1731
1889
|
independence: independence === null ? null : independence.map((entry) => ({
|
|
1732
1890
|
plan_key: entry.plan_key,
|
|
@@ -1738,9 +1896,46 @@ export async function ganttLiveHeadDigest({ repoRoot, store }) {
|
|
|
1738
1896
|
coverage: entry.coverage,
|
|
1739
1897
|
projection_digest: digestTodoArtifact(entry),
|
|
1740
1898
|
})),
|
|
1899
|
+
note_heads: noteHeads,
|
|
1741
1900
|
});
|
|
1742
1901
|
}
|
|
1743
1902
|
|
|
1903
|
+
async function noteHeadsForGantt({ repoRoot, store }) {
|
|
1904
|
+
const bindings = [];
|
|
1905
|
+
for (const member of store.members) {
|
|
1906
|
+
try {
|
|
1907
|
+
const chain = await readTodoNoteEvents({ repoRoot, planKey: member.plan.plan_key });
|
|
1908
|
+
bindings.push({ plan_key: member.plan.plan_key, note_head_digest: chain.head_digest, error: null });
|
|
1909
|
+
} catch (error) {
|
|
1910
|
+
if (typeof error?.code !== 'string' || !error.code.startsWith('NOTE_')) throw error;
|
|
1911
|
+
bindings.push({ plan_key: member.plan.plan_key, note_head_digest: null, error: error.code });
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
return bindings;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
async function notesForGantt({ repoRoot, store }) {
|
|
1918
|
+
const contexts = [];
|
|
1919
|
+
const warnings = [];
|
|
1920
|
+
const headBindings = [];
|
|
1921
|
+
for (const member of store.members) {
|
|
1922
|
+
try {
|
|
1923
|
+
const projected = await readTodoNoteContextsForPlan({
|
|
1924
|
+
repoRoot, store, planKey: member.plan.plan_key,
|
|
1925
|
+
});
|
|
1926
|
+
contexts.push(...projected.contexts);
|
|
1927
|
+
headBindings.push({
|
|
1928
|
+
plan_key: member.plan.plan_key, note_head_digest: projected.note_head_digest,
|
|
1929
|
+
});
|
|
1930
|
+
} catch (error) {
|
|
1931
|
+
if (typeof error?.code !== 'string' || !error.code.startsWith('NOTE_')) throw error;
|
|
1932
|
+
warnings.push({ plan_key: member.plan.plan_key, code: error.code, message: error.message });
|
|
1933
|
+
headBindings.push({ plan_key: member.plan.plan_key, note_head_digest: null });
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
return { contexts, warnings, headBindings };
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1744
1939
|
async function independenceForGantt({ repoRoot, store }) {
|
|
1745
1940
|
const frontier = computeReadyFrontier(store);
|
|
1746
1941
|
const status = projectTodoStatus(store);
|
|
@@ -1829,7 +2024,7 @@ async function seamProposalsForGantt({ repoRoot, store }) {
|
|
|
1829
2024
|
|
|
1830
2025
|
export async function renderTodoGanttForProject({
|
|
1831
2026
|
repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
|
|
1832
|
-
scope = DEFAULT_GANTT_SCOPE,
|
|
2027
|
+
scope = DEFAULT_GANTT_SCOPE, includeNotes = true,
|
|
1833
2028
|
}) {
|
|
1834
2029
|
const store = readModel
|
|
1835
2030
|
?? (stable ? await readTodoStoreStable({ repoRoot }) : await readTodoStore({ repoRoot }));
|
|
@@ -1839,9 +2034,11 @@ export async function renderTodoGanttForProject({
|
|
|
1839
2034
|
const presentation = await loadTodoGanttPresentation({ repoRoot, readModel: store });
|
|
1840
2035
|
const topology = mergedTopology(store);
|
|
1841
2036
|
const chain = projectTodoChainV1(topology);
|
|
1842
|
-
const [independence, seamProposals] = await Promise.all([
|
|
2037
|
+
const [independence, seamProposals, notes] = await Promise.all([
|
|
1843
2038
|
independenceForGantt({ repoRoot, store }),
|
|
1844
2039
|
seamProposalsForGantt({ repoRoot, store }),
|
|
2040
|
+
includeNotes ? notesForGantt({ repoRoot, store })
|
|
2041
|
+
: { contexts: null, warnings: [], headBindings: [] },
|
|
1845
2042
|
]);
|
|
1846
2043
|
const layout = layoutTodoGantt(store, chain, { scope, independence, seamProposals });
|
|
1847
2044
|
// When the diagram hides history, the page also carries the full diagram so
|
|
@@ -1875,6 +2072,7 @@ export async function renderTodoGanttForProject({
|
|
|
1875
2072
|
presentation_digest: presentation.presentation_digest,
|
|
1876
2073
|
chain_digest: digestTodoArtifact(chain),
|
|
1877
2074
|
layout_digest: digestTodoArtifact(layout),
|
|
2075
|
+
note_bindings_digest: digestTodoArtifact(notes.headBindings),
|
|
1878
2076
|
renderer_version: TODO_GANTT_RENDERER_VERSION,
|
|
1879
2077
|
project_display_name: identity.displayName,
|
|
1880
2078
|
folded_task_count: layout.scope.folded_task_count,
|
|
@@ -1887,10 +2085,17 @@ export async function renderTodoGanttForProject({
|
|
|
1887
2085
|
anchorOutcomes,
|
|
1888
2086
|
presentation,
|
|
1889
2087
|
metadata,
|
|
2088
|
+
noteContexts: notes.contexts,
|
|
2089
|
+
noteWarnings: notes.warnings,
|
|
1890
2090
|
});
|
|
1891
2091
|
return { store, metadata, memberBindings, rendered };
|
|
1892
2092
|
}
|
|
1893
2093
|
|
|
2094
|
+
/** 公開配信面は呼び出し側の既定値忘れに依存せず、常にnote本文を除外する。 */
|
|
2095
|
+
export async function renderPublicTodoGanttForProject(options = {}) {
|
|
2096
|
+
return renderTodoGanttForProject({ ...options, includeNotes: false });
|
|
2097
|
+
}
|
|
2098
|
+
|
|
1894
2099
|
async function gantt({ repoRoot, outputRef, env, scope = DEFAULT_GANTT_SCOPE }) {
|
|
1895
2100
|
const { store, metadata, memberBindings, rendered } = await renderTodoGanttForProject({
|
|
1896
2101
|
repoRoot, env, scope,
|
|
@@ -1975,7 +2180,7 @@ async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_S
|
|
|
1975
2180
|
port,
|
|
1976
2181
|
render: async () => {
|
|
1977
2182
|
const store = await readTodoStoreStable({ repoRoot });
|
|
1978
|
-
const { rendered } = await
|
|
2183
|
+
const { rendered } = await renderPublicTodoGanttForProject({
|
|
1979
2184
|
repoRoot, stable: true, displayName: identity.displayName, scope, readModel: store,
|
|
1980
2185
|
});
|
|
1981
2186
|
return { html: rendered.html, head_digest: await ganttLiveHeadDigest({ repoRoot, store }) };
|
|
@@ -2020,6 +2225,10 @@ async function ensureActiveProjectDashboard({ repoRoot, env }) {
|
|
|
2020
2225
|
async function verify({ repoRoot, requestedPlanKey }) {
|
|
2021
2226
|
const store = await readTodoStore({ repoRoot });
|
|
2022
2227
|
const members = selectMembers(store, requestedPlanKey);
|
|
2228
|
+
// note chainはlifecycle storeと独立だが、verifyは両方を検査する。破損を空扱いしない。
|
|
2229
|
+
for (const member of members) {
|
|
2230
|
+
await readTodoNoteEvents({ repoRoot, planKey: member.plan.plan_key });
|
|
2231
|
+
}
|
|
2023
2232
|
const verifiedSourceInventories = new Map();
|
|
2024
2233
|
for (const member of members) {
|
|
2025
2234
|
const unverified = member.tasks.find((task) => task.evidence_unverified);
|
|
@@ -2136,6 +2345,10 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2136
2345
|
if ((argv.length === 1 && argv[0] === 'status')
|
|
2137
2346
|
|| (argv.length === 2 && argv[0] === 'status' && argv[1] === '--json')) {
|
|
2138
2347
|
action = (repoRoot) => status({ repoRoot });
|
|
2348
|
+
} else if (argv.length === 6 && argv[0] === 'show'
|
|
2349
|
+
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
2350
|
+
&& argv[3] === '--task' && isTodoIdentifier(argv[4]) && argv[5] === '--json') {
|
|
2351
|
+
action = (repoRoot) => todoDetail({ repoRoot, planKey: argv[2], taskId: argv[4] });
|
|
2139
2352
|
} else if ((argv.length === 1 && argv[0] === 'bindings')
|
|
2140
2353
|
|| (argv.length === 2 && argv[0] === 'bindings' && argv[1] === '--json')) {
|
|
2141
2354
|
action = (repoRoot) => bindings({ repoRoot, requestedPlanKey: null });
|
|
@@ -2143,6 +2356,26 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2143
2356
|
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
2144
2357
|
&& (argv.length === 3 || argv[3] === '--json')) {
|
|
2145
2358
|
action = (repoRoot) => bindings({ repoRoot, requestedPlanKey: argv[2] });
|
|
2359
|
+
} else if ((argv.length === 7 || argv.length === 9) && argv[0] === 'note'
|
|
2360
|
+
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
2361
|
+
&& argv[3] === '--task' && isTodoIdentifier(argv[4])
|
|
2362
|
+
&& ['--message', '--input'].includes(argv[5])
|
|
2363
|
+
&& ((argv[5] === '--message' && argv[6].length > 0)
|
|
2364
|
+
|| (argv[5] === '--input' && isTodoRef(argv[6])))
|
|
2365
|
+
&& (argv.length === 7 || (argv[7] === '--supersedes' && isTodoDigest(argv[8])))) {
|
|
2366
|
+
action = (repoRoot) => appendNote({
|
|
2367
|
+
repoRoot, env, planKey: argv[2], taskId: argv[4],
|
|
2368
|
+
message: argv[5] === '--message' ? argv[6] : null,
|
|
2369
|
+
inputRef: argv[5] === '--input' ? argv[6] : null,
|
|
2370
|
+
supersedes: argv[8] ?? null,
|
|
2371
|
+
});
|
|
2372
|
+
} else if (argv.length === 5 && argv[0] === 'note' && argv[1] === 'list'
|
|
2373
|
+
&& argv[2] === '--plan' && isTodoIdentifier(argv[3]) && argv[4] === '--json') {
|
|
2374
|
+
action = (repoRoot) => listNotes({ repoRoot, planKey: argv[3], taskId: null });
|
|
2375
|
+
} else if (argv.length === 7 && argv[0] === 'note' && argv[1] === 'list'
|
|
2376
|
+
&& argv[2] === '--plan' && isTodoIdentifier(argv[3])
|
|
2377
|
+
&& argv[4] === '--task' && isTodoIdentifier(argv[5]) && argv[6] === '--json') {
|
|
2378
|
+
action = (repoRoot) => listNotes({ repoRoot, planKey: argv[3], taskId: argv[5] });
|
|
2146
2379
|
} else if (argv.length === 5 && argv[0] === 'independence' && argv[1] === 'witness'
|
|
2147
2380
|
&& argv[2] === 'migrate' && argv[3] === '--plan' && isTodoIdentifier(argv[4])) {
|
|
2148
2381
|
action = (repoRoot) => independenceWitnessMigrate({ repoRoot, planKey: argv[4] });
|
package/src/todo-contracts.mjs
CHANGED
|
@@ -9,6 +9,8 @@ export const TODO_EVENT_KINDS = Object.freeze([
|
|
|
9
9
|
// 新しいevent schema版は作らない。
|
|
10
10
|
'phase_close_unaudited',
|
|
11
11
|
]);
|
|
12
|
+
export const TODO_NOTE_EVENT_SCHEMA = 'lattice.todo_note_event.v1';
|
|
13
|
+
export const TODO_NOTE_CONTEXT_SCHEMA = 'lattice.todo_note_context.v1';
|
|
12
14
|
export const TODO_LIMITS = Object.freeze({
|
|
13
15
|
tasksPerPlan: 512,
|
|
14
16
|
edgesPerPlan: 2_048,
|
|
@@ -16,11 +18,14 @@ export const TODO_LIMITS = Object.freeze({
|
|
|
16
18
|
journalSegmentBytes: 1_048_576,
|
|
17
19
|
snapshotBytes: 8_388_608,
|
|
18
20
|
narrativeSectionBytes: 262_144,
|
|
21
|
+
noteBodyBytes: 16_384,
|
|
22
|
+
noteContextBytes: 65_536,
|
|
19
23
|
});
|
|
20
24
|
|
|
21
25
|
const DIGEST = /^[0-9a-f]{64}$/;
|
|
22
26
|
const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/;
|
|
23
27
|
const CONTROL = /[\u0000-\u001f\u007f]/u;
|
|
28
|
+
const NOTE_FORBIDDEN_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
|
|
24
29
|
|
|
25
30
|
export const isTodoDigest = (value) => typeof value === 'string' && DIGEST.test(value);
|
|
26
31
|
export const isTodoIdentifier = (value) => typeof value === 'string' && IDENTIFIER.test(value);
|
|
@@ -89,6 +94,67 @@ export function todoSelfDigest(value, field) {
|
|
|
89
94
|
return digestTodoArtifact(projection);
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
const noteBody = (value) => typeof value === 'string' && value.length > 0
|
|
98
|
+
&& Buffer.byteLength(value, 'utf8') <= TODO_LIMITS.noteBodyBytes
|
|
99
|
+
&& !NOTE_FORBIDDEN_CONTROL.test(value);
|
|
100
|
+
|
|
101
|
+
/** lifecycle journalとは独立したtask note event v1を検証する。 */
|
|
102
|
+
export function validateTodoNoteEvent(value) {
|
|
103
|
+
try {
|
|
104
|
+
return exactRecord(value, [
|
|
105
|
+
'schema', 'project_id', 'plan_key', 'task_id', 'plan_version', 'sequence',
|
|
106
|
+
'previous_digest', 'actor', 'recorded_at', 'body', 'supersedes', 'event_digest',
|
|
107
|
+
]) && value.schema === TODO_NOTE_EVENT_SCHEMA
|
|
108
|
+
&& isTodoIdentifier(value.project_id) && isTodoIdentifier(value.plan_key)
|
|
109
|
+
&& isTodoIdentifier(value.task_id) && isTodoIdentifier(value.plan_version)
|
|
110
|
+
&& Number.isSafeInteger(value.sequence) && value.sequence >= 1
|
|
111
|
+
&& (value.sequence === 1 ? value.previous_digest === null : isTodoDigest(value.previous_digest))
|
|
112
|
+
&& actor(value.actor) && isStrictTodoTimestamp(value.recorded_at) && noteBody(value.body)
|
|
113
|
+
&& (value.supersedes === null || isTodoDigest(value.supersedes))
|
|
114
|
+
&& isTodoDigest(value.event_digest) && value.supersedes !== value.event_digest
|
|
115
|
+
&& value.event_digest === todoSelfDigest(value, 'event_digest');
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function noteContextEntry(value) {
|
|
122
|
+
return exactRecord(value, [
|
|
123
|
+
'event_digest', 'origin_plan_version', 'origin_task_id', 'actor', 'recorded_at',
|
|
124
|
+
'body', 'supersedes', 'superseded_by', 'correction_state',
|
|
125
|
+
]) && isTodoDigest(value.event_digest) && isTodoIdentifier(value.origin_plan_version)
|
|
126
|
+
&& isTodoIdentifier(value.origin_task_id) && actor(value.actor)
|
|
127
|
+
&& isStrictTodoTimestamp(value.recorded_at) && noteBody(value.body)
|
|
128
|
+
&& (value.supersedes === null || isTodoDigest(value.supersedes))
|
|
129
|
+
&& (value.superseded_by === null || isTodoDigest(value.superseded_by))
|
|
130
|
+
&& ['current', 'superseded'].includes(value.correction_state)
|
|
131
|
+
&& ((value.correction_state === 'current' && value.superseded_by === null)
|
|
132
|
+
|| (value.correction_state === 'superseded' && isTodoDigest(value.superseded_by)));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 個別ToDo詳細とstart結果へ必ず同梱するbounded note contextを検証する。 */
|
|
136
|
+
export function validateTodoNoteContext(value) {
|
|
137
|
+
try {
|
|
138
|
+
if (!exactRecord(value, [
|
|
139
|
+
'schema', 'project_id', 'plan_key', 'task_id', 'notes', 'note_head_digest',
|
|
140
|
+
'overflow_count', 'full_history_command', 'context_digest',
|
|
141
|
+
]) || value.schema !== TODO_NOTE_CONTEXT_SCHEMA
|
|
142
|
+
|| !isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
|
|
143
|
+
|| !isTodoIdentifier(value.task_id) || !Array.isArray(value.notes)
|
|
144
|
+
|| value.notes.length > TODO_LIMITS.tasksPerPlan || !value.notes.every(noteContextEntry)
|
|
145
|
+
|| !(value.note_head_digest === null || isTodoDigest(value.note_head_digest))
|
|
146
|
+
|| !isNonNegativeSafeInteger(value.overflow_count)
|
|
147
|
+
|| value.full_history_command !== `lattice todo note list --plan ${value.plan_key} --task ${value.task_id} --json`
|
|
148
|
+
|| !isTodoDigest(value.context_digest)
|
|
149
|
+
|| value.context_digest !== todoSelfDigest(value, 'context_digest')) return false;
|
|
150
|
+
if ((value.notes.length === 0) !== (value.note_head_digest === null)) return false;
|
|
151
|
+
return value.notes.reduce((bytes, note) => bytes + Buffer.byteLength(note.body, 'utf8'), 0)
|
|
152
|
+
<= TODO_LIMITS.noteContextBytes;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
92
158
|
const nullableDigest = (value) => value === null || isTodoDigest(value);
|
|
93
159
|
const nullableText = (value) => value === null || (typeof value === 'string' && value.length > 0 && Buffer.byteLength(value) <= 16_384);
|
|
94
160
|
const actor = (value) => exactRecord(value, ['host', 'session', 'agent'])
|
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
import { DOCUMENT_STATUS, SEVERABILITY_LABEL, escapeHtmlAttribute, escapeHtmlText, foldIndex, planActivity, presentationLookup, refKey, renderPhaseProgress, renderRelationList, renderSeamProposalOverview, renderTaskIndex, statusMarkup, taskReference } from './todo-gantt-html-shared.mjs';
|
|
2
|
+
import { renderTodoMarkdown } from './todo-markdown-renderer.mjs';
|
|
3
|
+
|
|
4
|
+
function renderNoteContext(context) {
|
|
5
|
+
if (context === null) {
|
|
6
|
+
return '<section class="work-log"><h2>作業記録</h2><p class="note-warning">作業記録を読み取れません。概要の読取警告を確認してください。</p></section>';
|
|
7
|
+
}
|
|
8
|
+
if (context.notes.length === 0) {
|
|
9
|
+
return '<section class="work-log"><h2>作業記録</h2><p>記録はありません。</p></section>';
|
|
10
|
+
}
|
|
11
|
+
const entries = context.notes.map((note) => {
|
|
12
|
+
const rendered = renderTodoMarkdown(note.body);
|
|
13
|
+
const filtering = rendered.discarded.length === 0 ? ''
|
|
14
|
+
: '<p class="note-warning">安全上表示できない要素を除外しました。</p>';
|
|
15
|
+
const correction = note.correction_state === 'superseded'
|
|
16
|
+
? `訂正済み(後継 ${note.superseded_by.slice(0, 12)}…)` : '現行';
|
|
17
|
+
return `<article class="work-log-entry"><header><span>${escapeHtmlText(note.recorded_at)}</span><span>${escapeHtmlText(correction)}</span></header><div class="work-log-body">${rendered.html}</div>${filtering}<p class="work-log-origin">来歴: ${escapeHtmlText(note.origin_plan_version)}/${escapeHtmlText(note.origin_task_id)}・記録者 ${escapeHtmlText(`${note.actor.host}/${note.actor.agent}`)}</p></article>`;
|
|
18
|
+
}).join('');
|
|
19
|
+
const overflow = context.overflow_count === 0 ? ''
|
|
20
|
+
: `<p class="note-warning">ほか ${context.overflow_count}件は上限のため省略。全履歴: <code>${escapeHtmlText(context.full_history_command)}</code></p>`;
|
|
21
|
+
return `<section class="work-log"><h2>作業記録</h2>${entries}${overflow}<p class="work-log-head">note head: <code>${escapeHtmlText(context.note_head_digest ?? 'none')}</code></p></section>`;
|
|
22
|
+
}
|
|
2
23
|
|
|
3
24
|
/**
|
|
4
25
|
* 図の外が語るための独立性要約を、plan単位の投影から引ける形へ畳む(ADR 0129 Decision 3)。
|
|
@@ -63,7 +84,9 @@ export function renderIndependenceNote(ref, node, summary) {
|
|
|
63
84
|
return `<p class="readiness-note"><strong>並列可否:</strong> 要直列です。</p><ul class="independence-conflicts">${items}</ul>`;
|
|
64
85
|
}
|
|
65
86
|
|
|
66
|
-
export function renderRightPane(
|
|
87
|
+
export function renderRightPane(
|
|
88
|
+
sections, layout, presentation, readModel, notesEnabled = false, noteWarnings = [],
|
|
89
|
+
) {
|
|
67
90
|
const lookup = presentationLookup(presentation);
|
|
68
91
|
const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
|
|
69
92
|
const nodeByKey = new Map(layout.nodes.map((node) => [refKey(node.ref), node]));
|
|
@@ -104,7 +127,9 @@ export function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
104
127
|
const dispatchSummary = `${readyHeadline}${independenceNote}`;
|
|
105
128
|
const activeLinks = active.length === 0 ? '<p>作業中の工程はありません。</p>'
|
|
106
129
|
: `<ul class="active-list">${active.map((section) => `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(refKey(section.ref))}">${escapeHtmlText(taskReference(section, lookup))} — ${escapeHtmlText(section.task.title)}</button></li>`).join('')}</ul>`;
|
|
107
|
-
const
|
|
130
|
+
const noteWarningMarkup = noteWarnings.length === 0 ? ''
|
|
131
|
+
: `<section class="gantt-warning"><h2>作業記録の読取警告</h2><ul>${noteWarnings.map((warning) => `<li><code>${escapeHtmlText(warning.plan_key)}</code>: <strong>${escapeHtmlText(warning.code)}</strong> — ${escapeHtmlText(warning.message)}</li>`).join('')}</ul></section>`;
|
|
132
|
+
const overview = `<section class="right-overview" data-right-panel="overview"><h1>工程を選択してください</h1><p>左の依存工程図から工程を選ぶと、題名・状態・前提・後続を表示します。</p><div class="status-summary"><span>☐ 未着手 ${counts.pending}</span><span>▶ 作業中 ${counts['in-progress']}</span><span>✅ 完了 ${counts.done}</span><span>⛔ ブロック中 ${counts.blocked}</span></div>${noteWarningMarkup}${dispatchSummary}${renderSeamProposalOverview(layout)}${renderPhaseProgress(readModel)}<h2>作業中</h2>${activeLinks}</section>`;
|
|
108
133
|
const details = sections.map((section) => {
|
|
109
134
|
const key = refKey(section.ref);
|
|
110
135
|
const node = nodeByKey.get(key);
|
|
@@ -126,7 +151,8 @@ export function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
126
151
|
// Say it plainly when the reader will not find this ToDo on the diagram.
|
|
127
152
|
const foldedNote = !folds.has(key) ? ''
|
|
128
153
|
: '<p class="fold-note">完走済みのため図には描いていません。図に出すには <code>lattice todo gantt --scope all</code> を実行してください。</p>';
|
|
129
|
-
|
|
154
|
+
const workLog = notesEnabled ? renderNoteContext(section.noteContext) : '';
|
|
155
|
+
return `<article class="task-detail" data-detail-key="${escapeHtmlAttribute(key)}" hidden><header><span class="detail-status status-${escapeHtmlAttribute(section.state.status)}">${escapeHtmlText(status.mark)} ${escapeHtmlText(status.label)}</span><span class="detail-reference">${escapeHtmlText(taskReference(section, lookup))}</span></header><h1>${escapeHtmlText(section.task.title)}</h1><p class="detail-category"><strong>カテゴリ:</strong> ${escapeHtmlText(category)}</p>${categoryDescription}<p><strong>正規ID:</strong> <code>${escapeHtmlText(`${section.ref.plan_key}/${section.task.task_id}`)}</code></p>${blockedReason}${readiness}${independenceNote}${foldedNote}<section><h2>前提工程</h2>${renderRelationList(incoming.get(key), sectionByKey, lookup, '登録済みの前提工程はありません。', folds)}</section><section><h2>後続工程</h2>${renderRelationList(outgoing.get(key), sectionByKey, lookup, '登録済みの後続工程はありません。', folds)}</section>${workLog}<p class="anchor-status">${escapeHtmlText(anchorText)}</p><details class="task-diagnostics"><summary>開発者向け診断</summary><dl><dt>canonical ref</dt><dd><code>${escapeHtmlText(`${section.ref.project_id}/${section.ref.plan_key}/${section.task.task_id}`)}</code></dd><dt>anchor</dt><dd>${escapeHtmlText(section.anchorOutcome.anchored ? 'verified' : section.anchorOutcome.reason)}</dd></dl></details></article>`;
|
|
130
156
|
}).join('');
|
|
131
157
|
const taskIndex = renderTaskIndex(sections, lookup, folds, planActivity(readModel));
|
|
132
158
|
return `<div class="right-toolbar"><button type="button" data-show-overview>概要</button><button type="button" data-show-selected hidden>選択工程へ戻る</button><button type="button" data-show-task-index>全工程一覧</button></div><div class="right-content">${overview}<div data-right-panel="details" hidden>${details}</div><section class="task-index" data-right-panel="task-index" hidden><h1>全工程</h1><p>Latticeに登録された全工程を現在の状態とともに表示しています。planは動いているものを最終活動の新しい順で上に、完走したものを古い順で下にまとめ、plan内は登録順です。</p>${taskIndex}</section></div>`;
|
|
@@ -56,6 +56,12 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
|
|
|
56
56
|
.active-list,.relation-list{margin:0;padding:0;list-style:none}.active-list li+li,.relation-list li+li{margin-top:8px}
|
|
57
57
|
.active-list button{width:100%}.anchor-status,.readiness-note,.category-description,.relation-empty{color:var(--text-secondary)}
|
|
58
58
|
.task-detail>header{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:8px}.detail-status,.detail-reference{font-size:12px;font-weight:600}.detail-reference{color:var(--text-secondary)}
|
|
59
|
+
.work-log{margin-top:24px;padding-top:1px;border-top:1px solid var(--border)}
|
|
60
|
+
.work-log-entry{margin-top:10px;padding:12px;border:1px solid var(--border);border-left:4px solid var(--accent);background:var(--surface-2)}
|
|
61
|
+
.work-log-entry>header{display:flex;flex-wrap:wrap;justify-content:space-between;gap:6px 12px;color:var(--text-secondary);font-size:12px;font-weight:600}
|
|
62
|
+
.work-log-body{overflow-wrap:anywhere}.work-log-body pre{max-width:100%;overflow:auto;padding:8px;background:var(--surface-1)}.work-log-body code{overflow-wrap:anywhere}
|
|
63
|
+
.work-log-origin,.work-log-head,.note-warning{color:var(--text-secondary);font-size:12px;overflow-wrap:anywhere}.note-warning{color:var(--critical)}
|
|
64
|
+
.gantt-warning{margin:16px 0;padding:12px;border:1px solid var(--critical);background:var(--surface-2)}.gantt-warning h2{margin:0 0 8px}.gantt-warning ul{margin:0;padding-left:20px}
|
|
59
65
|
.relation-list button{display:flex;width:100%;flex-direction:column}.relation-list button span{font-weight:400}.relation-kind{display:block;margin-top:4px;color:var(--text-secondary);font-size:12px}
|
|
60
66
|
.task-diagnostics{margin:16px 0;color:var(--text-secondary);font-size:12px}.task-diagnostics summary{cursor:pointer;color:var(--text-primary);font-weight:600}.task-diagnostics dl{display:grid;grid-template-columns:auto 1fr;gap:4px 12px}.task-diagnostics dd{margin:0;overflow-wrap:anywhere}
|
|
61
67
|
.task-index>h1{margin:0 0 8px;font-size:19px;font-weight:650;line-height:1.45}.task-index>p{margin:0 0 24px;color:var(--text-secondary)}
|
package/src/todo-gantt-html.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { renderDiagramLegend, renderRightPane } from './todo-gantt-html-independ
|
|
|
6
6
|
import { escapeHtmlAttribute, escapeHtmlText, refKey } from './todo-gantt-html-shared.mjs';
|
|
7
7
|
import { CSS } from './todo-gantt-html-style.mjs';
|
|
8
8
|
|
|
9
|
-
export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.
|
|
9
|
+
export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v18';
|
|
10
10
|
export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
|
|
11
11
|
export const TODO_GANTT_HTML_MAX_BYTES = 24 * 1024 * 1024;
|
|
12
12
|
|
|
@@ -40,9 +40,12 @@ function digest(value) {
|
|
|
40
40
|
* still read, because anchor verification needs them, and their size is still
|
|
41
41
|
* bounded — but they are not rendered into the page.
|
|
42
42
|
*/
|
|
43
|
-
function normalizeSections(readModel, narratives, anchorOutcomes) {
|
|
43
|
+
function normalizeSections(readModel, narratives, anchorOutcomes, noteContexts) {
|
|
44
44
|
const supplied = new Map(narratives.map((entry) => [refKey(entry.ref), entry]));
|
|
45
45
|
const outcomes = new Map(anchorOutcomes.map((entry) => [refKey(entry.ref), entry]));
|
|
46
|
+
const notes = noteContexts === null ? null : new Map(noteContexts.map((entry) => [
|
|
47
|
+
refKey({ project_id: entry.project_id, plan_key: entry.plan_key, task_id: entry.task_id }), entry,
|
|
48
|
+
]));
|
|
46
49
|
const result = [];
|
|
47
50
|
const counted = new Set();
|
|
48
51
|
let proseBytes = 0;
|
|
@@ -69,7 +72,20 @@ function normalizeSections(readModel, narratives, anchorOutcomes) {
|
|
|
69
72
|
ref, narrative_ref: narrativeRef, anchored: false, reason: 'anchor_missing',
|
|
70
73
|
origin_line: task.narrative_anchor?.origin_line ?? null,
|
|
71
74
|
};
|
|
72
|
-
|
|
75
|
+
const noteContext = notes?.get(refKey(ref)) ?? null;
|
|
76
|
+
if (noteContext !== null) {
|
|
77
|
+
proseBytes += noteContext.notes.reduce((bytes, note) => (
|
|
78
|
+
bytes + Buffer.byteLength(note.body, 'utf8')
|
|
79
|
+
), 0);
|
|
80
|
+
if (proseBytes > TODO_GANTT_PROSE_MAX_BYTES) {
|
|
81
|
+
throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt embedded prose limit exceeded', {
|
|
82
|
+
prose_bytes: proseBytes, prose_limit: TODO_GANTT_PROSE_MAX_BYTES,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
result.push({
|
|
87
|
+
ref, task, state: states.get(task.task_id), narrativeRef, anchorOutcome, noteContext,
|
|
88
|
+
});
|
|
73
89
|
}
|
|
74
90
|
}
|
|
75
91
|
return { sections: result, proseBytes };
|
|
@@ -132,19 +148,23 @@ const CONTROLLER = `
|
|
|
132
148
|
|
|
133
149
|
export function renderTodoGanttHtml({
|
|
134
150
|
readModel, layout, narratives = [], anchorOutcomes = [], presentation = null, metadata = {},
|
|
135
|
-
expandedLayout = null,
|
|
151
|
+
expandedLayout = null, noteContexts = null, noteWarnings = [],
|
|
136
152
|
}) {
|
|
137
153
|
if (readModel?.schema !== 'lattice.todo_store_read.v1' || !Array.isArray(readModel.members)) {
|
|
138
154
|
throw new TypeError('readModel must be lattice.todo_store_read.v1');
|
|
139
155
|
}
|
|
140
156
|
if (!Array.isArray(narratives)) throw new TypeError('narratives must be an array');
|
|
141
157
|
if (!Array.isArray(anchorOutcomes)) throw new TypeError('anchorOutcomes must be an array');
|
|
158
|
+
if (!(noteContexts === null || Array.isArray(noteContexts))) {
|
|
159
|
+
throw new TypeError('noteContexts must be null or an array');
|
|
160
|
+
}
|
|
161
|
+
if (!Array.isArray(noteWarnings)) throw new TypeError('noteWarnings must be an array');
|
|
142
162
|
if (presentation !== null
|
|
143
163
|
&& (presentation?.schema !== 'lattice.todo_gantt_presentation_model.v1'
|
|
144
164
|
|| presentation.project_id !== readModel.project_id)) {
|
|
145
165
|
throw new TypeError('presentation must be lattice.todo_gantt_presentation_model.v1');
|
|
146
166
|
}
|
|
147
|
-
const normalized = normalizeSections(readModel, narratives, anchorOutcomes);
|
|
167
|
+
const normalized = normalizeSections(readModel, narratives, anchorOutcomes, noteContexts);
|
|
148
168
|
const displayName = projectDisplayName(readModel, metadata);
|
|
149
169
|
const svg = renderTodoGanttSvg(layout, { presentation });
|
|
150
170
|
// The expanded diagram travels with the page so the badge can bring the
|
|
@@ -153,7 +173,9 @@ export function renderTodoGanttHtml({
|
|
|
153
173
|
const diagrams = expandedSvg === ''
|
|
154
174
|
? `<div data-diagram="live">${svg}</div>`
|
|
155
175
|
: `<div data-diagram="live">${svg}</div><div data-diagram="expanded" hidden>${expandedSvg}</div>`;
|
|
156
|
-
const rightPane = renderRightPane(
|
|
176
|
+
const rightPane = renderRightPane(
|
|
177
|
+
normalized.sections, layout, presentation, readModel, noteContexts !== null, noteWarnings,
|
|
178
|
+
);
|
|
157
179
|
const staticData = serializeJsonForScript({
|
|
158
180
|
renderer_version: TODO_GANTT_RENDERER_VERSION,
|
|
159
181
|
metadata,
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
mkdir, open, readFile, readdir, rename, rm, stat,
|
|
4
|
+
} from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
TODO_LIMITS,
|
|
9
|
+
TODO_NOTE_CONTEXT_SCHEMA,
|
|
10
|
+
TODO_NOTE_EVENT_SCHEMA,
|
|
11
|
+
canonicalizeTodoArtifact,
|
|
12
|
+
exactRecord,
|
|
13
|
+
isTodoDigest,
|
|
14
|
+
isTodoIdentifier,
|
|
15
|
+
todoSelfDigest,
|
|
16
|
+
validateTodoPlan,
|
|
17
|
+
validateTodoNoteContext,
|
|
18
|
+
validateTodoNoteEvent,
|
|
19
|
+
} from './todo-contracts.mjs';
|
|
20
|
+
import { validatePhaseTodoRevision, validateTodoRevision } from './todo-revision.mjs';
|
|
21
|
+
|
|
22
|
+
const NOTE_ROOT_REF = '.lattice/todo/notes';
|
|
23
|
+
const SEALED_NAME = /^(\d{12})-(\d{12})-([0-9a-f]{64})-([0-9a-f]{64})\.jsonl$/u;
|
|
24
|
+
const ZERO_DIGEST = '0'.repeat(64);
|
|
25
|
+
const MAX_SEGMENTS = 4_096;
|
|
26
|
+
|
|
27
|
+
export class TodoNoteStoreError extends Error {
|
|
28
|
+
constructor(code, reason, detail = {}) {
|
|
29
|
+
super(reason);
|
|
30
|
+
this.name = 'TodoNoteStoreError';
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.detail = { reason, ...detail };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fail(code, reason, detail) {
|
|
37
|
+
throw new TodoNoteStoreError(code, reason, detail);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sha256(bytes) {
|
|
41
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function canonicalLine(value) {
|
|
45
|
+
return Buffer.from(`${canonicalizeTodoArtifact(value)}\n`, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function notePaths(repoRoot, planKey) {
|
|
49
|
+
if (!isTodoIdentifier(planKey)) throw new TypeError('planKey must be a todo identifier');
|
|
50
|
+
const root = path.resolve(repoRoot, NOTE_ROOT_REF, planKey);
|
|
51
|
+
return { root, active: path.join(root, 'active.jsonl'), sealed: path.join(root, 'sealed') };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readOptionalBounded(ref, { missing = false } = {}) {
|
|
55
|
+
try {
|
|
56
|
+
const metadata = await stat(ref);
|
|
57
|
+
if (!metadata.isFile() || metadata.size > TODO_LIMITS.journalSegmentBytes) {
|
|
58
|
+
fail('NOTE_LOG_CORRUPT', 'note_segment_invalid', { ref });
|
|
59
|
+
}
|
|
60
|
+
return await readFile(ref);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error instanceof TodoNoteStoreError) throw error;
|
|
63
|
+
if (missing && error?.code === 'ENOENT') return null;
|
|
64
|
+
fail('NOTE_LOG_CORRUPT', 'note_segment_unreadable', { ref });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseCanonicalSegment(bytes, ref) {
|
|
69
|
+
if (bytes.length === 0 || bytes.at(-1) !== 0x0a) {
|
|
70
|
+
fail('NOTE_LOG_CORRUPT', 'note_segment_not_canonical', { ref });
|
|
71
|
+
}
|
|
72
|
+
const lines = bytes.toString('utf8').slice(0, -1).split('\n');
|
|
73
|
+
const events = [];
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
let event;
|
|
76
|
+
try { event = JSON.parse(line); } catch { fail('NOTE_LOG_CORRUPT', 'note_json_invalid', { ref }); }
|
|
77
|
+
if (!validateTodoNoteEvent(event) || canonicalLine(event).toString('utf8') !== `${line}\n`) {
|
|
78
|
+
fail('NOTE_LOG_CORRUPT', 'note_event_invalid', { ref });
|
|
79
|
+
}
|
|
80
|
+
events.push(event);
|
|
81
|
+
}
|
|
82
|
+
return events;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function sealedFiles(ref) {
|
|
86
|
+
try {
|
|
87
|
+
const names = await readdir(ref);
|
|
88
|
+
if (names.length > MAX_SEGMENTS || names.some((name) => !SEALED_NAME.test(name))) {
|
|
89
|
+
fail('NOTE_LOG_CORRUPT', 'note_sealed_inventory_invalid', { ref });
|
|
90
|
+
}
|
|
91
|
+
return names.sort();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof TodoNoteStoreError) throw error;
|
|
94
|
+
if (error?.code === 'ENOENT') return [];
|
|
95
|
+
fail('NOTE_LOG_CORRUPT', 'note_sealed_inventory_unreadable', { ref });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function validateEventChain(events, { projectId, planKey }) {
|
|
100
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
101
|
+
const event = events[index];
|
|
102
|
+
const previous = events[index - 1] ?? null;
|
|
103
|
+
if (event.project_id !== projectId || event.plan_key !== planKey
|
|
104
|
+
|| event.sequence !== index + 1
|
|
105
|
+
|| event.previous_digest !== (previous?.event_digest ?? null)) {
|
|
106
|
+
fail('NOTE_LOG_CORRUPT', 'note_digest_chain_invalid', {
|
|
107
|
+
plan_key: planKey, sequence: event.sequence,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** planに属する独立note chainをbyte-levelで検証して読む。missingだけは空chainである。 */
|
|
114
|
+
export async function readTodoNoteEvents(options = {}) {
|
|
115
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
116
|
+
const { active, sealed } = notePaths(repoRoot, options.planKey);
|
|
117
|
+
const names = await sealedFiles(sealed);
|
|
118
|
+
const events = [];
|
|
119
|
+
let previousSegmentDigest = ZERO_DIGEST;
|
|
120
|
+
for (const name of names) {
|
|
121
|
+
const match = name.match(SEALED_NAME);
|
|
122
|
+
const bytes = await readOptionalBounded(path.join(sealed, name));
|
|
123
|
+
const segmentDigest = sha256(bytes);
|
|
124
|
+
const segmentEvents = parseCanonicalSegment(bytes, path.join(sealed, name));
|
|
125
|
+
if (Number(match[1]) !== segmentEvents[0]?.sequence
|
|
126
|
+
|| Number(match[2]) !== segmentEvents.at(-1)?.sequence
|
|
127
|
+
|| match[3] !== previousSegmentDigest || match[4] !== segmentDigest) {
|
|
128
|
+
fail('NOTE_LOG_CORRUPT', 'note_seal_invalid', { ref: path.join(sealed, name) });
|
|
129
|
+
}
|
|
130
|
+
events.push(...segmentEvents);
|
|
131
|
+
previousSegmentDigest = segmentDigest;
|
|
132
|
+
}
|
|
133
|
+
const activeBytes = await readOptionalBounded(active, { missing: true });
|
|
134
|
+
if (activeBytes !== null) events.push(...parseCanonicalSegment(activeBytes, active));
|
|
135
|
+
if (events.length > 0) {
|
|
136
|
+
validateEventChain(events, { projectId: events[0].project_id, planKey: options.planKey });
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
events,
|
|
140
|
+
head_digest: events.at(-1)?.event_digest ?? null,
|
|
141
|
+
active_bytes: activeBytes ?? Buffer.alloc(0),
|
|
142
|
+
previous_segment_digest: previousSegmentDigest,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function atomicWrite(ref, bytes) {
|
|
147
|
+
await mkdir(path.dirname(ref), { recursive: true });
|
|
148
|
+
const temporary = path.join(path.dirname(ref),
|
|
149
|
+
`.${path.basename(ref)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
150
|
+
let handle;
|
|
151
|
+
try {
|
|
152
|
+
handle = await open(temporary, 'wx', 0o600);
|
|
153
|
+
await handle.writeFile(bytes);
|
|
154
|
+
await handle.sync();
|
|
155
|
+
await handle.close();
|
|
156
|
+
handle = null;
|
|
157
|
+
await rename(temporary, ref);
|
|
158
|
+
const directory = await open(path.dirname(ref), 'r');
|
|
159
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
160
|
+
} finally {
|
|
161
|
+
if (handle) await handle.close();
|
|
162
|
+
await rm(temporary, { force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function withNoteLock(repoRoot, callback) {
|
|
167
|
+
const root = path.resolve(repoRoot, NOTE_ROOT_REF);
|
|
168
|
+
await mkdir(root, { recursive: true });
|
|
169
|
+
const lockRef = path.join(root, '.write.lock');
|
|
170
|
+
let handle;
|
|
171
|
+
try { handle = await open(lockRef, 'wx', 0o600); }
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (error?.code === 'EEXIST') fail('NOTE_WRITE_CONFLICT', 'note_store_locked');
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
try { return await callback(); }
|
|
177
|
+
finally { await handle.close(); await rm(lockRef, { force: true }); }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** lifecycle artifactsへ触れず、独立note chainだけへ1 eventを追記する。 */
|
|
181
|
+
export async function appendTodoNote(options = {}) {
|
|
182
|
+
const keys = [
|
|
183
|
+
'repoRoot', 'projectId', 'planKey', 'planVersion', 'taskId', 'actor',
|
|
184
|
+
'recordedAt', 'body', 'supersedes', 'eligibleSupersedes',
|
|
185
|
+
];
|
|
186
|
+
if (!exactRecord(options, keys)
|
|
187
|
+
|| !Array.isArray(options.eligibleSupersedes)
|
|
188
|
+
|| !options.eligibleSupersedes.every(isTodoDigest)
|
|
189
|
+
|| ![options.projectId, options.planKey, options.planVersion, options.taskId]
|
|
190
|
+
.every(isTodoIdentifier)) throw new TypeError('todo note append options invalid');
|
|
191
|
+
const repoRoot = path.resolve(options.repoRoot);
|
|
192
|
+
return withNoteLock(repoRoot, async () => {
|
|
193
|
+
const chain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey });
|
|
194
|
+
if (options.supersedes !== null) {
|
|
195
|
+
const target = chain.events.find(({ event_digest: digest }) => digest === options.supersedes);
|
|
196
|
+
if (target === undefined || !options.eligibleSupersedes.includes(options.supersedes)) {
|
|
197
|
+
fail('NOTE_SUPERSEDES_INVALID', 'superseded_note_not_in_same_task', {
|
|
198
|
+
plan_key: options.planKey, task_id: options.taskId,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const previous = chain.events.at(-1) ?? null;
|
|
203
|
+
const event = {
|
|
204
|
+
schema: TODO_NOTE_EVENT_SCHEMA,
|
|
205
|
+
project_id: options.projectId,
|
|
206
|
+
plan_key: options.planKey,
|
|
207
|
+
task_id: options.taskId,
|
|
208
|
+
plan_version: options.planVersion,
|
|
209
|
+
sequence: (previous?.sequence ?? 0) + 1,
|
|
210
|
+
previous_digest: previous?.event_digest ?? null,
|
|
211
|
+
actor: options.actor,
|
|
212
|
+
recorded_at: options.recordedAt,
|
|
213
|
+
body: options.body,
|
|
214
|
+
supersedes: options.supersedes,
|
|
215
|
+
event_digest: '',
|
|
216
|
+
};
|
|
217
|
+
event.event_digest = todoSelfDigest(event, 'event_digest');
|
|
218
|
+
if (!validateTodoNoteEvent(event)) throw new TypeError('todo note event input invalid');
|
|
219
|
+
|
|
220
|
+
const paths = notePaths(repoRoot, options.planKey);
|
|
221
|
+
const eventBytes = canonicalLine(event);
|
|
222
|
+
if (chain.active_bytes.length > 0
|
|
223
|
+
&& chain.active_bytes.length + eventBytes.length > TODO_LIMITS.journalSegmentBytes) {
|
|
224
|
+
const activeEvents = parseCanonicalSegment(chain.active_bytes, paths.active);
|
|
225
|
+
const segmentDigest = sha256(chain.active_bytes);
|
|
226
|
+
const name = `${String(activeEvents[0].sequence).padStart(12, '0')}`
|
|
227
|
+
+ `-${String(activeEvents.at(-1).sequence).padStart(12, '0')}`
|
|
228
|
+
+ `-${chain.previous_segment_digest}-${segmentDigest}.jsonl`;
|
|
229
|
+
await atomicWrite(path.join(paths.sealed, name), chain.active_bytes);
|
|
230
|
+
await atomicWrite(paths.active, eventBytes);
|
|
231
|
+
} else {
|
|
232
|
+
await atomicWrite(paths.active, Buffer.concat([chain.active_bytes, eventBytes]));
|
|
233
|
+
}
|
|
234
|
+
return event;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function noteProjectionEntry(event, supersededBy) {
|
|
239
|
+
return {
|
|
240
|
+
event_digest: event.event_digest,
|
|
241
|
+
origin_plan_version: event.plan_version,
|
|
242
|
+
origin_task_id: event.task_id,
|
|
243
|
+
actor: event.actor,
|
|
244
|
+
recorded_at: event.recorded_at,
|
|
245
|
+
body: event.body,
|
|
246
|
+
supersedes: event.supersedes,
|
|
247
|
+
superseded_by: supersededBy ?? null,
|
|
248
|
+
correction_state: supersededBy === undefined ? 'current' : 'superseded',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function migrationIndex(migrations) {
|
|
253
|
+
if (!Array.isArray(migrations)) throw new TypeError('todo note migrations must be an array');
|
|
254
|
+
const index = new Map();
|
|
255
|
+
for (const migration of migrations) {
|
|
256
|
+
if (!exactRecord(migration, ['from_plan_version', 'to_plan_version', 'task_migration'])
|
|
257
|
+
|| !isTodoIdentifier(migration.from_plan_version)
|
|
258
|
+
|| !isTodoIdentifier(migration.to_plan_version)
|
|
259
|
+
|| migration.from_plan_version === migration.to_plan_version
|
|
260
|
+
|| !Array.isArray(migration.task_migration)
|
|
261
|
+
|| index.has(migration.from_plan_version)) {
|
|
262
|
+
fail('NOTE_PROJECTION_INVALID', 'note_migration_history_invalid');
|
|
263
|
+
}
|
|
264
|
+
const taskMap = new Map();
|
|
265
|
+
for (const entry of migration.task_migration) {
|
|
266
|
+
if (!exactRecord(entry, ['from_task_id', 'to_task_id'])
|
|
267
|
+
|| !isTodoIdentifier(entry.from_task_id)
|
|
268
|
+
|| !(entry.to_task_id === null || isTodoIdentifier(entry.to_task_id))
|
|
269
|
+
|| taskMap.has(entry.from_task_id)) {
|
|
270
|
+
fail('NOTE_PROJECTION_INVALID', 'note_task_migration_invalid');
|
|
271
|
+
}
|
|
272
|
+
taskMap.set(entry.from_task_id, entry.to_task_id === 'removed' ? null : entry.to_task_id);
|
|
273
|
+
}
|
|
274
|
+
index.set(migration.from_plan_version, {
|
|
275
|
+
toPlanVersion: migration.to_plan_version, taskMap,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return index;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resolveNoteTarget(event, { currentPlanVersion, currentTaskIds, migrations }) {
|
|
282
|
+
let version = event.plan_version;
|
|
283
|
+
let taskId = event.task_id;
|
|
284
|
+
const seen = new Set();
|
|
285
|
+
while (version !== currentPlanVersion) {
|
|
286
|
+
if (seen.has(version)) fail('NOTE_PROJECTION_INVALID', 'note_migration_cycle');
|
|
287
|
+
seen.add(version);
|
|
288
|
+
const step = migrations.get(version);
|
|
289
|
+
if (step === undefined) return { kind: 'archived' };
|
|
290
|
+
const nextTaskId = step.taskMap.get(taskId);
|
|
291
|
+
if (nextTaskId === undefined || nextTaskId === null) return { kind: 'archived' };
|
|
292
|
+
version = step.toPlanVersion;
|
|
293
|
+
taskId = nextTaskId;
|
|
294
|
+
}
|
|
295
|
+
return currentTaskIds.has(taskId) ? { kind: 'active', taskId } : { kind: 'archived' };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 既存task_migrationを合成し、現行taskへ渡すbounded contextとremoved taskのarchived束を作る。
|
|
300
|
+
* note listはこの全履歴を使えるが、通常詳細/startはcontextを自動同梱する。
|
|
301
|
+
*/
|
|
302
|
+
export function projectTodoNoteContext(options = {}) {
|
|
303
|
+
if (!exactRecord(options, [
|
|
304
|
+
'projectId', 'planKey', 'currentPlanVersion', 'currentTaskId',
|
|
305
|
+
'currentTaskIds', 'events', 'migrations',
|
|
306
|
+
]) || ![options.projectId, options.planKey, options.currentPlanVersion, options.currentTaskId]
|
|
307
|
+
.every(isTodoIdentifier) || !Array.isArray(options.currentTaskIds)
|
|
308
|
+
|| !options.currentTaskIds.every(isTodoIdentifier)
|
|
309
|
+
|| !options.currentTaskIds.includes(options.currentTaskId)
|
|
310
|
+
|| !Array.isArray(options.events) || !options.events.every(validateTodoNoteEvent)) {
|
|
311
|
+
throw new TypeError('todo note projection options invalid');
|
|
312
|
+
}
|
|
313
|
+
const currentTaskIds = new Set(options.currentTaskIds);
|
|
314
|
+
const migrations = migrationIndex(options.migrations);
|
|
315
|
+
const supersededBy = new Map();
|
|
316
|
+
for (const event of options.events) {
|
|
317
|
+
if (event.project_id !== options.projectId || event.plan_key !== options.planKey) {
|
|
318
|
+
fail('NOTE_LOG_CORRUPT', 'note_identity_mismatch');
|
|
319
|
+
}
|
|
320
|
+
if (event.supersedes !== null) supersededBy.set(event.supersedes, event.event_digest);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const current = [];
|
|
324
|
+
const archived = [];
|
|
325
|
+
const sequenceByDigest = new Map(options.events.map((event) => [event.event_digest, event.sequence]));
|
|
326
|
+
for (const event of options.events) {
|
|
327
|
+
const target = resolveNoteTarget(event, {
|
|
328
|
+
currentPlanVersion: options.currentPlanVersion, currentTaskIds, migrations,
|
|
329
|
+
});
|
|
330
|
+
const entry = noteProjectionEntry(event, supersededBy.get(event.event_digest));
|
|
331
|
+
if (target.kind === 'archived') archived.push(entry);
|
|
332
|
+
else if (target.taskId === options.currentTaskId) current.push(entry);
|
|
333
|
+
}
|
|
334
|
+
current.sort((left, right) => sequenceByDigest.get(right.event_digest)
|
|
335
|
+
- sequenceByDigest.get(left.event_digest));
|
|
336
|
+
archived.reverse();
|
|
337
|
+
|
|
338
|
+
const notes = [];
|
|
339
|
+
let usedBytes = 0;
|
|
340
|
+
for (const entry of current) {
|
|
341
|
+
const bytes = Buffer.byteLength(entry.body, 'utf8');
|
|
342
|
+
if (usedBytes + bytes > TODO_LIMITS.noteContextBytes) continue;
|
|
343
|
+
notes.push(entry);
|
|
344
|
+
usedBytes += bytes;
|
|
345
|
+
}
|
|
346
|
+
const context = {
|
|
347
|
+
schema: TODO_NOTE_CONTEXT_SCHEMA,
|
|
348
|
+
project_id: options.projectId,
|
|
349
|
+
plan_key: options.planKey,
|
|
350
|
+
task_id: options.currentTaskId,
|
|
351
|
+
notes,
|
|
352
|
+
note_head_digest: current[0]?.event_digest ?? null,
|
|
353
|
+
overflow_count: current.length - notes.length,
|
|
354
|
+
full_history_command: `lattice todo note list --plan ${options.planKey}`
|
|
355
|
+
+ ` --task ${options.currentTaskId} --json`,
|
|
356
|
+
context_digest: '',
|
|
357
|
+
};
|
|
358
|
+
context.context_digest = todoSelfDigest(context, 'context_digest');
|
|
359
|
+
if (!validateTodoNoteContext(context)) {
|
|
360
|
+
fail('NOTE_PROJECTION_INVALID', 'note_context_invalid');
|
|
361
|
+
}
|
|
362
|
+
return { context, archived, history: current };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function readCanonicalJson(ref, { missing = false } = {}) {
|
|
366
|
+
let bytes;
|
|
367
|
+
try {
|
|
368
|
+
const metadata = await stat(ref);
|
|
369
|
+
if (!metadata.isFile() || metadata.size > TODO_LIMITS.snapshotBytes) {
|
|
370
|
+
fail('NOTE_PROJECTION_INVALID', 'note_projection_artifact_invalid', { ref });
|
|
371
|
+
}
|
|
372
|
+
bytes = await readFile(ref);
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (error instanceof TodoNoteStoreError) throw error;
|
|
375
|
+
if (missing && error?.code === 'ENOENT') return null;
|
|
376
|
+
fail('NOTE_PROJECTION_INVALID', 'note_projection_artifact_unreadable', { ref });
|
|
377
|
+
}
|
|
378
|
+
let value;
|
|
379
|
+
try { value = JSON.parse(bytes.toString('utf8')); }
|
|
380
|
+
catch { fail('NOTE_PROJECTION_INVALID', 'note_projection_json_invalid', { ref }); }
|
|
381
|
+
if (!bytes.equals(canonicalLine(value))) {
|
|
382
|
+
fail('NOTE_PROJECTION_INVALID', 'note_projection_artifact_not_canonical', { ref });
|
|
383
|
+
}
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function readNoteMigrations(repoRoot, planKey, eventVersions) {
|
|
388
|
+
const base = path.resolve(repoRoot, '.lattice/todo/plans', planKey);
|
|
389
|
+
let entries;
|
|
390
|
+
try { entries = await readdir(base, { withFileTypes: true }); }
|
|
391
|
+
catch (error) {
|
|
392
|
+
if (error?.code === 'ENOENT' && eventVersions.size === 0) return [];
|
|
393
|
+
fail('NOTE_PROJECTION_INVALID', 'note_plan_history_unreadable', { plan_key: planKey });
|
|
394
|
+
}
|
|
395
|
+
const versions = new Set();
|
|
396
|
+
const migrations = [];
|
|
397
|
+
for (const entry of entries) {
|
|
398
|
+
if (!entry.isDirectory() || !isTodoIdentifier(entry.name)) {
|
|
399
|
+
fail('NOTE_PROJECTION_INVALID', 'note_plan_history_inventory_invalid', { plan_key: planKey });
|
|
400
|
+
}
|
|
401
|
+
const versionRoot = path.join(base, entry.name);
|
|
402
|
+
const plan = await readCanonicalJson(path.join(versionRoot, 'plan.json'));
|
|
403
|
+
if (!validateTodoPlan(plan) || plan.plan_key !== planKey || plan.plan_version !== entry.name) {
|
|
404
|
+
fail('NOTE_PROJECTION_INVALID', 'note_historical_plan_invalid', { plan_version: entry.name });
|
|
405
|
+
}
|
|
406
|
+
versions.add(entry.name);
|
|
407
|
+
const revision = await readCanonicalJson(path.join(versionRoot, 'revision.json'), { missing: true });
|
|
408
|
+
if (revision === null) continue;
|
|
409
|
+
if (!(validateTodoRevision(revision) || validatePhaseTodoRevision(revision))
|
|
410
|
+
|| revision.plan_key !== planKey || revision.predecessor?.plan_version === undefined
|
|
411
|
+
|| !Array.isArray(revision.task_migration)) {
|
|
412
|
+
fail('NOTE_PROJECTION_INVALID', 'note_historical_revision_invalid', { plan_version: entry.name });
|
|
413
|
+
}
|
|
414
|
+
migrations.push({
|
|
415
|
+
from_plan_version: revision.predecessor.plan_version,
|
|
416
|
+
to_plan_version: entry.name,
|
|
417
|
+
task_migration: revision.task_migration.map(({ from_task_id: fromTaskId, to_task_id: toTaskId }) => ({
|
|
418
|
+
from_task_id: fromTaskId, to_task_id: toTaskId,
|
|
419
|
+
})),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
if ([...eventVersions].some((version) => !versions.has(version))) {
|
|
423
|
+
fail('NOTE_PROJECTION_INVALID', 'note_origin_plan_version_unknown');
|
|
424
|
+
}
|
|
425
|
+
return migrations;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** active store memberと歴史revisionから、通常供給用contextを一回で読む。 */
|
|
429
|
+
export async function readTodoNoteContext(options = {}) {
|
|
430
|
+
if (!exactRecord(options, ['repoRoot', 'store', 'planKey', 'taskId'])
|
|
431
|
+
|| !isTodoIdentifier(options.planKey) || !isTodoIdentifier(options.taskId)
|
|
432
|
+
|| options.store === null || typeof options.store !== 'object') {
|
|
433
|
+
throw new TypeError('todo note context read options invalid');
|
|
434
|
+
}
|
|
435
|
+
const repoRoot = path.resolve(options.repoRoot);
|
|
436
|
+
const member = options.store.members?.find(({ descriptor }) => (
|
|
437
|
+
descriptor.plan_key === options.planKey
|
|
438
|
+
));
|
|
439
|
+
if (member === undefined) fail('NOTE_TASK_NOT_FOUND', 'note_plan_not_active', {
|
|
440
|
+
plan_key: options.planKey,
|
|
441
|
+
});
|
|
442
|
+
const task = member.plan.tasks.find(({ task_id: taskId }) => taskId === options.taskId);
|
|
443
|
+
if (task === undefined) fail('NOTE_TASK_NOT_FOUND', 'note_task_not_active', {
|
|
444
|
+
plan_key: options.planKey, task_id: options.taskId,
|
|
445
|
+
});
|
|
446
|
+
const chain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey });
|
|
447
|
+
const migrations = await readNoteMigrations(repoRoot, options.planKey,
|
|
448
|
+
new Set(chain.events.map(({ plan_version: version }) => version)));
|
|
449
|
+
return projectTodoNoteContext({
|
|
450
|
+
projectId: member.plan.project_id,
|
|
451
|
+
planKey: options.planKey,
|
|
452
|
+
currentPlanVersion: member.plan.plan_version,
|
|
453
|
+
currentTaskId: task.task_id,
|
|
454
|
+
currentTaskIds: member.plan.tasks.map(({ task_id: taskId }) => taskId),
|
|
455
|
+
events: chain.events,
|
|
456
|
+
migrations,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Gantt用に1 planのchain/historyを一度だけ読み、全task contextへ投影する。 */
|
|
461
|
+
export async function readTodoNoteContextsForPlan(options = {}) {
|
|
462
|
+
if (!exactRecord(options, ['repoRoot', 'store', 'planKey'])
|
|
463
|
+
|| !isTodoIdentifier(options.planKey)
|
|
464
|
+
|| options.store === null || typeof options.store !== 'object') {
|
|
465
|
+
throw new TypeError('todo note contexts read options invalid');
|
|
466
|
+
}
|
|
467
|
+
const repoRoot = path.resolve(options.repoRoot);
|
|
468
|
+
const member = options.store.members?.find(({ descriptor }) => (
|
|
469
|
+
descriptor.plan_key === options.planKey
|
|
470
|
+
));
|
|
471
|
+
if (member === undefined) fail('NOTE_TASK_NOT_FOUND', 'note_plan_not_active', {
|
|
472
|
+
plan_key: options.planKey,
|
|
473
|
+
});
|
|
474
|
+
const chain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey });
|
|
475
|
+
const migrations = await readNoteMigrations(repoRoot, options.planKey,
|
|
476
|
+
new Set(chain.events.map(({ plan_version: version }) => version)));
|
|
477
|
+
const currentTaskIds = member.plan.tasks.map(({ task_id: taskId }) => taskId);
|
|
478
|
+
const projected = member.plan.tasks.map((task) => projectTodoNoteContext({
|
|
479
|
+
projectId: member.plan.project_id,
|
|
480
|
+
planKey: options.planKey,
|
|
481
|
+
currentPlanVersion: member.plan.plan_version,
|
|
482
|
+
currentTaskId: task.task_id,
|
|
483
|
+
currentTaskIds,
|
|
484
|
+
events: chain.events,
|
|
485
|
+
migrations,
|
|
486
|
+
}));
|
|
487
|
+
return {
|
|
488
|
+
contexts: projected.map(({ context }) => context),
|
|
489
|
+
archived: projected[0]?.archived ?? [],
|
|
490
|
+
note_head_digest: chain.head_digest,
|
|
491
|
+
};
|
|
492
|
+
}
|