@quolu/lattice 0.12.12 → 0.12.13

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.13",
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.v10';
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
 
@@ -131,14 +131,16 @@ function foldIndex(layout) {
131
131
  return new Map((layout?.folded ?? []).map((entry) => [refKey(entry.task), refKey(entry.fold)]));
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 folded ToDo keeps its row here — the index is the complete list — and it
140
+ // selects its OWN detail. Pointing the row at the fold node standing in for it
141
+ // on the diagram would open nothing: a fold node is not a ToDo, so no detail
142
+ // panel carries its key.
143
+ const selectKey = key;
142
144
  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
145
  }
144
146
 
@@ -156,13 +158,33 @@ function renderTaskIndex(sections, lookup, folds = new Map()) {
156
158
  const drawn = plan.tasks.filter((section) => !folds.has(refKey(section.ref)));
157
159
  const folded = plan.tasks.filter((section) => folds.has(refKey(section.ref)));
158
160
  const drawnList = drawn.length === 0 ? ''
159
- : `<ol class="task-index-list">${drawn.map((section) => renderTaskIndexEntry(section, lookup, folds)).join('')}</ol>`;
161
+ : `<ol class="task-index-list">${drawn.map((section) => renderTaskIndexEntry(section, lookup)).join('')}</ol>`;
160
162
  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>`;
163
+ : `<details class="task-index-folded"><summary>完走済みとして畳んだ工程 ${folded.length}件</summary><ol class="task-index-list">${folded.map((section) => renderTaskIndexEntry(section, lookup)).join('')}</ol></details>`;
162
164
  return `<section class="task-index-plan"><h2><code>${escapeHtmlText(plan.planKey)}</code></h2>${drawnList}${foldedList}</section>`;
163
165
  }).join('');
164
166
  }
165
167
 
168
+ /**
169
+ * Detail panel for a fold node.
170
+ *
171
+ * A fold node is the only thing standing on the diagram for the history it
172
+ * summarises, so it must open like any other node — otherwise clicking the
173
+ * folded part of the plan does nothing and the folded ToDos become unreachable.
174
+ */
175
+ function renderFoldDetail(fold, members, lookup) {
176
+ const laneLabels = fold.lanes.map((lane) => {
177
+ const entry = lookup.lanes.get(JSON.stringify([fold.ref.plan_key, lane]));
178
+ return entry === undefined ? lane : `${lane} — ${entry.name}`;
179
+ });
180
+ const chain = fold.longest_chain_task_count === 0 ? ''
181
+ : `<p><strong>構造上の最長依存鎖:</strong> このうち${fold.longest_chain_task_count}工程が乗っています。</p>`;
182
+ const memberList = members.length === 0
183
+ ? '<p class="relation-empty">構成工程を復元できませんでした。</p>'
184
+ : `<ol class="task-index-list">${members.map((section) => renderTaskIndexEntry(section, lookup)).join('')}</ol>`;
185
+ return `<article class="task-detail fold-detail" data-detail-key="${escapeHtmlAttribute(refKey(fold.ref))}" hidden><header><span class="detail-status status-done">▣ 完走済み(畳み込み)</span><span class="detail-reference">${escapeHtmlText(`${fold.task_count}工程`)}</span></header><h1>完了済み ${escapeHtmlText(String(fold.task_count))}件</h1><p><strong>plan:</strong> <code>${escapeHtmlText(fold.ref.plan_key)}</code></p><p class="detail-category"><strong>カテゴリ:</strong> ${escapeHtmlText(laneLabels.join('、'))}</p>${chain}<p class="fold-note">後続に作業中・未着手の工程が残っていないため、まとめて1個のノードとして描いています。図に全件を描くには <code>lattice todo gantt --scope all</code> を実行してください。</p><section><h2>含まれる工程 ${escapeHtmlText(String(members.length))}件</h2>${memberList}</section></article>`;
186
+ }
187
+
166
188
  function presentationLookup(presentation) {
167
189
  return {
168
190
  lanes: new Map((presentation?.lanes ?? []).map((lane) => [JSON.stringify([lane.plan_key, lane.lane]), lane])),
@@ -175,14 +197,19 @@ function taskReference(section, lookup) {
175
197
  return number === undefined ? `ID ${section.task.task_id}` : `工程 ${number.display_number}`;
176
198
  }
177
199
 
178
- function renderRelationList(relations, sectionByKey, lookup, emptyText) {
200
+ function renderRelationList(relations, sectionByKey, lookup, emptyText, folds = new Map()) {
179
201
  if (relations.length === 0) return `<p class="relation-empty">${escapeHtmlText(emptyText)}</p>`;
180
202
  return `<ul class="relation-list">${relations.map((relation) => {
181
- const target = sectionByKey.get(refKey(relation.ref));
203
+ const targetKey = refKey(relation.ref);
204
+ const target = sectionByKey.get(targetKey);
182
205
  if (target === undefined) return '';
183
206
  const join = relation.joinIds.length === 0 ? ''
184
207
  : `<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>`;
208
+ // Mark the ones the diagram no longer draws separately, so the reader knows
209
+ // why they cannot find this box on screen.
210
+ const reference = folds.has(targetKey)
211
+ ? `▣ ${taskReference(target, lookup)}` : taskReference(target, lookup);
212
+ 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
213
  }).join('')}</ul>`;
187
214
  }
188
215
 
@@ -211,6 +238,7 @@ function renderRightPane(sections, layout, presentation, readModel) {
211
238
  const lookup = presentationLookup(presentation);
212
239
  const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
213
240
  const nodeByKey = new Map(layout.nodes.map((node) => [refKey(node.ref), node]));
241
+ const folds = foldIndex(layout);
214
242
  const incoming = new Map(sections.map((section) => [refKey(section.ref), []]));
215
243
  const outgoing = new Map(sections.map((section) => [refKey(section.ref), []]));
216
244
  const addRelation = (relations, ownerKey, ref, joinIds) => {
@@ -223,7 +251,10 @@ function renderRightPane(sections, layout, presentation, readModel) {
223
251
  }
224
252
  entry.joinIds = [...new Set([...entry.joinIds, ...joinIds])].sort();
225
253
  };
226
- for (const edge of layout.edges) {
254
+ // Premises and successors come from the FULL graph. `layout.edges` is the
255
+ // drawn graph, where a fold unit's interior dependencies have been contracted
256
+ // away — reading those here would tell a folded ToDo it has no premises.
257
+ for (const edge of layout.full_edges ?? layout.edges) {
227
258
  addRelation(incoming, refKey(edge.to), edge.from, edge.join_ids);
228
259
  addRelation(outgoing, refKey(edge.from), edge.to, edge.join_ids);
229
260
  }
@@ -256,10 +287,25 @@ function renderRightPane(sections, layout, presentation, readModel) {
256
287
  const readiness = node?.visibility.next_ready
257
288
  ? `<p class="readiness-note">ready frontierの一員です。${ready.length > 1 ? '他のready工程と同時dispatchするのが既定です。subsetだけを選ぶ場合は理由を記録してください。' : '現在の唯一の着手候補です。'}</p>`
258
289
  : 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>`;
290
+ // A folded ToDo has no box of its own on the diagram. Say which fold node
291
+ // stands in for it, and make that node one click away.
292
+ const foldKey = folds.get(key);
293
+ const foldedNote = foldKey === undefined ? ''
294
+ : `<p class="fold-note">この工程は図の上では ▣ 畳み込みノードにまとめられています。<button type="button" class="fold-return" data-select-node-key="${escapeHtmlAttribute(foldKey)}">畳み込みノードを開く</button></p>`;
295
+ 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
296
  }).join('');
