@quolu/lattice 0.12.10 → 0.12.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.12.10",
3
+ "version": "0.12.11",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,7 +54,7 @@
54
54
  "test": "node scripts/run-product-tests.mjs",
55
55
  "test:sensor": "npm --prefix sensor test",
56
56
  "precheck": "node --check src/bridge-launch-agent.mjs",
57
- "check": "node --check bin/lattice.mjs && node --check bin/lattice-mcp.mjs && node --check bin/lattice-dashboard.mjs && node --check bin/lattice-bridge.mjs && node --check src/cli-stdio.mjs && node --check src/bridge-config.mjs && node --check src/bridge-server.mjs && node --check src/bridge-daemon.mjs && node --check src/bridge-cli.mjs && node --check src/project-cli.mjs && node --check src/sensor-cli.mjs && node --check src/sensor-runtime.mjs && node --check src/sensor-adapter.mjs && node --check src/factory-diagnostics.mjs && node --check src/runtime-errors.mjs && node --check src/runtime-contracts.mjs && node --check src/runtime-event-store.mjs && node --check src/hash-chain.mjs && node --check src/dag-chain.mjs && node --check src/todo-contracts.mjs && node --check src/todo-chain.mjs && node --check src/todo-store.mjs && node --check src/todo-migration.mjs && node --check src/todo-revision.mjs && node --check src/todo-status.mjs && node --check src/todo-cli.mjs && node --check src/todo-dashboard-registry.mjs && node --check src/todo-gantt-presentation.mjs && node --check src/todo-gantt-live.mjs && node --check src/bounded-seam.mjs && node --check src/todo-narrative-anchor.mjs && node --check src/todo-markdown-renderer.mjs && node --check src/todo-gantt-svg.mjs && node --check src/todo-gantt-html.mjs && node --check src/runtime-projection.mjs && node --check src/runtime-decision-verifier.mjs && node --check src/runtime-front-end.mjs && node --check src/runtime-cli.mjs && node --check src/rc3-dogfood-scaffold.mjs && node --check src/runtime-engine.mjs && node --check src/runtime-scripted-executor.mjs && node --check src/runtime-diff-observer.mjs && node --check src/runtime-worktree-executor.mjs && node --check src/runtime-hold-recompile.mjs && node --check src/rc3-scripted-campaign.mjs && node --check src/rc3-actual-dogfood.mjs && node --check src/rc4-stage1-dogfood.mjs && node --check research/fixtures/dispatch-record/src/dispatch-record.mjs && node --check test/research-dispatch-record.test.mjs",
57
+ "check": "node --check bin/lattice.mjs && node --check bin/lattice-mcp.mjs && node --check bin/lattice-dashboard.mjs && node --check bin/lattice-bridge.mjs && node --check src/cli-stdio.mjs && node --check src/todo-gantt-layout.mjs && node --check src/todo-gantt-scope.mjs && node --check src/bridge-config.mjs && node --check src/bridge-server.mjs && node --check src/bridge-daemon.mjs && node --check src/bridge-cli.mjs && node --check src/project-cli.mjs && node --check src/sensor-cli.mjs && node --check src/sensor-runtime.mjs && node --check src/sensor-adapter.mjs && node --check src/factory-diagnostics.mjs && node --check src/runtime-errors.mjs && node --check src/runtime-contracts.mjs && node --check src/runtime-event-store.mjs && node --check src/hash-chain.mjs && node --check src/dag-chain.mjs && node --check src/todo-contracts.mjs && node --check src/todo-chain.mjs && node --check src/todo-store.mjs && node --check src/todo-migration.mjs && node --check src/todo-revision.mjs && node --check src/todo-status.mjs && node --check src/todo-cli.mjs && node --check src/todo-dashboard-registry.mjs && node --check src/todo-gantt-presentation.mjs && node --check src/todo-gantt-live.mjs && node --check src/bounded-seam.mjs && node --check src/todo-narrative-anchor.mjs && node --check src/todo-markdown-renderer.mjs && node --check src/todo-gantt-svg.mjs && node --check src/todo-gantt-html.mjs && node --check src/runtime-projection.mjs && node --check src/runtime-decision-verifier.mjs && node --check src/runtime-front-end.mjs && node --check src/runtime-cli.mjs && node --check src/rc3-dogfood-scaffold.mjs && node --check src/runtime-engine.mjs && node --check src/runtime-scripted-executor.mjs && node --check src/runtime-diff-observer.mjs && node --check src/runtime-worktree-executor.mjs && node --check src/runtime-hold-recompile.mjs && node --check src/rc3-scripted-campaign.mjs && node --check src/rc3-actual-dogfood.mjs && node --check src/rc4-stage1-dogfood.mjs && node --check research/fixtures/dispatch-record/src/dispatch-record.mjs && node --check test/research-dispatch-record.test.mjs",
58
58
  "check:project-identity": "node --check src/project-identity.mjs",
