@quolu/lattice 0.36.1 → 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.
@@ -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
+ }
@@ -217,12 +217,13 @@ async function readJournal(repoRoot, journalRef) {
217
217
  const phaseTail = (event) => event.schema === 'lattice.todo_event.v3';
218
218
  const legacyTail = (event) => event.schema === 'lattice.todo_event.v1';
219
219
  // ADR 0147: genesisがv1/v2(phase無しplan)でも、暗黙のterminal-audit Phaseへの
220
- // phase_review/phase_accept/phase_reject/phase_reopenだけはv3 tail eventとして
221
- // 混在を許す。task側のevent(start/done/block/unblock/reopen)は従来どおりv1のまま
220
+ // phase_review/phase_accept/phase_reject/phase_reopen/phase_close_unaudited(ADR 0148)だけは
221
+ // v3 tail eventとして混在を許す。task側のevent(start/done/block/unblock/reopen)は従来どおりv1のまま
222
222
  // ——既存planの既存event bytesは1つも変わらない。新しく増えるのは、これまで
223
223
  // phase無しplanには存在し得なかったphase_*event kindの受け皿だけである。
224
224
  const implicitTerminalAuditTail = (event) => phaseTail(event)
225
- && ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen'].includes(event.kind);
225
+ && ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen', 'phase_close_unaudited']
226
+ .includes(event.kind);
226
227
  const legacyOrImplicitPhaseTail = (event) => legacyTail(event) || implicitTerminalAuditTail(event);
227
228
  if ((phaseSchema && events.some(({ schema }, index) => index === 0
228
229
  ? !['lattice.todo_event.v3', 'lattice.todo_event.v4'].includes(schema) : !phaseTail(events[index])))
@@ -271,7 +272,11 @@ function phasesOf(plan) {
271
272
 
272
273
  function derivedPhaseStatus(plan, taskStates, phaseStates, phaseId) {
273
274
  const state = phaseStates.get(phaseId);
274
- if (['reviewing', 'accepted', 'rejected'].includes(state.status)) return state.status;
275
+ // ADR 0148: closed_unauditedも他の終端状態と同じく確定済みとして扱う。ここへ足さないと、
276
+ // 全taskがdone(=構造的にgate_ready)のままなので、以後の呼び出しで毎回gate_readyへ
277
+ // 再導出されてしまい、記録した「監査なしで閉じた」がgantt折り畳み・phase_accept_dependencies
278
+ // 判定の上で一瞬で消える。
279
+ if (['reviewing', 'accepted', 'rejected', 'closed_unaudited'].includes(state.status)) return state.status;
275
280
  const phase = phasesOf(plan).find((entry) => entry.phase_id === phaseId);
276
281
  if (!phase.predecessor_phase_ids.every((id) => phaseStates.get(id)?.status === 'accepted')) return 'locked';
277
282
  // 暗黙のterminal-audit Phaseはtask側にphase_idフィールドが無い(v1/v2/v3にはそもそも
@@ -303,6 +308,13 @@ function projectPhaseStates(plan, events, taskStates) {
303
308
  const state = states.get(event.phase_id);
304
309
  state.status = 'rejected'; state.decision_event_digest = event.event_digest;
305
310
  state.decision_evidence = event.payload.decision_evidence;
311
+ } else if (event.kind === 'phase_close_unaudited') {
312
+ // ADR 0148: 監査していないので証拠(decision_evidence)は残さない。理由はevent payload
313
+ // 自身(decision_event_digestで指せるこの event)に記録済みで、accept/rejectと違い
314
+ // ここに複製しない。
315
+ const state = states.get(event.phase_id);
316
+ state.status = 'closed_unaudited'; state.decision_event_digest = event.event_digest;
317
+ state.decision_evidence = null;
306
318
  } else if (event.kind === 'phase_reopen') {
307
319
  Object.assign(states.get(event.phase_id), emptyPhaseState(event.phase_id));
308
320
  }
@@ -428,8 +440,17 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
428
440
  if (verifyEvidence) verifyEvidence(event.payload.decision_evidence);
429
441
  state.status = 'rejected'; state.decision_event_digest = event.event_digest;
430
442
  state.decision_evidence = event.payload.decision_evidence;
443
+ } else if (event.kind === 'phase_close_unaudited') {
444
+ // ADR 0148裁定3: reviewと同じ前提(gate_ready)を課す——所属ToDoが全てdoneでない段階では
445
+ // まだ監査の地点に到達していないので、監査なしで閉じることもできない。
446
+ if (currentStatus !== 'gate_ready') fail('STORE_INCONSISTENT', 'phase_gate_not_ready');
447
+ state.status = 'closed_unaudited'; state.decision_event_digest = event.event_digest;
448
+ state.decision_evidence = null;
431
449
  } else {
432
- if (!['accepted', 'rejected'].includes(currentStatus)
450
+ // phase_reopen。ADR 0148裁定5: closed_unauditedもaccepted/rejectedと同じく
451
+ // reopenで初期状態へ戻せる——監査せずに閉じた工程を、後から本当に監査したくなった時に
452
+ // 永久に締め出さない。
453
+ if (!['accepted', 'rejected', 'closed_unaudited'].includes(currentStatus)
433
454
  || state.decision_event_digest !== event.payload.target_decision_digest) {
434
455
  fail('STORE_INCONSISTENT', 'phase_reopen_binding_invalid');
435
456
  }
@@ -516,14 +537,16 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
516
537
  fail('STORE_INCONSISTENT', 'invalid_reopen_binding');
517
538
  }
518
539
  {
519
- // 暗黙のterminal-audit Phaseがacceptedの後にtaskだけを無警告でreopenできると、
520
- // 監査済みのまま(gantt上も畳まれたまま)裏で作業が再開する抜け道になり、ADR 0147の
521
- // 「監査の記録なしに閉じたことにさせない」を潜脱する。v4/v5の既存Phaseと同じ規律を
522
- // 暗黙Phaseにも及ぼし、phase_reopenを先に通させる。
540
+ // 暗黙のterminal-audit Phaseがaccepted/closed_unauditedの後にtaskだけを無警告でreopen
541
+ // できると、監査済み(または監査なしで閉じた)まま(gantt上も畳まれたまま)裏で作業が
542
+ // 再開する抜け道になり、ADR 0147/0148の「監査の記録なしに閉じたことにさせない」を
543
+ // 潜脱する。v4/v5の既存Phaseと同じ規律を暗黙Phaseにも及ぼし、phase_reopenを先に
544
+ // 通させる。closed_unauditedをここへ足さないと、ADR 0148で新設した状態だけが
545
+ // このgateを素通りしてしまう。
523
546
  const phaseId = isPhaselessTodoPlanSchema(plan.schema)
524
547
  ? TERMINAL_AUDIT_PHASE_ID
525
548
  : plan.tasks.find(({ task_id }) => task_id === event.task_id).phase_id;
526
- if (derivedPhaseStatus(plan, states, phaseStates, phaseId) === 'accepted') {
549
+ if (['accepted', 'closed_unaudited'].includes(derivedPhaseStatus(plan, states, phaseStates, phaseId))) {
527
550
  fail('STORE_INCONSISTENT', 'task_reopen_requires_phase_reopen');
528
551
  }
529
552
  }
@@ -1174,7 +1197,8 @@ function nextEvent(input, storeMember) {
1174
1197
  ? { done_mode: 'authored', imported: false, evidence: input.payload.evidence }
1175
1198
  : input.payload;
1176
1199
  const phaseCapablePlan = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(storeMember.plan.schema);
1177
- const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen'].includes(input.kind);
1200
+ const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen', 'phase_close_unaudited']
1201
+ .includes(input.kind);
1178
1202
  // ADR 0147: phase無しplanでも、暗黙のterminal-audit Phaseへのphase_*eventだけは
1179
1203
  // v3 tail eventとして書く(readJournalのlegacyOrImplicitPhaseTailが同じ規則で受理する)。
1180
1204
  // task側のevent(start/done等)はphase無しplanのままv1で書き続ける——既存の
@@ -1246,8 +1270,12 @@ function resolveTargetedEvent(input, storeMember) {
1246
1270
  };
1247
1271
  }
1248
1272
  if (input.kind === 'phase_reopen' && exactRecord(input.payload, ['reason', 'override_reason'])) {
1273
+ // ADR 0148裁定5: closed_unauditedもaccept/rejectと同じ「決定event」なので、reopenの
1274
+ // 結びつけ先として同列に探す。ここへ足し忘れると、監査なしで閉じたPhaseをreopenする
1275
+ // 手段が無くなる(target_decision_digestを解決できずphase_reopen_binding_invalidで拒否される)。
1249
1276
  const target = [...storeMember.journal.events].reverse().find((event) => (
1250
- ['phase_accept', 'phase_reject'].includes(event.kind) && event.phase_id === input.phase_id
1277
+ ['phase_accept', 'phase_reject', 'phase_close_unaudited'].includes(event.kind)
1278
+ && event.phase_id === input.phase_id
1251
1279
  ));
1252
1280
  if (target === undefined) fail('STORE_INCONSISTENT', 'phase_reopen_binding_invalid');
1253
1281
  return { ...input, payload: { ...input.payload, target_decision_digest: target.event_digest } };