@quolu/lattice 0.12.12 → 0.12.14

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.12",
3
+ "version": "0.12.14",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -6,6 +6,16 @@ import {
6
6
  } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
 
9
+ import packageJson from '../package.json' with { type: 'json' };
10
+
11
+ /**
12
+ * The version of the code in THIS process. A daemon loads its modules once at
13
+ * startup and keeps serving them, so installing a new package does not change
14
+ * what the running daemon serves. The daemon reports the version it started
15
+ * with; anything else is code that has already been replaced on disk.
16
+ */
17
+ export const TODO_DASHBOARD_CODE_VERSION = packageJson.version;
18
+
9
19
  const REGISTRY_SCHEMA = 'lattice.todo_dashboard_registry.v1';
10
20
  const DAEMON_SCHEMA = 'lattice.todo_dashboard_daemon.v1';
11
21
  const DEFAULT_PORT = 0;
@@ -182,7 +192,13 @@ async function daemonAttestation(descriptor, { timeoutMs = 2_000 } = {}) {
182
192
  if (body?.schema !== 'lattice.todo_dashboard_health.v1' || body.pid !== descriptor.pid
183
193
  || !Array.isArray(body.project_ids)) return null;
184
194
  const keys = Object.keys(body).sort().join(',');
185
- if (keys === 'pid,port,project_ids,schema' && body.port === descriptor.port) return 'current';
195
+ // 'legacy' means "alive, but serving code we have already replaced" — the
196
+ // caller starts a replacement and stops it. A daemon that predates the
197
+ // version field, or one still running an older package, is exactly that.
198
+ if (keys === 'pid,port,project_ids,schema,version' && body.port === descriptor.port) {
199
+ return body.version === TODO_DASHBOARD_CODE_VERSION ? 'current' : 'legacy';
200
+ }
201
+ if (keys === 'pid,port,project_ids,schema' && body.port === descriptor.port) return 'legacy';
186
202
  if (keys === 'pid,project_ids,schema') return 'legacy';
187
203
  return null;
188
204
  } catch { return null; }
@@ -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.v9';
9
+ export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v11';
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,23 +126,23 @@ function normalizeSections(readModel, narratives, anchorOutcomes) {
126
126
  return { sections: result, proseBytes };
127
127
  }
128
128
 
129
- /** task node key -> the fold node standing in for it, empty when nothing folded. */
129
+ /** Keys of the ToDos the diagram does not draw, empty when nothing was folded. */
130
130
  function foldIndex(layout) {
131
- return new Map((layout?.folded ?? []).map((entry) => [refKey(entry.task), refKey(entry.fold)]));
131
+ return new Set((layout?.folded ?? []).map((ref) => refKey(ref)));
132
132
  }
133
133
 