261
- const taskIndex = renderTaskIndex(sections, lookup, foldIndex(layout));
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>`;
297
+ const membersByFold = new Map();
298
+ for (const [taskKey, foldKey] of folds) {
299
+ const section = sectionByKey.get(taskKey);
300
+ if (section === undefined) continue;
301
+ if (!membersByFold.has(foldKey)) membersByFold.set(foldKey, []);
302
+ membersByFold.get(foldKey).push(section);
303
+ }
304
+ const foldDetails = (layout.scope?.folds ?? [])
305
+ .map((fold) => renderFoldDetail(fold, membersByFold.get(refKey(fold.ref)) ?? [], lookup))
306
+ .join('');
307
+ const taskIndex = renderTaskIndex(sections, lookup, folds);
308
+ 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}${foldDetails}</div><section class="task-index" data-right-panel="task-index" hidden><h1>全工程</h1><p>Latticeに登録された全工程を、現在の状態とともに登録順で表示しています。</p>${taskIndex}</section></div>`;
263
309
  }
264
310
 
265
311
  function renderDiagramLegend(presentation, layout = null) {
@@ -327,6 +373,9 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
327
373
  .task-index-list{margin:0;padding:0;list-style:none}.task-index-list li+li{margin-top:8px}.task-index-list button{display:grid;width:100%;grid-template-columns:1.5rem auto minmax(0,1fr);gap:4px 8px;align-items:baseline}
328
374
  .task-index-status{grid-row:1 / span 2;color:var(--text-secondary);font-size:13.5px;text-align:center}.task-index-status.status-in-progress{color:var(--accent)}.task-index-status.status-done{color:var(--good)}.task-index-status.status-blocked{color:var(--critical)}
329
375
  .task-index-reference{color:var(--text-secondary);font-size:12px;white-space:nowrap}.task-index-list strong{font-size:13.5px;font-weight:600;overflow-wrap:anywhere}.task-index-blocked-reason{grid-column:2 / -1;color:var(--text-secondary);font-size:12px;overflow-wrap:anywhere}
376
+ .fold-detail>section{margin-top:16px}.fold-detail h2{margin:0 0 12px;font-size:16px;font-weight:600}
377
+ .fold-return{margin-left:8px;padding:2px 8px;border:1px solid var(--border);border-radius:4px;background:var(--surface-2);color:var(--text-primary);font:500 12px/1.6 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif;cursor:pointer}
378
+ .fold-return:focus-visible{outline:2px solid var(--text-primary);outline-offset:2px}
330
379
  .todo-gantt text{font-family:system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif;pointer-events:none}
331
380
  .todo-node .node-surface{fill:var(--surface-2);stroke:var(--border);stroke-width:1}
332
381
  .todo-node .node-meta{fill:var(--text-secondary);font-size:12px;font-weight:500}
@@ -403,7 +403,7 @@ 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 }
406
+ ? { nodes: full.nodes, edges: full.edges, foldedByKey: new Map(), folds: [], grouping: null }
407
407
  : projectTodoGanttScope({
408
408
  nodes: full.nodes, edges: full.edges, wave: fullWaves.wave, longestChainKeys: longestNodeKeys,
409
409
  });
