@quolu/lattice 0.57.2 → 0.58.0

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.
@@ -1,4 +1,9 @@
1
1
  import { DOCUMENT_STATUS, SEVERABILITY_LABEL, escapeHtmlAttribute, escapeHtmlText, foldIndex, planActivity, presentationLookup, refKey, renderPhaseProgress, renderRelationList, renderSeamProposalOverview, renderTaskIndex, statusMarkup, taskReference } from './todo-gantt-html-shared.mjs';
2
+ import {
3
+ hasTodoStructurePresentation,
4
+ renderTodoStructureNoscript,
5
+ renderTodoStructurePanel,
6
+ } from './todo-gantt-structure.mjs';
2
7
  import { renderTodoMarkdown } from './todo-markdown-renderer.mjs';
3
8
 
4
9
  function renderNoteContext(context) {
@@ -136,6 +141,7 @@ export function renderIndependenceNote(ref, node, summary, status) {
136
141
 
137
142
  export function renderRightPane(
138
143
  sections, layout, presentation, readModel, notesEnabled = false, noteWarnings = [], expandable = false,
144
+ structurePresentation = null,
139
145
  ) {
140
146
  const lookup = presentationLookup(presentation);
141
147
  const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
@@ -211,7 +217,9 @@ export function renderRightPane(
211
217
  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}${independenceNote}${foldedNote}${designMemo}<section><h2>前提工程</h2>${renderRelationList(incoming.get(key), sectionByKey, lookup, '登録済みの前提工程はありません。', folds)}</section><section><h2>後続工程</h2>${renderRelationList(outgoing.get(key), sectionByKey, lookup, '登録済みの後続工程はありません。', folds)}</section>${workLog}<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>`;
212
218
  }).join('');
213
219
  const taskIndex = renderTaskIndex(sections, lookup, folds, planActivity(readModel));
214
- 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>`;
220
+ const structureButton = hasTodoStructurePresentation(structurePresentation)
221
+ ? '<button type="button" data-show-structure>構造検査</button>' : '';
222
+ 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>${structureButton}</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>${renderTodoStructurePanel(structurePresentation)}${renderTodoStructureNoscript(structurePresentation)}</div>`;
215
223
  }
216
224
 
217
225
  export function renderDiagramLegend(presentation, layout = null, expandable = false) {
@@ -74,6 +74,14 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
74
74
  .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}
75
75
  .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)}
76
76
  .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}
77
+ .structure-inspection>h1,.structure-noscript>h1{margin:0 0 8px;font-size:19px}.structure-inspection>p{color:var(--text-secondary)}
78
+ .structure-plan{margin:18px 0;border:1px solid var(--border);background:var(--surface-1)}.structure-plan>summary{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;padding:10px 12px;cursor:pointer;background:var(--surface-2)}.structure-plan>summary>code{font-weight:700}.structure-plan>summary>span{color:var(--text-secondary);font-size:12px}
79
+ .structure-verdict{padding:1px 7px;border:1px solid var(--border);border-radius:9999px}.structure-verdict.verdict-consistent{border-color:var(--good);color:var(--good)}.structure-verdict.verdict-inconsistent,.structure-verdict.verdict-unreadable{border-color:var(--critical);color:var(--critical)}.structure-verdict.verdict-unknown,.structure-verdict.verdict-stale{border-color:var(--accent);color:var(--accent)}
80
+ .structure-findings,.structure-graph,.structure-actions{margin:0;padding:12px;border-top:1px solid var(--border)}.structure-findings h3,.structure-graph h3{margin:0 0 8px;font-size:14px}.structure-findings ol,.structure-edge-list,.structure-actions ul,.structure-node details ul{margin:0;padding:0;list-style:none}
81
+ .structure-finding{padding:9px;border-left:4px solid var(--accent);background:var(--surface-2)}.structure-finding+.structure-finding{margin-top:7px}.structure-finding.severity-error{border-left-color:var(--critical)}.structure-finding.severity-notice{border-left-color:var(--good)}.structure-finding>header{display:flex;justify-content:space-between;gap:10px}.structure-finding>p{margin:5px 0 0;color:var(--text-secondary);font-size:12px}.structure-finding-targets{display:flex;flex-wrap:wrap;align-items:center;gap:4px;margin-top:6px}
82
+ .structure-finding-targets button,.structure-finding-edge-targets button,.structure-edge-list button{max-width:100%;padding:2px 5px;border:1px solid var(--border);background:var(--surface-1);cursor:pointer}.structure-finding-targets code,.structure-finding-edge-targets code,.structure-edge-list code{overflow-wrap:anywhere}.structure-finding-edge-targets{display:grid;gap:4px;margin-top:5px}.structure-finding-edge-targets button{text-align:left}
83
+ .structure-node-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(15rem,1fr));gap:8px}.structure-node{min-width:0;padding:9px;border:1px solid var(--border);border-left:4px solid var(--text-secondary);background:var(--surface-2);scroll-margin:56px}.structure-node.kind-task_transform{border-left-color:var(--accent)}.structure-node.kind-data{border-left-color:var(--critical)}.structure-node.kind-code,.structure-node.kind-source_symbol{border-left-color:var(--text-primary)}.structure-node.kind-external{border-left-color:var(--text-secondary)}.structure-node.kind-changeset{border-left-color:var(--good)}.structure-node.focused{outline:3px solid var(--critical);outline-offset:2px}.structure-node>header{display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 8px}.structure-node>header>span{font-weight:700}.structure-node>header>code{max-width:100%;overflow-wrap:anywhere;color:var(--text-secondary)}.structure-node>p{margin:5px 0;font-size:12px}.structure-node details{margin-top:6px}.structure-node details summary{cursor:pointer}.structure-node details li{overflow-wrap:anywhere}
84
+ .structure-edge-list{display:grid;gap:5px}.structure-edge-list li{display:grid;grid-template-columns:auto minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:5px;padding:6px;background:var(--surface-2);scroll-margin:56px}.structure-edge-list li.focused{outline:3px solid var(--critical);outline-offset:2px}.structure-edge-list li>span:first-child{color:var(--text-secondary);font-size:11px}.structure-actions summary{cursor:pointer;font-weight:650}.structure-actions li+li{margin-top:5px}.structure-severity-error{color:var(--critical)}.structure-noscript{margin:16px 24px;padding:12px;border:1px solid var(--critical)}
77
85
  .todo-gantt text{font-family:system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif;pointer-events:none}
78
86
  .todo-node .node-surface{fill:var(--surface-2);stroke:var(--border);stroke-width:1}
79
87
  .todo-node .node-meta{fill:var(--text-secondary);font-size:12px;font-weight:500}
@@ -6,8 +6,9 @@ import { renderTodoGanttSvg, TODO_GANTT_STATUS_PRESENTATION } from './todo-gantt
6
6
  import { renderDiagramLegend, renderRightPane } from './todo-gantt-html-independence.mjs';
7
7
  import { escapeHtmlAttribute, escapeHtmlText, refKey } from './todo-gantt-html-shared.mjs';
8
8
  import { CSS, NESTED_CSS } from './todo-gantt-html-style.mjs';
9
+ import { TODO_STRUCTURE_PRESENTATION_SCHEMA } from './todo-structure-presentation.mjs';
9
10
 
10
- export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v19';
11
+ export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v20';
11
12
  export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
12
13
  export const TODO_GANTT_HTML_MAX_BYTES = 24 * 1024 * 1024;
13
14
 
@@ -135,6 +136,7 @@ const CONTROLLER = `
135
136
  const overviewPanel=root.querySelector('[data-right-panel="overview"]');
136
137
  const detailsPanel=root.querySelector('[data-right-panel="details"]');
137
138
  const taskIndexPanel=root.querySelector('[data-right-panel="task-index"]');
139
+ const structurePanel=root.querySelector('[data-right-panel="structure"]');
138
140
  const selectedReturnButton=root.querySelector('[data-show-selected]');
139
141
  const detailPanels=[...root.querySelectorAll('[data-detail-key]')];
140
142
  const nodes=[...root.querySelectorAll('[data-node-key]')];
@@ -157,7 +159,7 @@ const CONTROLLER = `
157
159
  const toggleLane=(key)=>applyLane(activeLaneKey===key?null:key);
158
160
  // Both diagrams ship in the page; the badge picks which one is on screen.
159
161
  const setExpanded=(next)=>{if(diagrams.length<2)return;expanded=next;for(const diagram of diagrams)diagram.hidden=(diagram.dataset.diagram==='expanded')!==expanded;svg=diagrams.find(diagram=>!diagram.hidden)?.querySelector('[data-gantt-svg]')??svg;baseWidth=Number(svg?.dataset.svgWidth??0);baseHeight=Number(svg?.dataset.svgHeight??0);if(expandToggle){expandToggle.setAttribute('aria-expanded',String(expanded));}if(toggleLabel){toggleLabel.textContent=expanded?toggleLabel.dataset.expandedLabel:toggleLabel.dataset.collapsedLabel;}setZoom(zoom);applyLane(activeLaneKey);syncSelection();scroller?.scrollTo(0,0);};
160
- const showPanel=(name)=>{if(overviewPanel)overviewPanel.hidden=name!=='overview';if(detailsPanel)detailsPanel.hidden=name!=='details';if(taskIndexPanel)taskIndexPanel.hidden=name!=='task-index';if(selectedReturnButton)selectedReturnButton.hidden=name!=='task-index'||selectedKey===null;root.dataset.viewState=name;};
162
+ const showPanel=(name)=>{if(overviewPanel)overviewPanel.hidden=name!=='overview';if(detailsPanel)detailsPanel.hidden=name!=='details';if(taskIndexPanel)taskIndexPanel.hidden=name!=='task-index';if(structurePanel)structurePanel.hidden=name!=='structure';if(selectedReturnButton)selectedReturnButton.hidden=name!=='task-index'||selectedKey===null;root.dataset.viewState=name;};
161
163
  const syncSelection=()=>{for(const detail of detailPanels)detail.hidden=detail.dataset.detailKey!==selectedKey;for(const node of nodes){const selected=node.dataset.nodeKey===selectedKey;node.setAttribute('aria-selected',String(selected));node.classList.toggle('selected-node',selected);}for(const edge of edges){const selected=selectedKey!==null;edge.classList.toggle('selected-incident-edge',selected&&(edge.dataset.fromNodeKey===selectedKey||edge.dataset.toNodeKey===selectedKey));}};
162
164
  const showOverview=()=>{selectedKey=null;syncSelection();showPanel('overview');};
163
165
  const showTaskIndex=()=>showPanel('task-index');
@@ -167,6 +169,8 @@ const CONTROLLER = `
167
169
  const overviewButton=event.target.closest('[data-show-overview]');if(overviewButton&&root.contains(overviewButton)){showOverview();return;}
168
170
  const selectedButton=event.target.closest('[data-show-selected]');if(selectedButton&&root.contains(selectedButton)){showSelected();return;}
169
171
  const taskIndexButton=event.target.closest('[data-show-task-index]');if(taskIndexButton&&root.contains(taskIndexButton)){showTaskIndex();return;}
172
+ const structureButton=event.target.closest('[data-show-structure]');if(structureButton&&root.contains(structureButton)){showPanel('structure');return;}
173
+ const structureTarget=event.target.closest('[data-structure-target-id]');if(structureTarget&&root.contains(structureTarget)){const target=document.getElementById(structureTarget.dataset.structureTargetId);if(target&&root.contains(target)){for(const item of root.querySelectorAll('.structure-node.focused,.structure-edge-list li.focused'))item.classList.remove('focused');target.classList.add('focused');target.scrollIntoView({block:'center'});}return;}
170
174
  const expandButton=event.target.closest('[data-toggle-expanded]');if(expandButton&&root.contains(expandButton)){setExpanded(!expanded);return;}
171
175
  const selectButton=event.target.closest('[data-select-node-key]');if(selectButton&&root.contains(selectButton)){select(selectButton.dataset.selectNodeKey);return;}
172
176
  const zoomButton=event.target.closest('[data-zoom-action]');if(zoomButton&&root.contains(zoomButton)){const action=zoomButton.dataset.zoomAction;if(action==='in')setZoom(zoom<1&&zoom*1.25>=1?1:zoom*1.25,.001);else if(action==='out')setZoom(zoom>1&&zoom/1.25<=1?1:zoom/1.25,.001);else if(action==='reset')setZoom(1);else if(action==='fit'&&scroller){setZoom(Math.min(1,(scroller.clientWidth-16)/baseWidth),.001);scroller.scrollTo(0,0);}return;}
@@ -202,7 +206,7 @@ const NESTED_CONTROLLER = `
202
206
 
203
207
  export function renderTodoGanttHtml({
204
208
  readModel, layout, narratives = [], anchorOutcomes = [], presentation = null, metadata = {},
205
- expandedLayout = null, noteContexts = null, noteWarnings = [],
209
+ expandedLayout = null, noteContexts = null, noteWarnings = [], structurePresentation = null,
206
210
  }) {
207
211
  if (readModel?.schema !== 'lattice.todo_store_read.v1' || !Array.isArray(readModel.members)) {
208
212
  throw new TypeError('readModel must be lattice.todo_store_read.v1');
@@ -218,6 +222,11 @@ export function renderTodoGanttHtml({
218
222
  || presentation.project_id !== readModel.project_id)) {
219
223
  throw new TypeError('presentation must be lattice.todo_gantt_presentation_model.v1');
220
224
  }
225
+ if (structurePresentation !== null
226
+ && (structurePresentation?.schema !== TODO_STRUCTURE_PRESENTATION_SCHEMA
227
+ || structurePresentation.project_id !== readModel.project_id)) {
228
+ throw new TypeError('structurePresentation must be lattice.todo_structure_presentation.v1');
229
+ }
221
230
  const normalized = normalizeSections(readModel, narratives, anchorOutcomes, noteContexts);
222
231
  const displayName = projectDisplayName(readModel, metadata);
223
232
  const hasHierarchy = layout?.hierarchy?.schema === 'lattice.todo_gantt_hierarchy.v1';
@@ -230,12 +239,13 @@ export function renderTodoGanttHtml({
230
239
  : `<div data-diagram="live">${svg}</div><div data-diagram="expanded" hidden>${expandedSvg}</div>`;
231
240
  const rightPane = renderRightPane(
232
241
  normalized.sections, layout, presentation, readModel, noteContexts !== null, noteWarnings,
233
- expandedSvg !== '',
242
+ expandedSvg !== '', structurePresentation,
234
243
  );
235
244
  const staticData = serializeJsonForScript({
236
245
  renderer_version: TODO_GANTT_RENDERER_VERSION,
237
246
  metadata,
238
247
  presentation,
248
+ structure_presentation: structurePresentation,
239
249
  });
240
250
  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}${hasHierarchy ? NESTED_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>${renderAuditPendingChip(readModel)}<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, expandedSvg !== '')}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${diagrams}</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}${hasHierarchy ? NESTED_CONTROLLER : ''}</script></body></html>`;
241
251
  const htmlBytes = Buffer.byteLength(html, 'utf8');
@@ -0,0 +1,134 @@
1
+ import { escapeHtmlAttribute, escapeHtmlText } from './todo-gantt-html-shared.mjs';
2
+ import { TODO_STRUCTURE_PRESENTATION_SCHEMA } from './todo-structure-presentation.mjs';
3
+
4
+ const KIND_LABEL = Object.freeze({
5
+ task_transform: '工程変換', data: 'データ', code: 'コード', source_symbol: '既存コード',
6
+ external: '外部契約', changeset: 'commit', constant: '定数', final_product: '最終成果',
7
+ });
8
+ const EDGE_LABEL = Object.freeze({
9
+ input: '入力', output: '出力', sink: '受渡し', source_edge: '既存コード関係',
10
+ realization: '実装commit',
11
+ });
12
+
13
+ function valid(presentation) {
14
+ return presentation?.schema === TODO_STRUCTURE_PRESENTATION_SCHEMA
15
+ && Array.isArray(presentation.plans);
16
+ }
17
+
18
+ export function hasTodoStructurePresentation(presentation) {
19
+ return valid(presentation) && presentation.plans.length > 0;
20
+ }
21
+
22
+ function nodeId(planIndex, nodeIndex) {
23
+ return `structure-node-${planIndex}-${nodeIndex}`;
24
+ }
25
+
26
+ function edgeId(planIndex, edgeIndex) {
27
+ return `structure-edge-${planIndex}-${edgeIndex}`;
28
+ }
29
+
30
+ function focusRefs(finding) {
31
+ return [...new Set([
32
+ ...(finding.task_ids ?? []).map((taskId) => `task:${taskId}`),
33
+ ...(finding.data_refs ?? []).map((ref) => ref.startsWith('external/')
34
+ ? `external:${ref.slice('external/'.length)}` : `data:${ref}`),
35
+ ...(finding.code_refs ?? []).map((ref) => `code:${ref}`),
36
+ ...(finding.commit_oids ?? []).map((oid) => `commit:${oid}`),
37
+ ])];
38
+ }
39
+
40
+ function renderTaskDetail(plan, node) {
41
+ const task = plan.tasks.find(({ task_id: taskId }) => taskId === node.task_id);
42
+ if (task === undefined) return '';
43
+ const changed = task.changed_fields.length === 0
44
+ ? 'plannedからの変更なし' : `変更: ${task.changed_fields.join(', ')}`;
45
+ const anchors = task.code_anchors.length === 0 ? ''
46
+ : `<details><summary>code anchor ${task.code_anchors.length}件</summary><ul>${task.code_anchors.map((anchor) => `<li><code>${escapeHtmlText(anchor.effect)}</code> <code>${escapeHtmlText(anchor.path)}</code>${anchor.symbol === null ? '' : ` · ${escapeHtmlText(anchor.symbol)}`}</li>`).join('')}</ul></details>`;
47
+ return `<p><strong>${escapeHtmlText(task.form)}</strong> · ${escapeHtmlText(changed)}</p><p>planned: ${escapeHtmlText(task.planned_outcome)}</p>${task.form === 'realized' ? `<p>effective: ${escapeHtmlText(task.effective_outcome)}</p>` : ''}${anchors}`;
48
+ }
49
+
50
+ function renderCommitDetail(plan, node) {
51
+ if (node.kind !== 'changeset') return '';
52
+ const oid = node.ref.slice('commit:'.length);
53
+ const commit = plan.provenance?.commits.find(({ commit_oid: commitOid }) => commitOid === oid);
54
+ if (commit === undefined) return '';
55
+ return `<details><summary>変更path ${commit.changes.length}件</summary><ul>${commit.changes.map((change) => `<li><code>${escapeHtmlText(change.change)}</code> ${escapeHtmlText(change.path)}</li>`).join('')}</ul></details>`;
56
+ }
57
+
58
+ function renderNaturalRef(node) {
59
+ if (node.kind !== 'source_symbol' || node.natural_ref === null) return '';
60
+ const natural = node.natural_ref;
61
+ const label = [natural.path, natural.name].filter((value) => typeof value === 'string').join(' · ');
62
+ return label === '' ? '' : `<p>${escapeHtmlText(label)}</p>`;
63
+ }
64
+
65
+ function renderNodes(plan, planIndex) {
66
+ const ids = new Map();
67
+ const nodes = plan.graph.nodes.map((node, index) => {
68
+ const id = nodeId(planIndex, index);
69
+ ids.set(node.ref, id);
70
+ const form = node.kind === 'task_transform' ? ` · ${node.form}` : '';
71
+ return `<article class="structure-node kind-${escapeHtmlAttribute(node.kind)}" id="${id}" data-structure-node-ref="${escapeHtmlAttribute(node.ref)}"><header><span>${escapeHtmlText(KIND_LABEL[node.kind] ?? node.kind)}</span><code>${escapeHtmlText(node.ref)}</code></header>${node.kind === 'task_transform' ? renderTaskDetail(plan, node) : ''}${renderCommitDetail(plan, node)}${renderNaturalRef(node)}${form === '' ? '' : `<p class="structure-form">${escapeHtmlText(form.slice(3))}</p>`}</article>`;
72
+ }).join('');
73
+ return { ids, markup: nodes };
74
+ }
75
+
76
+ function targetButton(ref, ids) {
77
+ const id = ids.get(ref);
78
+ return id === undefined
79
+ ? `<code>${escapeHtmlText(ref)}</code>`
80
+ : `<button type="button" data-structure-target-id="${escapeHtmlAttribute(id)}"><code>${escapeHtmlText(ref)}</code></button>`;
81
+ }
82
+
83
+ function renderFindings(plan, ids, edgeIds) {
84
+ if (plan.unreadable_reason !== null) {
85
+ return `<section class="structure-findings"><h3>読取不能</h3><p class="structure-severity-error">${escapeHtmlText(plan.unreadable_reason)}</p></section>`;
86
+ }
87
+ if (plan.findings.length === 0) {
88
+ return '<section class="structure-findings"><h3>指摘</h3><p>保存artifactに指摘はありません。</p></section>';
89
+ }
90
+ return `<section class="structure-findings"><h3>指摘 ${plan.findings.length}件</h3><ol>${plan.findings.map((finding) => {
91
+ const refs = focusRefs(finding);
92
+ const relation = refs.length === 0 ? ''
93
+ : `<div class="structure-finding-targets">${refs.map((ref) => targetButton(ref, ids)).join('<span aria-hidden="true">→</span>')}</div>`;
94
+ const implicatedEdges = plan.graph.edges.flatMap((edge, index) => {
95
+ if (!refs.includes(edge.from) || !refs.includes(edge.to)) return [];
96
+ const id = edgeIds.get(index);
97
+ return [`<button type="button" data-structure-target-id="${escapeHtmlAttribute(id)}">edge: ${escapeHtmlText(EDGE_LABEL[edge.kind] ?? edge.kind)} <code>${escapeHtmlText(edge.from)}</code> → <code>${escapeHtmlText(edge.to)}</code></button>`];
98
+ });
99
+ const edgeTargets = implicatedEdges.length === 0 ? ''
100
+ : `<div class="structure-finding-edge-targets">${implicatedEdges.join('')}</div>`;
101
+ return `<li class="structure-finding severity-${escapeHtmlAttribute(finding.severity)}"><header><strong>${escapeHtmlText(finding.code)}</strong><span>${escapeHtmlText(finding.severity)}</span></header>${relation}${edgeTargets}<p>次: <code>${escapeHtmlText(finding.next_action)}</code></p></li>`;
102
+ }).join('')}</ol></section>`;
103
+ }
104
+
105
+ function renderEdges(plan, ids, edgeIds) {
106
+ if (plan.graph.edges.length === 0) return '<p>保存artifactにedgeはありません。</p>';
107
+ return `<ol class="structure-edge-list">${plan.graph.edges.map((edge, index) => `<li id="${edgeIds.get(index)}" data-structure-edge-from="${escapeHtmlAttribute(edge.from)}" data-structure-edge-to="${escapeHtmlAttribute(edge.to)}"><span>${escapeHtmlText(EDGE_LABEL[edge.kind] ?? edge.kind)}</span>${targetButton(edge.from, ids)}<span aria-hidden="true">→</span>${targetButton(edge.to, ids)}</li>`).join('')}</ol>`;
108
+ }
109
+
110
+ function renderPlan(plan, planIndex) {
111
+ const { ids, markup: nodes } = renderNodes(plan, planIndex);
112
+ const edgeIds = new Map(plan.graph.edges.map((_, index) => [index, edgeId(planIndex, index)]));
113
+ const verdict = plan.verdict ?? plan.compiled_verdict ?? plan.coverage;
114
+ const finalization = plan.finalization === null ? ''
115
+ : `<span>finalization: ${escapeHtmlText(plan.finalization.status)}</span>`;
116
+ const actions = plan.next_actions.length === 0 ? ''
117
+ : `<details class="structure-actions"><summary>次の操作</summary><ul>${plan.next_actions.map((action) => `<li><code>${escapeHtmlText(action)}</code></li>`).join('')}</ul></details>`;
118
+ return `<details class="structure-plan" open><summary><code>${escapeHtmlText(plan.plan_key)}</code><span class="structure-verdict verdict-${escapeHtmlAttribute(verdict ?? 'unknown')}">${escapeHtmlText(verdict ?? 'unknown')}</span><span>${escapeHtmlText(plan.freshness)}</span>${finalization}</summary>${renderFindings(plan, ids, edgeIds)}<section class="structure-graph" aria-label="${escapeHtmlAttribute(`${plan.plan_key} 構造グラフ`)}"><h3>node</h3><div class="structure-node-grid">${nodes}</div><h3>edge</h3>${renderEdges(plan, ids, edgeIds)}</section>${actions}</details>`;
119
+ }
120
+
121
+ export function renderTodoStructurePanel(presentation) {
122
+ if (!hasTodoStructurePresentation(presentation)) return '';
123
+ return `<section class="structure-inspection" data-right-panel="structure" hidden><h1>構造検査</h1><p>工程依存図とは別の面です。task、data、code、external、commit provenanceを最終的な受渡しとして表示します。</p>${presentation.plans.map(renderPlan).join('')}</section>`;
124
+ }
125
+
126
+ /** script無効時にもfinding一覧だけは隠さず読めるfallback。 */
127
+ export function renderTodoStructureNoscript(presentation) {
128
+ if (!hasTodoStructurePresentation(presentation)) return '';
129
+ const plans = presentation.plans.map((plan) => `<section><h2>${escapeHtmlText(plan.plan_key)} — ${escapeHtmlText(plan.verdict ?? plan.compiled_verdict ?? plan.coverage)}</h2>${plan.unreadable_reason === null
130
+ ? plan.findings.length === 0 ? '<p>指摘はありません。</p>'
131
+ : `<ol>${plan.findings.map((finding) => `<li><strong>${escapeHtmlText(finding.code)}</strong> (${escapeHtmlText(finding.severity)}) — <code>${escapeHtmlText(finding.next_action)}</code></li>`).join('')}</ol>`
132
+ : `<p>${escapeHtmlText(plan.unreadable_reason)}</p>`}</section>`).join('');
133
+ return `<noscript><section class="structure-noscript"><h1>構造検査の指摘</h1>${plans}</section></noscript>`;
134
+ }
@@ -22,12 +22,13 @@ import {
22
22
  } from './todo-store.mjs';
23
23
 
24
24
  /**
25
- * v6で`plan_notes`を足す。ADR 0054・0063の前例どおり既存versionへのin-place追加はしない。
25
+ * v7で`structure_finalization_pending`を足す。構造機能が未適用のplanでは空配列になり、
26
+ * dispatch面は変えない。ADR 0054・0063の前例どおり既存versionへのin-place追加はしない。
26
27
  *
27
28
  * plan単位noteは工程に属する義務で、taskへ着手した人のcontextには届くが、**まだ誰も
28
29
  * 着手していない工程の義務は、この欄が無いとどこにも出ない**。
29
30
  */
30
- export const TODO_STATUS_SCHEMA = 'lattice.todo_status_result.v6';
31
+ export const TODO_STATUS_SCHEMA = 'lattice.todo_status_result.v7';
31
32
  export const TODO_DISPATCH_FRONTIER_SCHEMA = 'lattice.todo_dispatch_frontier.v1';
32
33
  export const TODO_STATUS_LIST_LIMIT = 2_000;
33
34
  export const TODO_STATUS_LABEL_LIMIT = 160;
@@ -234,6 +235,18 @@ function parallelCandidateEntry(value) {
234
235
  || value.serialize_pairs.length > 0);
235
236
  }
236
237
 
238
+ function structureFinalizationPendingEntry(value) {
239
+ return exactRecord(value, ['plan_key', 'status', 'reason', 'stale_reasons', 'next_commands'])
240
+ && isTodoIdentifier(value.plan_key)
241
+ && ['missing', 'stale'].includes(value.status)
242
+ && isTodoStatusBoundedText(value.reason, TODO_STATUS_REASON_LIMIT)
243
+ && boundedList(value.stale_reasons,
244
+ (reason) => isTodoStatusBoundedText(reason, TODO_STATUS_LABEL_LIMIT))
245
+ && Array.isArray(value.next_commands) && value.next_commands.length > 0
246
+ && boundedList(value.next_commands,
247
+ (command) => isTodoStatusBoundedText(command, TODO_STATUS_REASON_LIMIT));
248
+ }
249
+
237
250
  function memberHead(value) {
238
251
  return exactRecord(value, [
239
252
  'plan_key', 'plan_version', 'through_sequence', 'journal_head_digest',
@@ -287,17 +300,19 @@ function dispatchFrontierEntry(value, projectId, nextReady) {
287
300
  && value.frontier_digest === expected.frontier_digest;
288
301
  }
289
302
 
290
- /** todo status v6 wire shapeを検証し、digestも再計算する。 */
303
+ /** todo status v7 wire shapeを検証し、digestも再計算する。 */
291
304
  export function validateTodoStatusResult(value) {
292
305
  try {
293
306
  return exactRecord(value, [
294
307
  'schema', 'project_id', 'active_set', 'next_ready', 'dispatch_frontier',
295
- 'blocked', 'audit_pending', 'plan_notes', 'coordination', 'parallel_candidates',
308
+ 'blocked', 'audit_pending', 'structure_finalization_pending', 'plan_notes',
309
+ 'coordination', 'parallel_candidates',
296
310
  'member_heads', 'result_digest',
297
311
  ]) && value.schema === TODO_STATUS_SCHEMA && isTodoIdentifier(value.project_id)
298
312
  && boundedList(value.active_set, activeTaskEntry) && boundedList(value.next_ready, taskEntry)
299
313
  && dispatchFrontierEntry(value.dispatch_frontier, value.project_id, value.next_ready)
300
314
  && boundedList(value.blocked, blockedEntry) && boundedList(value.audit_pending, auditPendingEntry)
315
+ && boundedList(value.structure_finalization_pending, structureFinalizationPendingEntry)
301
316
  && boundedList(value.plan_notes, planNoteEntry)
302
317
  && boundedList(value.coordination, coordinationEntry)
303
318
  && boundedList(value.parallel_candidates, parallelCandidateEntry)
@@ -535,6 +550,7 @@ export function computeReadyFrontier(readModel) {
535
550
  */
536
551
  export const TODO_STATUS_DISPATCH_ONLY = Object.freeze({
537
552
  planNotes: Object.freeze([]), parallelCandidates: Object.freeze([]),
553
+ structureFinalizations: Object.freeze([]),
538
554
  });
539
555
 
540
556
  /**
@@ -547,17 +563,23 @@ export const TODO_STATUS_DISPATCH_ONLY = Object.freeze({
547
563
  * 呼び出し元だけがそれを渡す。dispatch面しか見ない内部呼び出しは`TODO_STATUS_DISPATCH_ONLY`を使う。
548
564
  */
549
565
  export function projectTodoStatus(readModel, options = undefined) {
550
- if (!exactRecord(options, ['planNotes', 'parallelCandidates'])
566
+ if (!(exactRecord(options, ['planNotes', 'parallelCandidates'])
567
+ || exactRecord(options, ['planNotes', 'parallelCandidates', 'structureFinalizations']))
551
568
  || !Array.isArray(options.planNotes)) {
552
569
  fail('TODO_STATUS_INVALID_INPUT', 'todo_status_plan_notes_missing');
553
570
  }
554
571
  if (!Array.isArray(options.parallelCandidates)) {
555
572
  fail('TODO_STATUS_INVALID_INPUT', 'todo_status_parallel_candidates_missing');
556
573
  }
574
+ if (options.structureFinalizations !== undefined
575
+ && !Array.isArray(options.structureFinalizations)) {
576
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_structure_finalizations_invalid');
577
+ }
557
578
  const graph = buildTodoGraph(readModel);
558
579
  const { nodes, incoming, memberHeads, auditPending, coordination } = graph;
559
580
  const planNotes = [...options.planNotes];
560
581
  const parallelCandidates = [...options.parallelCandidates];
582
+ const structureFinalizations = [...(options.structureFinalizations ?? [])];
561
583
 
562
584
  const activeSet = [];
563
585
  const nextReady = [];
@@ -590,7 +612,8 @@ export function projectTodoStatus(readModel, options = undefined) {
590
612
  memberHeads.sort((left, right) => left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0);
591
613
  for (const [name, value] of [
592
614
  ['active_set', activeSet], ['next_ready', nextReady], ['blocked', blocked],
593
- ['audit_pending', auditPending], ['plan_notes', planNotes],
615
+ ['audit_pending', auditPending], ['structure_finalization_pending', structureFinalizations],
616
+ ['plan_notes', planNotes],
594
617
  ['coordination', coordination], ['parallel_candidates', parallelCandidates],
595
618
  ['member_heads', memberHeads],
596
619
  ]) enforceListLimit(name, value);
@@ -605,6 +628,9 @@ export function projectTodoStatus(readModel, options = undefined) {
605
628
  // 監査待ちは`member.phases`だけから作った別の列で、dispatch(next_ready/dispatch_frontier)へは
606
629
  // 影響しない。監査が進んでもfrontier_digestは動かない。
607
630
  audit_pending: auditPending,
631
+ // 全task done後、構造finalizationが無い/古いplanだけを別列へ出す。監査状態と
632
+ // dispatch状態を混ぜず、terminal受理へ進めない理由と正規コマンドを同時に示す。
633
+ structure_finalization_pending: structureFinalizations,
608
634
  // plan単位noteも同じく別の列である。noteの有無・件数はdispatchへ影響しないので、
609
635
  // next_ready・dispatch_frontier・frontier_digestはnoteを書いても1バイトも動かない。
610
636
  plan_notes: planNotes,