@quolu/lattice 0.48.0 → 0.50.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.
package/README.ja.md CHANGED
@@ -51,7 +51,7 @@ planを再コンパイルします。共有面が共有でなくなるので、
51
51
  `design_memo`を必ず持ちます。空欄やファイル参照だけでは登録できません。本当に何も考えていない場合だけ、
52
52
  正確なsentinel `NO_PLAN`を本文にします。Latticeはauthoring時に次の問いを返し、空欄のまま通しません。
53
53
 
54
- > If you have not thought through this ToDo, write exactly `NO_PLAN` in its design memo.
54
+ > あなたがこのToDoに対して、何も考えていないならば、設計メモに `NO_PLAN` と書いてください
55
55
 
56
56
  通常の`lattice todo show`と、成功するすべての`lattice todo start`は、この初期設計メモを自動で返します。
57
57
  作業開始後に増えた方針、棄却案、調査結果、注意、未解決事項は、別のappend-only note chainへ追記できます。
@@ -65,7 +65,7 @@ lattice todo show --plan <key> --task <id> --json
65
65
 
66
66
  動的工程表は、選択したToDoの右ペインへ初期設計メモと追記noteの本文を表示します。あわせて前提工程・
67
67
  後続工程それぞれの状態(未着手/作業中/完了/ブロック中)と、その工程の並列可否を示します。
68
- note本文を落とすのはrepo外へHTMLを出す公開配信面だけです。project別HTMLの生成や再生成は不要です。
68
+ note本文はHTMLを描くすべての面へ載ります。project別HTMLの生成や再生成は不要です。
69
69
 
70
70
  ### 変換の受入五条件
71
71
 
@@ -102,7 +102,7 @@ MarkItDownは別区分の第三者CLIです。
102
102
  [docs/00_product-contract.md](docs/00_product-contract.md)を参照してください。
103
103
 
104
104
  CLIの全体像は`lattice --help`、各公開namespaceの正規構文は
105
- `lattice <plan|run|event|todo|sensor|factory-diagnostics|runtime-errors|bridge> --help`で確認できます。
105
+ `lattice <plan|run|event|todo|sensor|factory-diagnostics|runtime-errors|bridge|hooks> --help`で確認できます。
106
106
  個別操作は`lattice <namespace> <subcommand> --help`または`lattice help <namespace> <subcommand>`で
107
107
  正規optionをstore非依存に確認できます。
108
108
 
@@ -126,7 +126,8 @@ codex-sidecar diagnostics --project . --preset auditor --json
126
126
  未初期化projectで`sensor sync`した場合は`LATTICE_SENSOR_NOT_INITIALIZED`と正規`next_action`を返します。
127
127
  その他のsensor失敗もexit code、signal、bounded stderrをtyped detailへ残し、原因を隠しません。
128
128
 
129
- Node.js 22.13以上を使用します。境界観測は配布物に同梱したLattice sensorだけを使い、PATH上の
129
+ Node.js 22.13以上、ただし25.xを除きます(`engines: >=22.13 <25 || >=26`。Node 25.xはV8 turboshaft
130
+ WASM JITの不具合で同梱sensorが壊れるため、bannerを出して起動を止めます)。境界観測は配布物に同梱したLattice sensorだけを使い、PATH上の
130
131
  廃止済みruntimeや旧cache/dataへfallbackしません。Spotterはproject単位で生成stateの所有境界を守ります。
131
132
 
132
133
  どのrepoでも、Latticeの導入状態はdirectoryの有無を推測せず、最初に次のtyped discoveryで判定します。
@@ -136,12 +137,12 @@ lattice status --json
136
137
  ```
137
138
 
138
139
  `state`は`uninitialized | ready | active_run | invalid`のいずれかです。`uninitialized`は
139
- 正常な未初期化状態で、`next_action`が正規の初期authoring入口を返します。初回planは
140
- 新規planはPhase監査とToDo schedulingを分離し、全ToDoに設計メモを持つ`lattice.plan_create_input.v4`のcanonical
141
- JSON+LFを用意し、次で作成します。既存v2/v4は互換契約として維持されます。
140
+ 正常な未初期化状態で、`next_action`が正規の初期authoring入口を返します。新規planはPhase監査と
141
+ ToDo schedulingを分離し、全ToDoに設計メモを持つ`lattice.plan_create_input.v4`のcanonical
142
+ JSON+LFを用意し、次で作成します。既存のv1〜v3は互換契約として維持され、`--schema-version`で取得できます。
142
143
 
143
144
  ```bash