59
59
  "ci": "npm run test && npm run test:sensor && npm run check && npm run check:project-identity"
60
60
  }
package/src/cli-help.mjs CHANGED
@@ -45,9 +45,9 @@ Read commands:
45
45
  status [--json]
46
46
  verify [--plan <key>] [--json]
47
47
  snapshot --rebuild --plan <key>
48
- gantt [--out <file>]
48
+ gantt [--out <file>] [--scope live|all] # 既定live: 完走した枝を畳む
49
49
  gantt status [--out <file>]
50
- gantt serve --port <port> # /projects/<project_id>/ をforeground配信
50
+ gantt serve --port <port> [--scope live|all] # /projects/<project_id>/ をforeground配信
51
51
  phase status --plan <key>
52
52
 
53
53
  Write commands:
@@ -106,7 +106,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
106
106
  'todo status': 'todo status [--json]',
107
107
  'todo verify': 'todo verify [--plan <key>] [--json]',
108
108
  'todo snapshot': 'todo snapshot --rebuild --plan <key>',
109
- 'todo gantt': 'todo gantt [--out <file>] | status [--out <file>] | serve --port <port>',
109
+ 'todo gantt': 'todo gantt [--out <file>] [--scope live|all] | status [--out <file>] | serve --port <port> [--scope live|all]',
110
110
  'todo phase': 'todo phase <status|review|accept|reject|reopen> --plan <key> [options]',
111
111
  'todo phase status': 'todo phase status --plan <key>',
112
112
  'todo phase review': 'todo phase review --plan <key> --phase <id> --reason <text>',
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
  }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Todo Gantt scope projection.
