@quolu/lattice 0.12.18 → 0.12.20

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.18",
3
+ "version": "0.12.20",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,12 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
- import {
4
- renderTodoMarkdownDocument,
5
- serializeJsonForScript,
6
- } from './todo-markdown-renderer.mjs';
3
+ import { serializeJsonForScript } from './todo-markdown-renderer.mjs';
7
4
  import { renderTodoGanttSvg, TODO_GANTT_STATUS_PRESENTATION } from './todo-gantt-svg.mjs';
8
5
 
9
- export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v14';
6
+ export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v16';
10
7
  export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
11
8
  export const TODO_GANTT_HTML_MAX_BYTES = 24 * 1024 * 1024;
12
9
 
@@ -46,22 +43,29 @@ function refKey(ref) {
46
43
  return JSON.stringify([ref.project_id, ref.plan_key, ref.task_id]);
47
44
  }
48
45
 
49
- function digest(value) {
50
- return createHash('sha256').update(value, 'utf8').digest('hex');
46
+ function compareText(left, right) {
47
+ return left < right ? -1 : left > right ? 1 : 0;
51
48
  }
52
49
 
53
- // The Markdown renderer deliberately permits safe web links for its standalone API.
54
- // A self-contained gantt permits fragments only, so its known allow-list output is de-linked here.
55
- function removeNavigation(markup) {
56
- return markup.replace(/<a href="[^"]*"(?: title="[^"]*")?>/gu, '<span class="deactivated-link">')
57
- .replaceAll('</a>', '</span>');
50
+ function digest(value) {
51
+ return createHash('sha256').update(value, 'utf8').digest('hex');
58
52
  }
59
53
 
54
+ /**
55
+ * Sections carry one row per ToDo, plus the prose accounting for the narratives
56
+ * behind them.
57
+ *
58
+ * The right pane shows the store, not the Markdown: per the 2026-07-19 ruling
59
+ * the "全工程" list is the plan rendered from the ToDo store, and the original
60
+ * documents are reached through each ToDo's source reference. The narratives are
61
+ * still read, because anchor verification needs them, and their size is still
62
+ * bounded — but they are not rendered into the page.
63
+ */
60
64
  function normalizeSections(readModel, narratives, anchorOutcomes) {
61
65
  const supplied = new Map(narratives.map((entry) => [refKey(entry.ref), entry]));
62
66
  const outcomes = new Map(anchorOutcomes.map((entry) => [refKey(entry.ref), entry]));
63
67
  const result = [];
64
- const documents = new Map();
68
+ const counted = new Set();
65
69
  let proseBytes = 0;
66
70
  for (const member of readModel.members) {
67
71
  const states = new Map(member.tasks.map((state) => [state.task_id, state]));
@@ -70,57 +74,23 @@ function normalizeSections(readModel, narratives, anchorOutcomes) {
70
74
  const narrative = supplied.get(refKey(ref));
71
75
  const markdown = narrative?.markdown ?? '';
72
76
  const narrativeRef = narrative?.narrative_ref ?? task.narrative_ref;
77
+ // ToDos that share one narrative document count its bytes once.
73
78
  const documentKey = narrativeRef === null
74
79
  ? refKey(ref) : JSON.stringify([member.plan.plan_key, narrativeRef, digest(markdown)]);
75
- let document = documents.get(documentKey);
76
- if (document === undefined) {
80
+ if (!counted.has(documentKey)) {
81
+ counted.add(documentKey);
77
82
  proseBytes += Buffer.byteLength(markdown, 'utf8');
78
83
  if (proseBytes > TODO_GANTT_PROSE_MAX_BYTES) {
79
84
  throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt embedded prose limit exceeded', {
80
85
  prose_bytes: proseBytes, prose_limit: TODO_GANTT_PROSE_MAX_BYTES,
81
86
  });
82
87
  }
83
- document = { markdown, narrativeRef, tasks: [], rendered: '' };
84
- documents.set(documentKey, document);
85
88
  }
86
89
  const anchorOutcome = outcomes.get(refKey(ref)) ?? {
87
90
  ref, narrative_ref: narrativeRef, anchored: false, reason: 'anchor_missing',
88
91
  origin_line: task.narrative_anchor?.origin_line ?? null,
89
92
  };
90
- const section = {
91
- ref, task, state: states.get(task.task_id), documentKey, narrativeRef, anchorOutcome, document,
92
- };
93
- document.tasks.push(section);
94
- result.push(section);
95
- }
96
- }
97
- for (const document of documents.values()) {
98
- const taskStatesByLine = new Map();
99
- for (const section of document.tasks) {
100
- if (!section.anchorOutcome.anchored) continue;
101
- const status = DOCUMENT_STATUS[section.state.status] ?? { mark: '?', label: '状態不明' };
102
- taskStatesByLine.set(section.anchorOutcome.origin_line, {
103
- status: section.state.status,
104
- mark: status.mark,
105
- label: status.label,
106
- narrativeKey: refKey(section.ref),
107
- blockedReason: section.state.blocked_reason,
108
- });
109
- }
110
- try {
111
- document.rendered = removeNavigation(renderTodoMarkdownDocument(
112
- document.markdown,
113
- { taskStatesByLine },
114
- ).html);
115
- } catch (error) {
116
- if (error?.code === 'TODO_MARKDOWN_SECTION_TOO_LARGE') {
117
- throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt narrative section limit exceeded', {
118
- narrative_ref: document.narrativeRef,
119
- prose_section_bytes: error.detail.actual_bytes,
120
- prose_section_limit: error.detail.maximum_bytes,
121
- });
122
- }
123
- throw error;
93
+ result.push({ ref, task, state: states.get(task.task_id), narrativeRef, anchorOutcome });
124
94
  }
