@quolu/lattice 0.39.0 → 0.39.2

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 { projectTodoStatus } from '../src/todo-status.mjs';
8
8
  import { ganttLiveHeadDigest, renderPublicTodoGanttForProject } from '../src/todo-cli.mjs';
9
9
  import {
10
10
  readVisibleTodoDashboardProjects,
11
+ todoDashboardMemberNeedsVisibility,
11
12
  writeTodoDashboardDaemonDescriptor,
12
13
  } from '../src/todo-dashboard-registry.mjs';
13
14
  import {
@@ -58,7 +59,9 @@ async function synchronize() {
58
59
  const active = await readVisibleTodoDashboardProjects({ env,
59
60
  projectHasActiveRun: async (entry) => {
60
61
  try {
61
- const active = projectTodoStatus(await readCachedStore(entry.repo_root)).active_set.length > 0;
62
+ const store = await readCachedStore(entry.repo_root);
63
+ const active = projectTodoStatus(store).active_set.length > 0
64
+ || store.members.some(todoDashboardMemberNeedsVisibility);
62
65
  reportedStoreReadFailures.delete(entry.project_id);
63
66
  return active;
64
67
  } catch (error) {
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "text": { "type": "string", "minLength": 1, "maxLength": 16384 },
49
49
  "designMemo": {
50
- "type": "string", "minLength": 1, "maxLength": 262144, "pattern": "\\S",
50
+ "type": "string", "minLength": 1, "maxLength": 16384, "pattern": "\\S",
51
51
  "description": "Markdown design memo. Use the literal NO_PLAN only when no plan exists."
52
52
  },
53
53
  "nullableText": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/text" }] },
@@ -158,7 +158,7 @@
158
158
  "task_id": { "$ref": "#/$defs/identifier" },
159
159
  "title": { "$ref": "#/$defs/nullableText" },
160
160
  "lane": { "$ref": "#/$defs/identifier" },
161
- "design_memo": { "type": "string", "minLength": 1, "maxLength": 262144, "pattern": "\\S" },
161
+ "design_memo": { "type": "string", "minLength": 1, "maxLength": 16384, "pattern": "\\S" },
162
162
  "narrative_ref": { "$ref": "#/$defs/nullableRepoRef" },
163
163
  "narrative_anchor": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/narrativeAnchor" }] },
164
164
  "compile_binding": { "$ref": "#/$defs/compileBinding" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.39.0",
3
+ "version": "0.39.2",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
package/src/cli-help.mjs CHANGED
@@ -26,7 +26,7 @@ Commands:
26
26
  create --input <file> [--serialization-reviewed]
27
27
  # 依存グラフがほぼ一直線(serialization_ratioが閾値超)なら一度突き返す。
28
28
  # 再考した上でなお直列でよいなら --serialization-reviewed を付けて再実行する
29
- create --schema --json # 既定は最新版(v3)のJSON Schemaを返す
29
+ create --schema --json # 既定は最新版(v4)のJSON Schemaを返す
30
30
  create --schema-version <1|2|3|4> --json
31
31
  show <plan_key> --json # task・依存・phase・状態をplan本体から1コマンドで投影する
32
32
  compile --request <request.json>
@@ -182,6 +182,9 @@ const SUBCOMMAND_USAGE = Object.freeze({
182
182
  'todo verify': 'todo verify [--plan <key>] [--json]',
183
183
  'todo snapshot': 'todo snapshot --rebuild --plan <key>',
184
184
  'todo gantt': 'todo gantt serve --port <port> [--scope live|all] # 動的表示のみ。静的HTML生成は廃止',
185
+ 'todo gantt serve': 'todo gantt serve --port <0..65535> [--scope live|all] # loopback動的viewer',
186
+ 'todo dashboard': 'todo dashboard adopt --json # 配信元rootの衝突を明示的に解消',
187
+ 'todo dashboard adopt': 'todo dashboard adopt --json # 現在repoをproject_idの配信元として明示採用',
185
188
  'todo phase': 'todo phase <status|review|accept|reject|reopen|close-unaudited> --plan <key> [options]'
186
189
  + ' | baseline --reason <text> [--except <plan_key>]...',
187
190
  'todo phase status': 'todo phase status --plan <key>',
@@ -7,6 +7,7 @@ import {
7
7
  TODO_DESIGN_MEMO_PROMPT,
8
8
  canonicalizeTodoArtifact,
9
9
  exactRecord,
10
+ explainTodoDesignMemo,
10
11
  isTodoDesignMemo,
11
12
  isStrictTodoTimestamp,
12
13
  isTodoDigest,
@@ -196,14 +197,16 @@ function statusResult(fields) {
196
197
  return result;
197
198
  }
198
199
 
199
- function invalidStatus({ cliVersion, repoRoot, reason }) {
200
+ function invalidStatus({ cliVersion, repoRoot, reason, nextAction = null }) {
200
201
  return statusResult({
201
202
  cli: { available: true, version: cliVersion },
202
203
  project: repoRoot === null ? null : { root: repoRoot, git_head: gitHead(repoRoot), project_id: null },
203
204
  state: 'invalid',
204
205
  store: { ref: STORE_REF, absolute_path: repoRoot === null ? null : path.join(repoRoot, STORE_REF) },
205
206
  active_plans: [], active_runs: [], can_create_plan: false,
206
- next_action: { command: repoRoot === null ? 'git status' : 'lattice todo verify', reason },
207
+ next_action: nextAction ?? {
208
+ command: repoRoot === null ? 'git status' : 'lattice todo verify', reason,
209
+ },
207
210
  });
208
211
  }
209
212
 
@@ -448,14 +451,43 @@ export async function runPlanCreate({ cwd, inputRef, stdout, serializationReview
448
451
  const repoRoot = resolveRepoRoot(cwd);
449
452
  if (repoRoot === null) throw new TodoStoreError('REPO_UNRESOLVED', 'git_toplevel_unresolved');
450
453
  const input = await readCanonicalInput(repoRoot, inputRef);
451
- if (input?.schema !== MEMO_PHASE_CREATE_INPUT_SCHEMA
452
- || !Array.isArray(input.tasks) || input.tasks.some((task) => !isTodoDesignMemo(task?.design_memo))) {
453
- throw new TodoStoreError('DESIGN_MEMO_REQUIRED', 'plan_create_design_memo_required', undefined, {
454
- prompt: TODO_DESIGN_MEMO_PROMPT,
454
+ if (input?.schema !== MEMO_PHASE_CREATE_INPUT_SCHEMA) {
455
+ throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_retired', undefined, {
456
+ violation_kind: 'const_mismatch', pointer: '/schema',
457
+ expected: MEMO_PHASE_CREATE_INPUT_SCHEMA,
458
+ actual: typeof input?.schema === 'string' ? input.schema : { type: typeof input?.schema },
459
+ next_action: CURRENT_CREATE_SCHEMA_COMMAND,
460
+ });
461
+ }
462
+ if (!Array.isArray(input.tasks)) {
463
+ throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_invalid', undefined, {
464
+ violation_kind: 'type', pointer: '/tasks', expected: { type: 'array', min_items: 1 },
465
+ actual: { type: input.tasks === null ? 'null' : typeof input.tasks },
455
466
  next_action: CURRENT_CREATE_SCHEMA_COMMAND,
456
467
  });
457
468
  }
458
- if (!validateCreateInput(input)) throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_invalid');
469
+ const invalidMemoIndex = input.tasks.findIndex((task) => !isTodoDesignMemo(task?.design_memo));
470
+ if (invalidMemoIndex >= 0) {
471
+ const explained = explainTodoDesignMemo(input.tasks[invalidMemoIndex]?.design_memo);
472
+ throw new TodoStoreError('DESIGN_MEMO_REQUIRED', 'plan_create_design_memo_required', undefined, {
473
+ violation_kind: explained.reason, pointer: `/tasks/${invalidMemoIndex}/design_memo`,
474
+ expected: explained.expected, actual: explained.actual,
475
+ prompt: TODO_DESIGN_MEMO_PROMPT, next_action: CURRENT_CREATE_SCHEMA_COMMAND,
476
+ });
477
+ }
478
+ if (!validateCreateInput(input)) {
479
+ const digestValid = isTodoDigest(input.input_digest)
480
+ && input.input_digest === todoSelfDigest(input, 'input_digest');
481
+ throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_invalid', undefined, {
482
+ violation_kind: digestValid ? 'schema_or_topology_invalid' : 'input_digest_mismatch',
483
+ pointer: digestValid ? '/' : '/input_digest',
484
+ expected: digestValid ? { schema: MEMO_PHASE_CREATE_INPUT_SCHEMA }
485
+ : todoSelfDigest(input, 'input_digest'),
486
+ actual: digestValid ? { validation: 'failed' }
487
+ : { type: typeof input.input_digest, matches_canonical_input: false },
488
+ next_action: 'correct_the_reported_pointer_then_rerun_plan_create',
489
+ });
490
+ }
459
491
  // dispatch_shapeのgateはstore初期化より前に判定する(拒否時にstoreへ何も書かないため、
460
492
  // 再考後の再実行がplan_key_already_existsで詰まらない)。
461
493
  const dispatchShape = computeTodoDispatchShapeForPlan({
@@ -624,9 +656,15 @@ export async function runPlanShow({ cwd, planKey, stdout }) {
624
656
  }
625
657
 
626
658
  export function projectStatusFailure({ cwd, stdout, cliVersion, error }) {
659
+ const projectRootConflict = error?.code === 'PROJECT_ROOT_CONFLICT';
627
660
  const result = invalidStatus({
628
661
  cliVersion, repoRoot: resolveRepoRoot(cwd),
629
- reason: `status_internal_failure:${error?.constructor?.name ?? 'Error'}`,
662
+ reason: projectRootConflict
663
+ ? 'project_root_conflict'
664
+ : `status_internal_failure:${error?.constructor?.name ?? 'Error'}`,
665
+ ...(projectRootConflict ? { nextAction: {
666
+ command: 'lattice todo dashboard adopt --json', reason: 'project_root_conflict',
667
+ } } : {}),
630
668
  });
631
669
  stdout.write(`${JSON.stringify(result)}\n`);
632
670
  return 1;
@@ -2287,6 +2287,11 @@ export async function runManagedSupervisorDaemon({
2287
2287
  }
2288
2288
  activation = await activateController({ repoRoot, runId: request.request_id,
2289
2289
  adapterKind: legacyMeta.executor_adapter });
2290
+ const activationDelayMs = process.env.NODE_ENV === 'test'
2291
+ ? Number(process.env.LATTICE_INTERNAL_TEST_ACTIVATION_DELAY_MS ?? 0) : 0;
2292
+ if (Number.isSafeInteger(activationDelayMs) && activationDelayMs > 0) {
2293
+ await new Promise((resolve) => setTimeout(resolve, activationDelayMs));
2294
+ }
2290
2295
  if (process.env.NODE_ENV === 'test'
2291
2296
  && process.env.LATTICE_INTERNAL_TEST_CONTROLLER_COUNT === '2') {
2292
2297
  additionalActivations = [await activateController({ repoRoot, runId: request.request_id,
@@ -3293,6 +3298,21 @@ export async function runManagedSupervisorDaemon({
3293
3298
  known = { ...known, state: 'in_progress', response: null };
3294
3299
  }
3295
3300
  }
3301
+ // 初回activateがsocket timeoutを超えても、同一request_idの再照会を二重activateとして
3302
+ // 実行しない。初回daemon(run_meta.v1)に残るin_progressは、activation代入前後のどちらでも
3303
+ // 同じ処理がまだ走っている状態なのでunknownを返す。daemon再起動後(run_meta.v2)の
3304
+ // in_progressだけは、直後のrecovery分岐がdurable ledgerから処理を再開する。
3305
+ if (known?.state === 'in_progress' && controlRequest.operation === 'activate'
3306
+ && !restarting) {
3307
+ const events = await readBoundedJson(path.join(runDir, 'events.json'), 'run events').catch(() => []);
3308
+ const journal = await eventStore.readEvents();
3309
+ const result = buildControlResult({ operation: controlRequest.operation, outcome: 'unknown',
3310
+ eventHeadDigest: events.at(-1)?.event_digest ?? null,
3311
+ controlHeadDigest: journal.at(-1)?.event_digest ?? null, activeEpoch: 1,
3312
+ unmet: ['RUN_OUTCOME_UNKNOWN', '同一request_idのactivateはまだ処理中'] });
3313
+ return buildControlResponse(controlRequest, 'unknown', result,
3314
+ journal.at(-1)?.event_digest ?? null);
3315
+ }
3296
3316
  if (known !== null && controlRequest.operation === 'activate') {
3297
3317
  const active = await resolveActiveRuntimePaths({ runDir }).catch(() => null);
3298
3318
  if (active?.pointer?.activation_request_id === controlRequest.request_id
package/src/todo-cli.mjs CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  canonicalizeTodoArtifact,
13
13
  digestTodoArtifact,
14
14
  exactRecord,
15
+ explainTodoDesignMemo,
15
16
  isTodoDigest,
16
17
  isTodoDesignMemo,
17
18
  isTodoIdentifier,
@@ -1116,10 +1117,20 @@ async function migrateDryRun({ repoRoot, inputRef, serializationReviewed = false
1116
1117
  const extraction = await readMigrationInput(repoRoot, inputRef, { requireValid: false });
1117
1118
  const violations = [];
1118
1119
  const tasks = Array.isArray(extraction?.tasks) ? extraction.tasks : [];
1119
- const missingMemoIds = tasks.filter((task) => !isTodoDesignMemo(task?.design_memo))
1120
- .map((task) => task?.task_id).filter(isTodoIdentifier).slice(0, 64);
1121
- if (extraction?.schema !== TODO_EXTRACTION_SCHEMA_V3 || missingMemoIds.length > 0) {
1122
- violations.push({ code: 'design_memo_required', path: '/tasks', task_ids: missingMemoIds,
1120
+ const invalidMemos = tasks.map((task, index) => ({
1121
+ task, index, explained: explainTodoDesignMemo(task?.design_memo),
1122
+ })).filter(({ explained }) => !explained.valid).slice(0, 64);
1123
+ if (extraction?.schema !== TODO_EXTRACTION_SCHEMA_V3) {
1124
+ violations.push({ code: 'schema_retired', path: '/schema', task_ids: [],
1125
+ expected: TODO_EXTRACTION_SCHEMA_V3,
1126
+ actual: typeof extraction?.schema === 'string' ? extraction.schema
1127
+ : { type: typeof extraction?.schema },
1128
+ next_action: 'lattice todo migrate --schema --json' });
1129
+ }
1130
+ for (const { task, index, explained } of invalidMemos) {
1131
+ violations.push({ code: `design_memo_${explained.reason}`, path: `/tasks/${index}/design_memo`,
1132
+ task_ids: isTodoIdentifier(task?.task_id) ? [task.task_id] : [],
1133
+ expected: explained.expected, actual: explained.actual,
1123
1134
  prompt: TODO_DESIGN_MEMO_PROMPT, next_action: 'lattice todo migrate --schema --json' });
1124
1135
  }
1125
1136
  const schemaValid = extraction?.schema === TODO_EXTRACTION_SCHEMA_V3
@@ -1171,13 +1182,23 @@ async function migrateDryRun({ repoRoot, inputRef, serializationReviewed = false
1171
1182
  task_ids: error?.detail?.critical_path_task_ids ?? [],
1172
1183
  next_action: 'reconsider_parallel_seams_or_pass_serialization_reviewed' });
1173
1184
  }
1185
+ } catch (error) {
1186
+ plannedPlan = null;
1187
+ dispatchShape = null;
1188
+ violations.push({ code: error?.detail?.reason ?? 'topology_invalid', path: '/hard_dependencies',
1189
+ task_ids: [], next_action: 'correct_extraction_topology' });
1190
+ }
1191
+ if (plannedPlan !== null && dispatchShape !== null) {
1174
1192
  violations.push(...extractionFreshnessViolations(repoRoot, extraction));
1175
1193
  let manifestPresent = false;
1176
1194
  try {
1177
1195
  const stats = await lstat(path.join(repoRoot, '.lattice', 'todo', 'manifest.json'));
1178
1196
  manifestPresent = stats.isFile() && !stats.isSymbolicLink();
1179
1197
  } catch (error) {
1180
- if (error?.code !== 'ENOENT') throw error;
1198
+ if (error?.code !== 'ENOENT') {
1199
+ throw new TodoStoreError('MIGRATION_DRY_RUN_IO_FAILED', 'manifest_status_unreadable',
1200
+ undefined, { path: '.lattice/todo/manifest.json' });
1201
+ }
1181
1202
  }
1182
1203
  if (manifestPresent) {
1183
1204
  const store = await readTodoStore({ repoRoot });
@@ -1190,9 +1211,6 @@ async function migrateDryRun({ repoRoot, inputRef, serializationReviewed = false
1190
1211
  next_action: 'choose_a_new_plan_key_or_use_revision' });
1191
1212
  }
1192
1213
  }
1193
- } catch (error) {
1194
- violations.push({ code: error?.detail?.reason ?? 'topology_invalid', path: '/hard_dependencies',
1195
- task_ids: [], next_action: 'correct_extraction_topology' });
1196
1214
  }
1197
1215
  }
1198
1216
  const bounded = violations.slice(0, 64);
@@ -2329,7 +2347,11 @@ async function ensureActiveProjectDashboard({ repoRoot, env }) {
2329
2347
  });
2330
2348
  } catch (error) {
2331
2349
  throw new TodoStoreError(error?.code ?? 'DASHBOARD_DAEMON_UNAVAILABLE',
2332
- 'dashboard_daemon_ensure_failed', undefined, { project_id: store.project_id });
2350
+ 'dashboard_daemon_ensure_failed', undefined, {
2351
+ project_id: store.project_id,
2352
+ ...(typeof error?.detail?.next_action === 'string'
2353
+ ? { next_action: error.detail.next_action } : {}),
2354
+ });
2333
2355
  }
2334
2356
  }
2335
2357
 
@@ -2573,10 +2595,10 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2573
2595
  action = (repoRoot) => serveGantt({
2574
2596
  repoRoot, port: Number(argv[3]), stdout, env, scope: argv[5],
2575
2597
  });
2576
- } else if (argv[0] === 'gantt') {
2598
+ } else if (argv[0] === 'gantt' && argv[1] !== 'serve') {
2577
2599
  action = () => {
2578
2600
  throw new TodoStoreError('STATIC_GANTT_RETIRED', 'dynamic_dashboard_only', undefined, {
2579
- next_action: 'lattice todo status --json',
2601
+ next_action: 'lattice todo gantt serve --port 0',
2580
2602
  });
2581
2603
  };
2582
2604
  } else if ((argv.length === 5 || argv.length === 6) && argv[0] === 'migrate'
@@ -2688,17 +2710,20 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2688
2710
  next_action: 'lattice todo --help',
2689
2711
  });
2690
2712
  }
2713
+ const argumentHelp = argv[0] === 'gantt' && argv[1] === 'serve'
2714
+ ? 'lattice todo gantt serve --help'
2715
+ : command === null ? 'lattice todo --help' : `lattice todo ${command} --help`;
2691
2716
  return typedArgumentFailure(stderr, 'INVALID_ARGUMENTS', 'todo_arguments_invalid', {
2692
- command, next_action: command === null ? 'lattice todo --help' : `lattice todo ${command} --help`,
2717
+ command, next_action: argumentHelp,
2693
2718
  });
2694
2719
  }
2695
2720
 
2696
2721
  try {
2697
2722
  const repoRoot = resolveRepoRoot(cwd);
2698
- const manualServe = argv[0] === 'gantt' && argv[1] === 'serve';
2723
+ const ganttCommand = argv[0] === 'gantt';
2699
2724
  const dashboardAdopt = argv[0] === 'dashboard' && argv[1] === 'adopt';
2700
2725
  const migrationDryRun = argv[0] === 'migrate' && argv.includes('--dry-run');
2701
- if (!manualServe && !dashboardAdopt && !migrationDryRun) {
2726
+ if (!ganttCommand && !dashboardAdopt && !migrationDryRun) {
2702
2727
  await ensureActiveProjectDashboard({ repoRoot, env });
2703
2728
  }
2704
2729
  const result = await action(repoRoot);
@@ -159,9 +159,34 @@ export function validateTodoNoteContext(value) {
159
159
 
160
160
  const nullableDigest = (value) => value === null || isTodoDigest(value);
161
161
  const nullableText = (value) => value === null || (typeof value === 'string' && value.length > 0 && Buffer.byteLength(value) <= 16_384);
162
- export const isTodoDesignMemo = (value) => typeof value === 'string' && value.trim().length > 0
163
- && Buffer.byteLength(value, 'utf8') <= TODO_LIMITS.noteBodyBytes
164
- && !NOTE_FORBIDDEN_CONTROL.test(value);
162
+ /** 設計メモの拒否理由を、本文をerrorへ複製せずAIが訂正できる形で返す。 */
163
+ export function explainTodoDesignMemo(value) {
164
+ const expected = {
165
+ type: 'string', non_whitespace: true,
166
+ max_characters: TODO_LIMITS.noteBodyBytes, max_utf8_bytes: TODO_LIMITS.noteBodyBytes,
167
+ forbidden_control_characters: false,
168
+ };
169
+ if (typeof value !== 'string') {
170
+ return { valid: false, reason: 'type', expected,
171
+ actual: { type: value === null ? 'null' : typeof value } };
172
+ }
173
+ const byteLength = Buffer.byteLength(value, 'utf8');
174
+ if (value.trim().length === 0) {
175
+ return { valid: false, reason: 'blank', expected,
176
+ actual: { byte_length: byteLength, non_whitespace: false } };
177
+ }
178
+ if (value.length > TODO_LIMITS.noteBodyBytes || byteLength > TODO_LIMITS.noteBodyBytes) {
179
+ return { valid: false, reason: 'too_large', expected,
180
+ actual: { character_length: value.length, byte_length: byteLength } };
181
+ }
182
+ if (NOTE_FORBIDDEN_CONTROL.test(value)) {
183
+ return { valid: false, reason: 'forbidden_control', expected,
184
+ actual: { byte_length: byteLength, contains_forbidden_control: true } };
185
+ }
186
+ return { valid: true };
187
+ }
188
+
189
+ export const isTodoDesignMemo = (value) => explainTodoDesignMemo(value).valid;
165
190
  const actor = (value) => exactRecord(value, ['host', 'session', 'agent'])
166
191
  && [value.host, value.session, value.agent].every(isTodoIdentifier);
167
192
  const provenance = (value) => value === null || (exactRecord(value, ['source_commit', 'source_event_digest'])
@@ -20,6 +20,7 @@ const REGISTRY_SCHEMA = 'lattice.todo_dashboard_registry.v1';
20
20
  const DAEMON_SCHEMA = 'lattice.todo_dashboard_daemon.v1';
21
21
  const DEFAULT_PORT = 0;
22
22
  export const TODO_DASHBOARD_STALE_MS = 2 * 60 * 60 * 1_000;
23
+ const TODO_DASHBOARD_ATTENTION_PHASE_STATUS = new Set(['gate_ready', 'reviewing', 'rejected']);
23
24
  const LOCK_ATTEMPTS = 240;
24
25
  const LOCK_WAIT_MS = 25;
25
26
  const LOCK_STALE_MS = 30_000;
@@ -54,6 +55,12 @@ function validEntry(entry) {
54
55
  && Number.isFinite(Date.parse(entry.last_seen_at));
55
56
  }
56
57
 
58
+ /** active taskが無くても、監査の判断待ち・棄却後なら公開工程から消してはいけない。 */
59
+ export function todoDashboardMemberNeedsVisibility(member) {
60
+ return Array.isArray(member?.phases)
61
+ && member.phases.some(({ status }) => TODO_DASHBOARD_ATTENTION_PHASE_STATUS.has(status));
62
+ }
63
+
57
64
  function validateRegistry(value) {
58
65
  if (value === null || typeof value !== 'object' || Array.isArray(value)
59
66
  || value.schema !== REGISTRY_SCHEMA || !Array.isArray(value.projects)
@@ -169,7 +176,10 @@ export async function registerTodoDashboardActivity({
169
176
  error.code = 'PROJECT_ROOT_CONFLICT';
170
177
  // 公開・CLI errorへlocal absolute pathを運ばない。project_idだけで人と機械が
171
178
  // 衝突対象を特定でき、registry bytesはこの分岐より後で一切変更しない。
172
- error.detail = { project_id: projectId };
179
+ error.detail = {
180
+ project_id: projectId,
181
+ next_action: 'lattice todo dashboard adopt --json',
182
+ };
173
183
  throw error;
174
184
  }
175
185
  const projects = current.projects.filter((entry) => entry.project_id !== projectId);
@@ -95,7 +95,7 @@ export function renderIndependenceNote(ref, node, summary) {
95
95
  }
96
96
 
97
97
  export function renderRightPane(
98
- sections, layout, presentation, readModel, notesEnabled = false, noteWarnings = [],
98
+ sections, layout, presentation, readModel, notesEnabled = false, noteWarnings = [], expandable = false,
99
99
  ) {
100
100
  const lookup = presentationLookup(presentation);
101
101
  const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
@@ -160,7 +160,9 @@ export function renderRightPane(
160
160
  const independenceNote = renderIndependenceNote(section.ref, node, independenceSummary);
161
161
  // Say it plainly when the reader will not find this ToDo on the diagram.
162
162
  const foldedNote = !folds.has(key) ? ''
163
- : '<p class="fold-note">完走済みのため図には描いていません。図に出すには動的dashboardを <code>lattice todo gantt serve --port &lt;port&gt; --scope all</code> で起動してください。</p>';
163
+ : expandable
164
+ ? '<p class="fold-note">完走済みのため既定の図には描いていません。左上の「完走済み」バッジを押すと、このページ内で表示できます。</p>'
165
+ : '<p class="fold-note">完走済みのため図には描いていません。図に出すには動的dashboardを <code>lattice todo gantt serve --port &lt;port&gt; --scope all</code> で起動してください。</p>';
164
166
  const workLog = notesEnabled ? renderNoteContext(section.noteContext) : '';
165
167
  const designMemo = renderDesignMemo(section.task);
166
168
  return `<article class="task-detail" data-detail-key="${escapeHtmlAttribute(key)}" hidden><header><span class="detail-status status-${escapeHtmlAttribute(section.state.status)}">${escapeHtmlText(status.mark)} ${escapeHtmlText(status.label)}</span><span class="detail-reference">${escapeHtmlText(taskReference(section, lookup))}</span></header><h1>${escapeHtmlText(section.task.title)}</h1><p class="detail-category"><strong>カテゴリ:</strong> ${escapeHtmlText(category)}</p>${categoryDescription}<p><strong>正規ID:</strong> <code>${escapeHtmlText(`${section.ref.plan_key}/${section.task.task_id}`)}</code></p>${blockedReason}${readiness}${independenceNote}${foldedNote}${designMemo}<section><h2>前提工程</h2>${renderRelationList(incoming.get(key), sectionByKey, lookup, '登録済みの前提工程はありません。', folds)}</section><section><h2>後続工程</h2>${renderRelationList(outgoing.get(key), sectionByKey, lookup, '登録済みの後続工程はありません。', folds)}</section>${workLog}<p class="anchor-status">${escapeHtmlText(anchorText)}</p><details class="task-diagnostics"><summary>開発者向け診断</summary><dl><dt>canonical ref</dt><dd><code>${escapeHtmlText(`${section.ref.project_id}/${section.ref.plan_key}/${section.task.task_id}`)}</code></dd><dt>anchor</dt><dd>${escapeHtmlText(section.anchorOutcome.anchored ? 'verified' : section.anchorOutcome.reason)}</dd></dl></details></article>`;
@@ -110,22 +110,46 @@ export function renderRelationList(relations, sectionByKey, lookup, emptyText, f
110
110
  }
111
111
 
112
112
  /** Phase states that are over: nothing is dispatched or judged under them again. */
113
- export const SETTLED_PHASE_STATUS = Object.freeze(['accepted', 'rejected']);
113
+ export const SETTLED_PHASE_STATUS = Object.freeze(['accepted', 'closed_unaudited']);
114
+
115
+ function phaseGuidance(planKey, phase) {
116
+ if (phase.status === 'gate_ready') return {
117
+ reason: '全ToDoは完了していますが、終端監査がまだ受理されていません。',
118
+ next: `lattice todo phase review --plan ${planKey} --phase ${phase.phase_id} --reason <text>`,
119
+ };
120
+ if (phase.status === 'reviewing') return {
121
+ reason: '終端監査を実施中です。受理または棄却の判断がまだ記録されていません。',
122
+ next: `lattice todo phase accept --plan ${planKey} --phase ${phase.phase_id} --input <file>`,
123
+ };
124
+ if (phase.status === 'rejected') return {
125
+ reason: '終端監査で棄却され、修正または再監査が必要です。',
126
+ next: `lattice todo phase reopen --plan ${planKey} --phase ${phase.phase_id} --reason <text>`,
127
+ };
128
+ return null;
129
+ }
114
130
 
115
131
  export function renderPhaseProgress(readModel) {
116
132
  const rows = [];
117
133
  const settledRows = [];
118
134
  for (const member of readModel.members) {
119
- if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7']
120
- .includes(member.plan.schema)) continue;
121
- // snapshot artifactの形式には縛られない導出ビュー(member.phases)を読む(ADR 0147)
122
- const phases = new Map(member.phases.map((phase) => [phase.phase_id, phase]));
123
- for (const phase of member.plan.phases) {
124
- const tasks = member.plan.tasks.filter((task) => task.phase_id === phase.phase_id);
135
+ // snapshot artifactの形式には縛られない導出ビュー(member.phases)を正本にする(ADR 0147)。
136
+ // plan.phasesは表示metadataだけを補い、暗黙terminal-auditやmetadata欠落を落とさない。
137
+ const metadata = new Map((member.plan.phases ?? []).map((phase) => [phase.phase_id, phase]));
138
+ for (const state of member.phases ?? []) {
139
+ const phase = metadata.get(state.phase_id) ?? {
140
+ phase_id: state.phase_id,
141
+ title: state.phase_id === 'terminal-audit' ? '終端監査(暗黙)' : state.phase_id,
142
+ gate_policy: 'heavy',
143
+ };
144
+ const tasks = metadata.has(state.phase_id)
145
+ ? member.plan.tasks.filter((task) => task.phase_id === state.phase_id)
146
+ : member.plan.tasks;
125
147
  const states = new Map(member.tasks.map((task) => [task.task_id, task.status]));
126
148
  const done = tasks.filter((task) => states.get(task.task_id) === 'done').length;
127
- const state = phases.get(phase.phase_id);
128
- const row = `<li class="phase-progress status-${escapeHtmlAttribute(state.status)}"><header><strong>${escapeHtmlText(phase.title ?? phase.phase_id)}</strong><span>${escapeHtmlText(state.status)}</span></header><p><code>${escapeHtmlText(`${member.plan.plan_key}/${phase.phase_id}`)}</code> — policy <code>${escapeHtmlText(phase.gate_policy)}</code> — ToDo ${done}/${tasks.length}</p><progress max="${tasks.length}" value="${done}">${done}/${tasks.length}</progress></li>`;
149
+ const guidance = phaseGuidance(member.plan.plan_key, state);
150
+ const guidanceMarkup = guidance === null ? ''
151
+ : `<p><strong>状態の意味:</strong> ${escapeHtmlText(guidance.reason)}</p><p><strong>次の一歩:</strong> <code>${escapeHtmlText(guidance.next)}</code></p>`;
152
+ const row = `<li class="phase-progress status-${escapeHtmlAttribute(state.status)}"><header><strong>${escapeHtmlText(phase.title ?? phase.phase_id)}</strong><span>${escapeHtmlText(state.status)}</span></header><p><code>${escapeHtmlText(`${member.plan.plan_key}/${phase.phase_id}`)}</code> — policy <code>${escapeHtmlText(phase.gate_policy)}</code> — ToDo ${done}/${tasks.length}</p>${guidanceMarkup}<progress max="${tasks.length}" value="${done}">${done}/${tasks.length}</progress></li>`;
129
153
  // A settled Phase is history. It stays reachable, but it does not push the
130
154
  // live ones off the first screen.
131
155
  (SETTLED_PHASE_STATUS.includes(state.status) ? settledRows : rows).push(row);
@@ -56,6 +56,14 @@ function normalizeSections(readModel, narratives, anchorOutcomes, noteContexts)
56
56
  const narrative = supplied.get(refKey(ref));
57
57
  const markdown = narrative?.markdown ?? '';
58
58
  const narrativeRef = narrative?.narrative_ref ?? task.narrative_ref;
59
+ if (typeof task.design_memo === 'string') {
60
+ proseBytes += Buffer.byteLength(task.design_memo, 'utf8');
61
+ if (proseBytes > TODO_GANTT_PROSE_MAX_BYTES) {
62
+ throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt embedded prose limit exceeded', {
63
+ prose_bytes: proseBytes, prose_limit: TODO_GANTT_PROSE_MAX_BYTES,
64
+ });
65
+ }
66
+ }
59
67
  // ToDos that share one narrative document count its bytes once.
60
68
  const documentKey = narrativeRef === null
61
69
  ? refKey(ref) : JSON.stringify([member.plan.plan_key, narrativeRef, digest(markdown)]);
@@ -175,6 +183,7 @@ export function renderTodoGanttHtml({
175
183
  : `<div data-diagram="live">${svg}</div><div data-diagram="expanded" hidden>${expandedSvg}</div>`;
176
184
  const rightPane = renderRightPane(
177
185
  normalized.sections, layout, presentation, readModel, noteContexts !== null, noteWarnings,
186
+ expandedSvg !== '',
178
187
  );
179
188
  const staticData = serializeJsonForScript({
180
189
  renderer_version: TODO_GANTT_RENDERER_VERSION,
@@ -59,7 +59,7 @@ const AUDIT_PENDING_PHASE_STATUSES = new Set(['gate_ready', 'reviewing', 'reject
59
59
  function auditPending(node) {
60
60
  if (!AUDIT_PENDING_PHASE_STATUSES.has(node.phase_status ?? null)) return false;
61
61
  const schema = node.plan_schema ?? null;
62
- return !['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(schema);
62
+ return !['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(schema);
63
63
  }
64
64
 
65
65
  /**
@@ -257,7 +257,26 @@ export function explainTodoExtraction(value) {
257
257
  return reject('parent_task_id_unresolved',
258
258
  `/tasks/${value.tasks.indexOf(badParent)}/source/parent_task_id`);
259
259
  }
260
- if (!localRefsResolve(value)) return reject('local_ref_unresolved', '');
260
+ const excluded = new Set(value.tasks
261
+ .filter((task) => task.disposition.startsWith('exclude_'))
262
+ .map(({ task_id }) => task_id));
263
+ const excludedParent = value.tasks.find((task) => task.disposition.startsWith('register_')
264
+ && task.source.parent_task_id !== null && excluded.has(task.source.parent_task_id));
265
+ if (excludedParent !== undefined) {
266
+ return reject('registered_parent_task_id_unresolved',
267
+ `/tasks/${value.tasks.indexOf(excludedParent)}/source/parent_task_id`, {
268
+ task_id: excludedParent.task_id,
269
+ expected: 'task_id not excluded from the compiled plan',
270
+ actual: excludedParent.source.parent_task_id,
271
+ });
272
+ }
273
+ const localRefViolation = firstUnregisteredLocalRef(value);
274
+ if (localRefViolation !== null) {
275
+ return reject('local_ref_unresolved', localRefViolation.path, {
276
+ task_id: localRefViolation.task_id,
277
+ expected: 'registered task_id', actual: localRefViolation.task_id,
278
+ });
279
+ }
261
280
  // ここまでの個別検査を全て通過したのに`validateTodoExtraction`がfalseを返す状況は、
262
281
  // このexplainがまだ言い当てられない違反があるということ。捏造せず未特定と申告する。
263
282
  return { valid: true };
@@ -273,11 +292,25 @@ function registeredTaskIds(value) {
273
292
  }
274
293
 
275
294
  function localRefsResolve(value) {
295
+ return firstUnregisteredLocalRef(value) === null;
296
+ }
297
+
298
+ function firstUnregisteredLocalRef(value) {
276
299
  const registered = registeredTaskIds(value);
277
- const local = (ref) => ref.project_id !== value.project_id || ref.plan_key !== value.plan_key
278
- || registered.has(ref.task_id);
279
- return value.hard_dependencies.every((edge) => local(edge.from) && local(edge.to))
280
- && value.joins.every((join) => local(join.before) && join.after.every(local));
300
+ const unresolved = (ref) => ref.project_id === value.project_id && ref.plan_key === value.plan_key
301
+ && !registered.has(ref.task_id);
302
+ for (const [index, edge] of value.hard_dependencies.entries()) {
303
+ if (unresolved(edge.from)) return { path: `/hard_dependencies/${index}/from`, task_id: edge.from.task_id };
304
+ if (unresolved(edge.to)) return { path: `/hard_dependencies/${index}/to`, task_id: edge.to.task_id };
305
+ }
306
+ for (const [index, join] of value.joins.entries()) {
307
+ if (unresolved(join.before)) return { path: `/joins/${index}/before`, task_id: join.before.task_id };
308
+ const afterIndex = join.after.findIndex(unresolved);
309
+ if (afterIndex >= 0) {
310
+ return { path: `/joins/${index}/after/${afterIndex}`, task_id: join.after[afterIndex].task_id };
311
+ }
312
+ }
313
+ return null;
281
314
  }
282
315
 
283
316
  /** Exact, bounded validation for the AI-authored G4 intermediate artifact. */
@@ -303,6 +336,11 @@ export function validateTodoExtraction(value) {
303
336
  const taskIds = new Set(value.tasks.map(({ task_id }) => task_id));
304
337
  if (value.tasks.some((task) => task.source.parent_task_id === task.task_id
305
338
  || (task.source.parent_task_id !== null && !taskIds.has(task.source.parent_task_id)))) return false;
339
+ const excluded = new Set(value.tasks
340
+ .filter((task) => task.disposition.startsWith('exclude_'))
341
+ .map(({ task_id }) => task_id));
342
+ if (value.tasks.some((task) => task.disposition.startsWith('register_')
343
+ && task.source.parent_task_id !== null && excluded.has(task.source.parent_task_id))) return false;
306
344
  return localRefsResolve(value);
307
345
  } catch {
308
346
  return false;
@@ -326,7 +364,12 @@ export function todoExtractionImportSource(task) {
326
364
  */
327
365
  export function compileTodoExtraction(value, repoRoot) {
328
366
  if (!validateTodoExtraction(value)) {
329
- throw new TodoStoreError('INVALID_TODO_EXTRACTION', 'schema_invalid');
367
+ const explained = explainTodoExtraction(value);
368
+ throw new TodoStoreError('INVALID_TODO_EXTRACTION', 'schema_invalid', undefined,
369
+ explained.valid ? undefined : {
370
+ violation_reason: explained.reason, violation_path: explained.path,
371
+ ...(explained.task_id === undefined ? {} : { task_id: explained.task_id }),
372
+ });
330
373
  }
331
374
  const unresolved = value.tasks
332
375
  .filter(({ disposition }) => disposition === 'unknown_requires_evidence')
@@ -1933,7 +1933,9 @@ function localTaskRef(ref, plan, taskId) {
1933
1933
  && ref.task_id === taskId;
1934
1934
  }
1935
1935
 
1936
- function taskSemantics(plan, taskId, idMap, { reconciliationMetadata = false } = {}) {
1936
+ function taskSemantics(plan, taskId, idMap, {
1937
+ reconciliationMetadata = false, includeDesignMemo = false,
1938
+ } = {}) {
1937
1939
  const task = plan.tasks.find(({ task_id }) => task_id === taskId);
1938
1940
  if (!task) return null;
1939
1941
  const mapId = (id) => id === null ? null : idMap.get(id) ?? id;
@@ -1942,6 +1944,7 @@ function taskSemantics(plan, taskId, idMap, { reconciliationMetadata = false } =
1942
1944
  compile_binding: task.compile_binding,
1943
1945
  } : {
1944
1946
  task_id: mapId(task.task_id), title: task.title, lane: task.lane,
1947
+ ...(includeDesignMemo ? { design_memo: task.design_memo } : {}),
1945
1948
  narrative_ref: task.narrative_ref, narrative_anchor: task.narrative_anchor ?? null,
1946
1949
  compile_binding: task.compile_binding, parent_task_id: mapId(task.parent_task_id ?? null),
1947
1950
  };
@@ -1967,7 +1970,7 @@ function taskSemantics(plan, taskId, idMap, { reconciliationMetadata = false } =
1967
1970
  }
1968
1971
 
1969
1972
  function phaseV3CarrySemantics(plan, taskId, taskIdMap, phaseIdMap,
1970
- { reconciliationMetadata = false } = {}) {
1973
+ { reconciliationMetadata = false, includeDesignMemo = false } = {}) {
1971
1974
  const task = plan.tasks.find(({ task_id: id }) => id === taskId);
1972
1975
  if (task === undefined) return null;
1973
1976
  const mapTaskRef = (ref) => ref.project_id === plan.project_id && ref.plan_key === plan.plan_key
@@ -1982,8 +1985,12 @@ function phaseV3CarrySemantics(plan, taskId, taskIdMap, phaseIdMap,
1982
1985
  task_id: taskIdMap.get(task.task_id) ?? task.task_id, title: task.title, lane: task.lane,
1983
1986
  compile_binding: task.compile_binding,
1984
1987
  phase_id: mappedPhaseId,
1985
- } : { ...task,
1988
+ } : {
1986
1989
  task_id: taskIdMap.get(task.task_id) ?? task.task_id,
1990
+ title: task.title, lane: task.lane,
1991
+ ...(includeDesignMemo ? { design_memo: task.design_memo } : {}),
1992
+ narrative_ref: task.narrative_ref, narrative_anchor: task.narrative_anchor ?? null,
1993
+ compile_binding: task.compile_binding,
1987
1994
  phase_id: mappedPhaseId,
1988
1995
  parent_task_id: task.parent_task_id === null ? null
1989
1996
  : taskIdMap.get(task.parent_task_id) ?? task.parent_task_id,
@@ -2013,14 +2020,16 @@ function phaseV3CarrySemantics(plan, taskId, taskIdMap, phaseIdMap,
2013
2020
 
2014
2021
  function validatePhaseV3Carry(previous, revision, migration, idMap, state) {
2015
2022
  const reconciliationMetadata = migration.state_policy === 'carry_reconciled_metadata';
2023
+ const predecessorTask = previous.plan.tasks.find(({ task_id }) => task_id === migration.from_task_id);
2024
+ const includeDesignMemo = !reconciliationMetadata && typeof predecessorTask?.design_memo === 'string';
2016
2025
  const phaseIdMap = new Map(revision.phase_migration
2017
2026
  .filter(({ from_phase_id, to_phase_id }) => from_phase_id !== null && to_phase_id !== 'removed')
2018
2027
  .map(({ from_phase_id, to_phase_id }) => [from_phase_id, to_phase_id]));
2019
2028
  const before = phaseV3CarrySemantics(previous.plan, migration.from_task_id, idMap, phaseIdMap,
2020
- { reconciliationMetadata });
2029
+ { reconciliationMetadata, includeDesignMemo });
2021
2030
  const after = phaseV3CarrySemantics(revision.desired_plan, migration.to_task_id,
2022
2031
  new Map(), new Map(),
2023
- { reconciliationMetadata });
2032
+ { reconciliationMetadata, includeDesignMemo });
2024
2033
  if (canonicalizeTodoArtifact(before.task) !== canonicalizeTodoArtifact(after.task)
2025
2034
  || canonicalizeTodoArtifact(before.incoming) !== canonicalizeTodoArtifact(after.incoming)) {
2026
2035
  fail('REVISION_INVALID', 'carry_semantics_changed', { from_task_id: migration.from_task_id });
@@ -2048,8 +2057,12 @@ function validateAcquirePhaseCarry(previous, revision, migration, idMap) {
2048
2057
  const phaseIdMap = new Map(revision.phase_migration
2049
2058
  .filter(({ from_phase_id, to_phase_id }) => from_phase_id !== null && to_phase_id !== 'removed')
2050
2059
  .map(({ from_phase_id, to_phase_id }) => [from_phase_id, to_phase_id]));
2051
- const before = phaseV3CarrySemantics(previous.plan, migration.from_task_id, idMap, phaseIdMap);
2052
- const after = phaseV3CarrySemantics(revision.desired_plan, migration.to_task_id, new Map(), new Map());
2060
+ const predecessorTask = previous.plan.tasks.find(({ task_id }) => task_id === migration.from_task_id);
2061
+ const includeDesignMemo = typeof predecessorTask?.design_memo === 'string';
2062
+ const before = phaseV3CarrySemantics(previous.plan, migration.from_task_id, idMap, phaseIdMap,
2063
+ { includeDesignMemo });
2064
+ const after = phaseV3CarrySemantics(revision.desired_plan, migration.to_task_id, new Map(), new Map(),
2065
+ { includeDesignMemo });
2053
2066
  if (before.task.phase_id !== null) {
2054
2067
  fail('REVISION_INVALID', 'acquire_phase_requires_unassigned_predecessor', { from_task_id: migration.from_task_id });
2055
2068
  }
@@ -2068,6 +2081,12 @@ function validateAcquirePhaseCarry(previous, revision, migration, idMap) {
2068
2081
  }
2069
2082
 
2070
2083
  function stateMigrationFor(previous, revision) {
2084
+ if (['lattice.todo_plan.v6', 'lattice.todo_plan.v7'].includes(previous.plan.schema)
2085
+ && !['lattice.todo_plan.v6', 'lattice.todo_plan.v7'].includes(revision.desired_plan.schema)) {
2086
+ fail('REVISION_INVALID', 'design_memo_schema_downgrade', {
2087
+ predecessor_schema: previous.plan.schema, desired_schema: revision.desired_plan.schema,
2088
+ });
2089
+ }
2071
2090
  const oldIds = previous.plan.tasks.map(({ task_id }) => task_id);
2072
2091
  const migrationIds = revision.task_migration.map(({ from_task_id }) => from_task_id);
2073
2092
  if (canonicalizeTodoArtifact([...oldIds].sort()) !== canonicalizeTodoArtifact([...migrationIds].sort())) {
@@ -2091,8 +2110,13 @@ function stateMigrationFor(previous, revision) {
2091
2110
  if (revision.schema === 'lattice.phase_todo_revision.v3') {
2092
2111
  validateAcquirePhaseCarry(previous, revision, migration, idMap);
2093
2112
  } else {
2094
- const before = taskSemantics(previous.plan, migration.from_task_id, idMap, {});
2095
- const after = taskSemantics(revision.desired_plan, migration.to_task_id, new Map(), {});
2113
+ const predecessorTask = previous.plan.tasks
2114
+ .find(({ task_id }) => task_id === migration.from_task_id);
2115
+ const includeDesignMemo = typeof predecessorTask?.design_memo === 'string';
2116
+ const before = taskSemantics(previous.plan, migration.from_task_id, idMap,
2117
+ { includeDesignMemo });
2118
+ const after = taskSemantics(revision.desired_plan, migration.to_task_id, new Map(),
2119
+ { includeDesignMemo });
2096
2120
  if (canonicalizeTodoArtifact(before) !== canonicalizeTodoArtifact(after)) {
2097
2121
  fail('REVISION_INVALID', 'carry_semantics_changed', { from_task_id: migration.from_task_id });
2098
2122
  }
@@ -2100,10 +2124,14 @@ function stateMigrationFor(previous, revision) {
2100
2124
  } else if (revision.schema === 'lattice.phase_todo_revision.v3') {
2101
2125
  validatePhaseV3Carry(previous, revision, migration, idMap, state);
2102
2126
  } else {
2127
+ const predecessorTask = previous.plan.tasks
2128
+ .find(({ task_id }) => task_id === migration.from_task_id);
2129
+ const includeDesignMemo = !reconciliationMetadata
2130
+ && typeof predecessorTask?.design_memo === 'string';
2103
2131
  const before = taskSemantics(previous.plan, migration.from_task_id, idMap,
2104
- { reconciliationMetadata });
2132
+ { reconciliationMetadata, includeDesignMemo });
2105
2133
  const after = taskSemantics(revision.desired_plan, migration.to_task_id, new Map(),
2106
- { reconciliationMetadata });
2134
+ { reconciliationMetadata, includeDesignMemo });
2107
2135
  if (canonicalizeTodoArtifact(before) !== canonicalizeTodoArtifact(after)) {
2108
2136
  fail('REVISION_INVALID', 'carry_semantics_changed', { from_task_id: migration.from_task_id });
2109
2137
  }