@quolu/lattice 0.77.0 → 0.78.1

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/README.ja.md CHANGED
@@ -386,8 +386,11 @@ pidへは送りません(pid再利用で無関係のprocessを止めうるた
386
386
  [ADR 0157](https://github.com/kitepon/Lattice/blob/main/docs/adr/0157-dashboard-daemons-are-discoverable-by-record.md)が正です。
387
387
  状態を書き込む`start / block / unblock / done / evidence promote / reopen / note / independence mode /
388
388
  revise / revise-phase / revise-set / phase review|accept|reject|reopen|close-unaudited|baseline /
389
- dashboard adopt`では、監査actorとして次の3環境変数を渡せます。未設定ならhost/`session`/USERから
390
- defaultします。渡した値がidentifierとして不正なら`ACTOR_UNRESOLVED`です。
389
+ dashboard adopt`では、監査actorとして次の3環境変数を渡せます。未設定なら、hostは端末ごとのlabel
390
+ (`terminal-`と16進12桁)、sessionは`session`、agentはUSERになります。hostのlabelは端末の乱数の識別子
391
+ (`~/.lattice/bridge-hub-terminal.json`)から導き、OSのhost名はjournalへ書きません
392
+ ([ADR 0197](https://github.com/kitepon/Lattice/blob/main/docs/adr/0197-terminal-label-instead-of-os-hostname.md))。
393
+ 渡した値がidentifierとして不正なら`ACTOR_UNRESOLVED`です。
391
394
 
392
395
  ```bash
393
396
  export LATTICE_TODO_ACTOR_HOST=<host-id>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.77.0",
3
+ "version": "0.78.1",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
@@ -16,22 +16,21 @@
16
16
  */
17
17
 
18
18
  import { randomBytes } from 'node:crypto';
19
- import { hostname } from 'node:os';
20
19
  import { link, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
21
20
  import path from 'node:path';
22
21
 
23
22
  import { bridgeConfigPaths } from './bridge-config.mjs';
24
23
  import { BRIDGE_HUB_HEARTBEAT_INTERVAL_MS, validateBridgeHubRegistrationRequest } from './bridge-hub-protocol.mjs';
24
+ import { TERMINAL_IDENTIFIER, TERMINAL_IDENTITY_SCHEMA, terminalIdentityPath, terminalPublicLabel } from './terminal-identity.mjs';
25
25
  import { readServedTodoDashboardProjectIds } from './todo-dashboard-registry.mjs';
26
26
 
27
27
  export { BRIDGE_HUB_HEARTBEAT_INTERVAL_MS };
28
28
 
29
- const TERMINAL_IDENTITY_SCHEMA = 'lattice.bridge_hub_terminal_identity.v1';
30
29
  const HEARTBEAT_RESULT_SCHEMA = 'lattice.bridge_hub_heartbeat_result.v1';
31
30
  const REGISTRATION_REQUEST_SCHEMA = 'lattice.bridge_hub_registration_request.v1';
32
31
  const REGISTER_PATH = '__lattice/hub/register';
33
32
  const DEFAULT_TIMEOUT_MS = 5_000;
34
- const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
33
+ const IDENTIFIER = TERMINAL_IDENTIFIER;
35
34
 
36
35
  export class BridgeHubHeartbeatError extends Error {
37
36
  constructor(code, message, detail = undefined, cause = undefined) {
@@ -42,10 +41,6 @@ export class BridgeHubHeartbeatError extends Error {
42
41
  }
43
42
  }
44
43
 
45
- function terminalIdentityPath(env) {
46
- return path.join(bridgeConfigPaths(env).root, 'bridge-hub-terminal.json');
47
- }
48
-
49
44
  /**
50
45
  * This terminal's stable bridge-hub identity, created once and reused across
51
46
  * restarts. It must survive restarts: registration is a full-state
@@ -85,13 +80,15 @@ export async function readOrCreateBridgeHubTerminalId({ env = process.env } = {}
85
80
  }
86
81
 
87
82
  /**
88
- * The terminal's own display name, shown for every project it registers
89
- * (registry entries are per-project but `display_name` is terminal-wide —
90
- * ADR 0162 Decision 2). Hostname, not a project name: the active project set
91
- * changes heartbeat to heartbeat but the terminal's identity does not.
83
+ * The name this terminal registers under (ADR 0197). It is the public label
84
+ * derived from the terminal id, never the OS hostname: the hub landing is
85
+ * served to anyone, and a hostname routinely holds its owner's name. Still
86
+ * terminal-wide (ADR 0162 Decision 2) — the active project set changes
87
+ * heartbeat to heartbeat but the terminal's identity does not.
92
88
  */
93
- function terminalDisplayName() {
94
- return hostname().slice(0, 128) || 'terminal';
89
+ function terminalDisplayName(terminalId) {
90
+ // 不正なidはここで名付けず、下のrequest検証に型付きで落とさせる。
91
+ return typeof terminalId === 'string' && IDENTIFIER.test(terminalId) ? terminalPublicLabel(terminalId) : 'terminal';
95
92
  }
96
93
 
97
94
  /** Build and validate one registration/heartbeat request. Throws rather than
@@ -100,7 +97,7 @@ export function buildBridgeHubRegistrationRequest({ terminalId, port, projectIds
100
97
  const request = {
101
98
  schema: REGISTRATION_REQUEST_SCHEMA,
102
99
  terminal_id: terminalId,
103
- display_name: terminalDisplayName(),
100
+ display_name: terminalDisplayName(terminalId),
104
101
  port,
105
102
  project_ids: [...new Set(projectIds)].sort((left, right) => left.localeCompare(right, 'en')),
106
103
  adopt: [...adopt],
@@ -36,6 +36,7 @@ import {
36
36
  projectBridgeHubRegistry, validateBridgeHubRegistryEntry,
37
37
  } from './bridge-hub-protocol.mjs';
38
38
  import { escapeHtml, renderBridgeHubLanding } from './bridge-hub-landing.mjs';
39
+ import { terminalPublicLabel } from './terminal-identity.mjs';
39
40
 
40
41
  const LOOPBACK = '127.0.0.1';
41
42
  const HUB_HTTP_ERROR_SCHEMA = 'lattice.bridge_hub_http_error.v1';
@@ -310,27 +311,43 @@ function validRegistryDocument(value) {
310
311
  && value.entries.every(validateBridgeHubRegistryEntry);
311
312
  }
312
313
 
314
+ /** What a terminal is called on the public listing (ADR 0197). Never its self-reported name. */
315
+ function publicTerminalName(terminalId, terminalDisplayNames) {
316
+ const label = terminalPublicLabel(terminalId);
317
+ return Object.hasOwn(terminalDisplayNames, label) ? terminalDisplayNames[label] : label;
318
+ }
319
+
313
320
  const EMPTY_VISIBILITY = Object.freeze({
314
321
  hidden_project_ids: Object.freeze([]), hidden_terminal_ids: Object.freeze([]), display_names: Object.freeze({}),
322
+ terminal_display_names: Object.freeze({}),
315
323
  });
316
324
 
317
325
  function validVisibilityDocument(value) {
318
326
  return value !== null && typeof value === 'object' && !Array.isArray(value)
319
327
  && value.schema === HUB_PUBLIC_VISIBILITY_SCHEMA
320
328
  && Object.keys(value).every((key) =>
321
- ['schema', 'hidden_project_ids', 'hidden_terminal_ids', 'display_names'].includes(key))
329
+ ['schema', 'hidden_project_ids', 'hidden_terminal_ids', 'display_names', 'terminal_display_names']
330
+ .includes(key))
322
331
  && Array.isArray(value.hidden_project_ids)
323
332
  && value.hidden_project_ids.every((entry) => typeof entry === 'string')
324
333
  && Array.isArray(value.hidden_terminal_ids)
325
334
  && value.hidden_terminal_ids.every((entry) => typeof entry === 'string')
326
335
  && value.display_names !== null && typeof value.display_names === 'object'
327
336
  && !Array.isArray(value.display_names)
328
- && Object.values(value.display_names).every((entry) => typeof entry === 'string');
337
+ && Object.values(value.display_names).every((entry) => typeof entry === 'string')
338
+ // 任意(ADR 0197)。無い旧fileはそのまま読め、端末は導出labelで出る。
339
+ && (value.terminal_display_names === undefined
340
+ || (value.terminal_display_names !== null && typeof value.terminal_display_names === 'object'
341
+ && !Array.isArray(value.terminal_display_names)
342
+ && Object.values(value.terminal_display_names)
343
+ .every((entry) => typeof entry === 'string' && entry.length > 0 && entry === entry.trim())));
329
344
  }
330
345
 
331
346
  /**
332
347
  * Read the operator's public-visibility file (ADR 0164 Decision 3): which
333
- * projects/terminals stay off the public listing, and how project ids render.
348
+ * projects/terminals stay off the public listing, how project ids render, and
349
+ * what each terminal is called in public (`terminal_display_names`, keyed by
350
+ * the terminal's public label — ADR 0197).
334
351
  * Hot-read per request so edits apply without a hub restart. A missing file
335
352
  * hides nothing; a malformed file is a typed error, never a silent republish
336
353
  * of an internal terminal.
@@ -352,6 +369,7 @@ export async function readBridgeHubPublicVisibility({ env = process.env } = {})
352
369
  hidden_project_ids: document.hidden_project_ids,
353
370
  hidden_terminal_ids: document.hidden_terminal_ids,
354
371
  display_names: document.display_names,
372
+ terminal_display_names: document.terminal_display_names ?? EMPTY_VISIBILITY.terminal_display_names,
355
373
  };
356
374
  }
357
375
 
@@ -478,10 +496,18 @@ export async function startBridgeHubServer({
478
496
  // the codebase's existing posture of not leaking topology to public
479
497
  // responses (bridge-server.mjs's public bridge-health strips pid/address
480
498
  // the same way).
499
+ //
500
+ // `display_name` is the exception that proved the rule: it used to be the
501
+ // terminal's self-reported name, which was its OS hostname, published to
502
+ // anyone (ADR 0197). The public name is now the hub's to give — the
503
+ // operator's entry in `terminal_display_names`, else the label derived
504
+ // from the terminal id. What a terminal reports about itself stays in the
505
+ // registry, so a terminal still running an older Lattice cannot put its
506
+ // hostname back on the page, and neither can a stale offline entry.
481
507
  const view = projected.map((entry) => ({
482
508
  schema: HUB_PUBLIC_PROJECT_SCHEMA,
483
509
  project_id: entry.project_id,
484
- display_name: entry.display_name,
510
+ display_name: publicTerminalName(entry.terminal_id, visibility.terminal_display_names),
485
511
  status: entry.status,
486
512
  last_seen_at: entry.last_seen_at,
487
513
  }));
@@ -0,0 +1,98 @@
1
+ /**
2
+ * This terminal's identity and the label it shows to others (ADR 0197).
3
+ *
4
+ * The identity is a random id kept in the Lattice config root. It was born as
5
+ * the bridge-hub's routing key (`bridge-hub-heartbeat.mjs`, ADR 0162) and stays
6
+ * that: the id itself never leaves the terminal↔hub wire.
7
+ *
8
+ * The label is what leaves. Todo journals are committed into public
9
+ * repositories and the hub landing is served to anyone, so neither may carry
10
+ * the OS hostname — a hostname is free text the operating system chose, and
11
+ * it routinely holds its owner's name. The label is derived one-way from the
12
+ * id: stable per terminal, so records and the landing still tell terminals
13
+ * apart, and it says nothing about the machine or the person.
14
+ */
15
+
16
+ import { createHash, randomBytes } from 'node:crypto';
17
+ import { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
18
+ import path from 'node:path';
19
+
20
+ import { bridgeConfigPaths } from './bridge-config.mjs';
21
+
22
+ export const TERMINAL_IDENTITY_SCHEMA = 'lattice.bridge_hub_terminal_identity.v1';
23
+ export const TERMINAL_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
24
+
25
+ const LABEL_DOMAIN = 'lattice.terminal_public_label.v1';
26
+ const LABEL_HEX_LENGTH = 12;
27
+
28
+ export function terminalIdentityPath(env = process.env) {
29
+ return path.join(bridgeConfigPaths(env).root, 'bridge-hub-terminal.json');
30
+ }
31
+
32
+ /**
33
+ * The label a terminal is shown under in journals and on the hub landing.
34
+ * One-way: the routing id cannot be recovered from it, so publishing the label
35
+ * does not hand out the key the hub reconciles registrations by.
36
+ */
37
+ export function terminalPublicLabel(terminalId) {
38
+ if (typeof terminalId !== 'string' || !TERMINAL_IDENTIFIER.test(terminalId)) {
39
+ throw new TypeError('terminal id is invalid');
40
+ }
41
+ const digest = createHash('sha256').update(`${LABEL_DOMAIN}\0${terminalId}`).digest('hex');
42
+ return `terminal-${digest.slice(0, LABEL_HEX_LENGTH)}`;
43
+ }
44
+
45
+ /** Whether a recorded host name is a public label — the only shape a published page may show as-is (ADR 0198). */
46
+ export function isTerminalPublicLabel(value) {
47
+ return typeof value === 'string' && /^terminal-[0-9a-f]{12}$/u.test(value);
48
+ }
49
+
50
+ /**
51
+ * Synchronous twin of `readOrCreateBridgeHubTerminalId`: same file, same
52
+ * schema, same no-overwrite publish. The todo CLI resolves its actor
53
+ * synchronously on every mutation, and both paths must agree on one id per
54
+ * terminal — a second file would give the journal and the landing two
55
+ * different labels for the same machine.
56
+ *
57
+ * Returns `null` when the id can be neither read nor created (read-only or
58
+ * missing home, invalid file). The caller decides what an unidentified
59
+ * terminal is called; this function never invents an id it could not persist,
60
+ * because an unpersisted id would change on every call.
61
+ */
62
+ export function readOrCreateTerminalIdSync({ env = process.env } = {}) {
63
+ let ref;
64
+ try {
65
+ ref = terminalIdentityPath(env);
66
+ mkdirSync(path.dirname(ref), { recursive: true, mode: 0o700 });
67
+ } catch {
68
+ return null;
69
+ }
70
+ for (let attempt = 0; attempt < 2; attempt += 1) {
71
+ try {
72
+ const value = JSON.parse(readFileSync(ref, 'utf8'));
73
+ return value?.schema === TERMINAL_IDENTITY_SCHEMA && TERMINAL_IDENTIFIER.test(value.terminal_id)
74
+ ? value.terminal_id : null;
75
+ } catch (error) {
76
+ if (error?.code !== 'ENOENT') return null;
77
+ }
78
+ const terminalId = randomBytes(16).toString('hex');
79
+ const temporary = `${ref}.${terminalId}.tmp`;
80
+ try {
81
+ writeFileSync(temporary, `${JSON.stringify({ schema: TERMINAL_IDENTITY_SCHEMA, terminal_id: terminalId })}\n`,
82
+ { encoding: 'utf8', mode: 0o600, flag: 'wx' });
83
+ } catch {
84
+ return null;
85
+ }
86
+ try {
87
+ // 完成済みのbytesだけを、既存のIDを上書きせず公開する。
88
+ linkSync(temporary, ref);
89
+ return terminalId;
90
+ } catch (error) {
91
+ if (error?.code !== 'EEXIST') return null;
92
+ // 同時生成の勝者が公開した完成済みIDを読む。
93
+ } finally {
94
+ try { unlinkSync(temporary); } catch { /* 一時fileは残っても識別子を壊さない */ }
95
+ }
96
+ }
97
+ return null;
98
+ }
package/src/todo-cli.mjs CHANGED
@@ -2,9 +2,6 @@ import { cliCommandKind } from './cli-command-kind.mjs';
2
2
  import { cliFailureClass } from './cli-failure-class.mjs';
3
3
  import { gitSync } from './git-process.mjs';
4
4
  import { createHash } from 'node:crypto';
5
- import {
6
- hostname,
7
- } from 'node:os';
8
5
  import {
9
6
  lstat, mkdir, readFile, realpath, writeFile,
10
7
  } from 'node:fs/promises';
@@ -39,6 +36,7 @@ import {
39
36
  ensureTodoDashboardActivity,
40
37
  removeTodoDashboardProject,
41
38
  } from './todo-dashboard-registry.mjs';
39
+ import { readOrCreateTerminalIdSync, terminalPublicLabel } from './terminal-identity.mjs';
42
40
  import { readProjectExternalPane, resolveProjectIdentity } from './project-identity.mjs';
43
41
  import { layoutTodoGantt } from './todo-gantt-layout.mjs';
44
42
  import { TODO_GANTT_SCOPES } from './todo-gantt-scope.mjs';
@@ -563,6 +561,16 @@ function sanitizeActorIdentifier(raw, fallback) {
563
561
  return isTodoIdentifier(compact) ? compact : fallback;
564
562
  }
565
563
 
564
+ /**
565
+ * 名乗りの無い操作のhost(ADR 0197)。journalは公開repositoryへcommitされるので、OSのhost名は
566
+ * 書かない——host名はOSが決めた自由文で、持ち主の名前を含むことがある。端末ごとの識別子から
567
+ * 一方向に導いたlabelを使い、端末の区別は保つ。識別子を読めず作れない端末は`host`になる。
568
+ */
569
+ function defaultActorHost(env) {
570
+ const terminalId = readOrCreateTerminalIdSync({ env });
571
+ return terminalId === null ? 'host' : terminalPublicLabel(terminalId);
572
+ }
573
+
566
574
  function mutationActor(env) {
567
575
  const provided = ACTOR_ENV_KEYS.map((key) => ({ key, value: env[key] }));
568
576
  const invalidEnvironment = provided
@@ -579,7 +587,7 @@ function mutationActor(env) {
579
587
  return {
580
588
  host: isTodoIdentifier(env.LATTICE_TODO_ACTOR_HOST)
581
589
  ? env.LATTICE_TODO_ACTOR_HOST
582
- : sanitizeActorIdentifier(hostname(), 'host'),
590
+ : defaultActorHost(env),
583
591
  session: isTodoIdentifier(env.LATTICE_TODO_ACTOR_SESSION)
584
592
  ? env.LATTICE_TODO_ACTOR_SESSION
585
593
  : 'session',
@@ -5,8 +5,39 @@ import {
5
5
  renderTodoStructurePanel,
6
6
  } from './todo-gantt-structure.mjs';
7
7
  import { renderTodoMarkdown } from './todo-markdown-renderer.mjs';
8
+ import { isTerminalPublicLabel } from './terminal-identity.mjs';
8
9
 
9
- function renderNoteContext(context) {
10
+ /**
11
+ * 記録に残る端末名を、ページへ出す名前へ写す(ADR 0198)。
12
+ *
13
+ * journalと作業記録の`actor.host`は、書いた時の自由文である——OSのhost名のことも、席が自分で付けた名前の
14
+ * こともあり、持ち主の名前を含みうる。ページは公開面へ配信されるので、そのまま出さない。出してよいのは
15
+ * 端末の公開label(`terminal-`と16進12桁)だけで、それ以外は同じ名前に同じ番号を振った「端末N」にする。
16
+ * 番号はその名前が最初に記録された時刻の順で、同時刻は名前の順。記録は追記だけなので、後から現れた名前は
17
+ * 後ろの番号になり、既に振った番号は動かない。記録の本体は変えない——変えるのは表示だけである。
18
+ */
19
+ export function recordedHostDisplayNames(sections) {
20
+ const firstSeen = new Map();
21
+ for (const section of sections) {
22
+ for (const note of section.noteContext?.notes ?? []) {
23
+ const host = note.actor.host;
24
+ if (isTerminalPublicLabel(host)) continue;
25
+ const known = firstSeen.get(host);
26
+ if (known === undefined || note.recorded_at < known) firstSeen.set(host, note.recorded_at);
27
+ }
28
+ }
29
+ const ordered = [...firstSeen].sort(([leftHost, leftAt], [rightHost, rightAt]) => (
30
+ leftAt < rightAt ? -1 : leftAt > rightAt ? 1 : leftHost < rightHost ? -1 : leftHost > rightHost ? 1 : 0));
31
+ return new Map(ordered.map(([host], index) => [host, `端末${index + 1}`]));
32
+ }
33
+
34
+ function recordedHostDisplayName(host, displayNames) {
35
+ if (isTerminalPublicLabel(host)) return host;
36
+ // 対応表に無い名前を素通しにしない。表を作る側の漏れは、名前でなく固定の語として現れる。
37
+ return displayNames.get(host) ?? '端末';
38
+ }
39
+
40
+ function renderNoteContext(context, hostDisplayNames) {
10
41
  if (context === null) {
11
42
  return '<section class="work-log"><h2>作業記録</h2><p class="note-warning">作業記録を読み取れません。概要の読取警告を確認してください。</p></section>';
12
43
  }
@@ -21,7 +52,7 @@ function renderNoteContext(context) {
21
52
  ? `訂正済み(後継 ${note.superseded_by.slice(0, 12)}…)` : '現行';
22
53
  // UTCのまま埋め、閲覧者の現地時刻への変換はブラウザ側へ委ねる(title属性に原文を残す)。
23
54
  const stamp = `<time datetime="${escapeHtmlAttribute(note.recorded_at)}" title="${escapeHtmlAttribute(note.recorded_at)}" data-utc-stamp>${escapeHtmlText(note.recorded_at)}</time>`;
24
- return `<article class="work-log-entry"><header>${stamp}<span>${escapeHtmlText(correction)}</span></header><div class="work-log-body">${rendered.html}</div>${filtering}<p class="work-log-origin">来歴: ${escapeHtmlText(note.origin_plan_version)}/${escapeHtmlText(note.origin_task_id)}・記録者 ${escapeHtmlText(`${note.actor.host}/${note.actor.agent}`)}</p></article>`;
55
+ return `<article class="work-log-entry"><header>${stamp}<span>${escapeHtmlText(correction)}</span></header><div class="work-log-body">${rendered.html}</div>${filtering}<p class="work-log-origin">来歴: ${escapeHtmlText(note.origin_plan_version)}/${escapeHtmlText(note.origin_task_id)}・記録者 ${escapeHtmlText(`${recordedHostDisplayName(note.actor.host, hostDisplayNames)}/${note.actor.agent}`)}</p></article>`;
25
56
  }).join('');
26
57
  const overflow = context.overflow_count === 0 ? ''
27
58
  : `<p class="note-warning">ほか ${context.overflow_count}件は上限のため省略。全履歴: <code>${escapeHtmlText(context.full_history_command)}</code></p>`;
@@ -144,6 +175,8 @@ export function renderRightPane(
144
175
  structurePresentation = null,
145
176
  ) {
146
177
  const lookup = presentationLookup(presentation);
178
+ // 番号はproject全体で1つの対応表から振る。工程ごとに振ると、同じ端末が工程によって別の番号になる。
179
+ const hostDisplayNames = recordedHostDisplayNames(sections);
147
180
  const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
148
181
  const semanticNodes = [...layout.nodes, ...(layout.hierarchy_nodes ?? [])];
149
182
  const nodeByKey = new Map(semanticNodes.map((node) => [refKey(node.ref), node]));
@@ -212,7 +245,7 @@ export function renderRightPane(
212
245
  : expandable
213
246
  ? '<p class="fold-note">完走済みのため既定の図には描いていません。左上の「完走済み」バッジを押すと、このページ内で表示できます。</p>'
214
247
  : '<p class="fold-note">完走済みのため図には描いていません。図に出すには動的dashboardを <code>lattice todo gantt serve --port &lt;port&gt; --scope all</code> で起動してください。</p>';
215
- const workLog = notesEnabled ? renderNoteContext(section.noteContext) : '';
248
+ const workLog = notesEnabled ? renderNoteContext(section.noteContext, hostDisplayNames) : '';
216
249
  const designMemo = renderDesignMemo(section.task);
217
250
  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>`;
218
251
  }).join('');