134
- function renderTaskIndexEntry(section, lookup, folds) {
134
+ function renderTaskIndexEntry(section, lookup) {
135
135
  const key = refKey(section.ref);
136
136
  const status = DOCUMENT_STATUS[section.state.status] ?? { mark: '?', label: '状態不明' };
137
137
  const blockedReason = section.state.status === 'blocked'
138
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;
139
+ // A ToDo the diagram does not draw keeps its row here — the index is the
140
+ // complete list and it selects its own detail, which exists either way.
141
+ const selectKey = key;
142
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
143
  }
144
144
 
145
- function renderTaskIndex(sections, lookup, folds = new Map()) {
145
+ function renderTaskIndex(sections, lookup, folds = new Set()) {
146
146
  const plans = [];
147
147
  for (const section of sections) {
148
148
  let plan = plans.at(-1);
@@ -156,9 +156,9 @@ function renderTaskIndex(sections, lookup, folds = new Map()) {
156
156
  const drawn = plan.tasks.filter((section) => !folds.has(refKey(section.ref)));
157
157
  const folded = plan.tasks.filter((section) => folds.has(refKey(section.ref)));
158
158
  const drawnList = drawn.length === 0 ? ''
159
- : `<ol class="task-index-list">${drawn.map((section) => renderTaskIndexEntry(section, lookup, folds)).join('')}</ol>`;
159
+ : `<ol class="task-index-list">${drawn.map((section) => renderTaskIndexEntry(section, lookup)).join('')}</ol>`;
160
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>`;
161
+ : `<details class="task-index-folded"><summary>完走済みとして畳んだ工程 ${folded.length}件</summary><ol class="task-index-list">${folded.map((section) => renderTaskIndexEntry(section, lookup)).join('')}</ol></details>`;
162
162
  return `<section class="task-index-plan"><h2><code>${escapeHtmlText(plan.planKey)}</code></h2>${drawnList}${foldedList}</section>`;
163
163
  }).join('');
164
164
  }
@@ -175,14 +175,18 @@ function taskReference(section, lookup) {
175
175
  return number === undefined ? `ID ${section.task.task_id}` : `工程 ${number.display_number}`;
176
176
  }
177
177
 
178
- function renderRelationList(relations, sectionByKey, lookup, emptyText) {
178
+ function renderRelationList(relations, sectionByKey, lookup, emptyText, folds = new Set()) {
179
179
  if (relations.length === 0) return `<p class="relation-empty">${escapeHtmlText(emptyText)}</p>`;
180
180
  return `<ul class="relation-list">${relations.map((relation) => {
181
- const target = sectionByKey.get(refKey(relation.ref));
181
+ const targetKey = refKey(relation.ref);
182
+ const target = sectionByKey.get(targetKey);
182
183
  if (target === undefined) return '';
183
184
  const join = relation.joinIds.length === 0 ? ''
184
185
  : `<span class="relation-kind">合流条件: ${escapeHtmlText(relation.joinIds.join(', '))}</span>`;
185
- return `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(refKey(target.ref))}"><strong>${escapeHtmlText(taskReference(target, lookup))}</strong><span>${escapeHtmlText(target.task.title)}</span></button>${join}</li>`;
186
+ // Say which ones the diagram does not draw, so the reader stops looking.
187
+ const reference = folds.has(targetKey)
188
+ ? `${taskReference(target, lookup)}(図では非表示)` : taskReference(target, lookup);
189
+ return `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(targetKey)}"><strong>${escapeHtmlText(reference)}</strong><span>${escapeHtmlText(target.task.title)}</span></button>${join}</li>`;
186
190
  }).join('')}</ul>`;
187
191
  }
188
192
 
@@ -211,6 +215,7 @@ function renderRightPane(sections, layout, presentation, readModel) {
211
215
  const lookup = presentationLookup(presentation);
212
216
  const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
213
217
  const nodeByKey = new Map(layout.nodes.map((node) => [refKey(node.ref), node]));
218
+ const folds = foldIndex(layout);
214
219
  const incoming = new Map(sections.map((section) => [refKey(section.ref), []]));
215
220
  const outgoing = new Map(sections.map((section) => [refKey(section.ref), []]));
216
221
  const addRelation = (relations, ownerKey, ref, joinIds) => {
@@ -223,7 +228,10 @@ function renderRightPane(sections, layout, presentation, readModel) {
223
228
  }
224
229
  entry.joinIds = [...new Set([...entry.joinIds, ...joinIds])].sort();
225
230
  };
226
- for (const edge of layout.edges) {
231
+ // Premises and successors come from the FULL graph. `layout.edges` is the
232
+ // drawn graph, where a fold unit's interior dependencies have been contracted
233
+ // away — reading those here would tell a folded ToDo it has no premises.
234
+ for (const edge of layout.full_edges ?? layout.edges) {
227
235
  addRelation(incoming, refKey(edge.to), edge.from, edge.join_ids);
228
236
  addRelation(outgoing, refKey(edge.from), edge.to, edge.join_ids);
229
237
  }
@@ -256,9 +264,12 @@ function renderRightPane(sections, layout, presentation, readModel) {
256
264
  const readiness = node?.visibility.next_ready
257
265
  ? `<p class="readiness-note">ready frontierの一員です。${ready.length > 1 ? '他のready工程と同時dispatchするのが既定です。subsetだけを選ぶ場合は理由を記録してください。' : '現在の唯一の着手候補です。'}</p>`
258
266
  : incoming.get(key).length === 0 ? '<p class="readiness-note">登録済みの前提工程はありません。図だけではdispatch可否を判定しません。</p>' : '';
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>`;
267
+ // Say it plainly when the reader will not find this ToDo on the diagram.
268
+ const foldedNote = !folds.has(key) ? ''
269
+ : '<p class="fold-note">完走済みのため図には描いていません。図に出すには <code>lattice todo gantt --scope all</code> を実行してください。</p>';
270
+ 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}${foldedNote}<section><h2>前提工程</h2>${renderRelationList(incoming.get(key), sectionByKey, lookup, '登録済みの前提工程はありません。', folds)}</section><section><h2>後続工程</h2>${renderRelationList(outgoing.get(key), sectionByKey, lookup, '登録済みの後続工程はありません。', folds)}</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>`;
260
271
  }).join('');
261
- const taskIndex = renderTaskIndex(sections, lookup, foldIndex(layout));
272
+ const taskIndex = renderTaskIndex(sections, lookup, folds);
262
273
  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>`;
263
274
  }
264
275
 
@@ -267,9 +278,9 @@ function renderDiagramLegend(presentation, layout = null) {
267
278
  const categoryDetails = categories === '' ? '' : `<details class="category-legend"><summary>カテゴリ説明</summary><dl>${categories}</dl></details>`;
268
279
  const foldedCount = layout?.scope?.folded_task_count ?? 0;
269
280
  const foldChip = foldedCount === 0 ? ''
270
- : `<span class="fold-chip">▣ 完走済み ${foldedCount}件を畳んで表示中</span>`;
281
+ : `<span class="fold-chip">完走済み ${foldedCount}件を非表示</span>`;
271
282
  const foldNote = foldedCount === 0 ? ''
272
- : `<p class="fold-note">後続に作業中・未着手が残っていない完了工程を、${layout.scope.fold_node_count}個の畳み込みノードへまとめています。生きた工程とその直接の前提工程は必ず展開したままです。全件を描くには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は畳み込み前の全工程で数えています。</p>`;
283
+ : '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。外した工程は右の「全工程」から辿れ、図に出すには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>';
273
284
  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>`;
274
285
  }
275
286
 
@@ -333,8 +344,6 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
333
344
  .todo-node .node-title{fill:var(--text-primary);font-size:13.5px;font-weight:400}
334
345
  .todo-node .node-title-line{font-size:13.5px;font-weight:400}
335
346
  .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
347
  .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
348
  .fold-note{flex:1 0 100%;margin:4px 0 0;color:var(--text-secondary);font-weight:400}
340
349
  .task-index-folded{margin-top:8px}
@@ -403,10 +403,8 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
403
403
 
404
404
  // Only the geometry stage below sees the narrowed graph.
405
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
- });
406
+ ? { nodes: full.nodes, edges: full.edges, foldedKeys: new Set() }
407
+ : projectTodoGanttScope({ nodes: full.nodes, edges: full.edges, wave: fullWaves.wave });
410
408
  const nodes = projected.nodes;
411
409
  const edges = projected.edges;
412
410
  const nodesByKey = new Map(nodes.map((node) => [node.key, node]));
@@ -477,19 +475,10 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
477
475
  ref: { ...node.ref }, title: node.title, lane: node.lane, status: node.status,
478
476
  wave: wave.get(node.key), row: transversePosition.get(node.key), visible,
479
477
  visibility: {
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,
478
+ longest_dependency_chain: longestNodeKeys.has(node.key),
484
479
  active: node.status === 'in-progress', next_ready: readyKeys.has(node.key),
485
480
  selected: false,
486
481
  },
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
- },
493
482
  geometry,
494
483
  };
495
484
  });
@@ -619,6 +608,16 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
619
608
  },
620
609
  nodes: projectedNodes,
621
610
  edges: projectedEdges,
611
+ // Every dependency in the plan, before folding contracted any of them away.
612
+ // The diagram draws `edges`; anything that describes a ToDo in words — the
613
+ // premises and successors in the right pane — reads this instead, so a
614
+ // folded ToDo keeps telling the truth about what it depended on.
615
+ full_edges: full.edges.map((edge) => ({
616
+ from: { ...full.nodesByKey.get(edge.from).ref },
617
+ to: { ...full.nodesByKey.get(edge.to).ref },
618
+ kinds: [...edge.kinds].sort(compareText),
619
+ join_ids: [...edge.joinIdentities.values()].map(({ join_id }) => join_id).sort(compareText),
620
+ })),
622
621
  connectors: junctionConnectors,
623
622
  groups: {
624
623
  plans: [...planMap.entries()].map(([plan_key, task_count]) => ({ plan_key, task_count })),
@@ -626,26 +625,14 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
626
625
  },
627
626
  scope: {
628
627
  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
- })),
628
+ folded_task_count: projected.foldedKeys.size,
638
629
  },
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
- })),
630
+ // The ToDos the diagram no longer draws. They stay in the task index and in
631
+ // every count, so the reader can still reach them by name.
632
+ folded: [...projected.foldedKeys]
633
+ .map((taskKey) => JSON.parse(taskKey))
634
+ .map(([project_id, plan_key, task_id]) => ({ project_id, plan_key, task_id }))
635
+ .sort(compareRefs),
649
636
  metrics: {
650
637
  crossing_count: crossingCount(edges, wave, transversePosition),
651
638
  visible_node_count: visibleKeys.size,
@@ -1,5 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
 
3
+ import { TODO_DASHBOARD_CODE_VERSION } from './todo-dashboard-registry.mjs';
4
+
3
5
  const LOOPBACK = '127.0.0.1';
4
6
  const POLL_MS = 500;
5
7
  const HTTP_ERROR_SCHEMA = 'lattice.todo_gantt_http_error.v1';
@@ -136,8 +138,12 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
136
138
  if (url.pathname === '/__lattice/health') {
137
139
  response.writeHead(200, { 'content-type': 'application/json; charset=utf-8',
138
140
  'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
141
+ // `version` is the package this process loaded at startup, not the one
142
+ // installed on disk. That difference is the whole point: it is how a
143
+ // caller learns the daemon is serving code that has been superseded.
139
144
  response.end(`${JSON.stringify({ schema: 'lattice.todo_dashboard_health.v1', pid: process.pid,
140
- port: actualPort, project_ids: registry.list().map(({ projectId }) => projectId) })}\n`);
145
+ port: actualPort, project_ids: registry.list().map(({ projectId }) => projectId),
146
+ version: TODO_DASHBOARD_CODE_VERSION })}\n`);
141
147
  return;
142
148
  }
143
149
  if (url.pathname === '/' || url.pathname === '/projects/') {
@@ -9,49 +9,35 @@
9
9
  * removed from the store; the diagram is a projection, and this module is
10
10
  * where that projection narrows.
11
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.
12
+ * The rule is "drop the dead branches": a completed ToDo that no longer leads
13
+ * to any live work is history, and history leaves the diagram entirely.
14
+ * Completed ToDos that are still the direct premise of live work stay visible,
15
+ * because they are the context for what is dispatchable right now.
16
+ *
17
+ * Summarising history into placeholder nodes was tried and is worse than
18
+ * drawing nothing: a summary box still occupies a column, so the diagram stays
19
+ * as wide as the finished plans ever made it while saying nothing a reader
20
+ * acts on. The count belongs in the legend and the ToDos themselves belong in
21
+ * the task index; neither needs floor space in the graph.
17
22
  *
18
23
  * This module is pure graph math over the layout's internal node/edge shape.
19
24
  * It must run AFTER dependency waves, the longest dependency chain and the
20
25
  * 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.
26
+ * the real plan, and measuring them on a narrowed graph would make them lie.
22
27
  */
23
28
 
24
29
  export const TODO_GANTT_SCOPES = Object.freeze(['live', 'all']);
25
30
 
26
31
  /**
27
32
  * Hops of completed predecessors kept in front of live work. 1 = keep the
28
- * direct premises of live ToDos, fold everything upstream of them.
33
+ * direct premises of live ToDos, drop everything upstream of them.
29
34
  */
30
35
  export const DEFAULT_FOLD_DISTANCE = 1;
31
36
 
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
37
  function compareText(left, right) {
48
38
  return left < right ? -1 : left > right ? 1 : 0;
49
39
  }
50
40
 
51
- export function isFoldNodeRef(ref) {
52
- return typeof ref?.task_id === 'string' && ref.task_id.startsWith(`${FOLD_TASK_ID_PREFIX}:`);
53
- }
54
-
55
41
  /**
56
42
  * Forward distance from each node to the nearest live (non-done) node, over the
57
43
  * dependency DAG. A live node is at distance 0; a node with no live descendant
@@ -82,215 +68,27 @@ function distanceToLive(nodes, edges, wave) {
82
68
  return distance;
83
69
  }
84
70
 
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
71
  /**
250
72
  * Project the full dependency graph onto the `live` scope.
251
73
  *
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.
74
+ * Removing nodes from a DAG cannot create a cycle, so the narrowed graph needs
75
+ * no verification: what is left is a subgraph of what was already acyclic.
259
76
  *
260
- * @returns {{nodes: Array, edges: Array, foldedByKey: Map, folds: Array, refined: boolean}}
77
+ * @returns {{nodes: Array, edges: Array, foldedKeys: Set<string>}}
261
78
  */
262
79
  export function projectTodoGanttScope({
263
- nodes, edges, wave, longestChainKeys = new Set(), foldDistance = DEFAULT_FOLD_DISTANCE,
80
+ nodes, edges, wave, foldDistance = DEFAULT_FOLD_DISTANCE,
264
81
  }) {
265
82
  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 };
83
+ const foldedKeys = new Set(nodes
84
+ .filter((node) => node.status === 'done' && distance.get(node.key) > foldDistance)
85
+ .map(({ key }) => key));
86
+ if (foldedKeys.size === 0) return { nodes, edges, foldedKeys };
87
+ return {
88
+ nodes: nodes.filter((node) => !foldedKeys.has(node.key)),
89
+ // An edge with a dropped endpoint goes with it. The kept node keeps its
90
+ // real premises in the right pane, which reads the full graph.
91
+ edges: edges.filter((edge) => !foldedKeys.has(edge.from) && !foldedKeys.has(edge.to)),
92
+ foldedKeys,
93
+ };
296
94
  }
@@ -140,31 +140,23 @@ 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;
144
143
  const classes = ['todo-node', STATUS_CLASSES[node.status] ?? 'status-unknown'];
145
- if (fold !== null) classes.push('folded-node');
146
144
  if (node.visibility.longest_dependency_chain) classes.push('longest-chain-node');
147
145
  if (node.visibility.active) classes.push('active-node');
148
146
  if (node.visibility.next_ready) classes.push('next-ready-node');
149
147
  if (node.visibility.selected) classes.push('selected-node');
150
148
  const key = nodeKey(node.ref);
151
149
  const nodeLaneKey = laneKey(node.ref.plan_key, node.lane);
152
- const status = fold === null
153
- ? TODO_GANTT_STATUS_PRESENTATION[node.status] ?? { mark: '?', label: '状態不明' }
154
- : { mark: '▣', label: '完走済み(畳み込み)' };
150
+ const status = TODO_GANTT_STATUS_PRESENTATION[node.status] ?? { mark: '?', label: '状態不明' };
155
151
  const lane = maps.lanes.get(nodeLaneKey);
156
- const laneLabel = fold === null
157
- ? (lane === undefined ? node.lane : `${node.lane}、${lane.name}`)
158
- : fold.lanes.join('、');
152
+ const laneLabel = lane === undefined ? node.lane : `${node.lane}、${lane.name}`;
159
153
  const taskNumber = maps.taskNumbers.get(key);
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}`;
154
+ const visibleReference = taskNumber === undefined
155
+ ? `ID ${node.ref.task_id}` : `工程 ${taskNumber.display_number}`;
156
+ const spokenReference = taskNumber === undefined
157
+ ? `ID ${node.ref.task_id}` : `工程${taskNumber.display_number}`;
164
158
  const readyLabel = node.visibility.next_ready ? '。ready frontierの同時dispatch候補' : '';
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`;
159
+ const identity = `正規ID ${node.ref.plan_key}/${node.ref.task_id}`;
168
160
  const ariaLabel = `${spokenReference}。${status.label}。${laneLabel}。${node.title}。${identity}${readyLabel}`;
169
161
  const statusBar = node.status === 'in-progress'
170
162
  ? `<line class="status-bar" x1="${x + 5}" y1="${y + 6}" x2="${x + 5}" y2="${y + height - 6}"></line>` : '';
@@ -186,8 +178,15 @@ function summaryChipWidth(label, minimum = 116) {
186
178
  function renderTodoSummary(layout, maps) {
187
179
  const markup = [];
188
180
  let groupX = 16;
189
- for (const plan of layout.groups.plans) {
190
- const lanes = layout.groups.lanes.filter((lane) => lane.plan_key === plan.plan_key);
181
+ // The band is a header for the diagram, so it covers what the diagram draws.
182
+ // Keeping a chip for every lane of every finished plan stretched the canvas to
183
+ // several times the width of the graph itself, all of it empty columns.
184
+ // The chips still count every ToDo in the lane, folded ones included.
185
+ const drawnPlans = new Set(layout.nodes.map((node) => node.ref.plan_key));
186
+ const drawnLanes = new Set(layout.nodes.map((node) => laneKey(node.ref.plan_key, node.lane)));
187
+ for (const plan of layout.groups.plans.filter(({ plan_key }) => drawnPlans.has(plan_key))) {
188
+ const lanes = layout.groups.lanes.filter((lane) => lane.plan_key === plan.plan_key
189
+ && drawnLanes.has(laneKey(lane.plan_key, lane.lane)));
191
190
  const laneChips = lanes.map((lane) => {
192
191
  const metadata = maps.lanes.get(laneKey(lane.plan_key, lane.lane));
193
192
  const fullLabel = metadata === undefined