@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 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, renderTodoGanttForProject } from '../src/todo-cli.mjs';
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 renderTodoGanttForProject({ repoRoot: entry.repo_root,
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.37.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 resolvedTaskId = readyTask?.task_id ?? taskId;
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.v1',
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 renderTodoGanttForProject({
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] });
@@ -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(sections, layout, presentation, readModel) {
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 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>${dispatchSummary}${renderSeamProposalOverview(layout)}${renderPhaseProgress(readModel)}<h2>作業中</h2>${activeLinks}</section>`;
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
- 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><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>`;
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)}
@@ -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.v17';
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
- result.push({ ref, task, state: states.get(task.task_id), narrativeRef, anchorOutcome });
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(normalized.sections, layout, presentation, readModel);
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
+ }