125
95
  }
126
96
  return { sections: result, proseBytes };
@@ -142,16 +112,37 @@ function renderTaskIndexEntry(section, lookup) {
142
112
  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
113
  }
144
114
 
145
- function renderTaskIndex(sections, lookup, folds = new Set()) {
146
- const plans = [];
115
+ /** plan_key -> 最終活動時刻。journalの末尾eventが、そのplanが最後に動いた時点。 */
116
+ function planActivity(readModel) {
117
+ return new Map((readModel?.members ?? []).map((member) => {
118
+ const events = member.journal?.events ?? [];
119
+ const last = events.at(-1)?.recorded_at ?? events[0]?.recorded_at ?? '';
120
+ return [member.plan.plan_key, last];
121
+ }));
122
+ }
123
+
124
+ function renderTaskIndex(sections, lookup, folds = new Set(), activity = new Map()) {
125
+ const byPlan = new Map();
147
126
  for (const section of sections) {
148
- let plan = plans.at(-1);
149
- if (plan === undefined || plan.planKey !== section.ref.plan_key) {
150
- plan = { planKey: section.ref.plan_key, tasks: [] };
151
- plans.push(plan);
152
- }
153
- plan.tasks.push(section);
127
+ if (!byPlan.has(section.ref.plan_key)) byPlan.set(section.ref.plan_key, []);
128
+ byPlan.get(section.ref.plan_key).push(section);
154
129
  }
130
+ // 全ToDoが図から外れたplanは終わった仕事。読む側が先に見たいのは動いているplanなので、
131
+ // 動いているものを最終活動の新しい順で上へ、終わったものを古い順で下へまとめる。
132
+ // plan内のToDo順は登録順のまま触らない。
133
+ const plans = [...byPlan.entries()].map(([planKey, tasks]) => ({
134
+ planKey,
135
+ tasks,
136
+ settled: tasks.every((section) => folds.has(refKey(section.ref))),
137
+ lastActivity: activity.get(planKey) ?? '',
138
+ }));
139
+ plans.sort((left, right) => {
140
+ if (left.settled !== right.settled) return left.settled ? 1 : -1;
141
+ const order = left.settled
142
+ ? compareText(left.lastActivity, right.lastActivity)
143
+ : compareText(right.lastActivity, left.lastActivity);
144
+ return order !== 0 ? order : compareText(left.planKey, right.planKey);
145
+ });
155
146
  return plans.map((plan) => {
156
147
  const drawn = plan.tasks.filter((section) => !folds.has(refKey(section.ref)));
157
148
  const folded = plan.tasks.filter((section) => folds.has(refKey(section.ref)));
@@ -281,8 +272,8 @@ function renderRightPane(sections, layout, presentation, readModel) {
281
272
  : '<p class="fold-note">完走済みのため図には描いていません。図に出すには <code>lattice todo gantt --scope all</code> を実行してください。</p>';
282
273
  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>`;
283
274
  }).join('');
284
- const taskIndex = renderTaskIndex(sections, lookup, folds);
285
- 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>`;
275
+ const taskIndex = renderTaskIndex(sections, lookup, folds, planActivity(readModel));
276
+ return `<div class="right-toolbar"><button type="button" data-show-overview>概要</button><button type="button" data-show-selected hidden>選択工程へ戻る</button><button type="button" data-show-task-index>全工程一覧</button></div><div class="right-content">${overview}<div data-right-panel="details" hidden>${details}</div><section class="task-index" data-right-panel="task-index" hidden><h1>全工程</h1><p>Latticeに登録された全工程を現在の状態とともに表示しています。planは動いているものを最終活動の新しい順で上に、完走したものを古い順で下にまとめ、plan内は登録順です。</p>${taskIndex}</section></div>`;
286
277
  }
287
278
 
288
279
  function renderDiagramLegend(presentation, layout = null, expandable = false) {