3
+ *
4
+ * The store carries every completed ToDo forward across plan revisions on
5
+ * purpose — an accepted artifact is the predecessor of the work that follows
6
+ * it. That makes the dependency diagram grow monotonically: once a campaign
7
+ * finishes, its whole tree stays on screen forever and every later revision
8
+ * stacks on top of it. The journal is the source of truth, so nothing is
9
+ * removed from the store; the diagram is a projection, and this module is
10
+ * where that projection narrows.
11
+ *
12
+ * The rule is "fold the dead branches": a completed ToDo that no longer leads
13
+ * to any live work is history, and history collapses into one labelled node
14
+ * per finished branch. Completed ToDos that are still the direct premise of
15
+ * live work stay visible, because they are the context for what is dispatchable
16
+ * right now.
17
+ *
18
+ * This module is pure graph math over the layout's internal node/edge shape.
19
+ * It must run AFTER dependency waves, the longest dependency chain and the
20
+ * ready frontier have been computed on the FULL graph — those numbers describe
21
+ * the real plan, and measuring them on a folded graph would make them lie.
22
+ */
23
+
24
+ export const TODO_GANTT_SCOPES = Object.freeze(['live', 'all']);
25
+
26
+ /**
27
+ * Hops of completed predecessors kept in front of live work. 1 = keep the
28
+ * direct premises of live ToDos, fold everything upstream of them.
29
+ */
30
+ export const DEFAULT_FOLD_DISTANCE = 1;
31
+
32
+ /**
33
+ * `task_id` values in the store match /^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$/, so
34
+ * a leading '~' cannot collide with a real ToDo.
35
+ */
36
+ const FOLD_TASK_ID_PREFIX = '~folded';
37
+
38
+ export class TodoGanttScopeError extends Error {
39
+ constructor(code, message, detail = null) {
40
+ super(message);
41
+ this.name = 'TodoGanttScopeError';
42
+ this.code = code;
43
+ this.detail = detail;
44
+ }
45
+ }
46
+
47
+ function compareText(left, right) {
48
+ return left < right ? -1 : left > right ? 1 : 0;
49
+ }
50
+
51
+ export function isFoldNodeRef(ref) {
52
+ return typeof ref?.task_id === 'string' && ref.task_id.startsWith(`${FOLD_TASK_ID_PREFIX}:`);
53
+ }
54
+
55
+ /**
56
+ * Forward distance from each node to the nearest live (non-done) node, over the
57
+ * dependency DAG. A live node is at distance 0; a node with no live descendant
58
+ * is at Infinity.
59
+ *
60
+ * Edges always increase the wave (`assignWaves` is a longest-path layering), so
61
+ * visiting nodes in descending wave order guarantees every successor is settled
62
+ * before the node that depends on it.
63
+ */
64
+ function distanceToLive(nodes, edges, wave) {
65
+ const outgoing = new Map(nodes.map(({ key }) => [key, []]));
66
+ for (const edge of edges) outgoing.get(edge.from).push(edge.to);
67
+ const distance = new Map();
68
+ const ordered = [...nodes].sort((left, right) => wave.get(right.key) - wave.get(left.key)
69
+ || compareText(right.key, left.key));
70
+ for (const node of ordered) {
71
+ if (node.status !== 'done') {
72
+ distance.set(node.key, 0);
73
+ continue;
74
+ }
75
+ let best = Infinity;
76
+ for (const successor of outgoing.get(node.key)) {
77
+ const settled = distance.get(successor);
78
+ if (settled !== undefined && settled + 1 < best) best = settled + 1;
79
+ }
80
+ distance.set(node.key, best);
81
+ }
82
+ return distance;
83
+ }
84
+
85
+ function foldableKeys(nodes, distance, foldDistance) {
86
+ return new Set(nodes
87
+ .filter((node) => node.status === 'done' && distance.get(node.key) > foldDistance)
88
+ .map(({ key }) => key));
89
+ }
90
+
91
+ /**
92
+ * Group foldable nodes into fold units.
93
+ *
94
+ * `byComponent` groups each weakly connected component of the foldable subgraph
95
+ * into one unit — the compact projection, one node per finished branch.
96
+ * `byComponentAndWave` additionally splits each component per dependency wave.
97
+ * The latter is always acyclic once contracted (every dependency edge strictly
98
+ * increases the wave, so contracted edges do too), which makes it the
99
+ * guaranteed-safe refinement when contraction would otherwise close a cycle.
100
+ */
101
+ function groupFoldable(nodes, edges, foldable, wave, splitByWave) {
102
+ const parent = new Map([...foldable].map((key) => [key, key]));
103
+ const find = (key) => {
104
+ let root = key;
105
+ while (parent.get(root) !== root) root = parent.get(root);
106
+ let cursor = key;
107
+ while (parent.get(cursor) !== root) {
108
+ const next = parent.get(cursor);
109
+ parent.set(cursor, root);
110
+ cursor = next;
111
+ }
112
+ return root;
113
+ };
114
+ const union = (left, right) => {
115
+ const leftRoot = find(left);
116
+ const rightRoot = find(right);
117
+ if (leftRoot === rightRoot) return;
118
+ if (compareText(leftRoot, rightRoot) <= 0) parent.set(rightRoot, leftRoot);
119
+ else parent.set(leftRoot, rightRoot);
120
+ };
121
+ const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
122
+ for (const edge of edges) {
123
+ if (!foldable.has(edge.from) || !foldable.has(edge.to)) continue;
124
+ // A fold unit never spans plans: the diagram groups by plan and a unit that
125
+ // straddled two plans would have no honest lane to sit in.
126
+ if (nodeByKey.get(edge.from).ref.plan_key !== nodeByKey.get(edge.to).ref.plan_key) continue;
127
+ union(edge.from, edge.to);
128
+ }
129
+
130
+ const unitByNode = new Map();
131
+ const members = new Map();
132
+ for (const key of [...foldable].sort(compareText)) {
133
+ const unitKey = splitByWave
134
+ ? JSON.stringify([find(key), wave.get(key)])
135
+ : JSON.stringify([find(key), null]);
136
+ unitByNode.set(key, unitKey);
137
+ if (!members.has(unitKey)) members.set(unitKey, []);
138
+ members.get(unitKey).push(key);
139
+ }
140
+ return { unitByNode, members };
141
+ }
142
+
143
+ function dominantLane(memberNodes) {
144
+ const counts = new Map();
145
+ for (const node of memberNodes) counts.set(node.lane, (counts.get(node.lane) ?? 0) + 1);
146
+ return [...counts.entries()]
147
+ .sort(([leftLane, leftCount], [rightLane, rightCount]) => rightCount - leftCount
148
+ || compareText(leftLane, rightLane))[0][0];
149
+ }
150
+
151
+ function buildSummaryNodes(nodes, members, longestChainKeys) {
152
+ const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
153
+ // Order fold units by their first member so synthetic ids are stable across runs.
154
+ const ordered = [...members.entries()]
155
+ .map(([unitKey, memberKeys]) => ({ unitKey, memberKeys: [...memberKeys].sort(compareText) }))
156
+ .sort((left, right) => compareText(left.memberKeys[0], right.memberKeys[0]));
157
+
158
+ const summaryByUnit = new Map();
159
+ ordered.forEach((unit, index) => {
160
+ const memberNodes = unit.memberKeys.map((key) => nodeByKey.get(key));
161
+ const { plan_key: planKey, project_id: projectId } = memberNodes[0].ref;
162
+ const ref = { project_id: projectId, plan_key: planKey, task_id: `${FOLD_TASK_ID_PREFIX}:${index}` };
163
+ const key = JSON.stringify([ref.project_id, ref.plan_key, ref.task_id]);
164
+ const lanes = [...new Set(memberNodes.map((node) => node.lane))].sort(compareText);
165
+ summaryByUnit.set(unit.unitKey, {
166
+ key,
167
+ ref,
168
+ title: `完了済み ${memberNodes.length}件`,
169
+ lane: dominantLane(memberNodes),
170
+ status: 'done',
171
+ plan_schema: memberNodes[0].plan_schema,
172
+ phase_id: null,
173
+ phase_status: null,
174
+ phase_ready: false,
175
+ fold: {
176
+ task_count: memberNodes.length,
177
+ lanes,
178
+ longest_chain_task_count: memberNodes
179
+ .filter((node) => longestChainKeys.has(node.key)).length,
180
+ task_refs: memberNodes.map((node) => ({ ...node.ref })),
181
+ },
182
+ });
183
+ });
184
+ return summaryByUnit;
185
+ }
186
+
187
+ function contract(nodes, edges, unitByNode, summaryByUnit) {
188
+ const mapKey = (key) => {
189
+ const unitKey = unitByNode.get(key);
190
+ return unitKey === undefined ? key : summaryByUnit.get(unitKey).key;
191
+ };
192
+ const keptNodes = nodes.filter((node) => !unitByNode.has(node.key));
193
+ const summaries = [...summaryByUnit.values()];
194
+ const resultNodes = [...keptNodes, ...summaries]
195
+ .sort((left, right) => compareText(left.key, right.key));
196
+
197
+ const contracted = new Map();
198
+ for (const edge of edges) {
199
+ const from = mapKey(edge.from);
200
+ const to = mapKey(edge.to);
201
+ if (from === to) continue; // interior of a fold unit
202
+ const key = JSON.stringify([from, to]);
203
+ let merged = contracted.get(key);
204
+ if (merged === undefined) {
205
+ // A rewired edge is an aggregate. Join identity describes how several
206
+ // premises meet at one ToDo; carrying it onto an aggregate edge would
207
+ // draw a junction marker for a join whose members are no longer on
208
+ // screen, so aggregates keep only the dependency kinds.
209
+ merged = { key, from, to, kinds: new Set(), joinIdentities: new Map(), aggregated: false };
210
+ contracted.set(key, merged);
211
+ }
212
+ for (const kind of edge.kinds) merged.kinds.add(kind);
213
+ if (from === edge.from && to === edge.to) {
214
+ for (const [identityKey, identity] of edge.joinIdentities) {
215
+ merged.joinIdentities.set(identityKey, identity);
216
+ }
217
+ } else {
218
+ merged.aggregated = true;
219
+ }
220
+ }
221
+ for (const edge of contracted.values()) {
222
+ if (edge.aggregated) edge.joinIdentities = new Map();
223
+ }
224
+ const resultEdges = [...contracted.values()].sort((left, right) => compareText(left.key, right.key));
225
+ return { nodes: resultNodes, edges: resultEdges };
226
+ }
227
+
228
+ /** Kahn peel; returns true when the graph is acyclic. */
229
+ function isAcyclic(nodes, edges) {
230
+ const indegree = new Map(nodes.map(({ key }) => [key, 0]));
231
+ const outgoing = new Map(nodes.map(({ key }) => [key, []]));
232
+ for (const edge of edges) {
233
+ outgoing.get(edge.from).push(edge.to);
234
+ indegree.set(edge.to, indegree.get(edge.to) + 1);
235
+ }
236
+ const ready = nodes.filter(({ key }) => indegree.get(key) === 0).map(({ key }) => key);
237
+ let visited = 0;
238
+ while (ready.length > 0) {
239
+ const current = ready.pop();
240
+ visited += 1;
241
+ for (const next of outgoing.get(current)) {
242
+ indegree.set(next, indegree.get(next) - 1);
243
+ if (indegree.get(next) === 0) ready.push(next);
244
+ }
245
+ }
246
+ return visited === nodes.length;
247
+ }
248
+
249
+ /**
250
+ * Project the full dependency graph onto the `live` scope.
251
+ *
252
+ * Contracting a weakly connected component can close a cycle: with a fold
253
+ * distance of 1 the shape `f1 -> s -> f2` is reachable, where `s` is a
254
+ * completed ToDo kept as the direct premise of live work and `f1`/`f2` belong
255
+ * to the same fold unit. Contracting that unit would produce
256
+ * `summary -> s -> summary`. So the contraction is verified, and on a cycle the
257
+ * grouping is refined to (component, wave) — provably acyclic, because every
258
+ * dependency edge strictly increases the wave.
259
+ *
260
+ * @returns {{nodes: Array, edges: Array, foldedByKey: Map, folds: Array, refined: boolean}}
261
+ */
262
+ export function projectTodoGanttScope({
263
+ nodes, edges, wave, longestChainKeys = new Set(), foldDistance = DEFAULT_FOLD_DISTANCE,
264
+ }) {
265
+ const distance = distanceToLive(nodes, edges, wave);
266
+ const foldable = foldableKeys(nodes, distance, foldDistance);
267
+ if (foldable.size === 0) {
268
+ return { nodes, edges, foldedByKey: new Map(), folds: [], refined: false };
269
+ }
270
+
271
+ let refined = false;
272
+ let grouping = groupFoldable(nodes, edges, foldable, wave, false);
273
+ let summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
274
+ let contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
275
+ if (!isAcyclic(contracted.nodes, contracted.edges)) {
276
+ refined = true;
277
+ grouping = groupFoldable(nodes, edges, foldable, wave, true);
278
+ summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
279
+ contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
280
+ if (!isAcyclic(contracted.nodes, contracted.edges)) {
281
+ throw new TodoGanttScopeError('TODO_SCOPE_CONTRACTION_CYCLIC',
282
+ 'todo gantt scope contraction produced a cycle after per-wave refinement',
283
+ { fold_unit_count: grouping.members.size });
284
+ }
285
+ }
286
+
287
+ const foldedByKey = new Map();
288
+ for (const [nodeKey, unitKey] of grouping.unitByNode) {
289
+ foldedByKey.set(nodeKey, summaryByUnit.get(unitKey).key);
290
+ }
291
+ const folds = [...summaryByUnit.values()]
292
+ .sort((left, right) => compareText(left.key, right.key))
293
+ .map((summary) => ({ ref: { ...summary.ref }, ...summary.fold }));
294
+
295
+ return { nodes: contracted.nodes, edges: contracted.edges, foldedByKey, folds, refined };
296
+ }
@@ -140,28 +140,39 @@ function presentationMaps(presentation) {
140
140
  function renderNode(node, maps) {
141
141
  if (!node.visible || node.geometry === null) return '';
142
142
  const { x, y, width, height } = node.geometry;
143
+ const fold = node.fold ?? null;
143
144
  const classes = ['todo-node', STATUS_CLASSES[node.status] ?? 'status-unknown'];
145
+ if (fold !== null) classes.push('folded-node');
144
146
  if (node.visibility.longest_dependency_chain) classes.push('longest-chain-node');
145
147
  if (node.visibility.active) classes.push('active-node');
146
148
  if (node.visibility.next_ready) classes.push('next-ready-node');
147
149
  if (node.visibility.selected) classes.push('selected-node');
148
150
  const key = nodeKey(node.ref);
149
151
  const nodeLaneKey = laneKey(node.ref.plan_key, node.lane);
150
- const status = TODO_GANTT_STATUS_PRESENTATION[node.status] ?? { mark: '?', label: '状態不明' };
152
+ const status = fold === null
153
+ ? TODO_GANTT_STATUS_PRESENTATION[node.status] ?? { mark: '?', label: '状態不明' }
154
+ : { mark: '▣', label: '完走済み(畳み込み)' };
151
155
  const lane = maps.lanes.get(nodeLaneKey);
152
- const laneLabel = lane === undefined ? node.lane : `${node.lane}、${lane.name}`;
156
+ const laneLabel = fold === null
157
+ ? (lane === undefined ? node.lane : `${node.lane}、${lane.name}`)
158
+ : fold.lanes.join('、');
153
159
  const taskNumber = maps.taskNumbers.get(key);
154
- const visibleReference = taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程 ${taskNumber.display_number}`;
155
- const spokenReference = taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程${taskNumber.display_number}`;
160
+ const visibleReference = fold !== null ? `${fold.task_count}工程`
161
+ : taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程 ${taskNumber.display_number}`;
162
+ const spokenReference = fold !== null ? `${fold.task_count}工程の畳み込み`
163
+ : taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程${taskNumber.display_number}`;
156
164
  const readyLabel = node.visibility.next_ready ? '。ready frontierの同時dispatch候補' : '';
157
- const ariaLabel = `${spokenReference}。${status.label}。${laneLabel}。${node.title}。正規ID ${node.ref.plan_key}/${node.ref.task_id}${readyLabel}`;
165
+ const identity = fold === null
166
+ ? `正規ID ${node.ref.plan_key}/${node.ref.task_id}`
167
+ : `${node.ref.plan_key}の完走済み${fold.task_count}工程をまとめたノード。全件を描くには --scope all`;
168
+ const ariaLabel = `${spokenReference}。${status.label}。${laneLabel}。${node.title}。${identity}${readyLabel}`;
158
169
  const statusBar = node.status === 'in-progress'
159
170
  ? `<line class="status-bar" x1="${x + 5}" y1="${y + 6}" x2="${x + 5}" y2="${y + height - 6}"></line>` : '';
160
171
  const titleLines = wrapLabel(node.title);
161
172
  const titleMarkup = titleLines.map((line, index) => `<tspan x="${x + 10}" dy="${index === 0 ? 0 : 17}" class="node-title-line">${escapeSvgText(line)}</tspan>`).join('');
162
173
  const taskNumberAttributes = taskNumber === undefined ? ''
163
174
  : ` data-task-number="${escapeSvgAttribute(taskNumber.display_number)}" data-task-number-normalized="${escapeSvgAttribute(taskNumber.normalized_number)}" data-task-number-globally-unique="${taskNumber.globally_unique ? 'true' : 'false'}"`;
164
- return `<g class="${classes.join(' ')}" data-node-key="${escapeSvgAttribute(key)}" data-lane-key="${escapeSvgAttribute(nodeLaneKey)}" data-project-id="${escapeSvgAttribute(node.ref.project_id)}" data-plan-key="${escapeSvgAttribute(node.ref.plan_key)}" data-task-id="${escapeSvgAttribute(node.ref.task_id)}"${taskNumberAttributes} tabindex="0" role="button" aria-selected="${node.visibility.selected ? 'true' : 'false'}" aria-label="${escapeSvgAttribute(ariaLabel)}"><rect class="node-surface" x="${x}" y="${y}" width="${width}" height="${height}" rx="4"></rect>${statusBar}<text class="status-mark" x="${x + 10}" y="${y + 21}">${escapeSvgText(status.mark)}</text><text class="node-meta" x="${x + 34}" y="${y + 20}">${escapeSvgText(`${status.label} · ${visibleReference}`)}</text><text class="node-title" x="${x + 10}" y="${y + 42}">${titleMarkup}</text><title>${escapeSvgText(`${spokenReference}: ${node.title} — ${status.label} — ${laneLabel} — 正規ID ${node.ref.plan_key}/${node.ref.task_id}`)}</title></g>`;
175
+ return `<g class="${classes.join(' ')}" data-node-key="${escapeSvgAttribute(key)}" data-lane-key="${escapeSvgAttribute(nodeLaneKey)}" data-project-id="${escapeSvgAttribute(node.ref.project_id)}" data-plan-key="${escapeSvgAttribute(node.ref.plan_key)}" data-task-id="${escapeSvgAttribute(node.ref.task_id)}"${taskNumberAttributes} tabindex="0" role="button" aria-selected="${node.visibility.selected ? 'true' : 'false'}" aria-label="${escapeSvgAttribute(ariaLabel)}"><rect class="node-surface" x="${x}" y="${y}" width="${width}" height="${height}" rx="4"></rect>${statusBar}<text class="status-mark" x="${x + 10}" y="${y + 21}">${escapeSvgText(status.mark)}</text><text class="node-meta" x="${x + 34}" y="${y + 20}">${escapeSvgText(`${status.label} · ${visibleReference}`)}</text><text class="node-title" x="${x + 10}" y="${y + 42}">${titleMarkup}</text><title>${escapeSvgText(`${spokenReference}: ${node.title} — ${status.label} — ${laneLabel} — ${identity}`)}</title></g>`;
165
176
  }
166
177
 
167
178
  function summaryLabel(value, maximum = 34) {
@@ -1122,13 +1122,14 @@ function resolveTargetedEvent(input, storeMember) {
1122
1122
  if (input.kind === 'done' && exactRecord(input.payload, [
1123
1123
  'done_mode', 'imported', 'evidence',
1124
1124
  ]) && input.payload.done_mode === 'evidence_promotion' && input.payload.imported === true) {
1125
- const target = [...storeMember.journal.events].reverse().find((event) => (
1126
- event.kind === 'done' && event.task_id === input.task_id
1127
- ));
1128
- if (target === undefined) fail('STORE_INCONSISTENT', 'invalid_evidence_promotion');
1125
+ // Same binding as `reopen`: an imported completion carried across a revision
1126
+ // is bound to the carrying `plan_genesis`, not to a `done` event. Whether the
1127
+ // completion is actually eligible for promotion stays replay's decision.
1128
+ const targetDigest = resolveDoneBindingDigest(storeMember, input.task_id);
1129
+ if (targetDigest === null) fail('STORE_INCONSISTENT', 'invalid_evidence_promotion');
1129
1130
  return {
1130
1131
  ...input,
1131
- payload: { ...input.payload, target_done_digest: target.event_digest },
1132
+ payload: { ...input.payload, target_done_digest: targetDigest },
1132
1133
  };
1133
1134
  }
1134
1135
  if (input.kind === 'phase_reopen' && exactRecord(input.payload, ['reason', 'override_reason'])) {