triflux 10.35.3 → 10.37.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.
Files changed (39) hide show
  1. package/bin/tfx-live.mjs +61 -1
  2. package/bin/triflux.mjs +18 -1
  3. package/cto/dashboard.mjs +60 -0
  4. package/cto/events.mjs +222 -0
  5. package/cto/hygiene-notify.mjs +170 -0
  6. package/cto/hygiene.mjs +629 -0
  7. package/cto/index.mjs +7 -2
  8. package/cto/lake-root.mjs +47 -6
  9. package/cto/status.mjs +4 -0
  10. package/hooks/agy-session-hook.mjs +2 -2
  11. package/hooks/codex-session-hook.mjs +1 -1
  12. package/hooks/hooks.json +12 -12
  13. package/hooks/lib/resolve-root.mjs +3 -0
  14. package/hooks/session-start-fast.mjs +108 -6
  15. package/hub/public/tray.html +36 -0
  16. package/hub/routing/q-learning.mjs +5 -2
  17. package/hub/team/claude-daemon-control.mjs +27 -2
  18. package/hub/team/conductor.mjs +54 -0
  19. package/hub/team/execution-mode.mjs +15 -1
  20. package/hub/team/notify.mjs +3 -0
  21. package/hub/team/swarm-hypervisor.mjs +14 -3
  22. package/hub/team/swarm-planner.mjs +11 -0
  23. package/hub/tray-state.mjs +54 -0
  24. package/hud/hud-qos-status.mjs +16 -5
  25. package/hud/renderers.mjs +2 -2
  26. package/package.json +5 -1
  27. package/scripts/__tests__/mcp-guard-engine.test.mjs +146 -0
  28. package/scripts/codex-profile-sanitize.mjs +38 -0
  29. package/scripts/ensure-agy-hooks.mjs +7 -8
  30. package/scripts/ensure-codex-hooks.mjs +11 -1
  31. package/scripts/lib/cli-agy.mjs +2 -0
  32. package/scripts/lib/codex-profile-config.mjs +142 -0
  33. package/scripts/lib/mcp-guard-engine.mjs +61 -1
  34. package/scripts/lib/stealth-fetch.mjs +176 -0
  35. package/scripts/lib/toml.mjs +23 -4
  36. package/scripts/pack.mjs +4 -0
  37. package/scripts/setup.mjs +96 -0
  38. package/scripts/tfx-route.sh +33 -1
  39. package/skills/tfx-research/SKILL.md +7 -0