@@ -619,6 +619,16 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
619
619
  },
620
620
  nodes: projectedNodes,
621
621
  edges: projectedEdges,
622
+ // Every dependency in the plan, before folding contracted any of them away.
623
+ // The diagram draws `edges`; anything that describes a ToDo in words — the
624
+ // premises and successors in the right pane — reads this instead, so a
625
+ // folded ToDo keeps telling the truth about what it depended on.
626
+ full_edges: full.edges.map((edge) => ({
627
+ from: { ...full.nodesByKey.get(edge.from).ref },
628
+ to: { ...full.nodesByKey.get(edge.to).ref },
629
+ kinds: [...edge.kinds].sort(compareText),
630
+ join_ids: [...edge.joinIdentities.values()].map(({ join_id }) => join_id).sort(compareText),
631
+ })),
622
632
  connectors: junctionConnectors,
623
633
  groups: {
624
634
  plans: [...planMap.entries()].map(([plan_key, task_count]) => ({ plan_key, task_count })),
@@ -628,7 +638,7 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
628
638
  requested: scope,
629
639
  folded_task_count: projected.foldedByKey.size,
630
640
  fold_node_count: projected.folds.length,
631
- per_wave_refinement: projected.refined,
641
+ grouping: projected.grouping,
632
642
  folds: projected.folds.map((entry) => ({
633
643
  ref: { ...entry.ref },
634
644
  task_count: entry.task_count,
@@ -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/') {
@@ -10,10 +10,16 @@
10
10
  * where that projection narrows.
11
11
  *
12
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.
13
+ * to any live work is history, and history collapses into as few labelled nodes
14
+ * as the graph allows one per plan when nothing forbids it. Completed ToDos
15
+ * that are still the direct premise of live work stay visible, because they are
16
+ * the context for what is dispatchable right now.
17
+ *
18
+ * Grouping by connectivity was tried first and does not compress real plans: a
19
+ * store whose finished ToDos rarely declare dependencies on each other yields
20
+ * one fold unit per ToDo, which draws the same number of boxes as no folding at
21
+ * all while hiding every title. History is grouped by the plan it belongs to,
22
+ * not by whether its members happen to be wired together.
17
23
  *
18
24
  * This module is pure graph math over the layout's internal node/edge shape.
19
25
  * It must run AFTER dependency waves, the longest dependency chain and the
@@ -89,50 +95,59 @@ function foldableKeys(nodes, distance, foldDistance) {
89
95
  }
90
96
 
91
97
  /**
92
- * Group foldable nodes into fold units.
98
+ * Grouping strategies, coarsest first. The projection takes the first one whose
99
+ * contraction stays acyclic.
100
+ *
101
+ * - `plan`: one fold unit per plan. All history of a finished plan becomes one
102
+ * node. This is the target shape and it is what a reader wants to see.
103
+ * - `plan_stage`: additionally split per kept-node depth. A cycle can only close
104
+ * through a node kept on screen, and that axis is exactly what `keptDepth`
105
+ * measures, so this is the smallest refinement that removes the usual cause.
106
+ * - `plan_wave`: additionally split per dependency wave. Provably acyclic —
107
+ * every dependency edge strictly increases the wave, so a contracted edge
108
+ * between two units always increases it too and no cycle can exist.
109
+ */
110
+ const GROUPING_LADDER = Object.freeze(['plan', 'plan_stage', 'plan_wave']);
111
+
112
+ /**
113
+ * Number of kept (non-foldable) nodes lying before each node on the longest
114
+ * path. Crossing a kept node strictly increases it; edges between two foldable
115
+ * nodes leave it unchanged.
93
116
  *
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.
117
+ * Edges always increase the wave (`assignWaves` is a longest-path layering), so
118
+ * visiting nodes in ascending wave order settles every predecessor first.
100
119
  */
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;
120
+ function keptDepth(nodes, edges, foldable, wave) {
121
+ const incoming = new Map(nodes.map(({ key }) => [key, []]));
122
+ for (const edge of edges) incoming.get(edge.to).push(edge.from);
123
+ const depth = new Map();
124
+ const ordered = [...nodes].sort((left, right) => wave.get(left.key) - wave.get(right.key)
125
+ || compareText(left.key, right.key));
126
+ for (const node of ordered) {
127
+ let best = 0;
128
+ for (const predecessor of incoming.get(node.key)) {
129
+ const settled = depth.get(predecessor);
130
+ if (settled === undefined) continue;
131
+ const candidate = settled + (foldable.has(predecessor) ? 0 : 1);
132
+ if (candidate > best) best = candidate;
111
133
  }
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);
134
+ depth.set(node.key, best);
128
135
  }
136
+ return depth;
137
+ }
129
138
 
139
+ /** Group foldable nodes into fold units under one of `GROUPING_LADDER`. */
140
+ function groupFoldable(nodes, foldable, strategy, axes) {
141
+ const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
142
+ // A fold unit never spans plans: the diagram groups by plan and a unit that
143
+ // straddled two plans would have no honest lane to sit in.
144
+ const axisOf = (key) => (strategy === 'plan' ? null
145
+ : strategy === 'plan_stage' ? axes.stage.get(key)
146
+ : axes.wave.get(key));
130
147
  const unitByNode = new Map();
131
148
  const members = new Map();
132
149
  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]);
150
+ const unitKey = JSON.stringify([nodeByKey.get(key).ref.plan_key, axisOf(key)]);
136
151
  unitByNode.set(key, unitKey);
137
152
  if (!members.has(unitKey)) members.set(unitKey, []);
138
153
  members.get(unitKey).push(key);
@@ -249,15 +264,14 @@ function isAcyclic(nodes, edges) {
249
264
  /**
250
265
  * Project the full dependency graph onto the `live` scope.
251
266
  *
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.
267
+ * Contraction can close a cycle: with a fold distance of 1 the shape
268
+ * `f1 -> s -> f2` is reachable, where `s` is a completed ToDo kept as the direct
269
+ * premise of live work and `f1`/`f2` land in the same fold unit. Contracting
270
+ * that unit would produce `summary -> s -> summary`. So each candidate grouping
271
+ * is verified and the coarsest acyclic one wins. The last rung is provably
272
+ * acyclic, so the throw below is a backstop, never a routine outcome.
259
273
  *
260
- * @returns {{nodes: Array, edges: Array, foldedByKey: Map, folds: Array, refined: boolean}}
274
+ * @returns {{nodes: Array, edges: Array, foldedByKey: Map, folds: Array, grouping: string}}
261
275
  */
262
276
  export function projectTodoGanttScope({
263
277
  nodes, edges, wave, longestChainKeys = new Set(), foldDistance = DEFAULT_FOLD_DISTANCE,
@@ -265,24 +279,25 @@ export function projectTodoGanttScope({
265
279
  const distance = distanceToLive(nodes, edges, wave);
266
280
  const foldable = foldableKeys(nodes, distance, foldDistance);
267
281
  if (foldable.size === 0) {
268
- return { nodes, edges, foldedByKey: new Map(), folds: [], refined: false };
282
+ return { nodes, edges, foldedByKey: new Map(), folds: [], grouping: null };
269
283
  }
270
284
 
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
+ const axes = { wave, stage: keptDepth(nodes, edges, foldable, wave) };
286
+ let selected = null;
287
+ for (const strategy of GROUPING_LADDER) {
288
+ const grouping = groupFoldable(nodes, foldable, strategy, axes);
289
+ const summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
290
+ const contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
291
+ if (!isAcyclic(contracted.nodes, contracted.edges)) continue;
292
+ selected = { strategy, grouping, summaryByUnit, contracted };
293
+ break;
285
294
  }
295
+ if (selected === null) {
296
+ throw new TodoGanttScopeError('TODO_SCOPE_CONTRACTION_CYCLIC',
297
+ 'todo gantt scope contraction produced a cycle under every grouping',
298
+ { attempted_groupings: [...GROUPING_LADDER] });
299
+ }
300
+ const { grouping, summaryByUnit, contracted } = selected;
286
301
 
287
302
  const foldedByKey = new Map();
288
303
  for (const [nodeKey, unitKey] of grouping.unitByNode) {
@@ -292,5 +307,7 @@ export function projectTodoGanttScope({
292
307
  .sort((left, right) => compareText(left.key, right.key))
293
308
  .map((summary) => ({ ref: { ...summary.ref }, ...summary.fold }));
294
309
 
295
- return { nodes: contracted.nodes, edges: contracted.edges, foldedByKey, folds, refined };
310
+ return {
311
+ nodes: contracted.nodes, edges: contracted.edges, foldedByKey, folds, grouping: selected.strategy,
312
+ };
296
313
  }