triflux 10.36.0 → 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.
- package/bin/tfx-live.mjs +61 -1
- package/bin/triflux.mjs +4 -1
- package/cto/dashboard.mjs +60 -0
- package/cto/events.mjs +222 -0
- package/cto/hygiene-notify.mjs +170 -0
- package/cto/hygiene.mjs +629 -0
- package/cto/index.mjs +7 -2
- package/cto/lake-root.mjs +47 -6
- package/cto/status.mjs +4 -0
- package/hooks/agy-session-hook.mjs +2 -2
- package/hooks/codex-session-hook.mjs +1 -1
- package/hooks/hooks.json +12 -12
- package/hooks/lib/resolve-root.mjs +3 -0
- package/hooks/session-start-fast.mjs +108 -6
- package/hub/public/tray.html +36 -0
- package/hub/routing/q-learning.mjs +5 -2
- package/hub/team/claude-daemon-control.mjs +27 -2
- package/hub/team/conductor.mjs +54 -0
- package/hub/team/execution-mode.mjs +15 -1
- package/hub/team/notify.mjs +3 -0
- package/hub/team/swarm-hypervisor.mjs +14 -3
- package/hub/team/swarm-planner.mjs +11 -0
- package/hub/tray-state.mjs +54 -0
- package/hud/hud-qos-status.mjs +16 -5
- package/hud/renderers.mjs +2 -2
- package/package.json +1 -1
- package/scripts/__tests__/mcp-guard-engine.test.mjs +146 -0
- package/scripts/codex-profile-sanitize.mjs +38 -0
- package/scripts/ensure-agy-hooks.mjs +7 -8
- package/scripts/ensure-codex-hooks.mjs +11 -1
- package/scripts/lib/cli-agy.mjs +2 -0
- package/scripts/lib/codex-profile-config.mjs +142 -0
- package/scripts/lib/mcp-guard-engine.mjs +61 -1
- package/scripts/lib/toml.mjs +23 -4
- package/scripts/pack.mjs +4 -0
- package/scripts/tfx-route.sh +33 -1
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)
|
|
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
|
@@ -499,12 +499,13 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
|
|
|
499
499
|
},
|
|
500
500
|
},
|
|
501
501
|
cto: {
|
|
502
|
-
usage: "tfx cto <collect|status|dashboard> [options]",
|
|
502
|
+
usage: "tfx cto <collect|status|dashboard|hygiene> [options]",
|
|
503
503
|
description: "repo-local authority layer console",
|
|
504
504
|
subcommands: {
|
|
505
505
|
collect: "refresh .triflux/lake/current.json from authority sources",
|
|
506
506
|
status: "print the current authority summary",
|
|
507
507
|
dashboard: "render the CTO console dashboard, optionally with --watch",
|
|
508
|
+
hygiene: "project CTO hygiene counts and actionable dry-run rows",
|
|
508
509
|
},
|
|
509
510
|
},
|
|
510
511
|
multi: {
|
|
@@ -2708,6 +2709,7 @@ function statusBadge(status) {
|
|
|
2708
2709
|
case "missing":
|
|
2709
2710
|
case "missing-file":
|
|
2710
2711
|
case "warning":
|
|
2712
|
+
case "skipped":
|
|
2711
2713
|
return `${YELLOW}${status}${RESET}`;
|
|
2712
2714
|
case "mismatch":
|
|
2713
2715
|
case "invalid":
|
|
@@ -2730,6 +2732,7 @@ function buildMcpStatusRows(statusInfo) {
|
|
|
2730
2732
|
else if (row.status === "mismatch")
|
|
2731
2733
|
detail = `expected ${row.expectedUrl || row.expectedCommand}`;
|
|
2732
2734
|
else if (row.status === "invalid-config") detail = "parse error";
|
|
2735
|
+
else if (row.status === "skipped") detail = row.message || "skipped";
|
|
2733
2736
|
else if (row.status === "stdio") detail = "configured as stdio";
|
|
2734
2737
|
return [
|
|
2735
2738
|
row.name,
|
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
|
+
}
|