@@ -0,0 +1,629 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ statSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { basename, dirname, join } from "node:path";
14
+
15
+ import { appendCtoEvent } from "./events.mjs";
16
+ import { resolveLakeRootDir } from "./lake-root.mjs";
17
+
18
+ const COUNT_KEYS = [
19
+ "active_tasks",
20
+ "completed_tasks",
21
+ "stale_sessions",
22
+ "orphan_worktrees",
23
+ "superseded_checkpoints",
24
+ "unknown_owner",
25
+ ];
26
+
27
+ function hasFlag(args, flag) {
28
+ return Array.isArray(args) && args.includes(flag);
29
+ }
30
+
31
+ function sleep(ms) {
32
+ return new Promise((resolve) => setTimeout(resolve, ms));
33
+ }
34
+
35
+ function writeJson(stdout, payload) {
36
+ stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
37
+ }
38
+
39
+ function readJson(filePath) {
40
+ return JSON.parse(readFileSync(filePath, "utf8"));
41
+ }
42
+
43
+ function firstExisting(paths) {
44
+ return paths.filter(Boolean).find((path) => existsSync(path)) || null;
45
+ }
46
+
47
+ function toIsoTime(value) {
48
+ if (typeof value === "string" && value.trim()) return value;
49
+ if (typeof value === "number" && Number.isFinite(value)) {
50
+ return new Date(value).toISOString();
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function normalizeLiveSession(session) {
56
+ return {
57
+ sessionId: String(session?.sessionId || session?.session_id || ""),
58
+ phase:
59
+ typeof session?.phase === "string"
60
+ ? session.phase
61
+ : typeof session?.status === "string"
62
+ ? session.status
63
+ : "active",
64
+ agent_id:
65
+ typeof session?.agent_id === "string"
66
+ ? session.agent_id
67
+ : typeof session?.agentId === "string"
68
+ ? session.agentId
69
+ : null,
70
+ started_at: toIsoTime(
71
+ session?.started_at ?? session?.startedAt ?? session?.lastHeartbeat,
72
+ ),
73
+ };
74
+ }
75
+
76
+ function normalizeOverlay(value) {
77
+ const sessions = Array.isArray(value)
78
+ ? value
79
+ : Array.isArray(value?.sessions)
80
+ ? value.sessions
81
+ : Array.isArray(value?.live_sessions)
82
+ ? value.live_sessions
83
+ : [];
84
+ return {
85
+ live_sessions: sessions
86
+ .map(normalizeLiveSession)
87
+ .filter((session) => session.sessionId),
88
+ };
89
+ }
90
+
91
+ async function readSynapseWithRegistry(opts = {}) {
92
+ const rootDir = opts.rootDir || process.cwd();
93
+ const persistPath = firstExisting([
94
+ opts.synapsePersistPath,
95
+ join(rootDir, ".triflux", "synapse-registry.json"),
96
+ join(rootDir, ".triflux", "synapse", "registry.json"),
97
+ join(homedir(), ".claude", "cache", "tfx-hub", "synapse-sessions.json"),
98
+ join(homedir(), ".claude", "cache", "tfx-hub", "synapse-registry.json"),
99
+ ]);
100
+ if (!persistPath) return { live_sessions: [] };
101
+
102
+ const { createSynapseRegistry } = await import(
103
+ "../hub/team/synapse-registry.mjs"
104
+ );
105
+ const registry = createSynapseRegistry({ persistPath });
106
+ return normalizeOverlay(registry.getActive());
107
+ }
108
+
109
+ async function readLiveOverlay(opts = {}) {
110
+ if (opts.overlay) return normalizeOverlay(opts.overlay);
111
+ try {
112
+ const reader = opts.synapseReader || readSynapseWithRegistry;
113
+ return normalizeOverlay(
114
+ await reader({
115
+ rootDir: opts.rootDir,
116
+ lakeRoot: opts.lakeRoot,
117
+ synapsePersistPath: opts.synapsePersistPath,
118
+ }),
119
+ );
120
+ } catch {
121
+ return { live_sessions: [] };
122
+ }
123
+ }
124
+
125
+ function readJsonLines(filePath) {
126
+ if (!existsSync(filePath)) return [];
127
+ return readFileSync(filePath, "utf8")
128
+ .split(/\r?\n/u)
129
+ .map((line) => line.trim())
130
+ .filter(Boolean)
131
+ .flatMap((line) => {
132
+ try {
133
+ return [JSON.parse(line)];
134
+ } catch {
135
+ return [];
136
+ }
137
+ });
138
+ }
139
+
140
+ function eventTime(entry) {
141
+ return typeof entry?.ts === "string" ? entry.ts : "";
142
+ }
143
+
144
+ function eventRef(entry) {
145
+ return entry?.ref &&
146
+ typeof entry.ref === "object" &&
147
+ !Array.isArray(entry.ref)
148
+ ? entry.ref
149
+ : {};
150
+ }
151
+
152
+ function hasKnownOwner(ref) {
153
+ const actor = ref?.actor;
154
+ if (actor && typeof actor === "object" && !Array.isArray(actor)) {
155
+ for (const key of ["cli", "session_id", "agent_id", "host"]) {
156
+ if (typeof actor[key] === "string" && actor[key].trim()) return true;
157
+ }
158
+ }
159
+ for (const key of ["owner", "owner_id", "agent_id"]) {
160
+ if (typeof ref?.[key] === "string" && ref[key].trim()) return true;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ function statusOf(ref, fallback) {
166
+ return typeof ref?.status === "string" && ref.status.trim()
167
+ ? ref.status.trim()
168
+ : fallback;
169
+ }
170
+
171
+ function rowFrom(kind, id, status, entry, ref, action) {
172
+ const row = {
173
+ kind,
174
+ id,
175
+ status,
176
+ last_event_at: eventTime(entry) || null,
177
+ };
178
+ if (entry?.summary) row.summary = String(entry.summary);
179
+ if (action) row.action = action;
180
+ if (action && !hasKnownOwner(ref)) row.owner = "unknown";
181
+ return row;
182
+ }
183
+
184
+ function rowKey(kind, id) {
185
+ return `${kind}:${id}`;
186
+ }
187
+
188
+ function hygieneKeyForRow(row) {
189
+ return rowKey(row.kind, row.id);
190
+ }
191
+
192
+ function upsertLatest(map, key, value) {
193
+ const existing = map.get(key);
194
+ if (!existing || eventTime(value.entry) >= eventTime(existing.entry)) {
195
+ map.set(key, value);
196
+ }
197
+ }
198
+
199
+ function emptyCounts() {
200
+ return Object.fromEntries(COUNT_KEYS.map((key) => [key, 0]));
201
+ }
202
+
203
+ function countsFromRows(rows) {
204
+ const counts = emptyCounts();
205
+ for (const row of rows) {
206
+ if (row.kind === "task") {
207
+ if (row.status === "completed") counts.completed_tasks += 1;
208
+ else counts.active_tasks += 1;
209
+ } else if (row.kind === "session" && row.status === "stale") {
210
+ counts.stale_sessions += 1;
211
+ } else if (row.kind === "worktree" && row.status === "orphaned") {
212
+ counts.orphan_worktrees += 1;
213
+ } else if (row.kind === "checkpoint" && row.status === "superseded") {
214
+ counts.superseded_checkpoints += 1;
215
+ }
216
+ }
217
+ counts.unknown_owner = rows.filter((row) => row.owner === "unknown").length;
218
+ return counts;
219
+ }
220
+
221
+ export function compactHygieneCounts(projection) {
222
+ const counts = projection?.counts || {};
223
+ return Object.fromEntries(COUNT_KEYS.map((key) => [key, counts[key] || 0]));
224
+ }
225
+
226
+ export function projectCtoHygiene({
227
+ current = {},
228
+ ledger = null,
229
+ overlay = {},
230
+ } = {}) {
231
+ const events = Array.isArray(ledger)
232
+ ? ledger
233
+ : Array.isArray(current?.ledger_tail)
234
+ ? current.ledger_tail
235
+ : [];
236
+ const tasks = new Map();
237
+ const staleSessions = new Map();
238
+ const worktrees = new Map();
239
+ const checkpointsBySession = new Map();
240
+ const explicitSupersededCheckpoints = new Map();
241
+ const appliedByKey = new Map();
242
+
243
+ for (const entry of events) {
244
+ const ref = eventRef(entry);
245
+ switch (entry?.event) {
246
+ case "hygiene_applied": {
247
+ const key =
248
+ typeof ref.hygiene_key === "string" && ref.hygiene_key.trim()
249
+ ? ref.hygiene_key.trim()
250
+ : null;
251
+ if (!key) break;
252
+ upsertLatest(appliedByKey, key, { entry, ref });
253
+ break;
254
+ }
255
+ case "task_claimed":
256
+ case "task_completed": {
257
+ const taskId = typeof ref.task_id === "string" ? ref.task_id : null;
258
+ if (!taskId) break;
259
+ upsertLatest(tasks, taskId, { entry, ref });
260
+ break;
261
+ }
262
+ case "session_stale": {
263
+ const sessionId =
264
+ typeof ref.session_id === "string" ? ref.session_id : null;
265
+ if (!sessionId) break;
266
+ upsertLatest(staleSessions, sessionId, { entry, ref });
267
+ break;
268
+ }
269
+ case "worktree_created":
270
+ case "worktree_removed": {
271
+ const key =
272
+ (typeof ref.worktree_path_hash === "string" &&
273
+ ref.worktree_path_hash) ||
274
+ (typeof ref.worktree_label === "string" && ref.worktree_label) ||
275
+ null;
276
+ if (!key) break;
277
+ upsertLatest(worktrees, key, { entry, ref });
278
+ break;
279
+ }
280
+ case "checkpoint_saved":
281
+ case "checkpoint_restored": {
282
+ const checkpointId =
283
+ typeof ref.checkpoint_id === "string" ? ref.checkpoint_id : null;
284
+ if (!checkpointId) break;
285
+ if (ref.status === "superseded") {
286
+ upsertLatest(explicitSupersededCheckpoints, checkpointId, {
287
+ entry,
288
+ ref,
289
+ });
290
+ }
291
+ const sessionId =
292
+ (typeof ref.session_id === "string" && ref.session_id) ||
293
+ (typeof ref.restored_from_session_id === "string" &&
294
+ ref.restored_from_session_id) ||
295
+ null;
296
+ if (!sessionId) break;
297
+ if (!checkpointsBySession.has(sessionId))
298
+ checkpointsBySession.set(sessionId, []);
299
+ checkpointsBySession.get(sessionId).push({ entry, ref, checkpointId });
300
+ break;
301
+ }
302
+ default:
303
+ break;
304
+ }
305
+ }
306
+
307
+ const rows = [];
308
+
309
+ for (const [taskId, item] of tasks) {
310
+ const completed = item.entry?.event === "task_completed";
311
+ const status = statusOf(item.ref, completed ? "completed" : "active");
312
+ rows.push(
313
+ rowFrom(
314
+ "task",
315
+ taskId,
316
+ completed || status === "completed" ? "completed" : status,
317
+ item.entry,
318
+ item.ref,
319
+ completed || status === "completed"
320
+ ? null
321
+ : "complete_or_reassign_task",
322
+ ),
323
+ );
324
+ }
325
+
326
+ for (const [sessionId, item] of staleSessions) {
327
+ rows.push(
328
+ rowFrom(
329
+ "session",
330
+ sessionId,
331
+ "stale",
332
+ item.entry,
333
+ item.ref,
334
+ "archive_or_resume_session",
335
+ ),
336
+ );
337
+ }
338
+
339
+ for (const session of overlay?.live_sessions || []) {
340
+ const phase = String(session?.phase || session?.status || "").toLowerCase();
341
+ if (phase !== "stale") continue;
342
+ const sessionId = String(
343
+ session?.sessionId || session?.session_id || "",
344
+ ).trim();
345
+ if (!sessionId || staleSessions.has(sessionId)) continue;
346
+ rows.push({
347
+ kind: "session",
348
+ id: sessionId,
349
+ status: "stale",
350
+ last_event_at: session?.started_at || null,
351
+ action: "archive_or_resume_session",
352
+ owner: session?.agent_id ? undefined : "unknown",
353
+ });
354
+ }
355
+
356
+ for (const [key, item] of worktrees) {
357
+ if (item.entry?.event === "worktree_removed") continue;
358
+ const status = statusOf(item.ref, "active");
359
+ if (status !== "orphaned") continue;
360
+ rows.push(
361
+ rowFrom(
362
+ "worktree",
363
+ item.ref.worktree_label || key,
364
+ "orphaned",
365
+ item.entry,
366
+ item.ref,
367
+ "remove_or_reassign_worktree",
368
+ ),
369
+ );
370
+ }
371
+
372
+ const superseded = new Map(explicitSupersededCheckpoints);
373
+ for (const checkpoints of checkpointsBySession.values()) {
374
+ const ordered = [...checkpoints].sort((a, b) =>
375
+ eventTime(a.entry).localeCompare(eventTime(b.entry)),
376
+ );
377
+ for (const item of ordered.slice(0, -1)) {
378
+ if (!superseded.has(item.checkpointId)) {
379
+ superseded.set(item.checkpointId, item);
380
+ }
381
+ }
382
+ }
383
+ for (const [checkpointId, item] of superseded) {
384
+ rows.push(
385
+ rowFrom(
386
+ "checkpoint",
387
+ checkpointId,
388
+ "superseded",
389
+ item.entry,
390
+ item.ref,
391
+ "prune_superseded_checkpoint",
392
+ ),
393
+ );
394
+ }
395
+
396
+ const sortedRows = rows
397
+ .filter((row) => {
398
+ const applied = appliedByKey.get(hygieneKeyForRow(row));
399
+ if (!applied) return true;
400
+ return eventTime(applied.entry) < String(row.last_event_at || "");
401
+ })
402
+ .map((row) => {
403
+ if (row.owner === undefined) {
404
+ const { owner: _owner, ...rest } = row;
405
+ return rest;
406
+ }
407
+ return row;
408
+ });
409
+ const counts = countsFromRows(sortedRows);
410
+
411
+ return {
412
+ schema_version: "cto-hygiene.v1",
413
+ dry_run: true,
414
+ counts,
415
+ rows: sortedRows,
416
+ };
417
+ }
418
+
419
+ function safeLabel(value) {
420
+ return (
421
+ String(value || "project")
422
+ .toLowerCase()
423
+ .replace(/[^a-z0-9._-]+/gu, "-")
424
+ .replace(/^-+|-+$/gu, "")
425
+ .slice(0, 40) || "project"
426
+ );
427
+ }
428
+
429
+ function rootHash(rootDir) {
430
+ return createHash("sha256")
431
+ .update(String(rootDir || ""))
432
+ .digest("hex")
433
+ .slice(0, 12);
434
+ }
435
+
436
+ export function ctoHygieneStewardLockPath(rootDir, lakeRoot) {
437
+ const label = safeLabel(basename(rootDir || "project"));
438
+ return join(lakeRoot, "stewards", `${label}-${rootHash(rootDir)}.apply.lock`);
439
+ }
440
+
441
+ export async function acquireCtoHygieneStewardLock(
442
+ rootDir,
443
+ lakeRoot,
444
+ opts = {},
445
+ ) {
446
+ const lockPath =
447
+ opts.lockPath || ctoHygieneStewardLockPath(rootDir, lakeRoot);
448
+ const timeoutMs = Math.max(0, Number(opts.timeoutMs) || 3000);
449
+ const retryMs = Math.max(1, Number(opts.retryMs) || 50);
450
+ const staleMs = Math.max(1000, Number(opts.staleMs) || 10 * 60_000);
451
+ const start = Date.now();
452
+
453
+ mkdirSync(dirname(lockPath), { recursive: true });
454
+
455
+ while (true) {
456
+ let fd = null;
457
+ try {
458
+ fd = openSync(lockPath, "wx", 0o600);
459
+ writeFileSync(
460
+ fd,
461
+ `${JSON.stringify(
462
+ {
463
+ pid: process.pid,
464
+ project_root: rootDir,
465
+ project_root_hash: rootHash(rootDir),
466
+ created_at: new Date().toISOString(),
467
+ },
468
+ null,
469
+ 2,
470
+ )}\n`,
471
+ "utf8",
472
+ );
473
+ return {
474
+ path: lockPath,
475
+ project_root: rootDir,
476
+ project_root_hash: rootHash(rootDir),
477
+ release() {
478
+ try {
479
+ closeSync(fd);
480
+ } catch {}
481
+ try {
482
+ unlinkSync(lockPath);
483
+ } catch {}
484
+ },
485
+ };
486
+ } catch (error) {
487
+ if (fd !== null) {
488
+ try {
489
+ closeSync(fd);
490
+ } catch {}
491
+ }
492
+ if (error?.code !== "EEXIST") throw error;
493
+ try {
494
+ if (Date.now() - statSync(lockPath).mtimeMs > staleMs) {
495
+ unlinkSync(lockPath);
496
+ continue;
497
+ }
498
+ } catch {}
499
+ if (Date.now() - start >= timeoutMs) {
500
+ throw new Error(`cto hygiene steward lock busy: ${lockPath}`);
501
+ }
502
+ await sleep(retryMs);
503
+ }
504
+ }
505
+ }
506
+
507
+ const APPLICABLE_STATUSES = new Set([
508
+ "active",
509
+ "stale",
510
+ "superseded",
511
+ "orphaned",
512
+ "hidden",
513
+ "completed",
514
+ ]);
515
+
516
+ function isApplicableRow(row) {
517
+ return Boolean(row?.action) || APPLICABLE_STATUSES.has(row?.status);
518
+ }
519
+
520
+ async function applyHygieneRows({
521
+ rootDir,
522
+ lakeRoot,
523
+ projection,
524
+ steward,
525
+ opts,
526
+ }) {
527
+ const rows = projection.rows.filter(isApplicableRow);
528
+ const appended = [];
529
+ const now = opts.now || new Date().toISOString();
530
+ for (const row of rows) {
531
+ const result = await appendCtoEvent(
532
+ lakeRoot,
533
+ {
534
+ event: "hygiene_applied",
535
+ now,
536
+ source: "tfx_cto_hygiene_apply",
537
+ project_root: rootDir,
538
+ status: row.status,
539
+ hygiene_key: hygieneKeyForRow(row),
540
+ hygiene_kind: row.kind,
541
+ hygiene_id: row.id,
542
+ hygiene_action: row.action || `acknowledge_${row.status}`,
543
+ summary: `hygiene apply ${row.kind}:${row.id} ${row.status}`,
544
+ actor: { cli: "tfx cto hygiene --apply" },
545
+ },
546
+ {
547
+ stderr: opts.stderr,
548
+ lockRetries: opts.ledgerLockRetries,
549
+ lockRetryDelayMs: opts.ledgerLockRetryDelayMs,
550
+ },
551
+ );
552
+ if (result.appended) appended.push(result.event);
553
+ }
554
+ return {
555
+ steward: {
556
+ lock_path: steward.path,
557
+ project_root: steward.project_root,
558
+ project_root_hash: steward.project_root_hash,
559
+ },
560
+ applicable_count: rows.length,
561
+ applied_count: appended.length,
562
+ events: appended,
563
+ };
564
+ }
565
+
566
+ export async function runHygiene(args = [], opts = {}) {
567
+ const rootDir = opts.rootDir || resolveLakeRootDir(process.cwd());
568
+ const lakeRoot = opts.lakeRoot || join(rootDir, ".triflux", "lake");
569
+ const stdout = opts.stdout || process.stdout;
570
+ const jsonOut = opts.json === true || hasFlag(args, "--json");
571
+ const dryRun = opts.dryRun === true || hasFlag(args, "--dry-run");
572
+ const apply = opts.apply === true || hasFlag(args, "--apply");
573
+ if (dryRun && apply) {
574
+ throw new Error("tfx cto hygiene accepts only one of --dry-run or --apply");
575
+ }
576
+ if (!dryRun && !apply) {
577
+ throw new Error("tfx cto hygiene requires --dry-run or --apply");
578
+ }
579
+
580
+ const buildProjection = async () => {
581
+ const currentPath = join(lakeRoot, "current.json");
582
+ const current = existsSync(currentPath) ? readJson(currentPath) : {};
583
+ const ledger = readJsonLines(join(lakeRoot, "ledger.jsonl"));
584
+ const overlay = await readLiveOverlay({ ...opts, rootDir, lakeRoot });
585
+ return projectCtoHygiene({
586
+ current,
587
+ ledger: ledger.length > 0 ? ledger : null,
588
+ overlay,
589
+ });
590
+ };
591
+
592
+ let steward = null;
593
+ let projection;
594
+ let applyResult = null;
595
+ if (apply) {
596
+ steward = await acquireCtoHygieneStewardLock(rootDir, lakeRoot, {
597
+ timeoutMs: opts.stewardLockTimeoutMs,
598
+ retryMs: opts.stewardLockRetryMs,
599
+ staleMs: opts.stewardLockStaleMs,
600
+ lockPath: opts.stewardLockPath,
601
+ });
602
+ try {
603
+ projection = await buildProjection();
604
+ applyResult = await applyHygieneRows({
605
+ rootDir,
606
+ lakeRoot,
607
+ projection,
608
+ steward,
609
+ opts,
610
+ });
611
+ projection = { ...projection, dry_run: false, apply: applyResult };
612
+ } finally {
613
+ steward.release();
614
+ }
615
+ } else {
616
+ projection = await buildProjection();
617
+ }
618
+
619
+ if (jsonOut) writeJson(stdout, projection);
620
+ else {
621
+ stdout.write(
622
+ apply
623
+ ? `cto hygiene apply: ${applyResult.applied_count} event(s) appended for ${applyResult.applicable_count} actionable row(s)\n`
624
+ : `cto hygiene dry-run: ${projection.counts.active_tasks} active tasks, ${projection.counts.stale_sessions} stale sessions, ${projection.counts.orphan_worktrees} orphan worktrees\n`,
625
+ );
626
+ }
627
+
628
+ return projection;
629
+ }
package/cto/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- const SUBCOMMANDS = ["collect", "status", "dashboard"];
1
+ const SUBCOMMANDS = ["collect", "status", "dashboard", "hygiene"];
2
2
 
3
3
  function printUsage(subcommand) {
4
4
  if (subcommand) {
@@ -6,12 +6,13 @@ function printUsage(subcommand) {
6
6
  }
7
7
  console.log(`
8
8
  Usage
9
- tfx cto <collect|status|dashboard> [options]
9
+ tfx cto <collect|status|dashboard|hygiene> [options]
10
10
 
11
11
  Subcommands
12
12
  collect Refresh .triflux/lake/current.json from repo-local authority sources
13
13
  status Print the current authority summary
14
14
  dashboard Render the CTO console dashboard, optionally with --watch
15
+ hygiene Project CTO hygiene counts and actionable dry-run rows
15
16
  `);
16
17
  }
17
18
 
@@ -31,6 +32,10 @@ export async function cmdCto(cmdArgs, opts = {}) {
31
32
  const { runDashboard } = await import("./dashboard.mjs");
32
33
  return runDashboard(rest, opts);
33
34
  }
35
+ case "hygiene": {
36
+ const { runHygiene } = await import("./hygiene.mjs");
37
+ return runHygiene(rest, opts);
38
+ }
34
39
  case undefined:
35
40
  case "":
36
41
  printUsage();