@quolu/lattice 0.38.1 → 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.
@@ -27,6 +27,8 @@ const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/;
27
27
  const CONTROL = /[\u0000-\u001f\u007f]/u;
28
28
  const NOTE_FORBIDDEN_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
29
29
 
30
+ export const TODO_DESIGN_MEMO_PROMPT = 'あなたがこのToDoに対して、何も考えていないならば、設計メモに `NO_PLAN` と書いてください';
31
+
30
32
  export const isTodoDigest = (value) => typeof value === 'string' && DIGEST.test(value);
31
33
  export const isTodoIdentifier = (value) => typeof value === 'string' && IDENTIFIER.test(value);
32
34
  export const isNonNegativeSafeInteger = (value) => Number.isSafeInteger(value) && value >= 0;
@@ -157,6 +159,9 @@ export function validateTodoNoteContext(value) {
157
159
 
158
160
  const nullableDigest = (value) => value === null || isTodoDigest(value);
159
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);
160
165
  const actor = (value) => exactRecord(value, ['host', 'session', 'agent'])
161
166
  && [value.host, value.session, value.agent].every(isTodoIdentifier);
162
167
  const provenance = (value) => value === null || (exactRecord(value, ['source_commit', 'source_event_digest'])
@@ -246,6 +251,33 @@ function taskV4(value) {
246
251
  && isTodoIdentifier(value.phase_id);
247
252
  }
248
253
 
254
+ function taskV5(value) {
255
+ return exactRecord(value, [
256
+ 'task_id', 'title', 'lane', 'design_memo', 'narrative_ref', 'narrative_anchor',
257
+ 'compile_binding', 'parent_task_id',
258
+ ]) && isTodoIdentifier(value.task_id) && nullableText(value.title) && isTodoIdentifier(value.lane)
259
+ && isTodoDesignMemo(value.design_memo)
260
+ && (value.narrative_ref === null || isTodoRef(value.narrative_ref))
261
+ && (value.narrative_anchor === null || (validateTodoNarrativeAnchor(value.narrative_anchor)
262
+ && value.narrative_ref === value.narrative_anchor.origin_plan_ref))
263
+ && compileBinding(value.compile_binding)
264
+ && (value.parent_task_id === null || isTodoIdentifier(value.parent_task_id));
265
+ }
266
+
267
+ function taskV6(value) {
268
+ return exactRecord(value, [
269
+ 'task_id', 'title', 'lane', 'design_memo', 'narrative_ref', 'narrative_anchor',
270
+ 'compile_binding', 'parent_task_id', 'phase_id',
271
+ ]) && isTodoIdentifier(value.task_id) && nullableText(value.title) && isTodoIdentifier(value.lane)
272
+ && isTodoDesignMemo(value.design_memo)
273
+ && (value.narrative_ref === null || isTodoRef(value.narrative_ref))
274
+ && (value.narrative_anchor === null || (validateTodoNarrativeAnchor(value.narrative_anchor)
275
+ && value.narrative_ref === value.narrative_anchor.origin_plan_ref))
276
+ && compileBinding(value.compile_binding)
277
+ && (value.parent_task_id === null || isTodoIdentifier(value.parent_task_id))
278
+ && isTodoIdentifier(value.phase_id);
279
+ }
280
+
249
281
  function phaseV1(value) {
250
282
  return exactRecord(value, [
251
283
  'phase_id', 'title', 'gate_policy', 'predecessor_phase_ids', 'required_evidence_slots',
@@ -300,13 +332,18 @@ export function validateTodoPlan(value) {
300
332
  const taskValidator = value?.schema === 'lattice.todo_plan.v1' ? taskV1
301
333
  : value?.schema === 'lattice.todo_plan.v2' ? taskV2
302
334
  : value?.schema === 'lattice.todo_plan.v3' ? taskV3
303
- : ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(value?.schema) ? taskV4 : null;
335
+ : ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(value?.schema) ? taskV4
336
+ : value?.schema === 'lattice.todo_plan.v6' ? taskV5
337
+ : value?.schema === 'lattice.todo_plan.v7' ? taskV6 : null;
304
338
  const planKeys = [
305
339
  'schema', 'project_id', 'plan_key', 'plan_version', 'predecessor_plan_digest',
306
340
  'tasks', 'hard_dependencies', 'joins', 'topology_digest', 'plan_digest',
307
341
  ];
308
- if (['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(value?.schema)) planKeys.push('phases');
309
- if (value?.schema === 'lattice.todo_plan.v5') planKeys.push('phase_accept_dependencies');
342
+ if (['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7']
343
+ .includes(value?.schema)) planKeys.push('phases');
344
+ if (['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(value?.schema)) {
345
+ planKeys.push('phase_accept_dependencies');
346
+ }
310
347
  if (!exactRecord(value, planKeys) || taskValidator === null || !isTodoIdentifier(value.project_id)
311
348
  || !isTodoIdentifier(value.plan_key) || !isTodoIdentifier(value.plan_version)
312
349
  || !nullableDigest(value.predecessor_plan_digest) || !Array.isArray(value.tasks)
@@ -319,9 +356,10 @@ export function validateTodoPlan(value) {
319
356
  && Array.isArray(join.after) && join.after.length > 0 && join.after.length <= TODO_LIMITS.tasksPerPlan
320
357
  && join.after.every(nodeRef) && nodeRef(join.before))
321
358
  || !isTodoDigest(value.topology_digest) || !isTodoDigest(value.plan_digest)
322
- || (['lattice.todo_plan.v3', 'lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(value.schema)
359
+ || (['lattice.todo_plan.v3', 'lattice.todo_plan.v4', 'lattice.todo_plan.v5',
360
+ 'lattice.todo_plan.v6', 'lattice.todo_plan.v7'].includes(value.schema)
323
361
  && !validParentGraph(value.tasks))) return false;
324
- if (['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(value.schema)
362
+ if (['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(value.schema)
325
363
  && (!Array.isArray(value.phases) || value.phases.length === 0
326
364
  || value.phases.length > TODO_LIMITS.tasksPerPlan || !value.phases.every(phaseV1)
327
365
  || value.phases.some((entry, index) => index > 0
@@ -329,7 +367,7 @@ export function validateTodoPlan(value) {
329
367
  || new Set(value.phases.map(({ phase_id }) => phase_id)).size !== value.phases.length
330
368
  || value.tasks.some(({ phase_id }) => !value.phases.some((phase) => phase.phase_id === phase_id))
331
369
  || !validPhaseGraph(value.phases))) return false;
332
- if (value.schema === 'lattice.todo_plan.v5'
370
+ if (['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(value.schema)
333
371
  && (!Array.isArray(value.phase_accept_dependencies)
334
372
  || value.phase_accept_dependencies.length > TODO_LIMITS.edgesPerPlan
335
373
  || !value.phase_accept_dependencies.every((edge) => exactRecord(edge, ['from', 'to'])
@@ -347,9 +385,9 @@ export function validateTodoPlan(value) {
347
385
  const topology = {
348
386
  project_id: value.project_id, plan_key: value.plan_key, plan_version: value.plan_version,
349
387
  tasks: value.tasks, hard_dependencies: value.hard_dependencies, joins: value.joins,
350
- ...(['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(value.schema)
388
+ ...(['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(value.schema)
351
389
  ? { phases: value.phases } : {}),
352
- ...(value.schema === 'lattice.todo_plan.v5'
390
+ ...(['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(value.schema)
353
391
  ? { phase_accept_dependencies: value.phase_accept_dependencies } : {}),
354
392
  };
355
393
  return value.topology_digest === digestTodoArtifact(topology)
@@ -151,11 +151,11 @@ export async function readVisibleTodoDashboardProjects({
151
151
 
152
152
  export async function registerTodoDashboardActivity({
153
153
  repoRoot, projectId, displayName = projectId, sessionId, env = process.env,
154
- now = new Date(),
154
+ now = new Date(), adopt = false,
155
155
  }) {
156
156
  if (!path.isAbsolute(repoRoot) || !identifier(projectId) || !identifier(sessionId)
157
157
  || typeof displayName !== 'string' || displayName.length === 0 || displayName !== displayName.trim()
158
- || !(now instanceof Date) || !Number.isFinite(now.getTime())) {
158
+ || !(now instanceof Date) || !Number.isFinite(now.getTime()) || typeof adopt !== 'boolean') {
159
159
  throw new TypeError('dashboard activity is invalid');
160
160
  }
161
161
  const canonicalRoot = await realpath(repoRoot);
@@ -163,13 +163,27 @@ export async function registerTodoDashboardActivity({
163
163
  await mkdir(refs.root, { recursive: true, mode: 0o700 });
164
164
  await withLock(refs.lock, async () => {
165
165
  const current = validateRegistry(await readJson(refs.registry, { schema: REGISTRY_SCHEMA, projects: [] }));
166
+ const existing = current.projects.find((entry) => entry.project_id === projectId);
167
+ if (existing !== undefined && existing.repo_root !== canonicalRoot && !adopt) {
168
+ const error = new Error('project id is already owned by another canonical root');
169
+ error.code = 'PROJECT_ROOT_CONFLICT';
170
+ // 公開・CLI errorへlocal absolute pathを運ばない。project_idだけで人と機械が
171
+ // 衝突対象を特定でき、registry bytesはこの分岐より後で一切変更しない。
172
+ error.detail = { project_id: projectId };
173
+ throw error;
174
+ }
166
175
  const projects = current.projects.filter((entry) => entry.project_id !== projectId);
167
176
  projects.push({ project_id: projectId, display_name: displayName, repo_root: canonicalRoot,
168
177
  session_id: sessionId, last_seen_at: now.toISOString() });
169
178
  projects.sort((left, right) => left.project_id.localeCompare(right.project_id, 'en'));
170
179
  await atomicJson(refs.registry, { schema: REGISTRY_SCHEMA, projects });
171
180
  });
172
- return { projectId, displayName, repoRoot: canonicalRoot, sessionId };
181
+ return { projectId, displayName, repoRoot: canonicalRoot, sessionId, adopted: adopt };
182
+ }
183
+
184
+ /** 配信元rootを移す唯一の明示入口。通常activity登録はこのflagを立てない。 */
185
+ export async function adoptTodoDashboardActivity(options) {
186
+ return registerTodoDashboardActivity({ ...options, adopt: true });
173
187
  }
174
188
 
175
189
  function validDaemonDescriptor(descriptor) {
@@ -21,6 +21,16 @@ function renderNoteContext(context) {
21
21
  return `<section class="work-log"><h2>作業記録</h2>${entries}${overflow}<p class="work-log-head">note head: <code>${escapeHtmlText(context.note_head_digest ?? 'none')}</code></p></section>`;
22
22
  }
23
23
 
24
+ function renderDesignMemo(task) {
25
+ if (typeof task.design_memo !== 'string') {
26
+ return '<section class="design-memo"><h2>設計メモ</h2><p class="note-warning">未登録(legacy ToDo)</p></section>';
27
+ }
28
+ const rendered = renderTodoMarkdown(task.design_memo);
29
+ const filtering = rendered.discarded.length === 0 ? ''
30
+ : '<p class="note-warning">安全上表示できない要素を除外しました。</p>';
31
+ return `<section class="design-memo"><h2>設計メモ</h2><div class="design-memo-body">${rendered.html}</div>${filtering}</section>`;
32
+ }
33
+
24
34
  /**
25
35
  * 図の外が語るための独立性要約を、plan単位の投影から引ける形へ畳む(ADR 0129 Decision 3)。
26
36
  */
@@ -150,9 +160,10 @@ export function renderRightPane(
150
160
  const independenceNote = renderIndependenceNote(section.ref, node, independenceSummary);
151
161
  // Say it plainly when the reader will not find this ToDo on the diagram.
152
162
  const foldedNote = !folds.has(key) ? ''
153
- : '<p class="fold-note">完走済みのため図には描いていません。図に出すには <code>lattice todo gantt --scope all</code> を実行してください。</p>';
163
+ : '<p class="fold-note">完走済みのため図には描いていません。図に出すには動的dashboardを <code>lattice todo gantt serve --port &lt;port&gt; --scope all</code> で起動してください。</p>';
154
164
  const workLog = notesEnabled ? renderNoteContext(section.noteContext) : '';
155
- 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}<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>`;
165
+ const designMemo = renderDesignMemo(section.task);
166
+ 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>`;
156
167
  }).join('');
157
168
  const taskIndex = renderTaskIndex(sections, lookup, folds, planActivity(readModel));
158
169
  return `<div class="right-toolbar"><button type="button" data-show-overview>概要</button><button type="button" data-show-selected hidden>選択工程へ戻る</button><button type="button" data-show-task-index>全工程一覧</button></div><div class="right-content">${overview}<div data-right-panel="details" hidden>${details}</div><section class="task-index" data-right-panel="task-index" hidden><h1>全工程</h1><p>Latticeに登録された全工程を現在の状態とともに表示しています。planは動いているものを最終活動の新しい順で上に、完走したものを古い順で下にまとめ、plan内は登録順です。</p>${taskIndex}</section></div>`;
@@ -172,7 +183,7 @@ export function renderDiagramLegend(presentation, layout = null, expandable = fa
172
183
  const foldNote = foldedCount === 0 ? ''
173
184
  : expandable
174
185
  ? '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。上のバッジを押すと外した工程も含めて描きます。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>'
175
- : '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。外した工程は右の「全工程」から辿れ、図に出すには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>';
186
+ : '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。外した工程は右の「全工程」から辿れ、図に出すには動的dashboardを <code>lattice todo gantt serve --port &lt;port&gt; --scope all</code> で起動してください。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>';
176
187
  const independenceLegend = layout.independence === null ? ''
177
188
  : '<span>∥ 独立検証済</span><span>⛓ 要直列</span><span>? 未検査</span>';
178
189
  // 独立性の記録がある間は「全件同時dispatchが既定」と無条件に述べない(ADR 0129 Decision 3)。
@@ -116,7 +116,8 @@ export function renderPhaseProgress(readModel) {
116
116
  const rows = [];
117
117
  const settledRows = [];
118
118
  for (const member of readModel.members) {
119
- if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(member.plan.schema)) continue;
119
+ if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7']
120
+ .includes(member.plan.schema)) continue;
120
121
  // snapshot artifactの形式には縛られない導出ビュー(member.phases)を読む(ADR 0147)。
121
122
  const phases = new Map(member.phases.map((phase) => [phase.phase_id, phase]));
122
123
  for (const phase of member.plan.phases) {
@@ -130,7 +131,9 @@ export function renderPhaseProgress(readModel) {
130
131
  (SETTLED_PHASE_STATUS.includes(state.status) ? settledRows : rows).push(row);
131
132
  }
132
133
  }
133
- const decoupled = readModel.members.some(({ plan }) => plan.schema === 'lattice.todo_plan.v5');
134
+ const decoupled = readModel.members.some(({ plan }) => (
135
+ ['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(plan.schema)
136
+ ));
134
137
  const guidance = decoupled
135
138
  ? 'ToDo完了とPhase受理は別です。Phaseは重監査の順序を表し、通常ToDoの開始順はToDo依存だけで決まります。'
136
139
  : 'ToDo完了とPhase受理は別です。<code>gate_ready</code>では後続Phaseはまだ解放されません。';
@@ -251,7 +251,7 @@ function readyTaskKeys(readModel, nodes, nodesByKey, incoming) {
251
251
  member.plan.project_id, member.plan.plan_key, phase.phase_id,
252
252
  ]), phase.status);
253
253
  }
254
- if (member.plan.schema === 'lattice.todo_plan.v5') {
254
+ if (['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(member.plan.schema)) {
255
255
  for (const edge of member.plan.phase_accept_dependencies ?? []) {
256
256
  const target = refKey(refOf(edge.to, 'phase_accept_dependencies.to'));
257
257
  if (!nodesByKey.has(target)) {
@@ -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 schema !== 'lattice.todo_plan.v4' && schema !== 'lattice.todo_plan.v5';
62
+ return !['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(schema);
63
63
  }
64
64
 
65
65
  /**
@@ -3,6 +3,7 @@ import {
3
3
  exactRecord,
4
4
  isStrictTodoTimestamp,
5
5
  isTodoDigest,
6
+ isTodoDesignMemo,
6
7
  isTodoIdentifier,
7
8
  isTodoRef,
8
9
  todoSelfDigest,
@@ -15,6 +16,7 @@ import {
15
16
 
16
17
  export const TODO_EXTRACTION_SCHEMA = 'lattice.todo_extraction.v1';
17
18
  export const TODO_EXTRACTION_SCHEMA_V2 = 'lattice.todo_extraction.v2';
19
+ export const TODO_EXTRACTION_SCHEMA_V3 = 'lattice.todo_extraction.v3';
18
20
 
19
21
  const V1_DISPOSITIONS = new Set([
20
22
  'register_pending',
@@ -93,14 +95,17 @@ function historicalStart(value) {
93
95
  }
94
96
 
95
97
  function extractionTask(value, schema) {
96
- const v2 = schema === TODO_EXTRACTION_SCHEMA_V2;
98
+ const v2 = [TODO_EXTRACTION_SCHEMA_V2, TODO_EXTRACTION_SCHEMA_V3].includes(schema);
99
+ const v3 = schema === TODO_EXTRACTION_SCHEMA_V3;
97
100
  const keys = [
98
101
  'task_id', 'title', 'lane', 'narrative_ref', 'compile_binding', 'disposition',
99
102
  'completion', 'source', 'migration_context',
100
103
  ];
101
104
  if (v2) keys.push('start');
105
+ if (v3) keys.push('design_memo');
102
106
  if (!exactRecord(value, keys) || !isTodoIdentifier(value.task_id) || !boundedText(value.title)
103
107
  || !isTodoIdentifier(value.lane) || (value.narrative_ref !== null && !isTodoRef(value.narrative_ref))
108
+ || (v3 && !isTodoDesignMemo(value.design_memo))
104
109
  || value.compile_binding !== null || !(v2 ? V2_DISPOSITIONS : V1_DISPOSITIONS).has(value.disposition)
105
110
  || !sourceLocation(value.source) || !migrationContext(value.migration_context)) return false;
106
111
  if (!v2) return value.disposition === 'register_done' ? completion(value.completion) : value.completion === null;
@@ -126,7 +131,7 @@ function validateJoins(value) {
126
131
  && sortedStrictly(value, (join) => join.id);
127
132
  }
128
133
 
129
- const reject = (reason, path = '') => ({ valid: false, reason, path });
134
+ const reject = (reason, path = '', detail = {}) => ({ valid: false, reason, path, ...detail });
130
135
 
131
136
  /** value自体がexactRecordの対象になれる素朴なobjectかどうか(配列・nullを除く)。 */
132
137
  function plainObject(value) {
@@ -175,10 +180,13 @@ export function explainTodoExtraction(value) {
175
180
  try {
176
181
  if (!plainObject(value)) return reject('not_an_object', '');
177
182
  const schema = value.schema;
178
- if (![TODO_EXTRACTION_SCHEMA, TODO_EXTRACTION_SCHEMA_V2].includes(schema)) {
179
- return reject('schema_mismatch', '/schema');
183
+ if (![TODO_EXTRACTION_SCHEMA, TODO_EXTRACTION_SCHEMA_V2, TODO_EXTRACTION_SCHEMA_V3].includes(schema)) {
184
+ return reject('schema_mismatch', '/schema', {
185
+ expected: TODO_EXTRACTION_SCHEMA_V3, actual: typeof schema === 'string' ? schema : null,
186
+ });
180
187
  }
181
- const v2 = schema === TODO_EXTRACTION_SCHEMA_V2;
188
+ const v2 = [TODO_EXTRACTION_SCHEMA_V2, TODO_EXTRACTION_SCHEMA_V3].includes(schema);
189
+ const v3 = schema === TODO_EXTRACTION_SCHEMA_V3;
182
190
  const topKeys = [
183
191
  'schema', 'project_id', 'plan_key', 'plan_version', 'actor', 'recorded_at',
184
192
  'tasks', 'hard_dependencies', 'joins', 'extraction_digest',
@@ -199,11 +207,33 @@ export function explainTodoExtraction(value) {
199
207
  'start', 'completion', 'source', 'migration_context']
200
208
  : ['task_id', 'title', 'lane', 'narrative_ref', 'compile_binding', 'disposition',
201
209
  'completion', 'source', 'migration_context'];
210
+ if (v3) taskKeys.push('design_memo');
202
211
  for (const [index, task] of value.tasks.entries()) {
203
212
  const taskKeyCheck = explainKeys(task, taskKeys, `/tasks/${index}`);
204
213
  if (!taskKeyCheck.valid) return taskKeyCheck;
214
+ if (v3 && !isTodoDesignMemo(task.design_memo)) {
215
+ return reject('design_memo_required', `/tasks/${index}/design_memo`, {
216
+ task_id: isTodoIdentifier(task.task_id) ? task.task_id : null,
217
+ expected: 'non-empty Markdown or NO_PLAN',
218
+ });
219
+ }
220
+ if (task.disposition === 'register_done'
221
+ && task.completion?.done_mode !== 'historical_import') {
222
+ return reject('enum_mismatch', `/tasks/${index}/completion/done_mode`, {
223
+ task_id: isTodoIdentifier(task.task_id) ? task.task_id : null,
224
+ expected: 'historical_import', actual: task.completion?.done_mode ?? null,
225
+ });
226
+ }
227
+ if (!Array.isArray(task.migration_context?.notes)) {
228
+ return reject('expected_array', `/tasks/${index}/migration_context/notes`, {
229
+ task_id: isTodoIdentifier(task.task_id) ? task.task_id : null,
230
+ expected: 'array', actual: typeof task.migration_context?.notes,
231
+ });
232
+ }
205
233
  if (!extractionTask(task, schema)) {
206
- return reject('task_shape_invalid', `/tasks/${index}`);
234
+ return reject('task_shape_invalid', `/tasks/${index}`, {
235
+ task_id: isTodoIdentifier(task.task_id) ? task.task_id : null,
236
+ });
207
237
  }
208
238
  }
209
239
  const sortCheck = explainSortedStrictly(value.tasks, (task) => task.task_id, '/tasks');
@@ -216,7 +246,9 @@ export function explainTodoExtraction(value) {
216
246
  if (!isTodoDigest(value.extraction_digest)) return reject('invalid_digest', '/extraction_digest');
217
247
  const expectedDigest = todoSelfDigest(value, 'extraction_digest');
218
248
  if (value.extraction_digest !== expectedDigest) {
219
- return reject('extraction_digest_mismatch', '/extraction_digest');
249
+ return reject('extraction_digest_mismatch', '/extraction_digest', {
250
+ expected: expectedDigest, actual: value.extraction_digest,
251
+ });
220
252
  }
221
253
  const taskIds = new Set(value.tasks.map(({ task_id: taskId }) => taskId));
222
254
  const badParent = value.tasks.find((task) => task.source.parent_task_id === task.task_id
@@ -255,7 +287,7 @@ export function validateTodoExtraction(value) {
255
287
  if (!exactRecord(value, [
256
288
  'schema', 'project_id', 'plan_key', 'plan_version', 'actor', 'recorded_at',
257
289
  'tasks', 'hard_dependencies', 'joins', 'extraction_digest',
258
- ]) || ![TODO_EXTRACTION_SCHEMA, TODO_EXTRACTION_SCHEMA_V2].includes(schema)
290
+ ]) || ![TODO_EXTRACTION_SCHEMA, TODO_EXTRACTION_SCHEMA_V2, TODO_EXTRACTION_SCHEMA_V3].includes(schema)
259
291
  || !isTodoIdentifier(value.project_id)
260
292
  || !isTodoIdentifier(value.plan_key) || !isTodoIdentifier(value.plan_version)
261
293
  || !actor(value.actor) || !isStrictTodoTimestamp(value.recorded_at)
@@ -317,7 +349,8 @@ export function compileTodoExtraction(value, repoRoot) {
317
349
  repositories: [{ repo_id: 'self', path: '.' }],
318
350
  },
319
351
  plan: {
320
- schema: 'lattice.todo_plan.v2',
352
+ schema: value.schema === TODO_EXTRACTION_SCHEMA_V3
353
+ ? 'lattice.todo_plan.v6' : 'lattice.todo_plan.v2',
321
354
  project_id: value.project_id,
322
355
  plan_key: value.plan_key,
323
356
  plan_version: value.plan_version,
@@ -326,9 +359,13 @@ export function compileTodoExtraction(value, repoRoot) {
326
359
  task_id: task.task_id,
327
360
  title: task.title,
328
361
  lane: task.lane,
362
+ ...(value.schema === TODO_EXTRACTION_SCHEMA_V3
363
+ ? { design_memo: task.design_memo } : {}),
329
364
  narrative_ref: task.narrative_ref,
330
365
  narrative_anchor: null,
331
366
  compile_binding: null,
367
+ ...(value.schema === TODO_EXTRACTION_SCHEMA_V3
368
+ ? { parent_task_id: task.source.parent_task_id } : {}),
332
369
  })),
333
370
  hard_dependencies: value.hard_dependencies,
334
371
  joins: value.joins,
@@ -66,14 +66,14 @@ export function phaseTodoRevisionPlanVersion({
66
66
  projectId, planKey, predecessor, desiredPlan, taskMigration, phaseMigration,
67
67
  }) {
68
68
  const versionDigest = digestTodoArtifact({
69
- schema: desiredPlan.schema === 'lattice.todo_plan.v5'
69
+ schema: ['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(desiredPlan.schema)
70
70
  ? 'lattice.phase_todo_revision_version.v2' : 'lattice.phase_todo_revision_version.v1', project_id: projectId,
71
71
  plan_key: planKey, predecessor, desired_topology: {
72
72
  schema: desiredPlan.schema, project_id: desiredPlan.project_id,
73
73
  plan_key: desiredPlan.plan_key, predecessor_plan_digest: desiredPlan.predecessor_plan_digest,
74
74
  tasks: desiredPlan.tasks, phases: desiredPlan.phases,
75
75
  hard_dependencies: desiredPlan.hard_dependencies, joins: desiredPlan.joins,
76
- ...(desiredPlan.schema === 'lattice.todo_plan.v5'
76
+ ...(['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(desiredPlan.schema)
77
77
  ? { phase_accept_dependencies: desiredPlan.phase_accept_dependencies } : {}),
78
78
  }, task_migration: taskMigration, phase_migration: phaseMigration,
79
79
  });
@@ -295,8 +295,9 @@ export function validatePhaseTodoRevision(value) {
295
295
  if (!exactRecord(value, keys) || (!revisionV1 && !revisionV2 && !revisionV3)) return false;
296
296
  if (!isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)) return false;
297
297
  if (!validPredecessor(value.predecessor) || !validateTodoPlan(value.desired_plan)
298
- || value.desired_plan.schema !== ((revisionV2 || revisionV3)
299
- ? 'lattice.todo_plan.v5' : 'lattice.todo_plan.v4')
298
+ || !(revisionV3
299
+ ? ['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(value.desired_plan.schema)
300
+ : value.desired_plan.schema === (revisionV2 ? 'lattice.todo_plan.v5' : 'lattice.todo_plan.v4'))
300
301
  || value.desired_plan.project_id !== value.project_id
301
302
  || value.desired_plan.plan_key !== value.plan_key
302
303
  || value.desired_plan.predecessor_plan_digest !== value.predecessor.plan_digest
@@ -370,7 +371,7 @@ export function validateTodoRevision(value) {
370
371
  || !exactRecord(value, revisionV1 ? REVISION_V1_KEYS : REVISION_V2_KEYS)
371
372
  || !isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
372
373
  || !validPredecessor(value.predecessor) || !validateTodoPlan(value.desired_plan)
373
- || value.desired_plan.schema !== 'lattice.todo_plan.v3'
374
+ || !['lattice.todo_plan.v3', 'lattice.todo_plan.v6'].includes(value.desired_plan.schema)
374
375
  || value.desired_plan.project_id !== value.project_id
375
376
  || value.desired_plan.plan_key !== value.plan_key
376
377
  || value.desired_plan.predecessor_plan_digest !== value.predecessor.plan_digest
@@ -729,7 +730,7 @@ export function explainTodoRevision(value) {
729
730
  const predecessorCheck = explainPredecessor(value.predecessor);
730
731
  if (!predecessorCheck.valid) return predecessorCheck;
731
732
  if (!validateTodoPlan(value.desired_plan)) return reject('desired_plan_invalid', '/desired_plan');
732
- if (value.desired_plan.schema !== 'lattice.todo_plan.v3') {
733
+ if (!['lattice.todo_plan.v3', 'lattice.todo_plan.v6'].includes(value.desired_plan.schema)) {
733
734
  return reject('desired_plan_schema_mismatch', '/desired_plan/schema');
734
735
  }
735
736
  if (value.desired_plan.project_id !== value.project_id) {
@@ -817,8 +818,9 @@ export function explainPhaseTodoRevision(value) {
817
818
  const predecessorCheck = explainPredecessor(value.predecessor);
818
819
  if (!predecessorCheck.valid) return predecessorCheck;
819
820
  if (!validateTodoPlan(value.desired_plan)) return reject('desired_plan_invalid', '/desired_plan');
820
- const expectedPlanSchema = (revisionV2 || revisionV3) ? 'lattice.todo_plan.v5' : 'lattice.todo_plan.v4';
821
- if (value.desired_plan.schema !== expectedPlanSchema) {
821
+ const expectedPlanSchemas = revisionV3 ? ['lattice.todo_plan.v5', 'lattice.todo_plan.v7']
822
+ : [revisionV2 ? 'lattice.todo_plan.v5' : 'lattice.todo_plan.v4'];
823
+ if (!expectedPlanSchemas.includes(value.desired_plan.schema)) {
822
824
  return reject('desired_plan_schema_mismatch', '/desired_plan/schema');
823
825
  }
824
826
  if (value.desired_plan.project_id !== value.project_id) {
@@ -247,9 +247,9 @@ function buildTodoGraph(readModel) {
247
247
  status: state.status,
248
248
  blocked_reason: state.blocked_reason,
249
249
  plan_schema: member.plan.schema,
250
- phase_id: ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(member.plan.schema)
250
+ phase_id: ['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(member.plan.schema)
251
251
  ? task.phase_id : null,
252
- phase_status: ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(member.plan.schema)
252
+ phase_status: ['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(member.plan.schema)
253
253
  ? phases.get(task.phase_id)?.status : null,
254
254
  phase_ready: member.plan.schema !== 'lattice.todo_plan.v4'
255
255
  || phases.get(task.phase_id)?.status === 'active',
@@ -272,7 +272,7 @@ function buildTodoGraph(readModel) {
272
272
  for (const join of member.plan.joins) {
273
273
  for (const after of join.after) addPredecessor(after, join.before);
274
274
  }
275
- if (member.plan.schema === 'lattice.todo_plan.v5') {
275
+ if (['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(member.plan.schema)) {
276
276
  for (const edge of member.plan.phase_accept_dependencies) {
277
277
  const target = refKey(edge.to);
278
278
  if (!nodes.has(target)) fail('TODO_STATUS_INVALID_INPUT', 'todo_status_dependency_dangling');