144
- lattice plan create --schema-version 4 --json
145
+ lattice plan create --schema-version 4 --json # v4のJSON Schemaを取る(作成はしない)
145
146
  ```
146
147
 
147
148
  ```bash
@@ -303,8 +304,9 @@ daemonの生死はdescriptor 1枚では持ちません。daemonは自分のpid
303
304
  配信を続けているdaemonを引き取ります。signalを送るのはその場で再認証を通った相手だけで、応答しない
304
305
  pidへは送りません(pid再利用で無関係のprocessを止めうるためです)。規約は
305
306
  [ADR 0157](docs/adr/0157-dashboard-daemons-are-discoverable-by-record.md)が正です。
306
- 状態を書き込む`start / block / unblock / done / evidence promote / reopen / revise / revise-phase / revise-set`
307
- では、監査actorとして次の3環境変数をすべて設定してください。
307
+ 状態を書き込む`start / block / unblock / done / evidence promote / reopen / note / independence mode /
308
+ revise / revise-phase / revise-set / phase review|accept|reject|reopen|close-unaudited|baseline /
309
+ dashboard adopt`では、監査actorとして次の3環境変数をすべて設定してください。
308
310
 
309
311
  ```bash
310
312
  export LATTICE_TODO_ACTOR_HOST=<host-id>
package/README.md CHANGED
@@ -78,8 +78,8 @@ lattice todo show --plan <key> --task <id> --json
78
78
 
79
79
  The selected ToDo's detail pane shows the initial design memo and the append-only note bodies, plus the
80
80
  status of each prerequisite and dependent ToDo (pending, in progress, done, blocked) and whether the ToDo
81
- can run in parallel. Only a public distribution surface HTML served outside the repository — drops note
82
- bodies. Static per-project HTML is not generated; `lattice todo gantt serve` and the shared dashboard
81
+ can run in parallel. Note bodies are carried by every rendered surface, including HTML served outside the
82
+ repository. Static per-project HTML is not generated; `lattice todo gantt serve` and the shared dashboard
83
83
  read the store dynamically.
84
84
 
85
85
  ### The five acceptance conditions
@@ -120,7 +120,8 @@ the operating AI.
120
120
  npm install -g @quolu/lattice
121
121
  ```
122
122
 
123
- Requires **Node.js 22.13+**. The structure sensor ships inside the package there is nothing
123
+ Requires **Node.js 22.13 or newer, except 25.x** (`engines: >=22.13 <25 || >=26`; Node 25's V8
124
+ turboshaft WASM JIT breaks the bundled sensor, so it is blocked with a banner). The structure sensor ships inside the package — there is nothing
124
125
  else to install, and Lattice never falls back to a sensor on your `PATH`.
125
126
 
126
127
  ## Quick start
