@quolu/lattice 0.12.10 → 0.12.12

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
@@ -20,6 +20,7 @@ import { projectTodoChainV1 } from './todo-chain.mjs';
20
20
  import { ensureTodoDashboardActivity } from './todo-dashboard-registry.mjs';
21
21
  import { resolveProjectIdentity } from './project-identity.mjs';
22
22
  import { layoutTodoGantt } from './todo-gantt-layout.mjs';
23
+ import { TODO_GANTT_SCOPES } from './todo-gantt-scope.mjs';
23
24
  import { loadTodoGanttPresentation } from './todo-gantt-presentation.mjs';
24
25
  import { startTodoGanttLiveServer } from './todo-gantt-live.mjs';
25
26
  import {
@@ -55,6 +56,7 @@ const CLI_ERROR_SCHEMA = 'lattice.cli_error.v2';
55
56
  const DEFAULT_GANTT_REF = '.lattice/generated/gantt.html';
56
57
  const GANTT_DESCRIPTOR_SUFFIX = '.status.json';
57
58
  const MAX_GANTT_DESCRIPTOR_BYTES = 65_536;
59
+ const DEFAULT_GANTT_SCOPE = 'live';
58
60
  const MAX_MIGRATION_INPUT_BYTES = 8_388_608;
59
61
  const ACTOR_ENV_KEYS = Object.freeze([
60
62
  'LATTICE_TODO_ACTOR_HOST',
@@ -596,15 +598,19 @@ function ganttDescriptorRef(outputRef) {
596
598
  return `${outputRef}${GANTT_DESCRIPTOR_SUFFIX}`;
597
599
  }
598
600
 
601
+ // v2 records the scope the artifact was drawn at. Without it `gantt status`
602
+ // would re-render at the default scope and report a `--scope all` artifact as
603
+ // stale even though nothing in the store had moved.
599
604
  function validateGanttArtifactDescriptor(value) {
600
605
  return exactRecord(value, [
601
606
  'schema', 'project_id', 'output_ref', 'manifest_digest', 'renderer_version',
602
- 'html_digest', 'artifact_digest',
603
- ]) && value.schema === 'lattice.todo_gantt_artifact.v1'
607
+ 'scope', 'html_digest', 'artifact_digest',
608
+ ]) && value.schema === 'lattice.todo_gantt_artifact.v2'
604
609
  && isTodoIdentifier(value.project_id) && isTodoRef(value.output_ref)
605
610
  && isTodoDigest(value.manifest_digest)
606
611
  && typeof value.renderer_version === 'string'
607
612
  && /^lattice\.todo_gantt_renderer\.v[1-9][0-9]*$/u.test(value.renderer_version)
613
+ && TODO_GANTT_SCOPES.includes(value.scope)
608
614
  && isTodoDigest(value.html_digest) && isTodoDigest(value.artifact_digest)
609
615
  && value.artifact_digest === todoSelfDigest(value, 'artifact_digest');
610
616
  }
@@ -636,6 +642,7 @@ function parseGanttDescriptor(bytes, descriptorRef) {
636
642
 
637
643
  export async function renderTodoGanttForProject({
638
644
  repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
645
+ scope = DEFAULT_GANTT_SCOPE,
639
646
  }) {
640
647
  const store = readModel
641
648
  ?? (stable ? await readTodoStoreStable({ repoRoot }) : await readTodoStore({ repoRoot }));
@@ -645,7 +652,7 @@ export async function renderTodoGanttForProject({
645
652
  const presentation = await loadTodoGanttPresentation({ repoRoot, readModel: store });
646
653
  const topology = mergedTopology(store);
647
654
  const chain = projectTodoChainV1(topology);
648
- const layout = layoutTodoGantt(store, chain);
655
+ const layout = layoutTodoGantt(store, chain, { scope });
649
656
  const narrative = await loadNarratives(store, repoRoot);
650
657
  const anchorOutcomes = verifyNarrativeAnchors({
651
658
  readModel: store,
@@ -673,6 +680,7 @@ export async function renderTodoGanttForProject({
673
680
  layout_digest: digestTodoArtifact(layout),
674
681
  renderer_version: TODO_GANTT_RENDERER_VERSION,
675
682
  project_display_name: identity.displayName,
683
+ folded_task_count: layout.scope.folded_task_count,
676
684
  };
677
685
  const rendered = renderTodoGanttHtml({
678
686
  readModel: store,
@@ -685,12 +693,14 @@ export async function renderTodoGanttForProject({
685
693
  return { store, metadata, memberBindings, rendered };
686
694
  }
687
695
 
688
- async function gantt({ repoRoot, outputRef, env }) {
689
- const { store, metadata, memberBindings, rendered } = await renderTodoGanttForProject({ repoRoot, env });
696
+ async function gantt({ repoRoot, outputRef, env, scope = DEFAULT_GANTT_SCOPE }) {
697
+ const { store, metadata, memberBindings, rendered } = await renderTodoGanttForProject({
698
+ repoRoot, env, scope,
699
+ });
690
700
  await atomicWriteOutput(repoRoot, outputRef, rendered.html);
691
- const descriptor = { schema: 'lattice.todo_gantt_artifact.v1', project_id: store.project_id,
701
+ const descriptor = { schema: 'lattice.todo_gantt_artifact.v2', project_id: store.project_id,
692
702
  output_ref: outputRef, manifest_digest: metadata.manifest_digest,
693
- renderer_version: TODO_GANTT_RENDERER_VERSION, html_digest: rendered.html_digest,
703
+ renderer_version: TODO_GANTT_RENDERER_VERSION, scope, html_digest: rendered.html_digest,
694
704
  artifact_digest: '' };
695
705
  descriptor.artifact_digest = todoSelfDigest(descriptor, 'artifact_digest');
696
706
  await atomicWriteOutput(repoRoot, ganttDescriptorRef(outputRef),
@@ -699,6 +709,8 @@ async function gantt({ repoRoot, outputRef, env }) {
699
709
  schema: 'lattice.todo_gantt_result.v1',
700
710
  project_id: store.project_id,
701
711
  output_ref: outputRef,
712
+ scope,
713
+ folded_task_count: metadata.folded_task_count,
702
714
  manifest_digest: metadata.manifest_digest,
703
715
  member_bindings: memberBindings,
704
716
  narrative_bindings_digest: metadata.narrative_bindings_digest,
@@ -727,7 +739,7 @@ async function ganttStatus({ repoRoot, outputRef, env }) {
727
739
  const result = { schema: 'lattice.todo_gantt_status_result.v1', project_id: store.project_id,
728
740
  output_ref: outputRef, descriptor_ref: descriptorRef, artifact_status: 'missing',
729
741
  current_manifest_digest: store.manifest.manifest_digest, artifact_manifest_digest: null,
730
- html_digest: null, renderer_version: null, result_digest: '' };
742
+ html_digest: null, renderer_version: null, scope: null, result_digest: '' };
731
743
  result.result_digest = todoSelfDigest(result, 'result_digest');
732
744
  return result;
733
745
  }
@@ -737,7 +749,9 @@ async function ganttStatus({ repoRoot, outputRef, env }) {
737
749
  throw new TodoStoreError('GANTT_ARTIFACT_INVALID', 'artifact_digest_mismatch', undefined,
738
750
  { output_ref: outputRef, descriptor_ref: descriptorRef });
739
751
  }
740
- const current = await renderTodoGanttForProject({ repoRoot, env });
752
+ // Re-render at the artifact's own scope: comparing a `--scope all` artifact
753
+ // against a default-scope render would report a false `stale`.
754
+ const current = await renderTodoGanttForProject({ repoRoot, env, scope: descriptor.scope });
741
755
  if (descriptor.project_id !== current.store.project_id) {
742
756
  throw new TodoStoreError('GANTT_ARTIFACT_INVALID', 'artifact_project_mismatch', undefined,
743
757
  { output_ref: outputRef });
@@ -749,12 +763,12 @@ async function ganttStatus({ repoRoot, outputRef, env }) {
749
763
  project_id: current.store.project_id, output_ref: outputRef, descriptor_ref: descriptorRef,
750
764
  artifact_status: artifactStatus, current_manifest_digest: current.metadata.manifest_digest,
751
765
  artifact_manifest_digest: descriptor.manifest_digest, html_digest: descriptor.html_digest,
752
- renderer_version: descriptor.renderer_version, result_digest: '' };
766
+ renderer_version: descriptor.renderer_version, scope: descriptor.scope, result_digest: '' };
753
767
  result.result_digest = todoSelfDigest(result, 'result_digest');
754
768
  return result;
755
769
  }
756
770
 
757
- async function serveGantt({ repoRoot, port, stdout, env }) {
771
+ async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_SCOPE }) {
758
772
  const initialStore = await readTodoStoreStable({ repoRoot });
759
773
  const identity = await resolveProjectIdentity({ repoRoot, projectId: initialStore.project_id, env });
760
774
  const live = await startTodoGanttLiveServer({
@@ -763,7 +777,7 @@ async function serveGantt({ repoRoot, port, stdout, env }) {
763
777
  port,
764
778
  render: async () => {
765
779
  const { rendered, metadata } = await renderTodoGanttForProject({
766
- repoRoot, stable: true, displayName: identity.displayName,
780
+ repoRoot, stable: true, displayName: identity.displayName, scope,
767
781
  });
768
782
  return { html: rendered.html, head_digest: metadata.manifest_digest };
769
783
  },
@@ -919,9 +933,15 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
919
933
  action = (repoRoot) => rebuildSnapshot({ repoRoot, planKey: argv[3] });
920
934
  } else if (argv.length === 1 && argv[0] === 'gantt') {
921
935
  action = (repoRoot) => gantt({ repoRoot, outputRef: DEFAULT_GANTT_REF, env });
936
+ } else if (argv.length === 3 && argv[0] === 'gantt' && argv[1] === '--scope'
937
+ && TODO_GANTT_SCOPES.includes(argv[2])) {
938
+ action = (repoRoot) => gantt({ repoRoot, outputRef: DEFAULT_GANTT_REF, env, scope: argv[2] });
922
939
  } else if (argv.length === 3 && argv[0] === 'gantt' && argv[1] === '--out'
923
940
  && isTodoRef(argv[2])) {
924
941
  action = (repoRoot) => gantt({ repoRoot, outputRef: argv[2], env });
942
+ } else if (argv.length === 5 && argv[0] === 'gantt' && argv[1] === '--out'
943
+ && isTodoRef(argv[2]) && argv[3] === '--scope' && TODO_GANTT_SCOPES.includes(argv[4])) {
944
+ action = (repoRoot) => gantt({ repoRoot, outputRef: argv[2], env, scope: argv[4] });
925
945
  } else if (argv.length === 2 && argv[0] === 'gantt' && argv[1] === 'status') {
926
946
  action = (repoRoot) => ganttStatus({ repoRoot, outputRef: DEFAULT_GANTT_REF, env });
927
947
  } else if (argv.length === 4 && argv[0] === 'gantt' && argv[1] === 'status'
@@ -931,6 +951,13 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
931
951
  && argv[2] === '--port' && /^(?:0|[1-9][0-9]{0,4})$/u.test(argv[3])
932
952
  && Number(argv[3]) <= 65_535) {
933
953
  action = (repoRoot) => serveGantt({ repoRoot, port: Number(argv[3]), stdout, env });
954
+ } else if (argv.length === 6 && argv[0] === 'gantt' && argv[1] === 'serve'
955
+ && argv[2] === '--port' && /^(?:0|[1-9][0-9]{0,4})$/u.test(argv[3])
956
+ && Number(argv[3]) <= 65_535 && argv[4] === '--scope'
957
+ && TODO_GANTT_SCOPES.includes(argv[5])) {
958
+ action = (repoRoot) => serveGantt({
959
+ repoRoot, port: Number(argv[3]), stdout, env, scope: argv[5],
960
+ });
934
961
  } else if (argv.length === 3 && argv[0] === 'migrate' && argv[1] === '--input'
935
962
  && isTodoRef(argv[2])) {
936
963
  action = (repoRoot) => migrate({ repoRoot, inputRef: argv[2] });
@@ -6,7 +6,7 @@ import {
6
6
  } from './todo-markdown-renderer.mjs';
7
7
  import { renderTodoGanttSvg, TODO_GANTT_STATUS_PRESENTATION } from './todo-gantt-svg.mjs';
8
8
 
9
- export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v8';
9
+ export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v9';
10
10
  export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
11
11
  export const TODO_GANTT_HTML_MAX_BYTES = 24 * 1024 * 1024;
12
12
 
@@ -126,7 +126,23 @@ function normalizeSections(readModel, narratives, anchorOutcomes) {
126
126
  return { sections: result, proseBytes };
127
127
  }
128
128
 
129
- function renderTaskIndex(sections, lookup) {
129
+ /** task node key -> the fold node standing in for it, empty when nothing folded. */
130
+ function foldIndex(layout) {
131
+ return new Map((layout?.folded ?? []).map((entry) => [refKey(entry.task), refKey(entry.fold)]));
132
+ }
133
+
134
+ function renderTaskIndexEntry(section, lookup, folds) {
135
+ const key = refKey(section.ref);
136
+ const status = DOCUMENT_STATUS[section.state.status] ?? { mark: '?', label: '状態不明' };
137
+ const blockedReason = section.state.status === 'blocked'
138
+ ? `<span class="task-index-blocked-reason">— ${escapeHtmlText(section.state.blocked_reason ?? '理由未記録')}</span>` : '';
139
+ // A folded ToDo keeps its row here — the index is the complete list — but
140
+ // selecting it points at the fold node that actually stands on the diagram.
141
+ const selectKey = folds.get(key) ?? key;
142
+ return `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(selectKey)}"><span class="task-index-status status-${escapeHtmlAttribute(section.state.status)}" role="img" aria-label="${escapeHtmlAttribute(status.label)}">${escapeHtmlText(status.mark)}</span><span class="task-index-reference">${escapeHtmlText(taskReference(section, lookup))}</span><strong>${escapeHtmlText(section.task.title)}</strong>${blockedReason}</button></li>`;
143
+ }
144
+
145
+ function renderTaskIndex(sections, lookup, folds = new Map()) {
130
146
  const plans = [];
131
147
  for (const section of sections) {
132
148
  let plan = plans.at(-1);
@@ -136,13 +152,15 @@ function renderTaskIndex(sections, lookup) {
136
152
  }
137
153
  plan.tasks.push(section);
138
154
  }
139
- return plans.map((plan) => `<section class="task-index-plan"><h2><code>${escapeHtmlText(plan.planKey)}</code></h2><ol class="task-index-list">${plan.tasks.map((section) => {
140
- const key = refKey(section.ref);
141
- const status = DOCUMENT_STATUS[section.state.status] ?? { mark: '?', label: '状態不明' };
142
- const blockedReason = section.state.status === 'blocked'
143
- ? `<span class="task-index-blocked-reason">— ${escapeHtmlText(section.state.blocked_reason ?? '理由未記録')}</span>` : '';
144
- return `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(key)}"><span class="task-index-status status-${escapeHtmlAttribute(section.state.status)}" role="img" aria-label="${escapeHtmlAttribute(status.label)}">${escapeHtmlText(status.mark)}</span><span class="task-index-reference">${escapeHtmlText(taskReference(section, lookup))}</span><strong>${escapeHtmlText(section.task.title)}</strong>${blockedReason}</button></li>`;
145
- }).join('')}</ol></section>`).join('');
155
+ return plans.map((plan) => {
156
+ const drawn = plan.tasks.filter((section) => !folds.has(refKey(section.ref)));
157
+ const folded = plan.tasks.filter((section) => folds.has(refKey(section.ref)));
158
+ const drawnList = drawn.length === 0 ? ''
159
+ : `<ol class="task-index-list">${drawn.map((section) => renderTaskIndexEntry(section, lookup, folds)).join('')}</ol>`;
160
+ const foldedList = folded.length === 0 ? ''
161
+ : `<details class="task-index-folded"><summary>完走済みとして畳んだ工程 ${folded.length}件</summary><ol class="task-index-list">${folded.map((section) => renderTaskIndexEntry(section, lookup, folds)).join('')}</ol></details>`;
162
+ return `<section class="task-index-plan"><h2><code>${escapeHtmlText(plan.planKey)}</code></h2>${drawnList}${foldedList}</section>`;
163
+ }).join('');
146
164
  }
147
165
 
148
166
  function presentationLookup(presentation) {
@@ -240,14 +258,19 @@ function renderRightPane(sections, layout, presentation, readModel) {
240
258
  : incoming.get(key).length === 0 ? '<p class="readiness-note">登録済みの前提工程はありません。図だけではdispatch可否を判定しません。</p>' : '';
241
259
  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}<section><h2>前提工程</h2>${renderRelationList(incoming.get(key), sectionByKey, lookup, '登録済みの前提工程はありません。')}</section><section><h2>後続工程</h2>${renderRelationList(outgoing.get(key), sectionByKey, lookup, '登録済みの後続工程はありません。')}</section><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>`;
242
260
  }).join('');
243
- const taskIndex = renderTaskIndex(sections, lookup);
261
+ const taskIndex = renderTaskIndex(sections, lookup, foldIndex(layout));
244
262
  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>元Markdown全文</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に登録された全工程を、現在の状態とともに登録順で表示しています。</p>${taskIndex}</section></div>`;
245
263
  }
246
264
 
247
- function renderDiagramLegend(presentation) {
265
+ function renderDiagramLegend(presentation, layout = null) {
248
266
  const categories = (presentation?.lanes ?? []).map((lane) => `<div class="category-entry"><dt><code>${escapeHtmlText(lane.lane)}</code> — ${escapeHtmlText(lane.name)}</dt><dd>${escapeHtmlText(lane.description)}</dd></div>`).join('');
249
267
  const categoryDetails = categories === '' ? '' : `<details class="category-legend"><summary>カテゴリ説明</summary><dl>${categories}</dl></details>`;
250
- return `<div class="diagram-legend" aria-label="工程図の凡例"><span>${statusMarkup('pending', ' 未着手')}</span><span>${statusMarkup('in-progress', ' 作業中')}</span><span>${statusMarkup('done', ' 完了')}</span><span>${statusMarkup('blocked', ' ブロック中')}</span><span>破線枠: ready frontier(同時dispatch推奨)</span><span>太線: 構造上の最長依存鎖</span><span>半円: 非接触の線交差</span><span>黒丸: 論理上の合流</span>${categoryDetails}<p>縦方向は時間ではなく、登録済み依存関係による工程段階です。ready frontierは全件同時dispatchが既定です。未登録の資源・host制約によりsubsetだけを選ぶ場合は理由を記録します。構造上の最長依存鎖は各工程を同じ重みとして数え、実時間・工数・納期を表しません。</p></div>`;
268
+ const foldedCount = layout?.scope?.folded_task_count ?? 0;
269
+ const foldChip = foldedCount === 0 ? ''
270
+ : `<span class="fold-chip">▣ 完走済み ${foldedCount}件を畳んで表示中</span>`;
271
+ const foldNote = foldedCount === 0 ? ''
272
+ : `<p class="fold-note">後続に作業中・未着手が残っていない完了工程を、${layout.scope.fold_node_count}個の畳み込みノードへまとめています。生きた工程とその直接の前提工程は必ず展開したままです。全件を描くには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は畳み込み前の全工程で数えています。</p>`;
273
+ return `<div class="diagram-legend" aria-label="工程図の凡例"><span>${statusMarkup('pending', ' 未着手')}</span><span>${statusMarkup('in-progress', ' 作業中')}</span><span>${statusMarkup('done', ' 完了')}</span><span>${statusMarkup('blocked', ' ブロック中')}</span><span>破線枠: ready frontier(同時dispatch推奨)</span><span>太線: 構造上の最長依存鎖</span><span>半円: 非接触の線交差</span><span>黒丸: 論理上の合流</span>${foldChip}${categoryDetails}${foldNote}<p>縦方向は時間ではなく、登録済み依存関係による工程段階です。ready frontierは全件同時dispatchが既定です。未登録の資源・host制約によりsubsetだけを選ぶ場合は理由を記録します。構造上の最長依存鎖は各工程を同じ重みとして数え、実時間・工数・納期を表しません。</p></div>`;
251
274
  }
252
275
 
253
276
  const CSS = `
@@ -310,6 +333,12 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
310
333
  .todo-node .node-title{fill:var(--text-primary);font-size:13.5px;font-weight:400}
311
334
  .todo-node .node-title-line{font-size:13.5px;font-weight:400}
312
335
  .todo-node .status-mark{fill:var(--text-secondary);font-size:13.5px;font-weight:400}
336
+ .folded-node .node-surface{fill:var(--surface-2);stroke:var(--border);stroke-width:2;stroke-dasharray:none}
337
+ .folded-node .node-title,.folded-node .node-meta{fill:var(--text-secondary)}
338
+ .fold-chip{padding:2px 8px;border:1px solid var(--border);border-radius:9999px;background:var(--surface-2);color:var(--text-primary);font-weight:650}
339
+ .fold-note{flex:1 0 100%;margin:4px 0 0;color:var(--text-secondary);font-weight:400}
340
+ .task-index-folded{margin-top:8px}
341
+ .task-index-folded>summary{cursor:pointer;padding:6px 0;color:var(--text-secondary);font-weight:600}
313
342
  .status-in-progress .node-surface{fill:var(--surface-1);stroke:var(--accent);stroke-width:2}
314
343
  .status-in-progress .status-mark{fill:var(--accent)}
315
344
  .status-in-progress .status-bar{stroke:var(--accent);stroke-width:2;stroke-linecap:round}
@@ -405,7 +434,7 @@ export function renderTodoGanttHtml({
405
434
  metadata,
406
435
  presentation,
407
436
  });
408
- const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong><button type="button" data-zoom-action="out" aria-label="縮小">−</button><button type="button" data-zoom-action="reset">等倍</button><button type="button" data-zoom-action="in" aria-label="拡大">+</button><button type="button" data-zoom-action="fit">全体表示</button><output class="zoom-readout" data-zoom-output aria-live="polite">100%</output><span class="diagram-note">縦=依存段階(時間ではない)</span></div>${renderDiagramLegend(presentation)}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${svg}</div></section><div class="pane-divider" data-pane-divider aria-hidden="true"></div><aside class="narrative-pane" aria-label="選択工程の詳細と全工程一覧">${rightPane}</aside></main><script type="application/json" id="todo-gantt-data">${staticData}</script><script>${CONTROLLER}</script></body></html>`;
437
+ const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong><button type="button" data-zoom-action="out" aria-label="縮小">−</button><button type="button" data-zoom-action="reset">等倍</button><button type="button" data-zoom-action="in" aria-label="拡大">+</button><button type="button" data-zoom-action="fit">全体表示</button><output class="zoom-readout" data-zoom-output aria-live="polite">100%</output><span class="diagram-note">縦=依存段階(時間ではない)</span></div>${renderDiagramLegend(presentation, layout)}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${svg}</div></section><div class="pane-divider" data-pane-divider aria-hidden="true"></div><aside class="narrative-pane" aria-label="選択工程の詳細と全工程一覧">${rightPane}</aside></main><script type="application/json" id="todo-gantt-data">${staticData}</script><script>${CONTROLLER}</script></body></html>`;
409
438
  const htmlBytes = Buffer.byteLength(html, 'utf8');
410
439
  if (htmlBytes > TODO_GANTT_HTML_MAX_BYTES) {
411
440
  throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt HTML limit exceeded', {
@@ -1,3 +1,5 @@
1
+ import { TODO_GANTT_SCOPES, projectTodoGanttScope } from './todo-gantt-scope.mjs';
2
+
1
3
  const TASK_LIMIT = 2_000;
2
4
  const EDGE_LIMIT = 8_000;
3
5
  const SWEEP_ROUNDS = 4;
@@ -370,18 +372,21 @@ function crossingCount(edges, wave, transversePosition) {
370
372
  return total;
371
373
  }
372
374
 
373
- export function layoutTodoGantt(readModel, chainProjection) {
374
- const { nodes, nodesByKey, edges } = normalizeInput(readModel, chainProjection);
375
- const { incoming, outgoing, wave } = assignWaves(nodes, nodesByKey, edges);
376
- const layers = orderLayers(nodes, wave, incoming, outgoing);
377
- const transversePosition = new Map();
378
- for (const layer of layers) {
379
- for (let index = 0; index < layer.length; index += 1) transversePosition.set(layer[index], index);
375
+ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
376
+ const scope = options.scope ?? 'live';
377
+ if (!TODO_GANTT_SCOPES.includes(scope)) {
378
+ fail('TODO_LAYOUT_INVALID_INPUT', `scope must be one of ${TODO_GANTT_SCOPES.join(', ')}`);
380
379
  }
381
380
 
381
+ // Every structural number below is measured on the FULL graph: the dependency
382
+ // waves, the longest dependency chain and the ready frontier describe the real
383
+ // plan. Folding first and measuring second would make all three lie.
384
+ const full = normalizeInput(readModel, chainProjection);
385
+ const fullWaves = assignWaves(full.nodes, full.nodesByKey, full.edges);
386
+
382
387
  const longestNodeKeys = new Set(chainProjection.longest_chain_node_refs.map((ref, index) => {
383
388
  const key = refKey(refOf(ref, `longest_chain_node_refs[${index}]`));
384
- if (!nodesByKey.has(key)) fail('TODO_LAYOUT_INVALID_INPUT', 'chain projection references an absent task');
389
+ if (!full.nodesByKey.has(key)) fail('TODO_LAYOUT_INVALID_INPUT', 'chain projection references an absent task');
385
390
  return key;
386
391
  }));
387
392
  const longestEdgeKeys = new Set(chainProjection.longest_chain_edges.map((edge, index) => {
@@ -389,12 +394,28 @@ export function layoutTodoGantt(readModel, chainProjection) {
389
394
  const from = refKey(refOf(edge.from, `longest_chain_edges[${index}].from`));
390
395
  const to = refKey(refOf(edge.to, `longest_chain_edges[${index}].to`));
391
396
  const key = JSON.stringify([from, to]);
392
- if (!edges.some((candidate) => candidate.key === key)) {
397
+ if (!full.edges.some((candidate) => candidate.key === key)) {
393
398
  fail('TODO_LAYOUT_INVALID_INPUT', 'chain projection edge is absent from the read model');
394
399
  }
395
400
  return key;
396
401
  }));
397
- const readyKeys = readyTaskKeys(readModel, nodes, nodesByKey, incoming);
402
+ const readyKeys = readyTaskKeys(readModel, full.nodes, full.nodesByKey, fullWaves.incoming);
403
+
404
+ // Only the geometry stage below sees the narrowed graph.
405
+ const projected = scope === 'all'
406
+ ? { nodes: full.nodes, edges: full.edges, foldedByKey: new Map(), folds: [], refined: false }
407
+ : projectTodoGanttScope({
408
+ nodes: full.nodes, edges: full.edges, wave: fullWaves.wave, longestChainKeys: longestNodeKeys,
409
+ });
410
+ const nodes = projected.nodes;
411
+ const edges = projected.edges;
412
+ const nodesByKey = new Map(nodes.map((node) => [node.key, node]));
413
+ const { incoming, outgoing, wave } = assignWaves(nodes, nodesByKey, edges);
414
+ const layers = orderLayers(nodes, wave, incoming, outgoing);
415
+ const transversePosition = new Map();
416
+ for (const layer of layers) {
417
+ for (let index = 0; index < layer.length; index += 1) transversePosition.set(layer[index], index);
418
+ }
398
419
  const visibleKeys = new Set(nodes.map(({ key }) => key));
399
420
  const displayBranches = edges.flatMap((edge, semanticIndex) => {
400
421
  const identities = [...edge.joinIdentities.entries()]
@@ -456,10 +477,19 @@ export function layoutTodoGantt(readModel, chainProjection) {
456
477
  ref: { ...node.ref }, title: node.title, lane: node.lane, status: node.status,
457
478
  wave: wave.get(node.key), row: transversePosition.get(node.key), visible,
458
479
  visibility: {
459
- longest_dependency_chain: longestNodeKeys.has(node.key),
480
+ // A fold node stands in for a finished branch, so it inherits the chain
481
+ // marking of the ToDos it replaced rather than carrying one of its own.
482
+ longest_dependency_chain: node.fold === undefined
483
+ ? longestNodeKeys.has(node.key) : node.fold.longest_chain_task_count > 0,
460
484
  active: node.status === 'in-progress', next_ready: readyKeys.has(node.key),
461
485
  selected: false,
462
486
  },
487
+ fold: node.fold === undefined ? null : {
488
+ task_count: node.fold.task_count,
489
+ lanes: [...node.fold.lanes],
490
+ longest_chain_task_count: node.fold.longest_chain_task_count,
491
+ task_refs: node.fold.task_refs.map((entry) => ({ ...entry })),
492
+ },
463
493
  geometry,
464
494
  };
465
495
  });
@@ -566,9 +596,11 @@ export function layoutTodoGantt(readModel, chainProjection) {
566
596
  for (const [x] of connector.route) routeMaximumX = Math.max(routeMaximumX, x);
567
597
  }
568
598
 
599
+ // Counts stay honest: the summary chips report every ToDo in the plan, not
600
+ // only the ones the narrowed diagram happens to draw.
569
601
  const planMap = new Map();
570
602
  const laneMap = new Map();
571
- for (const node of nodes) {
603
+ for (const node of full.nodes) {
572
604
  if (!planMap.has(node.ref.plan_key)) planMap.set(node.ref.plan_key, 0);
573
605
  planMap.set(node.ref.plan_key, planMap.get(node.ref.plan_key) + 1);
574
606
  const key = groupKey(node.ref.plan_key, node.lane);
@@ -592,12 +624,34 @@ export function layoutTodoGantt(readModel, chainProjection) {
592
624
  plans: [...planMap.entries()].map(([plan_key, task_count]) => ({ plan_key, task_count })),
593
625
  lanes: [...laneMap.values()],
594
626
  },
627
+ scope: {
628
+ requested: scope,
629
+ folded_task_count: projected.foldedByKey.size,
630
+ fold_node_count: projected.folds.length,
631
+ per_wave_refinement: projected.refined,
632
+ folds: projected.folds.map((entry) => ({
633
+ ref: { ...entry.ref },
634
+ task_count: entry.task_count,
635
+ lanes: [...entry.lanes],
636
+ longest_chain_task_count: entry.longest_chain_task_count,
637
+ })),
638
+ },
639
+ folded: [...projected.foldedByKey.entries()]
640
+ .map(([taskKey, foldKey]) => ({ task: JSON.parse(taskKey), fold: JSON.parse(foldKey) }))
641
+ .sort((left, right) => compareRefs(
642
+ { project_id: left.task[0], plan_key: left.task[1], task_id: left.task[2] },
643
+ { project_id: right.task[0], plan_key: right.task[1], task_id: right.task[2] },
644
+ ))
645
+ .map(({ task, fold }) => ({
646
+ task: { project_id: task[0], plan_key: task[1], task_id: task[2] },
647
+ fold: { project_id: fold[0], plan_key: fold[1], task_id: fold[2] },
648
+ })),
595
649
  metrics: {
596
650
  crossing_count: crossingCount(edges, wave, transversePosition),
597
651
  visible_node_count: visibleKeys.size,
598
652
  visible_edge_count: projectedEdges.filter(({ visible }) => visible).length,
599
- task_count: nodes.length,
600
- edge_count: edges.length,
653
+ task_count: full.nodes.length,
654
+ edge_count: full.edges.length,
601
655
  },
602
656
  };
603
657
  }