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
package/bin/tfx-live.mjs CHANGED
@@ -5,6 +5,9 @@ import { homedir, tmpdir } from "node:os";
5
5
  import { join as pathJoin, resolve as pathResolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { promisify } from "node:util";
8
+ import { runHygiene } from "../cto/hygiene.mjs";
9
+ import { notifyCtoHygieneOnce } from "../cto/hygiene-notify.mjs";
10
+ import { createNotifier } from "../hub/team/notify.mjs";
8
11
  import {
9
12
  escapePwshSingleQuoted as escapeRemotePwshSingleQuoted,
10
13
  probeRemoteEnv as probeRemoteHostEnv,
@@ -51,6 +54,8 @@ function usage() {
51
54
  " tfx-live peer [--cli-a codex] [--cli-b claude] [--session-a peerA] [--session-b peerB] [--transport-a tmux|uds|auto] [--transport-b tmux|uds|auto] [--short-a SHORT] [--short-b SHORT] [--session-id-a ID] [--session-id-b ID] [--bridge ABS] [--remote HOST] [--cwd DIR] [--rounds 4] [--mode counting|freeform] [--seed TEXT] [--timeout 60]",
52
55
  " tfx-live orchestrate --task TEXT [--mode peer|codex-led|claude-led] [--codex-transport exec|app-server-uds] [--cwd DIR] [--timeout 120]",
53
56
  " Runs the Claude(UDS)+Codex orchestration engine. --codex-transport app-server-uds drives a real `codex app-server` over WebSocket-over-UDS (experimental); default exec keeps the codex stdio one-shot path.",
57
+ " tfx-live cto-hygiene-notify --root DIR --state-file PATH [--json]",
58
+ " One-shot CTO hygiene dry-run notification: sends only when actionable hygiene state changes; no polling or apply/steward lock.",
54
59
  ].join("\n");
55
60
  }
56
61
 
@@ -1113,7 +1118,20 @@ async function hasTmuxSession(adapter, opts) {
1113
1118
 
1114
1119
  function daemonProbeUnavailableReason(probe, targetAttachable) {
1115
1120
  if (targetAttachable) return null;
1116
- if (!probe?.ok) return probe?.reason ?? "probe-failed";
1121
+ if (!probe?.ok) {
1122
+ const candidateCodes = Array.isArray(probe?.raw?.candidateResults)
1123
+ ? probe.raw.candidateResults
1124
+ .map((entry) => entry?.errorCode)
1125
+ .filter(Boolean)
1126
+ : [];
1127
+ if (candidateCodes.includes("stale-control-socket")) {
1128
+ return "stale-control-socket";
1129
+ }
1130
+ if (candidateCodes.includes("daemon-dir-missing")) {
1131
+ return "daemon-dir-missing";
1132
+ }
1133
+ return probe?.reason ?? "probe-failed";
1134
+ }
1117
1135
  return "target-not-found";
1118
1136
  }
1119
1137
 
@@ -2194,6 +2212,45 @@ function printJson(value) {
2194
2212
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
2195
2213
  }
2196
2214
 
2215
+ async function ctoHygieneNotify(flags) {
2216
+ const rootDir = flags.root ? pathResolve(flags.root) : process.cwd();
2217
+ const stateFile = flags["state-file"]
2218
+ ? pathResolve(flags["state-file"])
2219
+ : pathJoin(rootDir, ".triflux", "cto-hygiene-notify-state.json");
2220
+
2221
+ const projection = await runHygiene(["--dry-run", "--json"], {
2222
+ rootDir,
2223
+ dryRun: true,
2224
+ json: false,
2225
+ stdout: {
2226
+ write() {
2227
+ return true;
2228
+ },
2229
+ },
2230
+ });
2231
+
2232
+ const notifier = createNotifier({ stdout: process.stderr });
2233
+ const result = await notifyCtoHygieneOnce(projection, {
2234
+ notifier,
2235
+ stateFile,
2236
+ });
2237
+ const payload = {
2238
+ ok: true,
2239
+ stateFile,
2240
+ counts: projection.counts,
2241
+ ...result,
2242
+ };
2243
+
2244
+ if (flags.json) {
2245
+ printJson(payload);
2246
+ return;
2247
+ }
2248
+
2249
+ process.stdout.write(
2250
+ `cto hygiene notify: ${payload.reason} (${payload.hash})\n`,
2251
+ );
2252
+ }
2253
+
2197
2254
  async function main() {
2198
2255
  const { command, flags } = parseCli(process.argv.slice(2));
2199
2256
 
@@ -2220,6 +2277,8 @@ async function main() {
2220
2277
  await peer(flags);
2221
2278
  } else if (command === "orchestrate") {
2222
2279
  await orchestrate(flags);
2280
+ } else if (command === "cto-hygiene-notify") {
2281
+ await ctoHygieneNotify(flags);
2223
2282
  } else {
2224
2283
  throw new Error(`Unknown subcommand: ${command}\n${usage()}`);
2225
2284
  }
@@ -2229,6 +2288,7 @@ export {
2229
2288
  ADAPTERS,
2230
2289
  buildRemoteLiveCommand,
2231
2290
  callRemoteLive,
2291
+ ctoHygieneNotify,
2232
2292
  extractAssistantResponse,
2233
2293
  extractClaudeCompletedTaskListResponse,
2234
2294
  hasClaudeCompletedTaskListResponse,
package/bin/triflux.mjs CHANGED
@@ -75,6 +75,7 @@ import {
75
75
  formatPsmuxUpdateGuidance,
76
76
  probePsmuxSupport,
77
77
  } from "../scripts/lib/psmux-info.mjs";
78
+ import { main as stealthFetchMain } from "../scripts/lib/stealth-fetch.mjs";
78
79
  import {
79
80
  buildWindowsHubAutostartCommand,
80
81
  cleanupStaleSkills,
@@ -189,6 +190,11 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
189
190
  },
190
191
  ],
191
192
  },
193
+ "stealth-fetch": {
194
+ usage: "tfx stealth-fetch <url>",
195
+ description:
196
+ "cloakbrowser 기반 단일 URL fetch (http/https only, JSON stdout)",
197
+ },
192
198
  doctor: {
193
199
  usage:
194
200
  "tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--dynamic-routing] [--cleanup-stale-hubs --dry-run|--apply] [--cleanup-stale-tmux --prefix tfx-* --age-min N --dry-run|--apply] [--json]",
@@ -493,12 +499,13 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
493
499
  },
494
500
  },
495
501
  cto: {
496
- usage: "tfx cto <collect|status|dashboard> [options]",
502
+ usage: "tfx cto <collect|status|dashboard|hygiene> [options]",
497
503
  description: "repo-local authority layer console",
498
504
  subcommands: {
499
505
  collect: "refresh .triflux/lake/current.json from authority sources",
500
506
  status: "print the current authority summary",
501
507
  dashboard: "render the CTO console dashboard, optionally with --watch",
508
+ hygiene: "project CTO hygiene counts and actionable dry-run rows",
502
509
  },
503
510
  },
504
511
  multi: {
@@ -2702,6 +2709,7 @@ function statusBadge(status) {
2702
2709
  case "missing":
2703
2710
  case "missing-file":
2704
2711
  case "warning":
2712
+ case "skipped":
2705
2713
  return `${YELLOW}${status}${RESET}`;
2706
2714
  case "mismatch":
2707
2715
  case "invalid":
@@ -2724,6 +2732,7 @@ function buildMcpStatusRows(statusInfo) {
2724
2732
  else if (row.status === "mismatch")
2725
2733
  detail = `expected ${row.expectedUrl || row.expectedCommand}`;
2726
2734
  else if (row.status === "invalid-config") detail = "parse error";
2735
+ else if (row.status === "skipped") detail = row.message || "skipped";
2727
2736
  else if (row.status === "stdio") detail = "configured as stdio";
2728
2737
  return [
2729
2738
  row.name,
@@ -6327,6 +6336,7 @@ ${updateNotice}
6327
6336
  ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
6328
6337
  ${DIM} --json${RESET} ${GRAY}구조화된 진단 결과 JSON 출력${RESET}
6329
6338
  ${WHITE_BRIGHT}tfx auto${RESET} ${GRAY}tfx-auto 라우팅 결정 미리보기 (--cli codex|antigravity|claude)${RESET}
6339
+ ${WHITE_BRIGHT}tfx stealth-fetch${RESET} ${GRAY}cloakbrowser 기반 URL fetch (JSON stdout)${RESET}
6330
6340
  ${WHITE_BRIGHT}tfx mcp${RESET} ${GRAY}MCP registry 관리 (list/sync/add/remove)${RESET}
6331
6341
  ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 안정 버전으로 업데이트${RESET}
6332
6342
  ${DIM} --dev / dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
@@ -7281,6 +7291,13 @@ async function main() {
7281
7291
  enableHubAutostart: cmdArgs.includes("--enable-hub-autostart"),
7282
7292
  });
7283
7293
  return;
7294
+ case "stealth-fetch":
7295
+ if (cmdArgs.some(isHelpArg)) {
7296
+ printCommandHelp("stealth-fetch");
7297
+ return;
7298
+ }
7299
+ await stealthFetchMain([process.argv[0], "stealth-fetch", ...cmdArgs]);
7300
+ return;
7284
7301
  case "doctor": {
7285
7302
  if (cmdArgs.some(isHelpArg)) {
7286
7303
  printCommandHelp("doctor");
package/cto/dashboard.mjs CHANGED
@@ -196,6 +196,65 @@ function renderLedger(entries) {
196
196
  `;
197
197
  }
198
198
 
199
+ const HYGIENE_METRICS = Object.freeze([
200
+ ["active_tasks", "Active tasks"],
201
+ ["stale_sessions", "Stale sessions"],
202
+ ["orphan_worktrees", "Orphan worktrees"],
203
+ ["superseded_checkpoints", "Superseded checkpoints"],
204
+ ["unknown_owner", "Unknown owners"],
205
+ ]);
206
+
207
+ function hygieneCount(hygiene, key) {
208
+ const value = Number(hygiene?.[key] || 0);
209
+ return Number.isFinite(value) && value > 0 ? value : 0;
210
+ }
211
+
212
+ function renderHygiene(hygiene) {
213
+ if (!hygiene || typeof hygiene !== "object") return "";
214
+ const actions = Array.isArray(hygiene.actions)
215
+ ? hygiene.actions.slice(0, 5)
216
+ : [];
217
+ const actionCount = hygieneCount(hygiene, "action_count") || actions.length;
218
+ return `
219
+ <section class="panel">
220
+ <h2>Hygiene</h2>
221
+ <table>
222
+ <tbody>
223
+ ${HYGIENE_METRICS.map(
224
+ ([key, label]) => `
225
+ <tr>
226
+ <th>${escapeHtml(label)}</th>
227
+ <td>${hygieneCount(hygiene, key)}</td>
228
+ </tr>
229
+ `,
230
+ ).join("")}
231
+ <tr>
232
+ <th>Actions</th>
233
+ <td>${actionCount}</td>
234
+ </tr>
235
+ </tbody>
236
+ </table>
237
+ ${
238
+ actions.length
239
+ ? `<ul class="items">
240
+ ${actions
241
+ .map(
242
+ (action) => `
243
+ <li>
244
+ <strong>${escapeHtml(formatValue(action?.id || action?.kind, "item"))}</strong>
245
+ <span>${escapeHtml(formatValue(action?.action || action?.kind, "review"))}</span>
246
+ <em>${escapeHtml(formatValue(action?.status, "action"))}</em>
247
+ </li>
248
+ `,
249
+ )
250
+ .join("")}
251
+ </ul>`
252
+ : `<p class="empty">No hygiene actions reported.</p>`
253
+ }
254
+ </section>
255
+ `;
256
+ }
257
+
199
258
  function pageShell(title, body) {
200
259
  return `<!doctype html>
201
260
  <html lang="en">
@@ -326,6 +385,7 @@ function renderCurrentHtml(current) {
326
385
  <h2>Swarm Shards</h2>
327
386
  ${renderShards(shards)}
328
387
  </section>
388
+ ${renderHygiene(current.hygiene || current.summary?.hygiene)}
329
389
  <section class="panel">
330
390
  <h2>Recent Ledger</h2>
331
391
  ${renderLedger(Array.isArray(current.ledger_tail) ? current.ledger_tail : [])}
package/cto/events.mjs ADDED
@@ -0,0 +1,222 @@
1
+ import {
2
+ appendFileSync,
3
+ closeSync,
4
+ mkdirSync,
5
+ openSync,
6
+ unlinkSync,
7
+ } from "node:fs";
8
+ import { basename, join } from "node:path";
9
+
10
+ export const CTO_EVENT_SCHEMA_VERSION = "cto-event.v1";
11
+ export const DEFAULT_CTO_EVENT_SOURCE = "tfx_cto_event";
12
+
13
+ export const CTO_EVENT_TYPES = Object.freeze([
14
+ "session_started",
15
+ "session_heartbeat",
16
+ "session_stale",
17
+ "checkpoint_saved",
18
+ "checkpoint_restored",
19
+ "task_claimed",
20
+ "task_completed",
21
+ "pr_created",
22
+ "pr_merged_or_closed",
23
+ "worktree_created",
24
+ "worktree_removed",
25
+ "hygiene_applied",
26
+ ]);
27
+
28
+ export const CTO_HYGIENE_STATUSES = Object.freeze([
29
+ "active",
30
+ "idle",
31
+ "stale",
32
+ "superseded",
33
+ "orphaned",
34
+ "completed",
35
+ "blocked",
36
+ "unknown_owner",
37
+ "hidden",
38
+ "needs_attention",
39
+ ]);
40
+
41
+ const EVENT_TYPE_SET = new Set(CTO_EVENT_TYPES);
42
+ const STATUS_SET = new Set(CTO_HYGIENE_STATUSES);
43
+
44
+ function sleep(ms) {
45
+ return new Promise((resolve) => setTimeout(resolve, ms));
46
+ }
47
+
48
+ function maybeString(value) {
49
+ if (typeof value !== "string") return null;
50
+ const trimmed = value.trim();
51
+ return trimmed || null;
52
+ }
53
+
54
+ function toIsoTime(value) {
55
+ if (typeof value === "string" && value.trim()) return value;
56
+ if (value instanceof Date) return value.toISOString();
57
+ if (typeof value === "number" && Number.isFinite(value)) {
58
+ return new Date(value).toISOString();
59
+ }
60
+ return new Date().toISOString();
61
+ }
62
+
63
+ function shortHash(value) {
64
+ const str = String(value ?? "");
65
+ let h = 5381;
66
+ for (let i = 0; i < str.length; i += 1) {
67
+ h = (h * 33) ^ str.charCodeAt(i);
68
+ }
69
+ return (h >>> 0).toString(36);
70
+ }
71
+
72
+ function pathLabel(value) {
73
+ const str = String(value ?? "").replace(/[/\\]+$/u, "");
74
+ if (!str) return null;
75
+ const segments = str.split(/[/\\]+/u);
76
+ return segments[segments.length - 1] || null;
77
+ }
78
+
79
+ function normalizeNumericRefs(values) {
80
+ const rawValues = Array.isArray(values)
81
+ ? values
82
+ : values == null
83
+ ? []
84
+ : [values];
85
+ const refs = [];
86
+ for (const value of rawValues) {
87
+ const parsed =
88
+ typeof value === "number" && Number.isInteger(value)
89
+ ? value
90
+ : typeof value === "string"
91
+ ? Number(value.trim().replace(/^#/u, ""))
92
+ : NaN;
93
+ if (Number.isInteger(parsed) && parsed > 0 && !refs.includes(parsed)) {
94
+ refs.push(parsed);
95
+ }
96
+ }
97
+ return refs;
98
+ }
99
+
100
+ function normalizeActor(actor) {
101
+ if (!actor || typeof actor !== "object" || Array.isArray(actor)) return null;
102
+ const normalized = {};
103
+ for (const key of ["cli", "session_id", "agent_id", "host"]) {
104
+ const value = maybeString(actor[key]);
105
+ if (value) normalized[key] = value;
106
+ }
107
+ return Object.keys(normalized).length > 0 ? normalized : null;
108
+ }
109
+
110
+ function putString(target, key, value) {
111
+ const normalized = maybeString(value);
112
+ if (normalized) target[key] = normalized;
113
+ }
114
+
115
+ function defaultSummary(eventType, ref) {
116
+ if (ref.task_id) return `${eventType} ${ref.task_id}`;
117
+ if (ref.checkpoint_id) return `${eventType} ${ref.checkpoint_id}`;
118
+ if (ref.session_id) return `${eventType} ${ref.session_id}`;
119
+ return eventType;
120
+ }
121
+
122
+ export function normalizeCtoEvent(input = {}) {
123
+ const eventType = maybeString(input.event ?? input.event_type);
124
+ if (!eventType || !EVENT_TYPE_SET.has(eventType)) {
125
+ throw new Error(`unknown CTO event: ${eventType || "<empty>"}`);
126
+ }
127
+
128
+ const status = maybeString(input.status);
129
+ if (status && !STATUS_SET.has(status)) {
130
+ throw new Error(`invalid CTO status: ${status}`);
131
+ }
132
+
133
+ const ts = toIsoTime(input.now ?? input.ts);
134
+ const ref = {
135
+ schema_version: CTO_EVENT_SCHEMA_VERSION,
136
+ event_type: eventType,
137
+ };
138
+
139
+ putString(ref, "session_id", input.session_id);
140
+ putString(ref, "parent_session_id", input.parent_session_id);
141
+ putString(ref, "restored_from_session_id", input.restored_from_session_id);
142
+ putString(ref, "checkpoint_id", input.checkpoint_id);
143
+ putString(ref, "artifact_path", input.artifact_path);
144
+ putString(ref, "task_id", input.task_id);
145
+ putString(ref, "branch", input.branch);
146
+ putString(ref, "last_seen_at", input.last_seen_at);
147
+ putString(ref, "stale_reason", input.stale_reason);
148
+ putString(ref, "hygiene_key", input.hygiene_key);
149
+ putString(ref, "hygiene_kind", input.hygiene_kind);
150
+ putString(ref, "hygiene_id", input.hygiene_id);
151
+ putString(ref, "hygiene_action", input.hygiene_action);
152
+ if (status) ref.status = status;
153
+
154
+ const projectRoot = maybeString(input.project_root);
155
+ if (projectRoot) {
156
+ ref.project_root_hash = shortHash(projectRoot);
157
+ ref.project_root_label = pathLabel(projectRoot) || basename(projectRoot);
158
+ }
159
+
160
+ const worktreePath = maybeString(input.worktree_path ?? input.worktreePath);
161
+ if (worktreePath) {
162
+ ref.worktree_path_hash = shortHash(worktreePath);
163
+ ref.worktree_label = pathLabel(worktreePath) || basename(worktreePath);
164
+ }
165
+
166
+ const issueRefs = normalizeNumericRefs(input.issue_refs ?? input.issueRefs);
167
+ if (issueRefs.length > 0) ref.issue_refs = issueRefs;
168
+ const prRefs = normalizeNumericRefs(input.pr_refs ?? input.prRefs);
169
+ if (prRefs.length > 0) ref.pr_refs = prRefs;
170
+
171
+ const actor = normalizeActor(input.actor);
172
+ if (actor) ref.actor = actor;
173
+
174
+ return {
175
+ ts,
176
+ event: eventType,
177
+ source: maybeString(input.source) || DEFAULT_CTO_EVENT_SOURCE,
178
+ summary: maybeString(input.summary) || defaultSummary(eventType, ref),
179
+ ref,
180
+ };
181
+ }
182
+
183
+ export async function appendCtoEvent(lakeRoot, input, opts = {}) {
184
+ const event = normalizeCtoEvent(input);
185
+ const stderr = opts.stderr || process.stderr;
186
+ const lockRetries = Number.isInteger(opts.lockRetries) ? opts.lockRetries : 3;
187
+ const lockRetryDelayMs = Number.isFinite(opts.lockRetryDelayMs)
188
+ ? opts.lockRetryDelayMs
189
+ : 100;
190
+
191
+ mkdirSync(lakeRoot, { recursive: true });
192
+ const ledgerPath = join(lakeRoot, "ledger.jsonl");
193
+ const lockPath = join(lakeRoot, "ledger.jsonl.lock");
194
+ let fd = null;
195
+
196
+ for (let attempt = 0; attempt < lockRetries; attempt += 1) {
197
+ try {
198
+ fd = openSync(lockPath, "wx", 0o600);
199
+ break;
200
+ } catch (error) {
201
+ if (error?.code !== "EEXIST") throw error;
202
+ if (attempt < lockRetries - 1) await sleep(lockRetryDelayMs);
203
+ }
204
+ }
205
+
206
+ if (fd === null) {
207
+ stderr.write(
208
+ "[tfx cto event] warning: ledger lock timeout; skipped ledger append\n",
209
+ );
210
+ return { appended: false, event };
211
+ }
212
+
213
+ try {
214
+ appendFileSync(ledgerPath, `${JSON.stringify(event)}\n`, "utf8");
215
+ return { appended: true, event };
216
+ } finally {
217
+ closeSync(fd);
218
+ try {
219
+ unlinkSync(lockPath);
220
+ } catch {}
221
+ }
222
+ }
@@ -0,0 +1,170 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+
5
+ const ACTIONABLE_COUNT_KEYS = Object.freeze([
6
+ "active_tasks",
7
+ "stale_sessions",
8
+ "orphan_worktrees",
9
+ "superseded_checkpoints",
10
+ "unknown_owner",
11
+ ]);
12
+
13
+ function stableValue(value) {
14
+ if (Array.isArray(value)) return value.map(stableValue);
15
+ if (value && typeof value === "object") {
16
+ return Object.fromEntries(
17
+ Object.keys(value)
18
+ .sort()
19
+ .map((key) => [key, stableValue(value[key])]),
20
+ );
21
+ }
22
+ return value;
23
+ }
24
+
25
+ function stableJson(value) {
26
+ return JSON.stringify(stableValue(value));
27
+ }
28
+
29
+ function normalizeCount(value) {
30
+ return Number.isFinite(Number(value)) ? Number(value) : 0;
31
+ }
32
+
33
+ function actionableCounts(projection = {}) {
34
+ const counts = projection?.counts || {};
35
+ return Object.fromEntries(
36
+ ACTIONABLE_COUNT_KEYS.map((key) => [key, normalizeCount(counts[key])]),
37
+ );
38
+ }
39
+
40
+ function normalizeRows(rows) {
41
+ return (Array.isArray(rows) ? rows : [])
42
+ .map((row) => ({
43
+ kind: String(row?.kind || ""),
44
+ id: String(row?.id || ""),
45
+ status: String(row?.status || ""),
46
+ action: row?.action == null ? "" : String(row.action),
47
+ owner: row?.owner == null ? "" : String(row.owner),
48
+ }))
49
+ .sort((a, b) =>
50
+ [a.kind, a.id, a.status, a.action, a.owner]
51
+ .join("\u0000")
52
+ .localeCompare(
53
+ [b.kind, b.id, b.status, b.action, b.owner].join("\u0000"),
54
+ ),
55
+ );
56
+ }
57
+
58
+ export function ctoHygieneStateHash(projection = {}) {
59
+ const canonical = {
60
+ counts: actionableCounts(projection),
61
+ rows: normalizeRows(projection.rows),
62
+ };
63
+ return createHash("sha256").update(stableJson(canonical)).digest("hex");
64
+ }
65
+
66
+ function plural(count, singular, pluralWord = `${singular}s`) {
67
+ return `${count} ${count === 1 ? singular : pluralWord}`;
68
+ }
69
+
70
+ function summarizeCounts(counts) {
71
+ const parts = [];
72
+ if (counts.active_tasks)
73
+ parts.push(plural(counts.active_tasks, "active task"));
74
+ if (counts.stale_sessions)
75
+ parts.push(plural(counts.stale_sessions, "stale session"));
76
+ if (counts.orphan_worktrees)
77
+ parts.push(plural(counts.orphan_worktrees, "orphan worktree"));
78
+ if (counts.superseded_checkpoints)
79
+ parts.push(plural(counts.superseded_checkpoints, "superseded checkpoint"));
80
+ if (counts.unknown_owner)
81
+ parts.push(plural(counts.unknown_owner, "unknown owner"));
82
+ return parts;
83
+ }
84
+
85
+ export function buildCtoHygieneNotification(projection = {}, opts = {}) {
86
+ const counts = actionableCounts(projection);
87
+ const actionable = ACTIONABLE_COUNT_KEYS.some((key) => counts[key] > 0);
88
+ const hash = ctoHygieneStateHash(projection);
89
+ if (!actionable) {
90
+ return { actionable, hash, event: null, counts };
91
+ }
92
+
93
+ const summary = `CTO hygiene needs action: ${summarizeCounts(counts).join(", ")}`;
94
+ return {
95
+ actionable,
96
+ hash,
97
+ counts,
98
+ event: {
99
+ type: "ctoHygiene",
100
+ sessionId: opts.sessionId || "cto-hygiene",
101
+ summary,
102
+ timestamp:
103
+ typeof opts.now === "function"
104
+ ? opts.now()
105
+ : (opts.timestamp ?? new Date().toISOString()),
106
+ },
107
+ };
108
+ }
109
+
110
+ async function readState(stateFile) {
111
+ if (!stateFile) return null;
112
+ try {
113
+ return JSON.parse(await readFile(stateFile, "utf8"));
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ async function writeState(stateFile, state) {
120
+ if (!stateFile) return;
121
+ await mkdir(dirname(stateFile), { recursive: true });
122
+ await writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
123
+ }
124
+
125
+ export async function notifyCtoHygieneOnce(projection = {}, opts = {}) {
126
+ const notification = buildCtoHygieneNotification(projection, opts);
127
+ const previous = await readState(opts.stateFile);
128
+ const unchanged = previous?.hash === notification.hash;
129
+ const checkedAt =
130
+ typeof opts.now === "function" ? opts.now() : new Date().toISOString();
131
+
132
+ if (!notification.actionable) {
133
+ await writeState(opts.stateFile, {
134
+ hash: notification.hash,
135
+ actionable: false,
136
+ checkedAt,
137
+ });
138
+ return {
139
+ actionable: false,
140
+ notified: false,
141
+ reason: "not-actionable",
142
+ hash: notification.hash,
143
+ };
144
+ }
145
+
146
+ if (unchanged) {
147
+ return {
148
+ actionable: true,
149
+ notified: false,
150
+ reason: "unchanged-state",
151
+ hash: notification.hash,
152
+ };
153
+ }
154
+
155
+ const notifierResult = await opts.notifier.notify(notification.event);
156
+ await writeState(opts.stateFile, {
157
+ hash: notification.hash,
158
+ actionable: true,
159
+ notifiedAt: notification.event.timestamp,
160
+ });
161
+
162
+ return {
163
+ actionable: true,
164
+ notified: true,
165
+ reason: "changed-actionable-state",
166
+ hash: notification.hash,
167
+ event: notification.event,
168
+ notify: notifierResult,
169
+ };
170
+ }