@@ -156,7 +157,7 @@ lattice todo seam-proposal land --plan <key> --names names.json
156
157
  ```
157
158
 
158
159
  Full CLI surface: `lattice --help`, then
159
- `lattice <plan|run|event|todo|sensor|bridge|runtime-errors> --help`.
160
+ `lattice <plan|run|event|todo|sensor|factory-diagnostics|runtime-errors|bridge|hooks> --help`.
160
161
 
161
162
  ## Design principles
162
163
 
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { readTodoStoreStable } from '../src/todo-store.mjs';
7
7
  import { TODO_STATUS_DISPATCH_ONLY, projectTodoStatus } from '../src/todo-status.mjs';
8
8
  import { ganttLiveHeadDigest, renderTodoGanttForProject } from '../src/todo-cli.mjs';
9
+ import { readProjectExternalPane } from '../src/project-identity.mjs';
9
10
  import {
10
11
  forgetTodoDashboardDaemonRecord,
11
12
  readVisibleTodoDashboardProjects,
@@ -96,6 +97,10 @@ async function synchronize() {
96
97
  return {
97
98
  html: result.rendered.html,
98
99
  head_digest: await ganttLiveHeadDigest({ repoRoot: entry.repo_root, store }),
100
+ // 配信のたびに読む。外部ペインを差した/外した側にdaemon再起動を要求しない。
101
+ external_pane: await readProjectExternalPane({
102
+ repoRoot: entry.repo_root, projectId: entry.project_id,
103
+ }),
99
104
  };
100
105
  },
101
106
  readHead: async () => ganttLiveHeadDigest({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.48.0",
3
+ "version": "0.50.0",
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",
package/src/cli-help.mjs CHANGED
@@ -75,6 +75,7 @@ Read commands:
75
75
 
76
76
  Write commands:
77
77
  dashboard adopt --json # 衝突したproject_idの配信元rootを現在repoへ明示的に移す
78
+ dashboard remove <project_id> --json # 登録簿から1件外す(対象repoの外からも叩ける)
78
79
  note --plan <key> [--task <id>] (--message <text>|--input <file>)
79
80
  # ToDoへ作業継続に必要な方針・調査結果・注意をappend-onlyで追記する
80
81
  migrate --input <extraction.json> [--serialization-reviewed]
@@ -200,8 +201,10 @@ const SUBCOMMAND_USAGE = Object.freeze({
200
201
  'todo snapshot': 'todo snapshot --rebuild --plan <key>',
201
202
  'todo gantt': 'todo gantt serve --port <port> [--scope live|all] # 動的表示のみ。静的HTML生成は廃止',
202
203
  'todo gantt serve': 'todo gantt serve --port <0..65535> [--scope live|all] # loopback動的viewer',
203
- 'todo dashboard': 'todo dashboard adopt --json # 配信元rootの衝突を明示的に解消',
204
+ 'todo dashboard': 'todo dashboard adopt --json | remove <project_id> --json'
205
+ + ' # 配信元rootの衝突を明示的に解消/登録簿から1件外す',
204
206
  'todo dashboard adopt': 'todo dashboard adopt --json # 現在repoをproject_idの配信元として明示採用',
207
+ 'todo dashboard remove': 'todo dashboard remove <project_id> --json # 登録簿から1件外す(対象repo不在でも可)',
205
208
  'todo phase': 'todo phase <status|review|accept|reject|reopen|close-unaudited> --plan <key> [options]'
206
209
  + ' | baseline --reason <text> [--except <plan_key>]...',
207
210
  'todo phase status': 'todo phase status --plan <key>',
@@ -4,6 +4,9 @@ import { parseTree } from 'jsonc-parser';
4
4
 
5
5
  export const PROJECT_IDENTITY_REF = '.lattice/project.json';
6
6
  const MAX_BYTES = 65_536;
7
+ const URL_MAX_BYTES = 2_048;
8
+ const IDENTITY_KEYS = 'display_name,project_id,schema';
9
+ const IDENTITY_KEYS_WITH_PANE = 'display_name,external_pane,project_id,schema';
7
10
  const CONTROL = /[\u0000-\u001f\u007f]/u;
8
11
 
9
12
  function fail(reason) {
@@ -30,23 +33,45 @@ function duplicateKey(node) {
30
33
  return false;
31
34
  }
32
35
 
33
- export async function resolveProjectIdentity({ repoRoot, projectId, env = process.env }) {
34
- if (!path.isAbsolute(repoRoot) || typeof projectId !== 'string' || projectId.length === 0
35
- || env === null || typeof env !== 'object' || Array.isArray(env)) {
36
- throw new TypeError('project identity options invalid');
37
- }
38
- const override = env.LATTICE_PROJECT_DISPLAY_NAME;
39
- if (override !== undefined) {
40
- if (!displayName(override)) fail('environment_display_name_invalid');
41
- return Object.freeze({ projectId, displayName: override, source: 'environment' });
36
+ /** 外部ペインのURLは、閲覧者のブラウザが実際に取りに行く先である。絶対URLだけを通す。 */
37
+ function paneUrl(value) {
38
+ if (typeof value !== 'string' || value.length === 0 || CONTROL.test(value)
39
+ || Buffer.byteLength(value, 'utf8') > URL_MAX_BYTES) return null;
40
+ let url;
41
+ try { url = new URL(value); } catch { return null; }
42
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
43
+ return url;
44
+ }
45
+
46
+ /**
47
+ * projectごとに1枚だけ差せる外部ペインの設定。Latticeは中身が何のサービスかを知らない
48
+ * ——題名・埋め込み先URL・生存probe URLの3つだけを受け取る汎用の口である。
49
+ */
50
+ function externalPane(value) {
51
+ if (value === null || typeof value !== 'object' || Array.isArray(value)
52
+ || Object.keys(value).sort().join(',') !== 'probe_url,title,url') {
53
+ fail('external_pane_schema_invalid');
42
54
  }
55
+ if (!displayName(value.title)) fail('external_pane_title_invalid');
56
+ const url = paneUrl(value.url);
57
+ const probe = paneUrl(value.probe_url);
58
+ if (url === null) fail('external_pane_url_invalid');
59
+ if (probe === null) fail('external_pane_probe_url_invalid');
60
+ return Object.freeze({
61
+ title: value.title,
62
+ url: url.href,
63
+ probeUrl: probe.href,
64
+ frameOrigin: url.origin,
65
+ probeOrigin: probe.origin,
66
+ });
67
+ }
68
+
69
+ async function readIdentityDocument({ repoRoot, projectId }) {
43
70
  const canonicalRoot = await realpath(repoRoot);
44
71
  const ref = path.join(canonicalRoot, PROJECT_IDENTITY_REF);
45
72
  let stats;
46
73
  try { stats = await lstat(ref); } catch (error) {
47
- if (error?.code === 'ENOENT') {
48
- return Object.freeze({ projectId, displayName: projectId, source: 'project_id' });
49
- }
74
+ if (error?.code === 'ENOENT') return null;
50
75
  throw error;
51
76
  }
52
77
  if (!stats.isFile() || stats.isSymbolicLink() || stats.size > MAX_BYTES) fail('identity_file_unsafe');
@@ -61,11 +86,45 @@ export async function resolveProjectIdentity({ repoRoot, projectId, env = proces
61
86
  if (errors.length > 0 || tree === undefined || duplicateKey(tree)) fail('identity_json_invalid');
62
87
  let document;
63
88
  try { document = JSON.parse(text); } catch { fail('identity_json_invalid'); }
64
- if (document === null || typeof document !== 'object' || Array.isArray(document)
65
- || Object.keys(document).sort().join(',') !== 'display_name,project_id,schema'
89
+ const keys = document === null || typeof document !== 'object' || Array.isArray(document)
90
+ ? '' : Object.keys(document).sort().join(',');
91
+ if ((keys !== IDENTITY_KEYS && keys !== IDENTITY_KEYS_WITH_PANE)
66
92
  || document.schema !== 'lattice.project_identity.v1'
67
93
  || document.project_id !== projectId || !displayName(document.display_name)) {
68
94
  fail('identity_schema_invalid');
69
95
  }
96
+ return document;
97
+ }
98
+
99
+ export async function resolveProjectIdentity({ repoRoot, projectId, env = process.env }) {
100
+ if (!path.isAbsolute(repoRoot) || typeof projectId !== 'string' || projectId.length === 0
101
+ || env === null || typeof env !== 'object' || Array.isArray(env)) {
102
+ throw new TypeError('project identity options invalid');
103
+ }
104
+ const override = env.LATTICE_PROJECT_DISPLAY_NAME;
105
+ if (override !== undefined) {
106
+ if (!displayName(override)) fail('environment_display_name_invalid');
107
+ return Object.freeze({ projectId, displayName: override, source: 'environment' });
108
+ }
109
+ const document = await readIdentityDocument({ repoRoot, projectId });
110
+ if (document === null) {
111
+ return Object.freeze({ projectId, displayName: projectId, source: 'project_id' });
112
+ }
113
+ // external_paneは表示名の出典ではない。ここでは形の妥当性だけ確かめて落とす
114
+ // ——壊れた設定を黙って無視すると、差したはずのペインが出ない理由が追えなくなる。
115
+ if (document.external_pane !== undefined) externalPane(document.external_pane);
70
116
  return Object.freeze({ projectId, displayName: document.display_name, source: 'project_file' });
71
117
  }
118
+
119
+ /**
120
+ * 差してあるなら外部ペイン設定を、無ければnullを返す。配信のたびに読むので、
121
+ * 設定を書いた/消した側はdaemonの再起動を要さない(reloadで反映される)。
122
+ */
123
+ export async function readProjectExternalPane({ repoRoot, projectId }) {
124
+ if (!path.isAbsolute(repoRoot) || typeof projectId !== 'string' || projectId.length === 0) {
125
+ throw new TypeError('project identity options invalid');
126
+ }
127
+ const document = await readIdentityDocument({ repoRoot, projectId });
128
+ if (document === null || document.external_pane === undefined) return null;
129
+ return externalPane(document.external_pane);
130
+ }
package/src/todo-cli.mjs CHANGED
@@ -25,8 +25,9 @@ import { projectTodoChainV1 } from './todo-chain.mjs';
25
25
  import {
26
26
  adoptTodoDashboardActivity,
27
27
  ensureTodoDashboardActivity,
28
+ removeTodoDashboardProject,
28
29
  } from './todo-dashboard-registry.mjs';
29
- import { resolveProjectIdentity } from './project-identity.mjs';
30
+ import { readProjectExternalPane, resolveProjectIdentity } from './project-identity.mjs';
30
31
  import { layoutTodoGantt } from './todo-gantt-layout.mjs';
31
32
  import { TODO_GANTT_SCOPES } from './todo-gantt-scope.mjs';
32
33
  import { loadTodoGanttPresentation } from './todo-gantt-presentation.mjs';
@@ -2298,7 +2299,7 @@ async function seamProposalsForGantt({ repoRoot, store }) {
2298
2299
 
2299
2300
  export async function renderTodoGanttForProject({
2300
2301
  repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
2301
- scope = DEFAULT_GANTT_SCOPE, includeNotes = true,
2302
+ scope = DEFAULT_GANTT_SCOPE,
2302
2303
  }) {
2303
2304
  const store = readModel
2304
2305
  ?? (stable ? await readTodoStoreStable({ repoRoot }) : await readTodoStore({ repoRoot }));
@@ -2311,8 +2312,7 @@ export async function renderTodoGanttForProject({
2311
2312
  const [independence, seamProposals, notes] = await Promise.all([
2312
2313
  independenceForGantt({ repoRoot, store }),
2313
2314
  seamProposalsForGantt({ repoRoot, store }),
2314
- includeNotes ? notesForGantt({ repoRoot, store })
2315
- : { contexts: null, warnings: [], headBindings: [] },
2315
+ notesForGantt({ repoRoot, store }),
2316
2316
  ]);
2317
2317
  const layout = layoutTodoGantt(store, chain, { scope, independence, seamProposals });
2318
2318
  // When the diagram hides history, the page also carries the full diagram so
@@ -2365,16 +2365,6 @@ export async function renderTodoGanttForProject({
2365
2365
  return { store, metadata, memberBindings, rendered };
2366
2366
  }
2367
2367
 
2368
- /**
2369
- * 公開配信面はToDo本体の設計メモを表示し、append-only作業記録は除外する。
2370
- *
2371
- * loopbackのdashboardと`gantt serve`は公開配信面ではない。作業者本人が読む面なので
2372
- * 記録込みで描く。この入口はrepo外へHTMLを出す面が生えた時に使う。
2373
- */
2374
- export async function renderPublicTodoGanttForProject(options = {}) {
2375
- return renderTodoGanttForProject({ ...options, includeNotes: false });
2376
- }
2377
-
2378
2368
  async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_SCOPE }) {
2379
2369
  const initialStore = await readTodoStoreStable({ repoRoot });
2380
2370
  const identity = await resolveProjectIdentity({ repoRoot, projectId: initialStore.project_id, env });
@@ -2387,7 +2377,9 @@ async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_S
2387
2377
  const { rendered } = await renderTodoGanttForProject({
2388
2378
  repoRoot, stable: true, displayName: identity.displayName, scope, readModel: store,
2389
2379
  });
2390
- return { html: rendered.html, head_digest: await ganttLiveHeadDigest({ repoRoot, store }) };
2380
+ return { html: rendered.html, head_digest: await ganttLiveHeadDigest({ repoRoot, store }),
2381
+ // ローカルの動的dashboardも公開daemonと同じ口を通す(毎描画で読むので差し外しが即反映される)。
2382
+ external_pane: await readProjectExternalPane({ repoRoot, projectId: store.project_id }) };
2391
2383
  },
2392
2384
  readHead: async () => ganttLiveHeadDigest({
2393
2385
  repoRoot, store: await readTodoStoreStable({ repoRoot }),
@@ -2568,6 +2560,30 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2568
2560
  }
2569
2561
  }
2570
2562
 
2563
+ // 登録を外したいprojectのrepoは、もう存在しないことが普通である(それが外す理由になる)。
2564
+ // 通常dispatchより前に処理し、repoRoot解決・store読取・dashboard daemon起動を経由させない。
2565
+ if (argv.length === 4 && argv[0] === 'dashboard' && argv[1] === 'remove'
2566
+ && isTodoIdentifier(argv[2]) && argv[3] === '--json') {
2567
+ try {
2568
+ await removeTodoDashboardProject({ projectId: argv[2], env });
2569
+ const result = {
2570
+ schema: 'lattice.todo_dashboard_remove_result.v1',
2571
+ project_id: argv[2],
2572
+ removed: true,
2573
+ result_digest: '',
2574
+ };
2575
+ result.result_digest = todoSelfDigest(result, 'result_digest');
2576
+ stdout.write(`${JSON.stringify(result)}\n`);
2577
+ return 0;
2578
+ } catch (error) {
2579
+ if (typeof error?.code === 'string' && error.detail !== null
2580
+ && typeof error.detail === 'object') return typedFailure(stderr, error);
2581
+ return typedFailure(stderr, {
2582
+ code: 'INTERNAL_FAILURE', message: error?.constructor?.name ?? 'Error',
2583
+ });
2584
+ }
2585
+ }
2586
+
2571
2587
  let action = null;
2572
2588
  if ((argv.length === 1 && argv[0] === 'status')
2573
2589
  || (argv.length === 2 && argv[0] === 'status' && argv[1] === '--json')) {
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { homedir } from 'node:os';
4
4
  import {
5
- mkdir, open, readdir, readFile, realpath, rename, rm, writeFile,
5
+ mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile,
6
6
  } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
 
@@ -157,6 +157,17 @@ export async function readVisibleTodoDashboardProjects({
157
157
  || left.project_id.localeCompare(right.project_id, 'en'));
158
158
  }
159
159
 
160
+ /**
161
+ * 見るのはrepo_root directoryの存在だけ。`.lattice`の有無で判定すると、storeを持たない
162
+ * 生きたrepoまで登録簿から消える。ENOENT/ENOTDIR以外のerrorは「消えた証拠」ではないので
163
+ * 生存扱いのまま残す——判定できない相手を消す方が損害が大きい。
164
+ */
165
+ async function repoRootPresent(ref) {
166
+ try { return (await stat(ref)).isDirectory(); } catch (error) {
167
+ return !['ENOENT', 'ENOTDIR'].includes(error?.code);
168
+ }
169
+ }
170
+
160
171
  export async function registerTodoDashboardActivity({
161
172
  repoRoot, projectId, displayName = projectId, sessionId, env = process.env,
162
173
  now = new Date(), adopt = false,
@@ -169,6 +180,7 @@ export async function registerTodoDashboardActivity({
169
180
  const canonicalRoot = await realpath(repoRoot);
170
181
  const refs = paths(env);
171
182
  await mkdir(refs.root, { recursive: true, mode: 0o700 });
183
+ const pruned = [];
172
184
  await withLock(refs.lock, async () => {
173
185
  const current = validateRegistry(await readJson(refs.registry, { schema: REGISTRY_SCHEMA, projects: [] }));
174
186
  const existing = current.projects.find((entry) => entry.project_id === projectId);
@@ -183,13 +195,45 @@ export async function registerTodoDashboardActivity({
183
195
  };
184
196
  throw error;
185
197
  }
186
- const projects = current.projects.filter((entry) => entry.project_id !== projectId);
198
+ // 死んだ登録を落とす機会はここしかない。登録は全sessionが必ず通る一点であり、
199
+ // 掃除を「人がコマンドを叩いた時」に置くと、誰も叩かないので永久に積み上がる。
200
+ const projects = [];
201
+ for (const entry of current.projects) {
202
+ if (entry.project_id === projectId) continue;
203
+ if (await repoRootPresent(entry.repo_root)) projects.push(entry);
204
+ else pruned.push(entry.project_id);
205
+ }
187
206
  projects.push({ project_id: projectId, display_name: displayName, repo_root: canonicalRoot,
188
207
  session_id: sessionId, last_seen_at: now.toISOString() });
189
208
  projects.sort((left, right) => left.project_id.localeCompare(right.project_id, 'en'));
209
+ pruned.sort((left, right) => left.localeCompare(right, 'en'));
210
+ await atomicJson(refs.registry, { schema: REGISTRY_SCHEMA, projects });
211
+ });
212
+ return { projectId, displayName, repoRoot: canonicalRoot, sessionId, adopted: adopt, pruned };
213
+ }
214
+
215
+ /**
216
+ * 登録簿から1件を明示的に外す唯一の入口。対象repoが既に消えていても叩けるよう、
217
+ * storeもdaemonも経由しない。該当が無いのは暗黙の成功にしない——「消したつもり」で
218
+ * 別のproject_idが残り続ける状態を、成功の応答で隠すことになる。
219
+ */
220
+ export async function removeTodoDashboardProject({ projectId, env = process.env }) {
221
+ if (!identifier(projectId)) throw new TypeError('dashboard project id is invalid');
222
+ const refs = paths(env);
223
+ await mkdir(refs.root, { recursive: true, mode: 0o700 });
224
+ await withLock(refs.lock, async () => {
225
+ const current = validateRegistry(await readJson(refs.registry, { schema: REGISTRY_SCHEMA, projects: [] }));
226
+ const projects = current.projects.filter((entry) => entry.project_id !== projectId);
227
+ if (projects.length === current.projects.length) {
228
+ const error = new Error('dashboard project is not registered');
229
+ error.code = 'PROJECT_NOT_REGISTERED';
230
+ // 公開・CLI errorへlocal absolute pathを運ばない。project_idだけで対象を特定できる。
231
+ error.detail = { project_id: projectId };
232
+ throw error;
233
+ }
190
234
  await atomicJson(refs.registry, { schema: REGISTRY_SCHEMA, projects });
191
235
  });
192
- return { projectId, displayName, repoRoot: canonicalRoot, sessionId, adopted: adopt };
236
+ return { projectId, removed: true };
193
237
  }
194
238
 
195
239
  /** 配信元rootを移す唯一の明示入口。通常activity登録はこのflagを立てない。 */
@@ -11,16 +11,63 @@ function projectPath(projectId) {
11
11
  return `/projects/${encodeURIComponent(projectId)}/`;
12
12
  }
13
13
 
14
- function liveHtml(html, headDigest, eventsPath) {
14
+ /**
15
+ * 注入点が消えていたら黙って注入をやめない。タブが出ない理由を後から追えるように、
16
+ * 差せなかった事実をtyped errorとして立てる(差し込み位置は描画部品の実装に依る)。
17
+ */
18
+ function injectOnce(html, marker, replacement) {
19
+ const at = html.indexOf(marker);
20
+ if (at === -1 || html.indexOf(marker, at + marker.length) !== -1) {
21
+ const error = new Error(`external pane injection point not unique: ${marker}`);
22
+ error.code = 'EXTERNAL_PANE_INJECTION_FAILED';
23
+ error.detail = { marker };
24
+ throw error;
25
+ }
26
+ return `${html.slice(0, at)}${replacement}${html.slice(at + marker.length)}`;
27
+ }
28
+
29
+ const EXTERNAL_PANE_STYLE = '<style>.narrative-pane{display:flex;flex-direction:column}[data-right-panel="external"]{display:flex;flex:1 1 auto;min-height:0}[data-right-panel="external"]>iframe{flex:1 1 auto;width:100%;min-height:0;border:0;background:var(--surface-1)}</style>';
30
+
31
+ /**
32
+ * 生存判定は「非空の一覧を返すこと」だけを根拠にする。Latticeは差された先が何の
33
+ * サービスかを知らないので、payloadの意味には触らない(非空配列、または配列値の
34
+ * プロパティが1つ以上非空)。非200・fetch失敗・空一覧はすべて概要タブのままにする。
35
+ */
36
+ function externalPaneController(pane) {
37
+ return `<script>(()=>{const root=document.querySelector('[data-gantt-root]');const panel=root.querySelector('[data-right-panel="external"]');const frame=panel.querySelector('iframe');const content=root.querySelector('.right-content');const button=root.querySelector('[data-show-external-pane]');const show=()=>{if(frame.getAttribute('src')===null)frame.setAttribute('src',frame.dataset.src);content.hidden=true;panel.hidden=false;button.setAttribute('aria-pressed','true');root.dataset.viewState='external';};const leave=()=>{if(root.dataset.viewState==='external')return;panel.hidden=true;content.hidden=false;button.setAttribute('aria-pressed','false');};new MutationObserver(leave).observe(root,{attributeFilter:['data-view-state']});button.addEventListener('click',show);const nonEmpty=value=>Array.isArray(value)&&value.length>0;fetch(${JSON.stringify(pane.probeUrl)},{cache:'no-store'}).then(response=>response.ok?response.json():null).then(payload=>{if(nonEmpty(payload)||(payload!==null&&typeof payload==='object'&&Object.values(payload).some(nonEmpty)))show();}).catch(()=>{});})();</script>`;
38
+ }
39
+
40
+ /**
41
+ * projectに外部ペインが差してある時だけ、右ペインへタブ1枚とiframeを足す。
42
+ * Latticeは題名・埋め込み先・生存probeの3つしか知らない(差す側が明示的に書く口)。
43
+ */
44
+ function withExternalPane(html, pane) {
45
+ const tab = `<button type="button" data-show-external-pane aria-pressed="false">${escapeHtml(pane.title)}</button>`;
46
+ const iframe = `<section data-right-panel="external" hidden><iframe data-src="${escapeHtml(pane.url)}" title="${escapeHtml(pane.title)}"></iframe></section>`;
47
+ return injectOnce(
48
+ injectOnce(
49
+ injectOnce(
50
+ injectOnce(html, "default-src 'none'; connect-src 'self';",
51
+ `default-src 'none'; connect-src 'self' ${pane.probeOrigin}; frame-src ${pane.frameOrigin};`),
52
+ '</head>', `${EXTERNAL_PANE_STYLE}</head>`,
53
+ ),
54
+ '<button type="button" data-show-overview>概要</button>', `${tab}<button type="button" data-show-overview>概要</button>`,
55
+ ),
56
+ '<div class="right-content">', `${iframe}<div class="right-content">`,
57
+ ).replace('</body>', `${externalPaneController(pane)}</body>`);
58
+ }
59
+
60
+ function liveHtml(html, headDigest, eventsPath, externalPane = null) {
15
61
  const controller = `<script>(()=>{const badge=document.createElement('div');badge.setAttribute('role','status');badge.style.cssText='position:fixed;right:12px;bottom:12px;z-index:99;padding:6px 10px;border:1px solid #d9d8d4;border-radius:4px;background:#fcfcfb;font:600 12px system-ui';badge.textContent='進捗: 接続中';document.body.append(badge);let head=${JSON.stringify(headDigest)};const stream=new EventSource(${JSON.stringify(eventsPath)});stream.addEventListener('state',event=>{const next=JSON.parse(event.data);badge.textContent='進捗: 最新';if(next.head_digest!==head){badge.textContent='進捗: 更新を反映中';location.reload();}});stream.addEventListener('lattice-error',event=>{const detail=JSON.parse(event.data);badge.textContent='進捗: エラー '+detail.code;badge.style.borderColor='#d03b3b';});stream.onerror=()=>{badge.textContent='進捗: 再接続中';};})();</script>`;
16
62
  const publicMetadata = '<meta name="description" content="Latticeで管理しているプロジェクトの依存工程と進捗を確認できます。"><meta name="robots" content="noindex, nofollow"><meta name="theme-color" content="#f7f3ea">';
17
63
  const publicStyle = '<style>body[data-gantt-root]{grid-template-rows:auto minmax(0,1fr)}.lattice-live-brand{z-index:10;display:flex;align-items:center;gap:8px;min-width:0;padding:10px 16px;border-bottom:1px solid #d8d0c5;background:#f7f3ea;color:#6c655d;font:600 12px/1.5 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif}.lattice-live-brand a{color:#201d19;text-decoration:none}.lattice-live-brand a:hover{color:#315cbe}.lattice-live-brand strong{color:#201d19}.lattice-live-brand-note{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.lattice-live-back{margin-left:auto!important;color:#315cbe!important;font-weight:750}@media(max-width:560px){.lattice-live-brand{padding:9px 12px}.lattice-live-brand-note{display:none}}</style>';
18
64
  const publicHeader = '<header class="lattice-live-brand"><a href="https://kitepon.dev/">kitepon.dev</a><span aria-hidden="true">/</span><strong>Lattice</strong><span class="lattice-live-brand-note">公開工程表</span><a class="lattice-live-back" href="/projects/">一覧へ戻る</a></header>';
19
- return html
65
+ const live = html
20
66
  .replace("default-src 'none';", "default-src 'none'; connect-src 'self';")
21
67
  .replace('</head>', `${publicMetadata}${publicStyle}</head>`)
22
68
  .replace(/<body([^>]*)>/u, `<body$1>${publicHeader}`)
23
69
  .replace('</body>', `${controller}</body>`);
70
+ return externalPane === null ? live : withExternalPane(live, externalPane);
24
71
  }
25
72
 
26
73
  function escapeHtml(value) {
@@ -221,7 +268,9 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
221
268
  displayName: project.displayName,
222
269
  });
223
270
  lastHeads.set(project.projectId, rendered.head_digest);
224
- const html = liveHtml(rendered.html, rendered.head_digest, `${projectPath(project.projectId)}events`);
271
+ // 外部ペイン設定は描画のたびにrender側が読み直す。差した/外した側は再起動を要さない。
272
+ const html = liveHtml(rendered.html, rendered.head_digest,
273
+ `${projectPath(project.projectId)}events`, rendered.external_pane ?? null);
225
274
  response.writeHead(200, { 'content-type': 'text/html; charset=utf-8',
226
275
  'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
227
276
  response.end(html);