@quolu/lattice 0.35.0 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/todo-cli.mjs CHANGED
@@ -37,6 +37,8 @@ import {
37
37
  applyTodoRevisionSet,
38
38
  createTodoStoreWriter,
39
39
  TodoStoreError,
40
+ isPhaselessTodoPlanSchema,
41
+ TERMINAL_AUDIT_PHASE_ID,
40
42
  readTodoIndependenceArtifact,
41
43
  readTodoSeamProposalArtifact,
42
44
  readTodoStore,
@@ -52,6 +54,7 @@ import {
52
54
  } from './todo-store.mjs';
53
55
  import {
54
56
  appendTodoExtraction,
57
+ explainTodoExtraction,
55
58
  validateTodoExtraction,
56
59
  } from './todo-migration.mjs';
57
60
  import {
@@ -97,6 +100,7 @@ import {
97
100
  validateWitnessDraft,
98
101
  } from './witness-scaffold.mjs';
99
102
  import {
103
+ explainPhaseTodoRevision, explainTodoRevision, explainTodoRevisionSet,
100
104
  parseTodoSourceRef, todoLegacyReconciliationDigest, validatePhaseTodoRevision,
101
105
  validateTodoRevision, validateTodoRevisionSet,
102
106
  } from './todo-revision.mjs';
@@ -113,6 +117,31 @@ const ACTOR_ENV_KEYS = Object.freeze([
113
117
  'LATTICE_TODO_ACTOR_AGENT',
114
118
  ]);
115
119
 
120
+ /**
121
+ * `revise` / `revise-set` / `revise-phase` / `migrate`が実際に受理する最新契約のJSON
122
+ * Schemaを配布物から読む入口(`project-cli.mjs`の`runPlanCreateSchema`と同じ作法)。
123
+ *
124
+ * schemaを取る手段がCLIに無いと、AIはsrcを読んで必須keyを数えるしかなくなる
125
+ * (実運用で`phase_todo_revision.v3`の必須12 keyを試行錯誤で当てた)。storeは読まない
126
+ * ——`plan create --schema`と同じく決定的な出力・exit 0にする。
127
+ */
128
+ const TODO_SCHEMA_COMMANDS = Object.freeze({
129
+ revise: { title: 'lattice.todo_revision.v2', file: 'lattice.todo_revision.v2.schema.json' },
130
+ 'revise-set': { title: 'lattice.todo_revision_set.v3', file: 'lattice.todo_revision_set.v3.schema.json' },
131
+ 'revise-phase': {
132
+ title: 'lattice.phase_todo_revision.v3', file: 'lattice.phase_todo_revision.v3.schema.json',
133
+ },
134
+ migrate: { title: 'lattice.todo_extraction.v2', file: 'lattice.todo_extraction.v2.schema.json' },
135
+ });
136
+
137
+ async function runTodoSchemaCommand(command, stdout) {
138
+ const spec = TODO_SCHEMA_COMMANDS[command];
139
+ const schemaUrl = new URL(`../docs/schemas/${spec.file}`, import.meta.url);
140
+ const schema = JSON.parse(await readFile(schemaUrl, 'utf8'));
141
+ if (schema?.title !== spec.title) throw new TypeError(`bundled ${command} schema invalid`);
142
+ stdout.write(`${JSON.stringify(schema)}\n`);
143
+ }
144
+
116
145
  function usageFailure(stderr, argv) {
117
146
  const received = argv.length === 0 ? '(none)' : argv.join(' ').replace(/[\r\n]/gu, ' ');
118
147
  stderr.write(`lattice todo: unsupported command or arguments: ${received}\n`);
@@ -223,7 +252,11 @@ async function readMigrationInput(repoRoot, inputRef) {
223
252
  throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
224
253
  }
225
254
  if (!validateTodoExtraction(extraction)) {
226
- throw new TodoStoreError('INVALID_TODO_EXTRACTION', 'schema_invalid');
255
+ // 「schema_invalid」だけでは何のfieldがどう壊れているか分からない(ADR 0130の案内規律)。
256
+ // explainは可否判定を変えず、診断だけを追加する。
257
+ const explained = explainTodoExtraction(extraction);
258
+ throw new TodoStoreError('INVALID_TODO_EXTRACTION', 'schema_invalid', undefined,
259
+ explained.valid ? undefined : { violation_reason: explained.reason, violation_path: explained.path });
227
260
  }
228
261
  return extraction;
229
262
  }
@@ -232,6 +265,7 @@ async function readRevisionInput(repoRoot, inputRef, {
232
265
  validate = validateTodoRevision,
233
266
  invalidCode = 'REVISION_INVALID',
234
267
  invalidReason = 'revision_schema_or_digest_invalid',
268
+ explain = explainTodoRevision,
235
269
  } = {}) {
236
270
  const canonicalRoot = await realpath(repoRoot);
237
271
  const absolute = path.resolve(canonicalRoot, inputRef);
@@ -268,7 +302,16 @@ async function readRevisionInput(repoRoot, inputRef, {
268
302
  try { revision = JSON.parse(text.slice(0, -1)); } catch {
269
303
  throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
270
304
  }
271
- if (!validate(revision)) throw new TodoStoreError(invalidCode, invalidReason);
305
+ if (!validate(revision)) {
306
+ // 「schema_or_digest_invalid」だけでは何のfieldがどう壊れているか分からない
307
+ // (ADR 0130の案内規律)。explainは可否判定を変えず、診断だけを追加する。
308
+ // 呼び出し元がexplainを渡さない(phase decision入力等)場合はdetail無しのまま。
309
+ const explained = explain === null ? null : explain(revision);
310
+ throw new TodoStoreError(invalidCode, invalidReason, undefined,
311
+ explained === null || explained.valid ? undefined : {
312
+ violation_reason: explained.reason, violation_path: explained.path,
313
+ });
314
+ }
272
315
  if (text !== `${canonicalizeTodoArtifact(revision)}\n`) {
273
316
  throw new TodoStoreError(invalidCode, 'non_canonical_revision_bytes');
274
317
  }
@@ -373,6 +416,25 @@ function mutationActor(env) {
373
416
  return { host: entries[0].value, session: entries[1].value, agent: entries[2].value };
374
417
  }
375
418
 
419
+ /**
420
+ * phase無しplanで、この変異の結果terminal-audit Phaseがgate_ready(全task done・未監査)に
421
+ * なっていれば助言を返す(ADR 0147)。doneの結果だけを見て機械的に判定するので、既にreview
422
+ * まで進んでいれば`gate_ready`ではなくなり、二重に案内しない。phase付きplanや、まだ
423
+ * pending taskが残っているplanではterminal-audit Phase自体が無い/gate_readyでないので、
424
+ * このヘルパはnullを返し既存の`advisory: null`の挙動を変えない。
425
+ */
426
+ function terminalAuditDoneAdvisory(plan, phases) {
427
+ if (!isPhaselessTodoPlanSchema(plan.schema)) return null;
428
+ const phase = phases.find(({ phase_id }) => phase_id === TERMINAL_AUDIT_PHASE_ID);
429
+ if (phase?.status !== 'gate_ready') return null;
430
+ return {
431
+ terminal_audit_required: true, phase_id: TERMINAL_AUDIT_PHASE_ID, status: phase.status,
432
+ guidance: '全taskがdoneになった。このplanはphaseを持たないため、終端の重監査'
433
+ + '(todo phase review --plan <key> --phase terminal-audit → todo phase accept)を'
434
+ + '経るまで「閉じた」ことにはならない。',
435
+ };
436
+ }
437
+
376
438
  async function mutate({
377
439
  repoRoot, env, planKey, taskId, kind, payload, evidenceRef, advisory = null,
378
440
  }) {
@@ -383,13 +445,18 @@ async function mutate({
383
445
  if (kind === 'done' && payload === 'evidence_promotion') {
384
446
  eventPayload = { done_mode: 'evidence_promotion', imported: true, evidence };
385
447
  }
386
- const { event, snapshot } = await appendTodoEvent({
448
+ const { event, snapshot, plan, phases } = await appendTodoEvent({
387
449
  repoRoot,
388
450
  writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
389
451
  planKey,
390
452
  event: { kind, task_id: taskId, actor, payload: eventPayload },
391
453
  });
392
454
  const task = snapshot.tasks.find(({ task_id: current }) => current === event.task_id);
455
+ // advisoryは呼び出し側(startTask)がstart用に既に組んでいればそれを尊重し、無ければ
456
+ // done時だけ終端監査の要否を調べる。block/unblock/reopenはnullのまま(既存挙動を変えない)。
457
+ // Phase状態はsnapshot(v1にはphasesキーが無い)でなく、appendTodoEventが別途返す
458
+ // 導出ビュー`phases`から読む。
459
+ const resolvedAdvisory = advisory ?? (kind === 'done' ? terminalAuditDoneAdvisory(plan, phases) : null);
393
460
  const result = {
394
461
  schema: 'lattice.todo_mutation_result.v2',
395
462
  project_id: event.project_id,
@@ -402,7 +469,7 @@ async function mutate({
402
469
  journal_head_digest: event.event_digest,
403
470
  snapshot_digest: snapshot.snapshot_digest,
404
471
  status: task.status,
405
- advisory,
472
+ advisory: resolvedAdvisory,
406
473
  result_digest: '',
407
474
  };
408
475
  result.result_digest = todoSelfDigest(result, 'result_digest');
@@ -599,6 +666,8 @@ async function phaseDecision({ repoRoot, env, planKey, phaseId, outcome, inputRe
599
666
  const input = await readRevisionInput(repoRoot, inputRef, {
600
667
  validate: (value) => validatePhaseDecisionInput(value, outcome),
601
668
  invalidCode: 'PHASE_DECISION_INVALID', invalidReason: 'phase_decision_schema_or_digest_invalid',
669
+ // phase decision入力はrevision契約と別形状。既定のrevision explainを誤って当てない。
670
+ explain: null,
602
671
  });
603
672
  const payload = outcome === 'accept'
604
673
  ? { review_event_digest: input.review_event_digest, decision_evidence: input.decision_evidence,
@@ -609,11 +678,13 @@ async function phaseDecision({ repoRoot, env, planKey, phaseId, outcome, inputRe
609
678
  }
610
679
 
611
680
  async function phaseMutation({ repoRoot, env, planKey, phaseId, kind, payload }) {
612
- const { event, snapshot } = await appendTodoEvent({
681
+ const { event, snapshot, phases } = await appendTodoEvent({
613
682
  repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }), planKey,
614
683
  event: { kind, phase_id: phaseId, actor: mutationActor(env), payload },
615
684
  });
616
- const phase = snapshot.phases?.find(({ phase_id: current }) => current === phaseId);
685
+ // snapshot.phasesはv1(phase無しplan)には存在しない。導出ビュー`phases`を見る
686
+ // (これは暗黙のterminal-audit Phaseにも常に埋まっている)。
687
+ const phase = phases.find(({ phase_id: current }) => current === phaseId);
617
688
  if (phase === undefined) throw new TodoStoreError('STORE_INCONSISTENT', 'phase_not_active');
618
689
  const result = {
619
690
  schema: 'lattice.phase_mutation_result.v1', project_id: event.project_id,
@@ -629,14 +700,16 @@ async function phaseMutation({ repoRoot, env, planKey, phaseId, kind, payload })
629
700
  async function phaseStatus({ repoRoot, planKey }) {
630
701
  const store = await readTodoStore({ repoRoot });
631
702
  const [member] = selectMembers(store, planKey);
632
- if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(member.plan.schema)) {
633
- throw new TodoStoreError('PHASE_UNAVAILABLE', 'plan_has_no_phase_contract');
634
- }
703
+ // ADR 0147以降、phase無しplan(v1/v2/v3)もreadTodoStoreが導出済みの暗黙terminal-audit
704
+ // Phaseをmember.phasesへ積んでいる(snapshot artifactの形式は変えない・v1にはphasesキーが
705
+ // 無いのでsnapshot.phasesは直接読まない)。ここでPHASE_UNAVAILABLEへ拒否せず、その暗黙Phase
706
+ // をそのまま返す——`implicit`で機械可読に「宣言されたPhaseではない」ことを示す。
707
+ const implicit = isPhaselessTodoPlanSchema(member.plan.schema);
635
708
  const result = {
636
709
  schema: 'lattice.phase_status_result.v1', project_id: store.project_id,
637
710
  plan_key: member.plan.plan_key, plan_version: member.plan.plan_version,
638
711
  journal_head_digest: member.journal.events.at(-1).event_digest,
639
- phases: member.snapshot.phases, result_digest: '',
712
+ implicit, phases: member.phases, result_digest: '',
640
713
  };
641
714
  result.result_digest = todoSelfDigest(result, 'result_digest');
642
715
  return result;
@@ -689,6 +762,10 @@ async function migrate({ repoRoot, inputRef, serializationReviewed = false }) {
689
762
  max_frontier_width: dispatchShape.max_frontier_width,
690
763
  serialization_ratio: dispatchShape.serialization_ratio,
691
764
  },
765
+ // ADR 0147裁定3: phase無しplanの作成は拒否せず、終端監査が要ることを結果へ明示するに
766
+ // 留める。extraction経由のmigrateは常にphase無しplan(todo_plan.v2)を作るが、将来の
767
+ // 拡張に備えisPhaselessTodoPlanSchemaで動的に判定する。
768
+ terminal_audit_required: isPhaselessTodoPlanSchema(imported.plan.schema),
692
769
  result_digest: '',
693
770
  };
694
771
  result.result_digest = todoSelfDigest(result, 'result_digest');
@@ -711,6 +788,7 @@ async function reviseSet({ repoRoot, env, inputRef }) {
711
788
  validate: validateTodoRevisionSet,
712
789
  invalidCode: 'REVISION_SET_INVALID',
713
790
  invalidReason: 'revision_set_schema_invalid',
791
+ explain: explainTodoRevisionSet,
714
792
  });
715
793
  return applyTodoRevisionSet({
716
794
  repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }), revisionSet,
@@ -722,6 +800,7 @@ async function revisePhase({ repoRoot, env, planKey, inputRef }) {
722
800
  const revision = await readRevisionInput(repoRoot, inputRef, {
723
801
  validate: validatePhaseTodoRevision, invalidCode: 'REVISION_INVALID',
724
802
  invalidReason: 'phase_revision_schema_or_digest_invalid',
803
+ explain: explainPhaseTodoRevision,
725
804
  });
726
805
  if (revision.plan_key !== planKey) throw new TodoStoreError('REVISION_INVALID', 'requested_plan_mismatch');
727
806
  return applyPhaseTodoRevision({ repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
@@ -1922,6 +2001,20 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
1922
2001
  throw new TypeError('runTodoCli optionsが不正');
1923
2002
  }
1924
2003
 
2004
+ // `--schema --json`はstoreを読まない決定的な出力(`plan create --schema`と同じ規律)。
2005
+ // 通常dispatchより前に処理し、repoRoot解決やdashboard daemon起動を経由させない。
2006
+ if (argv.length === 3 && argv[1] === '--schema' && argv[2] === '--json'
2007
+ && Object.hasOwn(TODO_SCHEMA_COMMANDS, argv[0])) {
2008
+ try {
2009
+ await runTodoSchemaCommand(argv[0], stdout);
2010
+ return 0;
2011
+ } catch (error) {
2012
+ return typedFailure(stderr, {
2013
+ code: 'INTERNAL_FAILURE', message: error?.constructor?.name ?? 'Error',
2014
+ });
2015
+ }
2016
+ }
2017
+
1925
2018
  let action = null;
1926
2019
  if ((argv.length === 1 && argv[0] === 'status')
1927
2020
  || (argv.length === 2 && argv[0] === 'status' && argv[1] === '--json')) {
@@ -375,8 +375,8 @@ function validStateMigration(value) {
375
375
  'from_task_id', 'to_task_id', 'state_policy', 'state',
376
376
  ]) && isTodoIdentifier(entry.from_task_id)
377
377
  && (entry.to_task_id === 'removed' || isTodoIdentifier(entry.to_task_id))
378
- && ['carry', 'carry_reconciled_metadata', 'reset_pending', 'removed'].includes(entry.state_policy)
379
- && ((['carry', 'carry_reconciled_metadata'].includes(entry.state_policy)
378
+ && ['carry', 'carry_reconciled_metadata', 'reset_pending', 'removed', 'acquire_phase'].includes(entry.state_policy)
379
+ && ((['carry', 'carry_reconciled_metadata', 'acquire_phase'].includes(entry.state_policy)
380
380
  && entry.to_task_id !== 'removed' && validCarriedState(entry.state))
381
381
  || (entry.state_policy === 'reset_pending' && entry.to_task_id !== 'removed' && entry.state === null)
382
382
  || (entry.state_policy === 'removed' && entry.to_task_id === 'removed' && entry.state === null)))
@@ -117,7 +117,8 @@ export function renderPhaseProgress(readModel) {
117
117
  const settledRows = [];
118
118
  for (const member of readModel.members) {
119
119
  if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(member.plan.schema)) continue;
120
- const phases = new Map(member.snapshot.phases.map((phase) => [phase.phase_id, phase]));
120
+ // snapshot artifactの形式には縛られない導出ビュー(member.phases)を読む(ADR 0147)
121
+ const phases = new Map(member.phases.map((phase) => [phase.phase_id, phase]));
121
122
  for (const phase of member.plan.phases) {
122
123
  const tasks = member.plan.tasks.filter((task) => task.phase_id === phase.phase_id);
123
124
  const states = new Map(member.tasks.map((task) => [task.task_id, task.status]));
@@ -153,7 +153,9 @@ function normalizeInput(readModel, chainProjection) {
153
153
  }
154
154
  const { plan } = member;
155
155
  const statusByTask = new Map(member.tasks.map((task) => [task.task_id, task]));
156
- const statusByPhase = new Map((member.snapshot?.phases ?? [])
156
+ // snapshot artifactの形式(v1にはphasesキーが無い)には縛られない導出ビューを読む
157
+ // (readTodoStoreが常にmember.phasesとして埋める。ADR 0147)。
158
+ const statusByPhase = new Map((member.phases ?? [])
157
159
  .map((phase) => [phase.phase_id, phase.status]));
158
160
  for (const task of plan.tasks) {
159
161
  if (!plain(task) || typeof task.task_id !== 'string' || typeof task.lane !== 'string') {
@@ -174,7 +176,14 @@ function normalizeInput(readModel, chainProjection) {
174
176
  status: state.status,
175
177
  plan_schema: plan.schema ?? null,
176
178
  phase_id: task.phase_id ?? null,
177
- phase_status: task.phase_id === undefined ? null : statusByPhase.get(task.phase_id) ?? null,
179
+ // ADR 0147: phase無しplan(task.phase_idが無い世代)も、終端に暗黙のterminal-audit
180
+ // Phaseを1つ持つ(todo-store.mjsのphasesOf/TERMINAL_AUDIT_PHASE_ID)。ここで素通しせず
181
+ // nullのままにすると、gantt scope側(todo-gantt-scope.mjs)が「監査未了のplanを畳まない」
182
+ // 判定に使える材料を一切受け取れない。'terminal-audit'はTERMINAL_AUDIT_PHASE_IDと同じ
183
+ // 予約IDで、phase_readyの判定(v4だけを見る既存分岐)には影響しない。
184
+ phase_status: task.phase_id === undefined
185
+ ? statusByPhase.get('terminal-audit') ?? null
186
+ : statusByPhase.get(task.phase_id) ?? null,
178
187
  phase_ready: plan.schema !== 'lattice.todo_plan.v4'
179
188
  || statusByPhase.get(task.phase_id) === 'active',
180
189
  });
@@ -236,7 +245,8 @@ function readyTaskKeys(readModel, nodes, nodesByKey, incoming) {
236
245
  const phaseStatuses = new Map();
237
246
  const phaseAcceptIncoming = new Map(nodes.map(({ key }) => [key, new Set()]));
238
247
  for (const member of readModel.members) {
239
- for (const phase of member.snapshot?.phases ?? []) {
248
+ // snapshot artifactの形式には縛られない導出ビュー(member.phases)を読む(ADR 0147)
249
+ for (const phase of member.phases ?? []) {
240
250
  phaseStatuses.set(JSON.stringify([
241
251
  member.plan.project_id, member.plan.plan_key, phase.phase_id,
242
252
  ]), phase.status);
@@ -38,10 +38,35 @@ function compareText(left, right) {
38
38
  return left < right ? -1 : left > right ? 1 : 0;
39
39
  }
40
40
 
41
+ /**
42
+ * ADR 0147: phase無しplan(v1/v2/v3)は終端の暗黙Phase(terminal-audit)がacceptedになるまで
43
+ * 「閉じた」ことにならない。全taskがdoneでも監査未了なら、そのplanのToDoは生きた作業と同じ
44
+ * 扱い(distance 0)にして畳ませない——畳んでしまうと監査待ちであることが図から消え、
45
+ * ADR 0147が塞ごうとした「一度も重監査を通らず完走した」事故と外形が同じになる。
46
+ *
47
+ * v4/v5(phaseを宣言したplan)はこの判定の対象外にする——既存のPhase gateが重監査を担って
48
+ * おり、ここで同じ規律を足すとPhase単位の既存fold挙動を変えてしまう(非目標)。
49
+ * `phase_status`はレイアウト層(todo-gantt-layout.mjs)がplanの世代を問わず埋める。
50
+ * phase無しplanのtaskにはphase_idフィールド自体が無いため、そこでは暗黙Phaseの状態を
51
+ * 埋める。フィールドが無い/nullの入力(既存test・素のnode)は従来どおり対象外(false)になる。
52
+ *
53
+ * 対象は`gate_ready`(全task doneで監査待ち)・`reviewing`(監査中)・`rejected`
54
+ * (監査が通らず要フォロー)の3状態だけに絞る。`active`(一部taskがまだpending)は、
55
+ * 他のtaskが図に残っている限りplanが未完了だと分かるので対象にしない——ここまで
56
+ * 広げると、完走していない枝の通常foldまで止めてしまい既存挙動を変える。
57
+ */
58
+ const AUDIT_PENDING_PHASE_STATUSES = new Set(['gate_ready', 'reviewing', 'rejected']);
59
+ function auditPending(node) {
60
+ if (!AUDIT_PENDING_PHASE_STATUSES.has(node.phase_status ?? null)) return false;
61
+ const schema = node.plan_schema ?? null;
62
+ return schema !== 'lattice.todo_plan.v4' && schema !== 'lattice.todo_plan.v5';
63
+ }
64
+
41
65
  /**
42
66
  * Forward distance from each node to the nearest live (non-done) node, over the
43
67
  * dependency DAG. A live node is at distance 0; a node with no live descendant
44
- * is at Infinity.
68
+ * is at Infinity. A done node whose plan's terminal audit (ADR 0147) has not
69
+ * been accepted is also pinned at distance 0 — it must not fold away silently.
45
70
  *
46
71
  * Edges always increase the wave (`assignWaves` is a longest-path layering), so
47
72
  * visiting nodes in descending wave order guarantees every successor is settled
@@ -54,7 +79,7 @@ function distanceToLive(nodes, edges, wave) {
54
79
  const ordered = [...nodes].sort((left, right) => wave.get(right.key) - wave.get(left.key)
55
80
  || compareText(right.key, left.key));
56
81
  for (const node of ordered) {
57
- if (node.status !== 'done') {
82
+ if (node.status !== 'done' || auditPending(node)) {
58
83
  distance.set(node.key, 0);
59
84
  continue;
60
85
  }
@@ -126,6 +126,114 @@ function validateJoins(value) {
126
126
  && sortedStrictly(value, (join) => join.id);
127
127
  }
128
128
 
129
+ const reject = (reason, path = '') => ({ valid: false, reason, path });
130
+
131
+ /** value自体がexactRecordの対象になれる素朴なobjectかどうか(配列・nullを除く)。 */
132
+ function plainObject(value) {
133
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
134
+ }
135
+
136
+ /**
137
+ * 期待keyとの過不足を1件ずつ言い当てる。missing/unexpectedのどちらが先に見つかっても
138
+ * そこで止め、複数の欠落を一度に説明しない——`exactRecord`のbooleanと違い、
139
+ * 最初に見つかった違反fieldをpathへ刻む。
140
+ */
141
+ function explainKeys(value, requiredKeys, at) {
142
+ if (!plainObject(value) || Object.getPrototypeOf(value) !== Object.prototype) {
143
+ return reject('not_an_object', at);
144
+ }
145
+ const actualKeys = new Set(Object.keys(value));
146
+ for (const key of requiredKeys) {
147
+ if (!actualKeys.has(key)) return reject('missing_required_key', `${at}/${key}`);
148
+ }
149
+ const requiredSet = new Set(requiredKeys);
150
+ for (const key of actualKeys) {
151
+ if (!requiredSet.has(key)) return reject('unexpected_key', `${at}/${key}`);
152
+ }
153
+ return { valid: true };
154
+ }
155
+
156
+ function explainSortedStrictly(values, key, at) {
157
+ for (let index = 1; index < values.length; index += 1) {
158
+ if (!(key(values[index - 1]) < key(values[index]))) {
159
+ return reject('unsorted_or_duplicate_collection', `${at}/${index}`);
160
+ }
161
+ }
162
+ return { valid: true };
163
+ }
164
+
165
+ /**
166
+ * `lattice.todo_extraction.v1/v2`を、既存の`validateTodoExtraction`の可否は変えずに
167
+ * 診断する(ADR 0130の案内規律をmigration入口へ拡張)。
168
+ *
169
+ * 深いgraph整合(親task解決・edge/join local参照解決)はここでは個別に言い当てず、
170
+ * 単一のreasonへ丸める——既知の実運用の詰まりどころ(必須key欠落・task/edge/joinの
171
+ * ソート違反・digest不一致)を優先して解消する。それでも掴めない違反は
172
+ * `diagnosis_incomplete`として正直に返す。
173
+ */
174
+ export function explainTodoExtraction(value) {
175
+ try {
176
+ if (!plainObject(value)) return reject('not_an_object', '');
177
+ const schema = value.schema;
178
+ if (![TODO_EXTRACTION_SCHEMA, TODO_EXTRACTION_SCHEMA_V2].includes(schema)) {
179
+ return reject('schema_mismatch', '/schema');
180
+ }
181
+ const v2 = schema === TODO_EXTRACTION_SCHEMA_V2;
182
+ const topKeys = [
183
+ 'schema', 'project_id', 'plan_key', 'plan_version', 'actor', 'recorded_at',
184
+ 'tasks', 'hard_dependencies', 'joins', 'extraction_digest',
185
+ ];
186
+ const topKeyCheck = explainKeys(value, topKeys, '');
187
+ if (!topKeyCheck.valid) return topKeyCheck;
188
+ if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
189
+ if (!isTodoIdentifier(value.plan_key)) return reject('invalid_identifier', '/plan_key');
190
+ if (!isTodoIdentifier(value.plan_version)) return reject('invalid_identifier', '/plan_version');
191
+ if (!actor(value.actor)) return reject('invalid_actor', '/actor');
192
+ if (!isStrictTodoTimestamp(value.recorded_at)) return reject('invalid_timestamp', '/recorded_at');
193
+ if (!Array.isArray(value.tasks) || value.tasks.length === 0
194
+ || value.tasks.length > TODO_LIMITS.tasksPerPlan) {
195
+ return reject('bounded_collection_violation', '/tasks');
196
+ }
197
+ const taskKeys = v2
198
+ ? ['task_id', 'title', 'lane', 'narrative_ref', 'compile_binding', 'disposition',
199
+ 'start', 'completion', 'source', 'migration_context']
200
+ : ['task_id', 'title', 'lane', 'narrative_ref', 'compile_binding', 'disposition',
201
+ 'completion', 'source', 'migration_context'];
202
+ for (const [index, task] of value.tasks.entries()) {
203
+ const taskKeyCheck = explainKeys(task, taskKeys, `/tasks/${index}`);
204
+ if (!taskKeyCheck.valid) return taskKeyCheck;
205
+ if (!extractionTask(task, schema)) {
206
+ return reject('task_shape_invalid', `/tasks/${index}`);
207
+ }
208
+ }
209
+ const sortCheck = explainSortedStrictly(value.tasks, (task) => task.task_id, '/tasks');
210
+ if (!sortCheck.valid) return sortCheck;
211
+ if (new Set(value.tasks.map(({ task_id: taskId }) => taskId)).size !== value.tasks.length) {
212
+ return reject('duplicate_task_id', '/tasks');
213
+ }
214
+ if (!validateEdges(value.hard_dependencies)) return reject('hard_dependencies_invalid', '/hard_dependencies');
215
+ if (!validateJoins(value.joins)) return reject('joins_invalid', '/joins');
216
+ if (!isTodoDigest(value.extraction_digest)) return reject('invalid_digest', '/extraction_digest');
217
+ const expectedDigest = todoSelfDigest(value, 'extraction_digest');
218
+ if (value.extraction_digest !== expectedDigest) {
219
+ return reject('extraction_digest_mismatch', '/extraction_digest');
220
+ }
221
+ const taskIds = new Set(value.tasks.map(({ task_id: taskId }) => taskId));
222
+ const badParent = value.tasks.find((task) => task.source.parent_task_id === task.task_id
223
+ || (task.source.parent_task_id !== null && !taskIds.has(task.source.parent_task_id)));
224
+ if (badParent !== undefined) {
225
+ return reject('parent_task_id_unresolved',
226
+ `/tasks/${value.tasks.indexOf(badParent)}/source/parent_task_id`);
227
+ }
228
+ if (!localRefsResolve(value)) return reject('local_ref_unresolved', '');
229
+ // ここまでの個別検査を全て通過したのに`validateTodoExtraction`がfalseを返す状況は、
230
+ // このexplainがまだ言い当てられない違反があるということ。捏造せず未特定と申告する。
231
+ return { valid: true };
232
+ } catch {
233
+ return reject('diagnosis_failed', '');
234
+ }
235
+ }
236
+
129
237
  function registeredTaskIds(value) {
130
238
  return new Set(value.tasks
131
239
  .filter(({ disposition }) => disposition.startsWith('register_'))