@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.
- package/README.ja.md +29 -0
- package/README.md +25 -0
- package/bin/lattice-dashboard.mjs +2 -2
- package/package.json +1 -1
- package/src/cli-help.mjs +17 -1
- package/src/todo-cli.mjs +373 -12
- package/src/todo-contracts.mjs +80 -3
- package/src/todo-gantt-html-independence.mjs +29 -3
- package/src/todo-gantt-html-style.mjs +6 -0
- package/src/todo-gantt-html.mjs +28 -6
- package/src/todo-note-store.mjs +492 -0
- package/src/todo-store.mjs +40 -12
|
@@ -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
|
+
}
|
package/src/todo-store.mjs
CHANGED
|
@@ -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だけは
|
|
221
|
-
//
|
|
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']
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
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)
|
|
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']
|
|
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)
|
|
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 } };
|