@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.
@@ -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 { root, active: path.join(root, 'active.jsonl'), sealed: path.join(root, 'sealed') };
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
- /** planに属する独立note chainをbyte-levelで検証して読む。missingだけは空chainである。 */
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 { active, sealed } = notePaths(repoRoot, options.planKey);
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, options.taskId]
190
- .every(isTodoIdentifier)) throw new TypeError('todo note append options invalid');
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
- const chain = await readTodoNoteEvents({ repoRoot, planKey: options.planKey });
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
- fail('NOTE_SUPERSEDES_INVALID', 'superseded_note_not_in_same_task', {
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 = notePaths(repoRoot, options.planKey);
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 current = [];
375
+ const taskCurrent = [];
324
376
  const archived = [];
325
- const sequenceByDigest = new Map(options.events.map((event) => [event.event_digest, event.sequence]));
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) current.push(entry);
386
+ else if (target.taskId === options.currentTaskId) taskCurrent.push(entry);
333
387
  }
334
- current.sort((left, right) => sequenceByDigest.get(right.event_digest)
335
- - sequenceByDigest.get(left.event_digest));
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
- note_head_digest: current[0]?.event_digest ?? null,
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
- full_history_command: `lattice todo note list --plan ${options.planKey}`
355
- + ` --task ${options.currentTaskId} --json`,
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
+ }