@quolu/lattice 0.37.0 → 0.38.1

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.
@@ -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
+ }