@quolu/lattice 0.38.0 → 0.39.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/src/todo-cli.mjs CHANGED
@@ -1,24 +1,29 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { createHash, randomBytes } from 'node:crypto';
2
+ import { createHash } from 'node:crypto';
3
3
  import {
4
- lstat, mkdir, open, readFile, realpath, rename, rm, writeFile,
4
+ lstat, mkdir, readFile, realpath, writeFile,
5
5
  } from 'node:fs/promises';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { parseTree } from 'jsonc-parser';
9
9
 
10
10
  import {
11
+ TODO_DESIGN_MEMO_PROMPT,
11
12
  canonicalizeTodoArtifact,
12
13
  digestTodoArtifact,
13
14
  exactRecord,
14
15
  isTodoDigest,
16
+ isTodoDesignMemo,
15
17
  isTodoIdentifier,
16
18
  isTodoRef,
17
19
  todoSelfDigest,
18
20
  validateEvidenceDescriptor,
19
21
  } from './todo-contracts.mjs';
20
22
  import { projectTodoChainV1 } from './todo-chain.mjs';
21
- import { ensureTodoDashboardActivity } from './todo-dashboard-registry.mjs';
23
+ import {
24
+ adoptTodoDashboardActivity,
25
+ ensureTodoDashboardActivity,
26
+ } from './todo-dashboard-registry.mjs';
22
27
  import { resolveProjectIdentity } from './project-identity.mjs';
23
28
  import { layoutTodoGantt } from './todo-gantt-layout.mjs';
24
29
  import { TODO_GANTT_SCOPES } from './todo-gantt-scope.mjs';
@@ -26,7 +31,6 @@ import { loadTodoGanttPresentation } from './todo-gantt-presentation.mjs';
26
31
  import { startTodoGanttLiveServer } from './todo-gantt-live.mjs';
27
32
  import {
28
33
  renderTodoGanttHtml,
29
- TODO_GANTT_HTML_MAX_BYTES,
30
34
  TODO_GANTT_RENDERER_VERSION,
31
35
  } from './todo-gantt-html.mjs';
32
36
  import { verifyNarrativeAnchors } from './todo-narrative-anchor.mjs';
