@quolu/lattice 0.48.0 → 0.49.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.48.0",
3
+ "version": "0.49.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>',
package/src/todo-cli.mjs CHANGED
@@ -25,6 +25,7 @@ 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
30
  import { resolveProjectIdentity } from './project-identity.mjs';
30
31
  import { layoutTodoGantt } from './todo-gantt-layout.mjs';
@@ -2568,6 +2569,30 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2568
2569
  }
2569
2570
  }
2570
2571
 
2572
+ // 登録を外したいprojectのrepoは、もう存在しないことが普通である(それが外す理由になる)。
2573
+ // 通常dispatchより前に処理し、repoRoot解決・store読取・dashboard daemon起動を経由させない。
2574
+ if (argv.length === 4 && argv[0] === 'dashboard' && argv[1] === 'remove'
2575
+ && isTodoIdentifier(argv[2]) && argv[3] === '--json') {
2576
+ try {
2577
+ await removeTodoDashboardProject({ projectId: argv[2], env });
2578
+ const result = {
2579
+ schema: 'lattice.todo_dashboard_remove_result.v1',
2580
+ project_id: argv[2],
2581
+ removed: true,
2582
+ result_digest: '',
2583
+ };
2584
+ result.result_digest = todoSelfDigest(result, 'result_digest');
2585
+ stdout.write(`${JSON.stringify(result)}\n`);
2586
+ return 0;
2587
+ } catch (error) {
2588
+ if (typeof error?.code === 'string' && error.detail !== null
2589
+ && typeof error.detail === 'object') return typedFailure(stderr, error);
2590
+ return typedFailure(stderr, {
2591
+ code: 'INTERNAL_FAILURE', message: error?.constructor?.name ?? 'Error',
2592
+ });
2593
+ }
2594
+ }
2595
+
2571
2596
  let action = null;
2572
2597
  if ((argv.length === 1 && argv[0] === 'status')
2573
2598
  || (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を立てない。 */