@quolu/lattice 0.47.0 → 0.48.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/bin/lattice-dashboard.mjs +2 -2
- package/package.json +1 -1
- package/src/cli-help.mjs +2 -2
- package/src/project-cli.mjs +7 -1
- package/src/todo-cli.mjs +109 -18
- package/src/todo-contracts.mjs +84 -14
- package/src/todo-independence-guidance.mjs +24 -1
- package/src/todo-note-store.mjs +148 -21
- package/src/todo-parallel-candidates.mjs +114 -0
- package/src/todo-status.mjs +175 -8
- package/src/todo-store.mjs +139 -2
package/src/todo-note-store.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
TODO_LIMITS,
|
|
9
9
|
TODO_NOTE_CONTEXT_SCHEMA,
|
|
10
10
|
TODO_NOTE_EVENT_SCHEMA,
|
|
11
|
+
TODO_NOTE_EVENT_V2_SCHEMA,
|
|
11
12
|
canonicalizeTodoArtifact,
|
|
12
13
|
exactRecord,
|
|
13
14
|
isTodoDigest,
|
|
@@ -45,10 +46,37 @@ function canonicalLine(value) {
|
|
|
45
46
|
return Buffer.from(`${canonicalizeTodoArtifact(value)}\n`, 'utf8');
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* task noteとplan noteは**別のchain file**へ積む。
|
|
51
|
+
*
|
|
52
|
+
* 混ぜると、plan note(`todo_note_event.v2`)を1件書いた時点で、旧CLIにとってその planの
|
|
53
|
+
* chain全体が壊れたものになる——`parseCanonicalSegment`は1 eventずつbyte完全一致で検証し、
|
|
54
|
+
* 1件でも通らなければchainごと`NOTE_LOG_CORRUPT`で落とすためである。noteの読みは
|
|
55
|
+
* `todo start`の前提条件(fail closed)なので、**旧CLIでstartが通らなくなる**。しかも
|
|
56
|
+
* store��書いたものは戻せないので、rollbackで復旧できない。
|
|
57
|
+
*
|
|
58
|
+
* 旧readerが読むのは`active.jsonl`と`sealed/*`だけで、plan直下を列挙しない。別名のfileへ
|
|
59
|
+
* 積めば存在に気づかず、task noteの読み書きは1バイトも変わらない。旧CLIからplan noteは
|
|
60
|
+
* 見えないままだが、**旧CLIではplan noteを書けない以上、読めなくても行動が変わらない**。
|
|
61
|
+
*/
|
|
48
62
|
function notePaths(repoRoot, planKey) {
|
|
49
63
|
if (!isTodoIdentifier(planKey)) throw new TypeError('planKey must be a todo identifier');
|
|
50
64
|
const root = path.resolve(repoRoot, NOTE_ROOT_REF, planKey);
|
|
51
|
-
return {
|
|
65
|
+
return {
|
|
66
|
+
root,
|
|
67
|
+
active: path.join(root, 'active.jsonl'),
|
|
68
|
+
sealed: path.join(root, 'sealed'),
|
|
69
|
+
planActive: path.join(root, 'plan-active.jsonl'),
|
|
70
|
+
planSealed: path.join(root, 'plan-sealed'),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** chainごとのpathと、そのchainが受けるevent schema。 */
|
|
75
|
+
function chainRefs(repoRoot, planKey, scope) {
|
|
76
|
+
const refs = notePaths(repoRoot, planKey);
|
|
77
|
+
return scope === 'plan'
|
|
78
|
+
? { active: refs.planActive, sealed: refs.planSealed, schema: TODO_NOTE_EVENT_V2_SCHEMA }
|
|
79
|
+
: { active: refs.active, sealed: refs.sealed, schema: TODO_NOTE_EVENT_SCHEMA };
|
|
52
80
|
}
|
|
53
81
|
|
|
54
82
|
async function readOptionalBounded(ref, { missing = false } = {}) {
|
|
@@ -110,10 +138,15 @@ function validateEventChain(events, { projectId, planKey }) {
|
|
|
110
138
|
}
|
|
111
139
|
}
|
|
112
140
|
|
|
113
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* planに属する独立note chainをbyte-levelで検証して読む。missingだけは空chainである。
|
|
143
|
+
* `scope`で読むchainを選ぶ(既定はtask chain=旧CLIと同じ経路・同じbyte)。
|
|
144
|
+
*/
|
|
114
145
|
export async function readTodoNoteEvents(options = {}) {
|
|
115
146
|
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
116
|
-
const
|
|
147
|
+
const scope = options.scope ?? 'task';
|
|
148
|
+
if (!['plan', 'task'].includes(scope)) throw new TypeError('note chain scope must be plan or task');
|
|
149
|
+
const { active, sealed, schema } = chainRefs(repoRoot, options.planKey, scope);
|
|
117
150
|
const names = await sealedFiles(sealed);
|
|
118
151
|
const events = [];
|
|
119
152
|
let previousSegmentDigest = ZERO_DIGEST;
|
|
@@ -132,6 +165,11 @@ export async function readTodoNoteEvents(options = {}) {
|
|
|
132
165
|
}
|
|
133
166
|
const activeBytes = await readOptionalBounded(active, { missing: true });
|
|
134
167
|
if (activeBytes !== null) events.push(...parseCanonicalSegment(activeBytes, active));
|
|
168
|
+
// chainの分離は「そのfileへ何が積まれるか」で守る。混ざったchainは分離の意味を失うので
|
|
169
|
+
// 黙って受けず、読んだ時点でtypedに落とす。
|
|
170
|
+
if (events.some((event) => event.schema !== schema)) {
|
|
171
|
+
fail('NOTE_LOG_CORRUPT', 'note_chain_scope_mixed', { plan_key: options.planKey, scope });
|
|
172
|
+
}
|
|
135
173
|
if (events.length > 0) {
|
|
136
174
|
validateEventChain(events, { projectId: events[0].project_id, planKey: options.planKey });
|
|
137
175
|
}
|
|
@@ -186,22 +224,32 @@ export async function appendTodoNote(options = {}) {
|
|
|
186
224
|
if (!exactRecord(options, keys)
|
|
187
225
|
|| !Array.isArray(options.eligibleSupersedes)
|
|
188
226
|
|| !options.eligibleSupersedes.every(isTodoDigest)
|
|
189
|
-
|| ![options.projectId, options.planKey, options.planVersion
|
|
190
|
-
|
|
227
|
+
|| ![options.projectId, options.planKey, options.planVersion].every(isTodoIdentifier)
|
|
228
|
+
// taskId nullがplan単位noteの指定。scopeはeventのschemaが持つ。
|
|
229
|
+
|| !(options.taskId === null || isTodoIdentifier(options.taskId))) {
|
|
230
|
+
throw new TypeError('todo note append options invalid');
|
|
231
|
+
}
|
|
232
|
+
const planScoped = options.taskId === null;
|
|
233
|
+
const scope = planScoped ? 'plan' : 'task';
|
|
191
234
|
const repoRoot = path.resolve(options.repoRoot);
|
|
192
235
|
return withNoteLock(repoRoot, async () => {
|
|
193
|
-
|
|
236
|
+
// sequenceとprevious_digestはchainごとに独立。note系の起点は1(journal系の0ではない)。
|
|
237
|
+
const chain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey, scope });
|
|
194
238
|
if (options.supersedes !== null) {
|
|
195
239
|
const target = chain.events.find(({ event_digest: digest }) => digest === options.supersedes);
|
|
196
240
|
if (target === undefined || !options.eligibleSupersedes.includes(options.supersedes)) {
|
|
197
|
-
|
|
241
|
+
// plan noteはplan noteだけを、task noteは同じtaskのnoteだけを訂正できる。
|
|
242
|
+
// scopeを跨ぐ訂正を許すと、届く先が違うものを同じ履歴として畳むことになる。
|
|
243
|
+
fail('NOTE_SUPERSEDES_INVALID', planScoped
|
|
244
|
+
? 'superseded_note_not_plan_scoped' : 'superseded_note_not_in_same_task', {
|
|
198
245
|
plan_key: options.planKey, task_id: options.taskId,
|
|
199
246
|
});
|
|
200
247
|
}
|
|
201
248
|
}
|
|
202
249
|
const previous = chain.events.at(-1) ?? null;
|
|
203
250
|
const event = {
|
|
204
|
-
schema: TODO_NOTE_EVENT_SCHEMA,
|
|
251
|
+
schema: planScoped ? TODO_NOTE_EVENT_V2_SCHEMA : TODO_NOTE_EVENT_SCHEMA,
|
|
252
|
+
...(planScoped ? { scope: 'plan' } : {}),
|
|
205
253
|
project_id: options.projectId,
|
|
206
254
|
plan_key: options.planKey,
|
|
207
255
|
task_id: options.taskId,
|
|
@@ -217,7 +265,7 @@ export async function appendTodoNote(options = {}) {
|
|
|
217
265
|
event.event_digest = todoSelfDigest(event, 'event_digest');
|
|
218
266
|
if (!validateTodoNoteEvent(event)) throw new TypeError('todo note event input invalid');
|
|
219
267
|
|
|
220
|
-
const paths =
|
|
268
|
+
const paths = chainRefs(repoRoot, options.planKey, scope);
|
|
221
269
|
const eventBytes = canonicalLine(event);
|
|
222
270
|
if (chain.active_bytes.length > 0
|
|
223
271
|
&& chain.active_bytes.length + eventBytes.length > TODO_LIMITS.journalSegmentBytes) {
|
|
@@ -235,10 +283,12 @@ export async function appendTodoNote(options = {}) {
|
|
|
235
283
|
});
|
|
236
284
|
}
|
|
237
285
|
|
|
286
|
+
/** v1(task note)とv2(plan note)を、明示`scope`を持つ1つの形へ正規化する。 */
|
|
238
287
|
function noteProjectionEntry(event, supersededBy) {
|
|
239
288
|
return {
|
|
240
289
|
event_digest: event.event_digest,
|
|
241
290
|
origin_plan_version: event.plan_version,
|
|
291
|
+
scope: event.schema === TODO_NOTE_EVENT_V2_SCHEMA ? 'plan' : 'task',
|
|
242
292
|
origin_task_id: event.task_id,
|
|
243
293
|
actor: event.actor,
|
|
244
294
|
recorded_at: event.recorded_at,
|
|
@@ -302,37 +352,52 @@ function resolveNoteTarget(event, { currentPlanVersion, currentTaskIds, migratio
|
|
|
302
352
|
export function projectTodoNoteContext(options = {}) {
|
|
303
353
|
if (!exactRecord(options, [
|
|
304
354
|
'projectId', 'planKey', 'currentPlanVersion', 'currentTaskId',
|
|
305
|
-
'currentTaskIds', 'events', 'migrations',
|
|
355
|
+
'currentTaskIds', 'events', 'planEvents', 'migrations',
|
|
306
356
|
]) || ![options.projectId, options.planKey, options.currentPlanVersion, options.currentTaskId]
|
|
307
357
|
.every(isTodoIdentifier) || !Array.isArray(options.currentTaskIds)
|
|
308
358
|
|| !options.currentTaskIds.every(isTodoIdentifier)
|
|
309
359
|
|| !options.currentTaskIds.includes(options.currentTaskId)
|
|
310
|
-
|| !Array.isArray(options.events) || !options.events.every(validateTodoNoteEvent)
|
|
360
|
+
|| !Array.isArray(options.events) || !options.events.every(validateTodoNoteEvent)
|
|
361
|
+
|| !Array.isArray(options.planEvents) || !options.planEvents.every(validateTodoNoteEvent)) {
|
|
311
362
|
throw new TypeError('todo note projection options invalid');
|
|
312
363
|
}
|
|
313
364
|
const currentTaskIds = new Set(options.currentTaskIds);
|
|
314
365
|
const migrations = migrationIndex(options.migrations);
|
|
366
|
+
// 訂正はscopeを跨げないので、supersedeの追跡もchainごとに閉じる。
|
|
315
367
|
const supersededBy = new Map();
|
|
316
|
-
for (const event of options.events) {
|
|
368
|
+
for (const event of [...options.events, ...options.planEvents]) {
|
|
317
369
|
if (event.project_id !== options.projectId || event.plan_key !== options.planKey) {
|
|
318
370
|
fail('NOTE_LOG_CORRUPT', 'note_identity_mismatch');
|
|
319
371
|
}
|
|
320
372
|
if (event.supersedes !== null) supersededBy.set(event.supersedes, event.event_digest);
|
|
321
373
|
}
|
|
322
374
|
|
|
323
|
-
const
|
|
375
|
+
const taskCurrent = [];
|
|
324
376
|
const archived = [];
|
|
325
|
-
const
|
|
377
|
+
const sequenceOf = (events) => new Map(events.map((event) => [event.event_digest, event.sequence]));
|
|
378
|
+
const taskSequence = sequenceOf(options.events);
|
|
379
|
+
const planSequence = sequenceOf(options.planEvents);
|
|
326
380
|
for (const event of options.events) {
|
|
381
|
+
const entry = noteProjectionEntry(event, supersededBy.get(event.event_digest));
|
|
327
382
|
const target = resolveNoteTarget(event, {
|
|
328
383
|
currentPlanVersion: options.currentPlanVersion, currentTaskIds, migrations,
|
|
329
384
|
});
|
|
330
|
-
const entry = noteProjectionEntry(event, supersededBy.get(event.event_digest));
|
|
331
385
|
if (target.kind === 'archived') archived.push(entry);
|
|
332
|
-
else if (target.taskId === options.currentTaskId)
|
|
386
|
+
else if (target.taskId === options.currentTaskId) taskCurrent.push(entry);
|
|
333
387
|
}
|
|
334
|
-
|
|
335
|
-
|
|
388
|
+
// plan noteは特定のtaskに属さないので、task migrationで宛先を失うことがない。全taskの
|
|
389
|
+
// contextへ載せる——工程レベルの義務は「次に着手する誰か」へ届くべきもので、誰が着手するかは
|
|
390
|
+
// 書いた時点で分からない。
|
|
391
|
+
const planCurrent = options.planEvents
|
|
392
|
+
.map((event) => noteProjectionEntry(event, supersededBy.get(event.event_digest)));
|
|
393
|
+
taskCurrent.sort((left, right) => taskSequence.get(right.event_digest)
|
|
394
|
+
- taskSequence.get(left.event_digest));
|
|
395
|
+
planCurrent.sort((left, right) => planSequence.get(right.event_digest)
|
|
396
|
+
- planSequence.get(left.event_digest));
|
|
397
|
+
// 2本のsequenceは独立なので、混ぜた全順序は時刻にもsequenceにも作れない(時刻は
|
|
398
|
+
// future_clock_skewがある以上信用できない)。決定的な規則で並べる: plan単位の申し送りは
|
|
399
|
+
// task固有のものより文脈が広いので先に読ませる。
|
|
400
|
+
const current = [...planCurrent, ...taskCurrent];
|
|
336
401
|
archived.reverse();
|
|
337
402
|
|
|
338
403
|
const notes = [];
|
|
@@ -349,10 +414,14 @@ export function projectTodoNoteContext(options = {}) {
|
|
|
349
414
|
plan_key: options.planKey,
|
|
350
415
|
task_id: options.currentTaskId,
|
|
351
416
|
notes,
|
|
352
|
-
|
|
417
|
+
// headはchainごとに言う。合成すると「どちらのheadか」と連結順を定義する必要が生まれ、
|
|
418
|
+
// 順序が決まっていなければ同じ状態から違うdigestが出る。
|
|
419
|
+
note_head_digest: taskCurrent[0]?.event_digest ?? null,
|
|
420
|
+
plan_note_head_digest: planCurrent[0]?.event_digest ?? null,
|
|
421
|
+
// overflowはcontext全体の予算の話でchainの性質ではないので、ここは合成でよい。
|
|
353
422
|
overflow_count: current.length - notes.length,
|
|
354
|
-
|
|
355
|
-
|
|
423
|
+
// plan noteを載せる以上、案内はplan全体を返す形でなければ「full」ではない。
|
|
424
|
+
full_history_command: `lattice todo note list --plan ${options.planKey} --json`,
|
|
356
425
|
context_digest: '',
|
|
357
426
|
};
|
|
358
427
|
context.context_digest = todoSelfDigest(context, 'context_digest');
|
|
@@ -450,6 +519,7 @@ export async function readTodoNoteContext(options = {}) {
|
|
|
450
519
|
plan_key: options.planKey, task_id: options.taskId,
|
|
451
520
|
});
|
|
452
521
|
const chain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey });
|
|
522
|
+
const planChain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey, scope: 'plan' });
|
|
453
523
|
const migrations = await readNoteMigrations(repoRoot, options.planKey,
|
|
454
524
|
new Set(chain.events.map(({ plan_version: version }) => version)));
|
|
455
525
|
return projectTodoNoteContext({
|
|
@@ -459,10 +529,65 @@ export async function readTodoNoteContext(options = {}) {
|
|
|
459
529
|
currentTaskId: task.task_id,
|
|
460
530
|
currentTaskIds: member.plan.tasks.map(({ task_id: taskId }) => taskId),
|
|
461
531
|
events: chain.events,
|
|
532
|
+
planEvents: planChain.events,
|
|
462
533
|
migrations,
|
|
463
534
|
});
|
|
464
535
|
}
|
|
465
536
|
|
|
537
|
+
/**
|
|
538
|
+
* status面の`plan_notes`欄の素材を、store内の全planぶん一度に読む。
|
|
539
|
+
*
|
|
540
|
+
* plan単位noteは「工程に属する義務」で、着手する誰かのcontextへは届くが、**まだ誰も
|
|
541
|
+
* 着手していない工程の義務はどこにも出ない**。statusはそれを出す唯一の面なので、
|
|
542
|
+
* ここでは本文を一切運ばない——載せるのは件数・帰属・次の一手だけで、中身は
|
|
543
|
+
* `note list`が持つ(`audit_pending`が330字のproseを落としたのと同じ判断・ADR 0159)。
|
|
544
|
+
*
|
|
545
|
+
* noteを持たないplanはentryごと出さない。「全plan常に1行」は、前campaignの
|
|
546
|
+
* witness `coverage: missing`と同じ「満杯で始まるので読み飛ばされる欄」になる。
|
|
547
|
+
*
|
|
548
|
+
* chainが壊れているplanが1つでもあれば`readTodoNoteEvents`のtyped errorがそのまま出る
|
|
549
|
+
* (fail closed)。壊れた義務記録を「義務なし」と同じ空へ丸めない。
|
|
550
|
+
*/
|
|
551
|
+
export async function readTodoPlanNotesForStatus(options = {}) {
|
|
552
|
+
if (!exactRecord(options, ['repoRoot', 'store'])
|
|
553
|
+
|| options.store === null || typeof options.store !== 'object'
|
|
554
|
+
|| !Array.isArray(options.store.members)) {
|
|
555
|
+
throw new TypeError('todo plan notes read options invalid');
|
|
556
|
+
}
|
|
557
|
+
const repoRoot = path.resolve(options.repoRoot);
|
|
558
|
+
const summaries = [];
|
|
559
|
+
for (const member of options.store.members) {
|
|
560
|
+
const planKey = member.plan.plan_key;
|
|
561
|
+
// plan noteは専用chainに在る。schemaでの選別は要らない——読む先そのものが分かれている。
|
|
562
|
+
const chain = await readTodoNoteEvents({ repoRoot, planKey, scope: 'plan' });
|
|
563
|
+
const superseded = new Set(chain.events
|
|
564
|
+
.filter(({ supersedes }) => supersedes !== null)
|
|
565
|
+
.map(({ supersedes }) => supersedes));
|
|
566
|
+
// 訂正されたnoteは数えない。数えると訂正するほど件数が増える。
|
|
567
|
+
const current = chain.events
|
|
568
|
+
.filter((event) => !superseded.has(event.event_digest))
|
|
569
|
+
.sort((left, right) => right.sequence - left.sequence);
|
|
570
|
+
if (current.length === 0) continue;
|
|
571
|
+
summaries.push({
|
|
572
|
+
plan_key: planKey,
|
|
573
|
+
// `note_context.note_head_digest`はtask chainのheadを指す。同名で別のchainを指すと
|
|
574
|
+
// 型が同じdigestなので取り違えてもvalidatorを通る——名前で区別する(room [211])。
|
|
575
|
+
plan_note_head_digest: current[0].event_digest,
|
|
576
|
+
count: current.length,
|
|
577
|
+
latest: current.slice(0, TODO_LIMITS.statusPlanNoteLatest).map((event) => ({
|
|
578
|
+
event_digest: event.event_digest,
|
|
579
|
+
actor_agent: event.actor.agent,
|
|
580
|
+
recorded_at: event.recorded_at,
|
|
581
|
+
})),
|
|
582
|
+
// 欄だけでは読まれない。届いた先で次に何を打てばいいかを名指しする。
|
|
583
|
+
next_commands: [`lattice todo note list --plan ${planKey} --json`],
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
summaries.sort((left, right) => (
|
|
587
|
+
left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0));
|
|
588
|
+
return summaries;
|
|
589
|
+
}
|
|
590
|
+
|
|
466
591
|
/** Gantt用に1 planのchain/historyを一度だけ読み、全task contextへ投影する。 */
|
|
467
592
|
export async function readTodoNoteContextsForPlan(options = {}) {
|
|
468
593
|
if (!exactRecord(options, ['repoRoot', 'store', 'planKey'])
|
|
@@ -478,6 +603,7 @@ export async function readTodoNoteContextsForPlan(options = {}) {
|
|
|
478
603
|
plan_key: options.planKey,
|
|
479
604
|
});
|
|
480
605
|
const chain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey });
|
|
606
|
+
const planChain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey, scope: 'plan' });
|
|
481
607
|
const migrations = await readNoteMigrations(repoRoot, options.planKey,
|
|
482
608
|
new Set(chain.events.map(({ plan_version: version }) => version)));
|
|
483
609
|
const currentTaskIds = member.plan.tasks.map(({ task_id: taskId }) => taskId);
|
|
@@ -488,6 +614,7 @@ export async function readTodoNoteContextsForPlan(options = {}) {
|
|
|
488
614
|
currentTaskId: task.task_id,
|
|
489
615
|
currentTaskIds,
|
|
490
616
|
events: chain.events,
|
|
617
|
+
planEvents: planChain.events,
|
|
491
618
|
migrations,
|
|
492
619
|
}));
|
|
493
620
|
return {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status面の`parallel_candidates`欄の素材(ob05・オーナー裁定C③)。
|
|
3
|
+
*
|
|
4
|
+
* **判定は1つも行わない。** ここに載るのは`projectIndependenceFrontier`が既に出している結果を
|
|
5
|
+
* 候補の視点で並べ直したものだけである。並列できそうな組を選ぶのはAIの仕事で、機械が持つのは
|
|
6
|
+
* 「まだ判定していないreadyはこれ」「判定済みの結果はこれ」だけ——推定・判断をLatticeの中へ
|
|
7
|
+
* 実装しない(所有境界)。
|
|
8
|
+
*
|
|
9
|
+
* `independenceForGantt`と違い、**記録が無いplanを飛ばさない**。artifactがnullの状態は
|
|
10
|
+
* 「まだ誰も判定していない」であって、この欄が最も要る場面である。飛ばすと沈黙が不在に見える。
|
|
11
|
+
*
|
|
12
|
+
* `todo status`と`lattice status`の両方が使うので、どちらのCLIにも属さない場所へ置く。
|
|
13
|
+
* gitの読みは呼び出し側から渡す(`gitHead`/`changedPathsSince`)——このmoduleがgitの
|
|
14
|
+
* 呼び方を決めると、2つのCLIが持つ既存の作法を上書きすることになる。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { projectIndependenceFrontier } from './todo-independence.mjs';
|
|
18
|
+
import { readTodoIndependenceArtifact } from './todo-store.mjs';
|
|
19
|
+
import { TODO_STATUS_DISPATCH_ONLY, computeReadyFrontier, projectTodoStatus } from './todo-status.mjs';
|
|
20
|
+
|
|
21
|
+
/** 記録が無いplanでは鮮度を見ないので、HEADの代わりに使う。project-cliと同じ作法。 */
|
|
22
|
+
const PLACEHOLDER_SHA = '0'.repeat(40);
|
|
23
|
+
|
|
24
|
+
const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
25
|
+
|
|
26
|
+
export async function readTodoParallelCandidatesForStatus(options = {}) {
|
|
27
|
+
const { repoRoot, store, gitHead, changedPathsSince = () => null } = options;
|
|
28
|
+
const frontier = computeReadyFrontier(store);
|
|
29
|
+
const status = projectTodoStatus(store, TODO_STATUS_DISPATCH_ONLY);
|
|
30
|
+
let currentBaseSha = null;
|
|
31
|
+
const candidates = [];
|
|
32
|
+
for (const member of store.members) {
|
|
33
|
+
const planKey = member.plan.plan_key;
|
|
34
|
+
const readyTaskIds = frontier.filter((task) => task.plan_key === planKey)
|
|
35
|
+
.map(({ task_id: taskId }) => taskId);
|
|
36
|
+
let artifact = null;
|
|
37
|
+
let unreadableReason = null;
|
|
38
|
+
try {
|
|
39
|
+
artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
// 読めない記録を「記録なし」へ丸めない(ADR 0131)。丸めると「壊れている」が
|
|
42
|
+
// 「まだ判定していない」と同じ形になり、沈黙が不在に見える。理由を載せて先へ進む
|
|
43
|
+
// ——1 planの壊れでstatus面ごと落とすと、他planの候補まで見えなくなる。
|
|
44
|
+
// `summarizeIndependence`(project-cli)と同じ答え方に揃える。
|
|
45
|
+
unreadableReason = error?.code
|
|
46
|
+
? `${error.code}:${error.detail?.reason ?? error.message}` : 'independence_unreadable';
|
|
47
|
+
}
|
|
48
|
+
// ready taskが1つも無く記録も無いplanは、判定する対象そのものが無い。entryごと出さない。
|
|
49
|
+
if (readyTaskIds.length === 0 && artifact === null && unreadableReason === null) continue;
|
|
50
|
+
// HEADが要るのは鮮度の判定だけである。記録が1つも無いplanでHEADを引くと、commitの無い
|
|
51
|
+
// repo(初期化直後・test fixture)で`git_head_unresolved`に落ちる——判定していない
|
|
52
|
+
// planを見るために、判定に使わない値の解決を要求してはいけない。
|
|
53
|
+
if (artifact !== null && currentBaseSha === null) {
|
|
54
|
+
try {
|
|
55
|
+
currentBaseSha = gitHead(repoRoot);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
// HEADが解決できない(commitが1つも無いrepo等)なら鮮度を判定できない。
|
|
58
|
+
// placeholderで代用すると「判定済みだが古い」と断言することになる——
|
|
59
|
+
// 知らないことを知っていると言わない。1 planの事情で面ごと落とさないのも同じ理由。
|
|
60
|
+
unreadableReason = error?.code
|
|
61
|
+
? `${error.code}:${error.detail?.reason ?? error.message}` : 'independence_base_unresolved';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (unreadableReason !== null) {
|
|
65
|
+
candidates.push({
|
|
66
|
+
plan_key: planKey,
|
|
67
|
+
// 壊れた記録から判定は読めない。coverageは名乗らず、理由を名乗る。
|
|
68
|
+
coverage: null,
|
|
69
|
+
unreadable_reason: unreadableReason,
|
|
70
|
+
unjudged_task_ids: readyTaskIds,
|
|
71
|
+
verified_parallel_groups: [],
|
|
72
|
+
serialize_pairs: [],
|
|
73
|
+
next_commands: [`lattice todo independence compile --plan ${planKey} --input <file>`],
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const baseSha = currentBaseSha ?? PLACEHOLDER_SHA;
|
|
78
|
+
const changedPaths = artifact !== null && artifact.base_sha !== null
|
|
79
|
+
&& artifact.base_sha !== baseSha
|
|
80
|
+
? changedPathsSince(repoRoot, artifact.base_sha) : null;
|
|
81
|
+
const projected = projectIndependenceFrontier({
|
|
82
|
+
artifact,
|
|
83
|
+
readyTaskIds,
|
|
84
|
+
activeTaskIds: status.active_set.filter((task) => task.plan_key === planKey)
|
|
85
|
+
.map(({ task_id: taskId }) => taskId),
|
|
86
|
+
plan: member.plan,
|
|
87
|
+
currentBaseSha: baseSha,
|
|
88
|
+
changedPaths,
|
|
89
|
+
});
|
|
90
|
+
const unjudged = projected.frontier.unknown.map(({ task_id: taskId }) => taskId);
|
|
91
|
+
// 1件だけの「並列group」は並列の情報を持たない(1つのtaskは常に自分と並列である)。
|
|
92
|
+
const groups = projected.frontier.parallel_groups
|
|
93
|
+
.filter((group) => group.task_ids.length > 1)
|
|
94
|
+
.map(({ task_ids: taskIds }) => ({ task_ids: taskIds }));
|
|
95
|
+
const pairs = projected.frontier.serialize_pairs.map((pair) => ({
|
|
96
|
+
task_ids: pair.task_ids, type: pair.type, detail: pair.detail,
|
|
97
|
+
}));
|
|
98
|
+
if (unjudged.length === 0 && groups.length === 0 && pairs.length === 0) continue;
|
|
99
|
+
candidates.push({
|
|
100
|
+
plan_key: planKey,
|
|
101
|
+
coverage: projected.coverage,
|
|
102
|
+
unreadable_reason: null,
|
|
103
|
+
unjudged_task_ids: unjudged,
|
|
104
|
+
verified_parallel_groups: groups,
|
|
105
|
+
serialize_pairs: pairs,
|
|
106
|
+
// 欄だけ置いて閉じない。未判定が残るなら宣言してcompile、済んでいるなら読み出し。
|
|
107
|
+
next_commands: unjudged.length > 0
|
|
108
|
+
? [`lattice todo independence compile --plan ${planKey} --input <file>`]
|
|
109
|
+
: [`lattice todo independence --plan ${planKey} --json`],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
candidates.sort((left, right) => compareText(left.plan_key, right.plan_key));
|
|
113
|
+
return candidates;
|
|
114
|
+
}
|