@@ -35,6 +39,7 @@ import {
35
39
  applyPhaseTodoRevision,
36
40
  applyTodoRevision,
37
41
  applyTodoRevisionSet,
42
+ buildTodoPlan,
38
43
  createTodoStoreWriter,
39
44
  TodoStoreError,
40
45
  isPhaselessTodoPlanSchema,
@@ -54,7 +59,9 @@ import {
54
59
  } from './todo-store.mjs';
55
60
  import {
56
61
  appendTodoExtraction,
62
+ compileTodoExtraction,
57
63
  explainTodoExtraction,
64
+ TODO_EXTRACTION_SCHEMA_V3,
58
65
  validateTodoExtraction,
59
66
  } from './todo-migration.mjs';
60
67
  import {
@@ -112,9 +119,6 @@ import {
112
119
  } from './todo-note-store.mjs';
113
120
 
114
121
  const CLI_ERROR_SCHEMA = 'lattice.cli_error.v2';
115
- const DEFAULT_GANTT_REF = '.lattice/generated/gantt.html';
116
- const GANTT_DESCRIPTOR_SUFFIX = '.status.json';
117
- const MAX_GANTT_DESCRIPTOR_BYTES = 65_536;
118
122
  const DEFAULT_GANTT_SCOPE = 'live';
119
123
  const MAX_MIGRATION_INPUT_BYTES = 8_388_608;
120
124
  const MAX_NOTE_INPUT_BYTES = 16_384;
@@ -138,7 +142,7 @@ const TODO_SCHEMA_COMMANDS = Object.freeze({
138
142
  'revise-phase': {
139
143
  title: 'lattice.phase_todo_revision.v3', file: 'lattice.phase_todo_revision.v3.schema.json',
140
144
  },
141
- migrate: { title: 'lattice.todo_extraction.v2', file: 'lattice.todo_extraction.v2.schema.json' },
145
+ migrate: { title: 'lattice.todo_extraction.v3', file: 'lattice.todo_extraction.v3.schema.json' },
142
146
  });
143
147
 
144
148
  async function runTodoSchemaCommand(command, stdout) {
@@ -149,12 +153,6 @@ async function runTodoSchemaCommand(command, stdout) {
149
153
  stdout.write(`${JSON.stringify(schema)}\n`);
150
154
  }
151
155
 
152
- function usageFailure(stderr, argv) {
153
- const received = argv.length === 0 ? '(none)' : argv.join(' ').replace(/[\r\n]/gu, ' ');
154
- stderr.write(`lattice todo: unsupported command or arguments: ${received}\n`);
155
- return 2;
156
- }
157
-
158
156
  function typedFailure(stderr, error) {
159
157
  const payload = {
160
158
  schema: CLI_ERROR_SCHEMA,
@@ -169,6 +167,18 @@ function typedFailure(stderr, error) {
169
167
  return 1;
170
168
  }
171
169
 
170
+ const TODO_COMMAND_NAMES = Object.freeze([
171
+ 'status', 'show', 'note', 'bindings', 'independence', 'seam-profile', 'seam-proposal',
172
+ 'verify', 'snapshot', 'gantt', 'dashboard', 'phase', 'migrate', 'start', 'block',
173
+ 'unblock', 'done', 'reopen', 'evidence', 'revise', 'revise-phase', 'revise-set',
174
+ ]);
175
+
176
+ function typedArgumentFailure(stderr, code, message, detail) {
177
+ const payload = { schema: CLI_ERROR_SCHEMA, code, message, detail };
178
+ stderr.write(`${JSON.stringify(payload)}\n`);
179
+ return 2;
180
+ }
181
+
172
182
  function resolveRepoRoot(cwd) {
173
183
  try {
174
184
  return execFileSync('git', ['rev-parse', '--show-toplevel'], {
@@ -257,7 +267,7 @@ function hasDuplicateJsonKey(node) {
257
267
  return false;
258
268
  }
259
269
 
260
- async function readMigrationInput(repoRoot, inputRef) {
270
+ async function readMigrationInput(repoRoot, inputRef, { requireValid = true } = {}) {
261
271
  const canonicalRoot = await realpath(repoRoot);
262
272
  const absolute = path.resolve(canonicalRoot, inputRef);
263
273
  if (!within(canonicalRoot, absolute) || absolute === canonicalRoot) {
@@ -295,12 +305,30 @@ async function readMigrationInput(repoRoot, inputRef) {
295
305
  try { extraction = JSON.parse(text); } catch {
296
306
  throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
297
307
  }
308
+ if (!requireValid) return extraction;
309
+ if (extraction?.schema !== TODO_EXTRACTION_SCHEMA_V3) {
310
+ throw new TodoStoreError('DESIGN_MEMO_REQUIRED', 'todo_extraction_v3_required', undefined, {
311
+ design_memo_prompt: TODO_DESIGN_MEMO_PROMPT,
312
+ next_action: 'lattice todo migrate --schema --json',
313
+ });
314
+ }
298
315
  if (!validateTodoExtraction(extraction)) {
299
316
  // 「schema_invalid」だけでは何のfieldがどう壊れているか分からない(ADR 0130の案内規律)。
300
317
  // explainは可否判定を変えず、診断だけを追加する。
301
318
  const explained = explainTodoExtraction(extraction);
302
- throw new TodoStoreError('INVALID_TODO_EXTRACTION', 'schema_invalid', undefined,
303
- explained.valid ? undefined : { violation_reason: explained.reason, violation_path: explained.path });
319
+ const detail = explained.valid ? undefined : {
320
+ violation_kind: explained.reason,
321
+ pointer: explained.path,
322
+ violation_reason: explained.reason,
323
+ violation_path: explained.path,
324
+ ...(explained.task_id === undefined ? {} : { task_id: explained.task_id }),
325
+ ...(explained.expected === undefined ? {} : { expected: explained.expected }),
326
+ ...(explained.actual === undefined ? {} : { actual: explained.actual }),
327
+ ...(explained.path.endsWith('/design_memo')
328
+ ? { design_memo_prompt: TODO_DESIGN_MEMO_PROMPT } : {}),
329
+ next_action: 'correct_the_reported_pointer_then_rerun_migrate_dry_run',
330
+ };
331
+ throw new TodoStoreError('INVALID_TODO_EXTRACTION', 'schema_invalid', undefined, detail);
304
332
  }
305
333
  return extraction;
306
334
  }
@@ -515,6 +543,8 @@ async function mutate({
515
543
  event: { kind, task_id: taskId, actor, payload: eventPayload },
516
544
  });
517
545
  const task = snapshot.tasks.find(({ task_id: current }) => current === event.task_id);
546
+ const authoredTask = plan.tasks.find(({ task_id: current }) => current === event.task_id);
547
+ if (authoredTask === undefined) throw new TypeError('mutation task content is missing');
518
548
  // advisoryは呼び出し側(startTask)がstart用に既に組んでいればそれを尊重し、無ければ
519
549
  // done時だけ終端監査の要否を調べる。block/unblock/reopenはnullのまま(既存挙動を変えない)。
520
550
  // Phase状態はsnapshot(v1にはphasesキーが無い)でなく、appendTodoEventが別途返す
@@ -525,7 +555,7 @@ async function mutate({
525
555
  throw new TypeError('start mutation requires note context');
526
556
  }
527
557
  const result = {
528
- schema: includesNoteContext ? 'lattice.todo_mutation_result.v3' : 'lattice.todo_mutation_result.v2',
558
+ schema: includesNoteContext ? 'lattice.todo_mutation_result.v4' : 'lattice.todo_mutation_result.v2',
529
559
  project_id: event.project_id,
530
560
  plan_key: event.plan_key,
531
561
  plan_version: event.plan_version,
@@ -537,13 +567,23 @@ async function mutate({
537
567
  snapshot_digest: snapshot.snapshot_digest,
538
568
  status: task.status,
539
569
  advisory: resolvedAdvisory,
540
- ...(includesNoteContext ? { note_context: noteContext } : {}),
570
+ ...(includesNoteContext ? {
571
+ design_memo: designMemoProjection(authoredTask),
572
+ note_context: noteContext,
573
+ } : {}),
541
574
  result_digest: '',
542
575
  };
543
576
  result.result_digest = todoSelfDigest(result, 'result_digest');
544
577
  return result;
545
578
  }
546
579
 
580
+ function designMemoProjection(task) {
581
+ if (typeof task.design_memo === 'string') {
582
+ return { status: 'available', markdown: task.design_memo, prompt: TODO_DESIGN_MEMO_PROMPT };
583
+ }
584
+ return { status: 'missing_legacy', markdown: null, prompt: TODO_DESIGN_MEMO_PROMPT };
585
+ }
586
+
547
587
  /**
548
588
  * 着手しようとしているtaskについて、記録済みの独立性から助言を組む(ADR 0128 Decision 5)。
549
589
  *
@@ -931,7 +971,7 @@ async function migrate({ repoRoot, inputRef, serializationReviewed = false }) {
931
971
 
932
972
  const imported = await appendTodoExtraction({ repoRoot, extraction });
933
973
  const result = {
934
- schema: 'lattice.todo_migrate_result.v1',
974
+ schema: 'lattice.todo_migrate_result.v2',
935
975
  project_id: imported.plan.project_id,
936
976
  plan_key: imported.plan.plan_key,
937
977
  plan_version: imported.plan.plan_version,
@@ -953,6 +993,230 @@ async function migrate({ repoRoot, inputRef, serializationReviewed = false }) {
953
993
  // 留める。extraction経由のmigrateは常にphase無しplan(todo_plan.v2)を作るが、将来の
954
994
  // 拡張に備えisPhaselessTodoPlanSchemaで動的に判定する。
955
995
  terminal_audit_required: isPhaselessTodoPlanSchema(imported.plan.schema),
996
+ phase_guidance: isPhaselessTodoPlanSchema(imported.plan.schema) ? {
997
+ capability: 'acquire_phase',
998
+ preserves_completed_state: true,
999
+ schema_command: 'lattice todo revise-phase --schema --json',
1000
+ required_state_policy: 'acquire_phase',
1001
+ next_action: `lattice todo revise-phase --plan ${imported.plan.plan_key} --input <phase-revision.json>`,
1002
+ } : null,
1003
+ result_digest: '',
1004
+ };
1005
+ result.result_digest = todoSelfDigest(result, 'result_digest');
1006
+ return result;
1007
+ }
1008
+
1009
+ function extractionFreshnessViolations(repoRoot, extraction) {
1010
+ let reachable;
1011
+ try {
1012
+ reachable = new Set(execFileSync('git', ['rev-list', '--objects', '--all'], {
1013
+ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
1014
+ }).split('\n').filter(Boolean).map((line) => line.split(' ')[0]));
1015
+ } catch {
1016
+ return [{ code: 'source_reachability_unreadable', path: '/tasks', task_ids: [],
1017
+ next_action: 'git fsck --connectivity-only' }];
1018
+ }
1019
+ const violations = [];
1020
+ for (const [index, task] of (Array.isArray(extraction.tasks) ? extraction.tasks : []).entries()) {
1021
+ const source = task?.source;
1022
+ if (source === null || typeof source !== 'object'
1023
+ || !['checked', 'unchecked'].includes(source.checkbox_state)
1024
+ || task.narrative_ref !== source.origin_plan_ref) continue;
1025
+ const at = `/tasks/${index}/source`;
1026
+ if (!reachable.has(source.source_commit)) {
1027
+ violations.push({ code: 'source_commit_unreachable', path: `${at}/source_commit`,
1028
+ task_ids: [task.task_id].filter(isTodoIdentifier), next_action: 'commit_or_reference_source_commit' });
1029
+ continue;
1030
+ }
1031
+ let markdown;
1032
+ try {
1033
+ markdown = execFileSync('git', ['show', `${source.source_commit}:${source.origin_plan_ref}`], {
1034
+ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 8_388_608,
1035
+ });
1036
+ } catch {
1037
+ violations.push({ code: 'source_path_unreadable', path: `${at}/origin_plan_ref`,
1038
+ task_ids: [task.task_id].filter(isTodoIdentifier), next_action: 'correct_pinned_source_ref' });
1039
+ continue;
1040
+ }
1041
+ const line = markdown.split('\n')[source.origin_line - 1];
1042
+ const checkbox = /^\s*[-*+]\s+\[([ xX])\]/u.exec(line ?? '');
1043
+ const actual = checkbox === null ? null : checkbox[1].toLowerCase() === 'x' ? 'checked' : 'unchecked';
1044
+ if (actual !== source.checkbox_state) {
1045
+ violations.push({ code: 'source_checkbox_state_mismatch', path: `${at}/checkbox_state`,
1046
+ task_ids: [task.task_id].filter(isTodoIdentifier), next_action: 'refresh_extraction_from_pinned_source' });
1047
+ }
1048
+ }
1049
+ return violations;
1050
+ }
1051
+
1052
+ function extractionAuthoringViolations(extraction, now = new Date()) {
1053
+ const violations = [];
1054
+ const tasks = Array.isArray(extraction?.tasks) ? extraction.tasks : [];
1055
+ for (const [index, task] of tasks.entries()) {
1056
+ const taskIds = isTodoIdentifier(task?.task_id) ? [task.task_id] : [];
1057
+ if (task?.disposition === 'register_done'
1058
+ && task?.completion?.done_mode !== 'historical_import') {
1059
+ violations.push({ code: 'enum_mismatch', path: `/tasks/${index}/completion/done_mode`,
1060
+ task_ids: taskIds, expected: 'historical_import',
1061
+ actual: task?.completion?.done_mode ?? null,
1062
+ next_action: 'use_historical_import_for_imported_completion' });
1063
+ }
1064
+ if (task?.migration_context !== null && typeof task?.migration_context === 'object'
1065
+ && !Array.isArray(task.migration_context.notes)) {
1066
+ violations.push({ code: 'expected_array', path: `/tasks/${index}/migration_context/notes`,
1067
+ task_ids: taskIds, expected: 'array', actual: typeof task.migration_context.notes,
1068
+ next_action: 'replace_notes_with_a_bounded_string_array' });
1069
+ }
1070
+ }
1071
+ if (typeof extraction?.extraction_digest === 'string') {
1072
+ const expected = todoSelfDigest(extraction, 'extraction_digest');
1073
+ if (extraction.extraction_digest !== expected) {
1074
+ violations.push({ code: 'extraction_digest_mismatch', path: '/extraction_digest', task_ids: [],
1075
+ expected, actual: extraction.extraction_digest,
1076
+ next_action: 'replace_extraction_digest_with_expected' });
1077
+ }
1078
+ }
1079
+ const edges = Array.isArray(extraction?.hard_dependencies) ? extraction.hard_dependencies : [];
1080
+ const edgeKey = (edge) => [edge?.from?.project_id, edge?.from?.plan_key, edge?.from?.task_id,
1081
+ edge?.to?.project_id, edge?.to?.plan_key, edge?.to?.task_id].join('\0');
1082
+ for (let index = 1; index < edges.length; index += 1) {
1083
+ if (edgeKey(edges[index - 1]) >= edgeKey(edges[index])) {
1084
+ violations.push({ code: 'array_not_sorted', path: `/hard_dependencies/${index}`, task_ids: [],
1085
+ expected: 'strict ascending order', sort_key: 'from.project_id, from.plan_key, from.task_id, '
1086
+ + 'to.project_id, to.plan_key, to.task_id',
1087
+ next_action: 'sort_hard_dependencies_by_the_reported_key' });
1088
+ break;
1089
+ }
1090
+ }
1091
+ const registered = new Set(tasks.filter(({ disposition }) => typeof disposition === 'string'
1092
+ && disposition.startsWith('register_')).map(({ task_id: taskId }) => taskId));
1093
+ const unresolvedLocal = (ref) => ref?.project_id === extraction?.project_id
1094
+ && ref?.plan_key === extraction?.plan_key && !registered.has(ref?.task_id);
1095
+ for (const [index, edge] of edges.entries()) {
1096
+ for (const side of ['from', 'to']) {
1097
+ if (unresolvedLocal(edge?.[side])) {
1098
+ violations.push({ code: 'local_ref_unresolved', path: `/hard_dependencies/${index}/${side}`,
1099
+ task_ids: isTodoIdentifier(edge[side].task_id) ? [edge[side].task_id] : [],
1100
+ expected: 'task_id registered in this extraction', actual: edge[side].task_id ?? null,
1101
+ next_action: 'register_the_task_or_reference_its_actual_project_and_plan' });
1102
+ }
1103
+ }
1104
+ }
1105
+ const recordedAt = Date.parse(extraction?.recorded_at);
1106
+ const maxFutureSkewMs = 5 * 60 * 1_000;
1107
+ if (Number.isFinite(recordedAt) && recordedAt > now.valueOf() + maxFutureSkewMs) {
1108
+ violations.push({ code: 'future_clock_skew', path: '/recorded_at', task_ids: [],
1109
+ expected: { current_time: now.toISOString(), max_future_skew_ms: maxFutureSkewMs },
1110
+ actual: extraction.recorded_at, next_action: 'correct_recorded_at_and_recompute_extraction_digest' });
1111
+ }
1112
+ return violations;
1113
+ }
1114
+
1115
+ async function migrateDryRun({ repoRoot, inputRef, serializationReviewed = false }) {
1116
+ const extraction = await readMigrationInput(repoRoot, inputRef, { requireValid: false });
1117
+ const violations = [];
1118
+ 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,
1123
+ prompt: TODO_DESIGN_MEMO_PROMPT, next_action: 'lattice todo migrate --schema --json' });
1124
+ }
1125
+ const schemaValid = extraction?.schema === TODO_EXTRACTION_SCHEMA_V3
1126
+ && validateTodoExtraction(extraction);
1127
+ if (!schemaValid) {
1128
+ const explained = explainTodoExtraction(extraction);
1129
+ if (!explained.valid && !explained.path.endsWith('/design_memo')) {
1130
+ violations.push({ code: explained.reason, path: explained.path,
1131
+ task_ids: isTodoIdentifier(explained.task_id) ? [explained.task_id] : [],
1132
+ ...(explained.expected === undefined ? {} : { expected: explained.expected }),
1133
+ ...(explained.actual === undefined ? {} : { actual: explained.actual }),
1134
+ next_action: 'correct_extraction_input' });
1135
+ }
1136
+ }
1137
+ for (const violation of extractionAuthoringViolations(extraction)) {
1138
+ if (!violations.some((entry) => entry.code === violation.code && entry.path === violation.path)) {
1139
+ violations.push(violation);
1140
+ }
1141
+ }
1142
+ const unresolvedTaskIds = tasks
1143
+ .filter(({ disposition }) => disposition === 'unknown_requires_evidence')
1144
+ .map(({ task_id: taskId }) => taskId).filter(isTodoIdentifier);
1145
+ if (unresolvedTaskIds.length > 0) {
1146
+ violations.push({ code: 'unknown_requires_evidence', path: '/tasks',
1147
+ task_ids: unresolvedTaskIds.slice(0, 64), next_action: 'adjudicate_task_disposition' });
1148
+ }
1149
+ const registered = tasks.filter(({ disposition }) => typeof disposition === 'string'
1150
+ && disposition.startsWith('register_'));
1151
+ if (registered.length === 0) {
1152
+ violations.push({ code: 'no_registered_tasks', path: '/tasks', task_ids: [],
1153
+ next_action: 'register_at_least_one_task' });
1154
+ }
1155
+
1156
+ let dispatchShape = null;
1157
+ let plannedPlan = null;
1158
+ if (schemaValid && unresolvedTaskIds.length === 0 && registered.length > 0) {
1159
+ try {
1160
+ const compiled = compileTodoExtraction(extraction, repoRoot);
1161
+ plannedPlan = buildTodoPlan(compiled.plan);
1162
+ dispatchShape = computeTodoDispatchShapeForPlan({
1163
+ projectId: extraction.project_id, planKey: extraction.plan_key,
1164
+ taskIds: registered.map(({ task_id: taskId }) => taskId),
1165
+ hardDependencies: extraction.hard_dependencies, joins: extraction.joins,
1166
+ });
1167
+ try {
1168
+ assertTodoDispatchShapeReviewed({ shape: dispatchShape, reviewed: serializationReviewed });
1169
+ } catch (error) {
1170
+ violations.push({ code: error?.detail?.reason ?? 'plan_shape_too_serial', path: '/hard_dependencies',
1171
+ task_ids: error?.detail?.critical_path_task_ids ?? [],
1172
+ next_action: 'reconsider_parallel_seams_or_pass_serialization_reviewed' });
1173
+ }
1174
+ violations.push(...extractionFreshnessViolations(repoRoot, extraction));
1175
+ let manifestPresent = false;
1176
+ try {
1177
+ const stats = await lstat(path.join(repoRoot, '.lattice', 'todo', 'manifest.json'));
1178
+ manifestPresent = stats.isFile() && !stats.isSymbolicLink();
1179
+ } catch (error) {
1180
+ if (error?.code !== 'ENOENT') throw error;
1181
+ }
1182
+ if (manifestPresent) {
1183
+ const store = await readTodoStore({ repoRoot });
1184
+ if (store.project_id !== extraction.project_id) {
1185
+ violations.push({ code: 'project_id_mismatch', path: '/project_id', task_ids: [],
1186
+ next_action: 'use_the_active_store_project_id' });
1187
+ }
1188
+ if (store.members.some(({ plan }) => plan.plan_key === extraction.plan_key)) {
1189
+ violations.push({ code: 'plan_key_already_exists', path: '/plan_key', task_ids: [],
1190
+ next_action: 'choose_a_new_plan_key_or_use_revision' });
1191
+ }
1192
+ }
1193
+ } catch (error) {
1194
+ violations.push({ code: error?.detail?.reason ?? 'topology_invalid', path: '/hard_dependencies',
1195
+ task_ids: [], next_action: 'correct_extraction_topology' });
1196
+ }
1197
+ }
1198
+ const bounded = violations.slice(0, 64);
1199
+ const result = {
1200
+ schema: 'lattice.todo_migrate_dry_run_result.v1',
1201
+ valid: bounded.length === 0,
1202
+ project_id: isTodoIdentifier(extraction?.project_id) ? extraction.project_id : null,
1203
+ plan_key: isTodoIdentifier(extraction?.plan_key) ? extraction.plan_key : null,
1204
+ violations: bounded,
1205
+ overflow_count: Math.max(0, violations.length - bounded.length),
1206
+ planned: plannedPlan === null ? null : {
1207
+ plan_schema: plannedPlan.schema,
1208
+ task_count: registered.length,
1209
+ topology_digest: plannedPlan.topology_digest,
1210
+ dispatch_shape: {
1211
+ task_count: dispatchShape.task_count,
1212
+ critical_path_length: dispatchShape.critical_path_length,
1213
+ max_frontier_width: dispatchShape.max_frontier_width,
1214
+ serialization_ratio: dispatchShape.serialization_ratio,
1215
+ },
1216
+ },
1217
+ next_action: bounded.length === 0
1218
+ ? `lattice todo migrate --input ${inputRef}${serializationReviewed ? ' --serialization-reviewed' : ''}`
1219
+ : 'correct_all_reported_violations_and_rerun_dry_run',
956
1220
  result_digest: '',
957
1221
  };
958
1222
  result.result_digest = todoSelfDigest(result, 'result_digest');
@@ -998,6 +1262,25 @@ async function status({ repoRoot }) {
998
1262
  return projectTodoStatus(await readTodoStore({ repoRoot }));
999
1263
  }
1000
1264
 
1265
+ async function adoptDashboardRoot({ repoRoot, env }) {
1266
+ const store = await readTodoStoreStable({ repoRoot });
1267
+ const actor = mutationActor(env);
1268
+ const identity = await resolveProjectIdentity({ repoRoot, projectId: store.project_id, env });
1269
+ await adoptTodoDashboardActivity({
1270
+ repoRoot, projectId: store.project_id, displayName: identity.displayName,
1271
+ sessionId: actor.session, env,
1272
+ });
1273
+ const result = {
1274
+ schema: 'lattice.todo_dashboard_adopt_result.v1',
1275
+ project_id: store.project_id,
1276
+ adopted: true,
1277
+ next_action: `open /projects/${encodeURIComponent(store.project_id)}/ on the dynamic dashboard`,
1278
+ result_digest: '',
1279
+ };
1280
+ result.result_digest = todoSelfDigest(result, 'result_digest');
1281
+ return result;
1282
+ }
1283
+
1001
1284
  async function todoDetail({ repoRoot, planKey, taskId }) {
1002
1285
  const store = await readTodoStore({ repoRoot });
1003
1286
  const [member] = selectMembers(store, planKey);
@@ -1008,11 +1291,12 @@ async function todoDetail({ repoRoot, planKey, taskId }) {
1008
1291
  repoRoot, store, planKey, taskId: task.task_id,
1009
1292
  });
1010
1293
  const result = {
1011
- schema: 'lattice.todo_detail_result.v1',
1294
+ schema: 'lattice.todo_detail_result.v2',
1012
1295
  project_id: store.project_id,
1013
1296
  plan_key: planKey,
1014
1297
  plan_version: member.plan.plan_version,
1015
1298
  task,
1299
+ design_memo: designMemoProjection(task),
1016
1300
  state,
1017
1301
  note_context: context,
1018
1302
  result_digest: '',
@@ -1762,107 +2046,6 @@ async function loadNarratives(store, repoRoot) {
1762
2046
  return { narratives, bindings };
1763
2047
  }
1764
2048
 
1765
- async function resolveOutput(repoRoot, outputRef) {
1766
- const canonicalRoot = await realpath(repoRoot);
1767
- const absolute = path.resolve(canonicalRoot, outputRef);
1768
- if (!within(canonicalRoot, absolute) || absolute === canonicalRoot) {
1769
- throw new TodoStoreError('OUTPUT_PATH_INVALID', 'output_path_outside_repo', undefined, { output_ref: outputRef });
1770
- }
1771
- let cursor = path.dirname(absolute);
1772
- const missing = [];
1773
- while (cursor !== canonicalRoot) {
1774
- try {
1775
- const stats = await lstat(cursor);
1776
- if (stats.isSymbolicLink() || !stats.isDirectory()) {
1777
- throw new TodoStoreError('OUTPUT_PATH_INVALID', 'unsafe_output_parent', undefined, { output_ref: outputRef });
1778
- }
1779
- const resolved = await realpath(cursor);
1780
- if (resolved !== cursor || !within(canonicalRoot, resolved)) {
1781
- throw new TodoStoreError('OUTPUT_PATH_INVALID', 'output_parent_alias_or_escape', undefined, { output_ref: outputRef });
1782
- }
1783
- break;
1784
- } catch (error) {
1785
- if (error instanceof TodoStoreError) throw error;
1786
- if (error?.code !== 'ENOENT') throw error;
1787
- missing.push(cursor);
1788
- cursor = path.dirname(cursor);
1789
- }
1790
- }
1791
- let target;
1792
- try { target = await lstat(absolute); } catch (error) { if (error?.code !== 'ENOENT') throw error; }
1793
- if (target !== undefined && (target.isSymbolicLink() || !target.isFile())) {
1794
- throw new TodoStoreError('OUTPUT_PATH_INVALID', 'unsafe_output_target', undefined, { output_ref: outputRef });
1795
- }
1796
- return { absolute, missing, target };
1797
- }
1798
-
1799
- async function atomicWriteOutput(repoRoot, outputRef, html) {
1800
- const { absolute } = await resolveOutput(repoRoot, outputRef);
1801
- await mkdir(path.dirname(absolute), { recursive: true });
1802
- // Re-resolve after mkdir so a raced alias cannot redirect the write.
1803
- const checked = await resolveOutput(repoRoot, outputRef);
1804
- const temporary = path.join(path.dirname(checked.absolute),
1805
- `.${path.basename(checked.absolute)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
1806
- let handle;
1807
- try {
1808
- handle = await open(temporary, 'wx', 0o600);
1809
- await handle.writeFile(html, 'utf8');
1810
- await handle.sync();
1811
- await handle.close();
1812
- handle = null;
1813
- await rename(temporary, checked.absolute);
1814
- } finally {
1815
- if (handle) await handle.close();
1816
- await rm(temporary, { force: true });
1817
- }
1818
- }
1819
-
1820
- function ganttDescriptorRef(outputRef) {
1821
- return `${outputRef}${GANTT_DESCRIPTOR_SUFFIX}`;
1822
- }
1823
-
1824
- // v2 records the scope the artifact was drawn at. Without it `gantt status`
1825
- // would re-render at the default scope and report a `--scope all` artifact as
1826
- // stale even though nothing in the store had moved.
1827
- function validateGanttArtifactDescriptor(value) {
1828
- return exactRecord(value, [
1829
- 'schema', 'project_id', 'output_ref', 'manifest_digest', 'renderer_version',
1830
- 'scope', 'html_digest', 'artifact_digest',
1831
- ]) && value.schema === 'lattice.todo_gantt_artifact.v2'
1832
- && isTodoIdentifier(value.project_id) && isTodoRef(value.output_ref)
1833
- && isTodoDigest(value.manifest_digest)
1834
- && typeof value.renderer_version === 'string'
1835
- && /^lattice\.todo_gantt_renderer\.v[1-9][0-9]*$/u.test(value.renderer_version)
1836
- && TODO_GANTT_SCOPES.includes(value.scope)
1837
- && isTodoDigest(value.html_digest) && isTodoDigest(value.artifact_digest)
1838
- && value.artifact_digest === todoSelfDigest(value, 'artifact_digest');
1839
- }
1840
-
1841
- async function readOptionalOutput(repoRoot, outputRef, maxBytes) {
1842
- const resolved = await resolveOutput(repoRoot, outputRef);
1843
- if (resolved.target === undefined) return null;
1844
- if (resolved.target.size > maxBytes) {
1845
- throw new TodoStoreError('GANTT_ARTIFACT_INVALID', 'artifact_size_limit_exceeded', undefined,
1846
- { output_ref: outputRef });
1847
- }
1848
- return readFile(resolved.absolute);
1849
- }
1850
-
1851
- function parseGanttDescriptor(bytes, descriptorRef) {
1852
- let value;
1853
- try {
1854
- const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
1855
- value = JSON.parse(text);
1856
- if (`${canonicalizeTodoArtifact(value)}\n` !== text || !validateGanttArtifactDescriptor(value)) {
1857
- throw new Error('non-canonical descriptor');
1858
- }
1859
- } catch {
1860
- throw new TodoStoreError('GANTT_ARTIFACT_INVALID', 'descriptor_invalid', undefined,
1861
- { descriptor_ref: descriptorRef });
1862
- }
1863
- return value;
1864
- }
1865
-
1866
2049
  /**
1867
2050
  * 図が描く全planについて独立性を投影する(ADR 0129 Decision 4)。
1868
2051
  *
@@ -2091,86 +2274,11 @@ export async function renderTodoGanttForProject({
2091
2274
  return { store, metadata, memberBindings, rendered };
2092
2275
  }
2093
2276
 
2094
- /** 公開配信面は呼び出し側の既定値忘れに依存せず、常にnote本文を除外する。 */
2277
+ /** 公開配信面はToDo本体の設計メモを表示し、append-only作業記録は除外する。 */
2095
2278
  export async function renderPublicTodoGanttForProject(options = {}) {
2096
2279
  return renderTodoGanttForProject({ ...options, includeNotes: false });
2097
2280
  }
2098
2281
 
2099
- async function gantt({ repoRoot, outputRef, env, scope = DEFAULT_GANTT_SCOPE }) {
2100
- const { store, metadata, memberBindings, rendered } = await renderTodoGanttForProject({
2101
- repoRoot, env, scope,
2102
- });
2103
- await atomicWriteOutput(repoRoot, outputRef, rendered.html);
2104
- const descriptor = { schema: 'lattice.todo_gantt_artifact.v2', project_id: store.project_id,
2105
- output_ref: outputRef, manifest_digest: metadata.manifest_digest,
2106
- renderer_version: TODO_GANTT_RENDERER_VERSION, scope, html_digest: rendered.html_digest,
2107
- artifact_digest: '' };
2108
- descriptor.artifact_digest = todoSelfDigest(descriptor, 'artifact_digest');
2109
- await atomicWriteOutput(repoRoot, ganttDescriptorRef(outputRef),
2110
- `${canonicalizeTodoArtifact(descriptor)}\n`);
2111
- const result = {
2112
- schema: 'lattice.todo_gantt_result.v1',
2113
- project_id: store.project_id,
2114
- output_ref: outputRef,
2115
- scope,
2116
- folded_task_count: metadata.folded_task_count,
2117
- manifest_digest: metadata.manifest_digest,
2118
- member_bindings: memberBindings,
2119
- narrative_bindings_digest: metadata.narrative_bindings_digest,
2120
- chain_digest: metadata.chain_digest,
2121
- layout_digest: metadata.layout_digest,
2122
- renderer_version: TODO_GANTT_RENDERER_VERSION,
2123
- html_digest: rendered.html_digest,
2124
- result_digest: '',
2125
- };
2126
- result.result_digest = todoSelfDigest(result, 'result_digest');
2127
- return result;
2128
- }
2129
-
2130
- async function ganttStatus({ repoRoot, outputRef, env }) {
2131
- const descriptorRef = ganttDescriptorRef(outputRef);
2132
- const [htmlBytes, descriptorBytes] = await Promise.all([
2133
- readOptionalOutput(repoRoot, outputRef, TODO_GANTT_HTML_MAX_BYTES),
2134
- readOptionalOutput(repoRoot, descriptorRef, MAX_GANTT_DESCRIPTOR_BYTES),
2135
- ]);
2136
- if ((htmlBytes === null) !== (descriptorBytes === null)) {
2137
- throw new TodoStoreError('GANTT_ARTIFACT_INVALID', 'artifact_pair_incomplete', undefined,
2138
- { output_ref: outputRef, descriptor_ref: descriptorRef });
2139
- }
2140
- if (htmlBytes === null) {
2141
- const store = await readTodoStore({ repoRoot });
2142
- const result = { schema: 'lattice.todo_gantt_status_result.v1', project_id: store.project_id,
2143
- output_ref: outputRef, descriptor_ref: descriptorRef, artifact_status: 'missing',
2144
- current_manifest_digest: store.manifest.manifest_digest, artifact_manifest_digest: null,
2145
- html_digest: null, renderer_version: null, scope: null, result_digest: '' };
2146
- result.result_digest = todoSelfDigest(result, 'result_digest');
2147
- return result;
2148
- }
2149
- const descriptor = parseGanttDescriptor(descriptorBytes, descriptorRef);
2150
- const htmlDigest = createHash('sha256').update(htmlBytes).digest('hex');
2151
- if (descriptor.output_ref !== outputRef || descriptor.html_digest !== htmlDigest) {
2152
- throw new TodoStoreError('GANTT_ARTIFACT_INVALID', 'artifact_digest_mismatch', undefined,
2153
- { output_ref: outputRef, descriptor_ref: descriptorRef });
2154
- }
2155
- // Re-render at the artifact's own scope: comparing a `--scope all` artifact
2156
- // against a default-scope render would report a false `stale`.
2157
- const current = await renderTodoGanttForProject({ repoRoot, env, scope: descriptor.scope });
2158
- if (descriptor.project_id !== current.store.project_id) {
2159
- throw new TodoStoreError('GANTT_ARTIFACT_INVALID', 'artifact_project_mismatch', undefined,
2160
- { output_ref: outputRef });
2161
- }
2162
- const artifactStatus = descriptor.renderer_version === TODO_GANTT_RENDERER_VERSION
2163
- && descriptor.manifest_digest === current.metadata.manifest_digest
2164
- && descriptor.html_digest === current.rendered.html_digest ? 'current' : 'stale';
2165
- const result = { schema: 'lattice.todo_gantt_status_result.v1',
2166
- project_id: current.store.project_id, output_ref: outputRef, descriptor_ref: descriptorRef,
2167
- artifact_status: artifactStatus, current_manifest_digest: current.metadata.manifest_digest,
2168
- artifact_manifest_digest: descriptor.manifest_digest, html_digest: descriptor.html_digest,
2169
- renderer_version: descriptor.renderer_version, scope: descriptor.scope, result_digest: '' };
2170
- result.result_digest = todoSelfDigest(result, 'result_digest');
2171
- return result;
2172
- }
2173
-
2174
2282
  async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_SCOPE }) {
2175
2283
  const initialStore = await readTodoStoreStable({ repoRoot });
2176
2284
  const identity = await resolveProjectIdentity({ repoRoot, projectId: initialStore.project_id, env });
@@ -2189,7 +2297,10 @@ async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_S
2189
2297
  repoRoot, store: await readTodoStoreStable({ repoRoot }),
2190
2298
  }),
2191
2299
  });
2192
- const result = { schema: 'lattice.todo_gantt_live_result.v2', project_id: live.projectId,
2300
+ const result = { schema: 'lattice.todo_gantt_live_result.v3', project_id: live.projectId,
2301
+ resource_scope: 'project', selection_scope: scope,
2302
+ included_plan_keys: initialStore.members.map(({ descriptor }) => descriptor.plan_key).sort(),
2303
+ media_type: 'text/html; charset=utf-8', dynamic: true,
2193
2304
  host: live.host, port: live.port, project_path: live.projectPath,
2194
2305
  url: live.url, events_url: live.eventsUrl };
2195
2306
  stdout.write(`${JSON.stringify(result)}\n`);
@@ -2283,10 +2394,18 @@ async function verify({ repoRoot, requestedPlanKey }) {
2283
2394
  ? sourceInventory.active.length : null,
2284
2395
  excluded_tombstone_count: reconciled
2285
2396
  ? sourceInventory.excluded_tombstones.length : null,
2397
+ reconciliation_guidance: {
2398
+ meaning: 'source_inventory_verification_state',
2399
+ lifecycle_blocked: false,
2400
+ dashboard_visibility_blocked: false,
2401
+ schema_command: reconciled ? null : 'lattice todo revise --schema --json',
2402
+ next_action: reconciled ? null
2403
+ : `lattice todo revise --plan ${member.descriptor.plan_key} --input <revision.json>`,
2404
+ },
2286
2405
  };
2287
2406
  });
2288
2407
  const result = {
2289
- schema: 'lattice.todo_verify_result.v2',
2408
+ schema: 'lattice.todo_verify_result.v3',
2290
2409
  project_id: store.project_id,
2291
2410
  requested_plan_key: requestedPlanKey,
2292
2411
  verified_members: verifiedMembers,
@@ -2327,6 +2446,14 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2327
2446
  throw new TypeError('runTodoCli optionsが不正');
2328
2447
  }
2329
2448
 
2449
+ if (argv[0] === 'migrate' && argv[1] === '--input'
2450
+ && typeof argv[2] === 'string' && path.isAbsolute(argv[2])) {
2451
+ return typedArgumentFailure(stderr, 'INPUT_OUTSIDE_REPOSITORY', 'absolute_input_path_rejected', {
2452
+ argument: '--input', expected: 'repo-relative path', actual: 'absolute path',
2453
+ next_action: 'place_the_input_inside_the_repository_and_pass_a_repo_relative_path',
2454
+ });
2455
+ }
2456
+
2330
2457
  // `--schema --json`はstoreを読まない決定的な出力(`plan create --schema`と同じ規律)。
2331
2458
  // 通常dispatchより前に処理し、repoRoot解決やdashboard daemon起動を経由させない。
2332
2459
  if (argv.length === 3 && argv[1] === '--schema' && argv[2] === '--json'
@@ -2345,6 +2472,9 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2345
2472
  if ((argv.length === 1 && argv[0] === 'status')
2346
2473
  || (argv.length === 2 && argv[0] === 'status' && argv[1] === '--json')) {
2347
2474
  action = (repoRoot) => status({ repoRoot });
2475
+ } else if (argv.length === 3 && argv[0] === 'dashboard'
2476
+ && argv[1] === 'adopt' && argv[2] === '--json') {
2477
+ action = (repoRoot) => adoptDashboardRoot({ repoRoot, env });
2348
2478
  } else if (argv.length === 6 && argv[0] === 'show'
2349
2479
  && argv[1] === '--plan' && isTodoIdentifier(argv[2])
2350
2480
  && argv[3] === '--task' && isTodoIdentifier(argv[4]) && argv[5] === '--json') {
@@ -2432,22 +2562,6 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2432
2562
  } else if (argv.length === 4 && argv[0] === 'snapshot' && argv[1] === '--rebuild'
2433
2563
  && argv[2] === '--plan' && isTodoIdentifier(argv[3])) {
2434
2564
  action = (repoRoot) => rebuildSnapshot({ repoRoot, planKey: argv[3] });
2435
- } else if (argv.length === 1 && argv[0] === 'gantt') {
2436
- action = (repoRoot) => gantt({ repoRoot, outputRef: DEFAULT_GANTT_REF, env });
2437
- } else if (argv.length === 3 && argv[0] === 'gantt' && argv[1] === '--scope'
2438
- && TODO_GANTT_SCOPES.includes(argv[2])) {
2439
- action = (repoRoot) => gantt({ repoRoot, outputRef: DEFAULT_GANTT_REF, env, scope: argv[2] });
2440
- } else if (argv.length === 3 && argv[0] === 'gantt' && argv[1] === '--out'
2441
- && isTodoRef(argv[2])) {
2442
- action = (repoRoot) => gantt({ repoRoot, outputRef: argv[2], env });
2443
- } else if (argv.length === 5 && argv[0] === 'gantt' && argv[1] === '--out'
2444
- && isTodoRef(argv[2]) && argv[3] === '--scope' && TODO_GANTT_SCOPES.includes(argv[4])) {
2445
- action = (repoRoot) => gantt({ repoRoot, outputRef: argv[2], env, scope: argv[4] });
2446
- } else if (argv.length === 2 && argv[0] === 'gantt' && argv[1] === 'status') {
2447
- action = (repoRoot) => ganttStatus({ repoRoot, outputRef: DEFAULT_GANTT_REF, env });
2448
- } else if (argv.length === 4 && argv[0] === 'gantt' && argv[1] === 'status'
2449
- && argv[2] === '--out' && isTodoRef(argv[3])) {
2450
- action = (repoRoot) => ganttStatus({ repoRoot, outputRef: argv[3], env });
2451
2565
  } else if (argv.length === 4 && argv[0] === 'gantt' && argv[1] === 'serve'
2452
2566
  && argv[2] === '--port' && /^(?:0|[1-9][0-9]{0,4})$/u.test(argv[3])
2453
2567
  && Number(argv[3]) <= 65_535) {
@@ -2459,6 +2573,19 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2459
2573
  action = (repoRoot) => serveGantt({
2460
2574
  repoRoot, port: Number(argv[3]), stdout, env, scope: argv[5],
2461
2575
  });
2576
+ } else if (argv[0] === 'gantt') {
2577
+ action = () => {
2578
+ throw new TodoStoreError('STATIC_GANTT_RETIRED', 'dynamic_dashboard_only', undefined, {
2579
+ next_action: 'lattice todo status --json',
2580
+ });
2581
+ };
2582
+ } else if ((argv.length === 5 || argv.length === 6) && argv[0] === 'migrate'
2583
+ && argv[1] === '--input' && isTodoRef(argv[2])
2584
+ && argv[3] === '--dry-run' && argv[4] === '--json'
2585
+ && (argv.length === 5 || argv[5] === '--serialization-reviewed')) {
2586
+ action = (repoRoot) => migrateDryRun({
2587
+ repoRoot, inputRef: argv[2], serializationReviewed: argv.length === 6,
2588
+ });
2462
2589
  } else if ((argv.length === 3 || argv.length === 4) && argv[0] === 'migrate' && argv[1] === '--input'
2463
2590
  && isTodoRef(argv[2]) && (argv.length === 3 || argv[3] === '--serialization-reviewed')) {
2464
2591
  action = (repoRoot) => migrate({
@@ -2553,12 +2680,27 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2553
2680
  action = (repoRoot) => mutate({ repoRoot, env, planKey: argv[2], taskId: argv[4],
2554
2681
  kind: 'reopen', payload: { reason: argv[6], override_reason: overrideReason }, evidenceRef: null });
2555
2682
  }
2556
- if (action === null) return usageFailure(stderr, argv);
2683
+ if (action === null) {
2684
+ const command = typeof argv[0] === 'string' ? argv[0] : null;
2685
+ if (command !== null && !TODO_COMMAND_NAMES.includes(command)) {
2686
+ return typedArgumentFailure(stderr, 'UNKNOWN_SUBCOMMAND', 'todo_subcommand_unknown', {
2687
+ command, available_commands: TODO_COMMAND_NAMES,
2688
+ next_action: 'lattice todo --help',
2689
+ });
2690
+ }
2691
+ return typedArgumentFailure(stderr, 'INVALID_ARGUMENTS', 'todo_arguments_invalid', {
2692
+ command, next_action: command === null ? 'lattice todo --help' : `lattice todo ${command} --help`,
2693
+ });
2694
+ }
2557
2695
 
2558
2696
  try {
2559
2697
  const repoRoot = resolveRepoRoot(cwd);
2560
2698
  const manualServe = argv[0] === 'gantt' && argv[1] === 'serve';
2561
- if (!manualServe) await ensureActiveProjectDashboard({ repoRoot, env });
2699
+ const dashboardAdopt = argv[0] === 'dashboard' && argv[1] === 'adopt';
2700
+ const migrationDryRun = argv[0] === 'migrate' && argv.includes('--dry-run');
2701
+ if (!manualServe && !dashboardAdopt && !migrationDryRun) {
2702
+ await ensureActiveProjectDashboard({ repoRoot, env });
2703
+ }
2562
2704
  const result = await action(repoRoot);
2563
2705
  if (result !== null) stdout.write(`${JSON.stringify(result)}\n`);
2564
2706
  return 0;