triflux 10.36.0 → 10.38.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 (40) hide show
  1. package/bin/tfx-live.mjs +61 -1
  2. package/bin/triflux.mjs +6 -1
  3. package/cto/dashboard.mjs +60 -0
  4. package/cto/events.mjs +489 -0
  5. package/cto/hygiene-notify.mjs +170 -0
  6. package/cto/hygiene.mjs +629 -0
  7. package/cto/index.mjs +12 -2
  8. package/cto/lake-root.mjs +47 -6
  9. package/cto/status.mjs +4 -0
  10. package/hooks/agy-session-hook.mjs +89 -29
  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/hooks/subagent-tracker.mjs +16 -2
  16. package/hub/public/tray.html +36 -0
  17. package/hub/routing/q-learning.mjs +5 -2
  18. package/hub/team/claude-daemon-control.mjs +42 -9
  19. package/hub/team/conductor.mjs +54 -0
  20. package/hub/team/execution-mode.mjs +15 -1
  21. package/hub/team/notify.mjs +34 -11
  22. package/hub/team/swarm-hypervisor.mjs +14 -3
  23. package/hub/team/swarm-planner.mjs +11 -0
  24. package/hub/tray-state.mjs +54 -0
  25. package/hud/hud-qos-status.mjs +16 -5
  26. package/hud/providers/gemini.mjs +116 -31
  27. package/hud/renderers.mjs +2 -2
  28. package/package.json +1 -1
  29. package/scripts/__tests__/mcp-guard-engine.test.mjs +146 -0
  30. package/scripts/check-codex-config-stable.mjs +38 -11
  31. package/scripts/codex-profile-sanitize.mjs +38 -0
  32. package/scripts/ensure-agy-hooks.mjs +7 -8
  33. package/scripts/ensure-codex-hooks.mjs +11 -1
  34. package/scripts/lib/cli-agy.mjs +2 -0
  35. package/scripts/lib/codex-profile-config.mjs +142 -0
  36. package/scripts/lib/mcp-guard-engine.mjs +61 -1
  37. package/scripts/lib/toml.mjs +23 -4
  38. package/scripts/pack.mjs +4 -0
  39. package/scripts/test-lock.mjs +34 -29
  40. 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) 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
@@ -499,12 +499,15 @@ 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|event> [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",
509
+ event:
510
+ "append wrapper lineage events: context-save, context-restore, pr-created, pr-merged-or-closed",
508
511
  },
509
512
  },
510
513
  multi: {
@@ -2708,6 +2711,7 @@ function statusBadge(status) {
2708
2711
  case "missing":
2709
2712
  case "missing-file":
2710
2713
  case "warning":
2714
+ case "skipped":
2711
2715
  return `${YELLOW}${status}${RESET}`;
2712
2716
  case "mismatch":
2713
2717
  case "invalid":
@@ -2730,6 +2734,7 @@ function buildMcpStatusRows(statusInfo) {
2730
2734
  else if (row.status === "mismatch")
2731
2735
  detail = `expected ${row.expectedUrl || row.expectedCommand}`;
2732
2736
  else if (row.status === "invalid-config") detail = "parse error";
2737
+ else if (row.status === "skipped") detail = row.message || "skipped";
2733
2738
  else if (row.status === "stdio") detail = "configured as stdio";
2734
2739
  return [
2735
2740
  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,489 @@
1
+ import {
2
+ appendFileSync,
3
+ closeSync,
4
+ mkdirSync,
5
+ openSync,
6
+ unlinkSync,
7
+ } from "node:fs";
8
+ import { basename, join } from "node:path";
9
+ import { resolveLakeRootDir } from "./lake-root.mjs";
10
+
11
+ export const CTO_EVENT_SCHEMA_VERSION = "cto-event.v1";
12
+ export const DEFAULT_CTO_EVENT_SOURCE = "tfx_cto_event";
13
+
14
+ export const CTO_EVENT_TYPES = Object.freeze([
15
+ "session_started",
16
+ "session_heartbeat",
17
+ "session_stale",
18
+ "checkpoint_saved",
19
+ "checkpoint_restored",
20
+ "task_claimed",
21
+ "task_completed",
22
+ "pr_created",
23
+ "pr_merged_or_closed",
24
+ "worktree_created",
25
+ "worktree_removed",
26
+ "hygiene_applied",
27
+ ]);
28
+
29
+ export const CTO_HYGIENE_STATUSES = Object.freeze([
30
+ "active",
31
+ "idle",
32
+ "stale",
33
+ "superseded",
34
+ "orphaned",
35
+ "completed",
36
+ "blocked",
37
+ "unknown_owner",
38
+ "hidden",
39
+ "needs_attention",
40
+ ]);
41
+
42
+ const EVENT_TYPE_SET = new Set(CTO_EVENT_TYPES);
43
+ const STATUS_SET = new Set(CTO_HYGIENE_STATUSES);
44
+
45
+ const EVENT_PRESETS = Object.freeze({
46
+ "context-save": {
47
+ event: "checkpoint_saved",
48
+ source: "gstack_context_save",
49
+ actor: { cli: "gstack context-save" },
50
+ ownerSurface: "gstack context-save -> tfx cto event context-save",
51
+ },
52
+ "checkpoint-saved": {
53
+ event: "checkpoint_saved",
54
+ ownerSurface: "checkpoint wrapper -> tfx cto event checkpoint-saved",
55
+ },
56
+ "context-restore": {
57
+ event: "checkpoint_restored",
58
+ source: "gstack_context_restore",
59
+ actor: { cli: "gstack context-restore" },
60
+ ownerSurface: "gstack context-restore -> tfx cto event context-restore",
61
+ },
62
+ "checkpoint-restored": {
63
+ event: "checkpoint_restored",
64
+ ownerSurface: "checkpoint wrapper -> tfx cto event checkpoint-restored",
65
+ },
66
+ "pr-created": {
67
+ event: "pr_created",
68
+ source: "tfx_pr_lifecycle",
69
+ actor: { cli: "gh pr create" },
70
+ ownerSurface: "gh pr create/merge/close -> tfx cto event pr-created",
71
+ },
72
+ "pr-merged": {
73
+ event: "pr_merged_or_closed",
74
+ source: "tfx_pr_lifecycle",
75
+ status: "completed",
76
+ actor: { cli: "gh pr merge" },
77
+ ownerSurface:
78
+ "gh pr create/merge/close -> tfx cto event pr-merged-or-closed",
79
+ },
80
+ "pr-closed": {
81
+ event: "pr_merged_or_closed",
82
+ source: "tfx_pr_lifecycle",
83
+ status: "completed",
84
+ actor: { cli: "gh pr close" },
85
+ ownerSurface:
86
+ "gh pr create/merge/close -> tfx cto event pr-merged-or-closed",
87
+ },
88
+ "pr-merged-or-closed": {
89
+ event: "pr_merged_or_closed",
90
+ source: "tfx_pr_lifecycle",
91
+ actor: { cli: "gh pr merge/close" },
92
+ ownerSurface:
93
+ "gh pr create/merge/close -> tfx cto event pr-merged-or-closed",
94
+ },
95
+ });
96
+
97
+ const OPTION_KEYS = Object.freeze({
98
+ event: "event",
99
+ "event-type": "event",
100
+ source: "source",
101
+ summary: "summary",
102
+ now: "now",
103
+ ts: "ts",
104
+ status: "status",
105
+ "session-id": "session_id",
106
+ "parent-session-id": "parent_session_id",
107
+ "restored-from-session-id": "restored_from_session_id",
108
+ "checkpoint-id": "checkpoint_id",
109
+ "artifact-path": "artifact_path",
110
+ artifact: "artifact_path",
111
+ "task-id": "task_id",
112
+ branch: "branch",
113
+ "last-seen-at": "last_seen_at",
114
+ "stale-reason": "stale_reason",
115
+ "hygiene-key": "hygiene_key",
116
+ "hygiene-kind": "hygiene_kind",
117
+ "hygiene-id": "hygiene_id",
118
+ "hygiene-action": "hygiene_action",
119
+ "project-root": "project_root",
120
+ "worktree-path": "worktree_path",
121
+ worktree: "worktree_path",
122
+ issue: "issue_refs",
123
+ "issue-ref": "issue_refs",
124
+ "issue-refs": "issue_refs",
125
+ pr: "pr_refs",
126
+ "pr-ref": "pr_refs",
127
+ "pr-refs": "pr_refs",
128
+ "actor-cli": "actor.cli",
129
+ "actor-session-id": "actor.session_id",
130
+ "actor-agent-id": "actor.agent_id",
131
+ "actor-host": "actor.host",
132
+ "owner-surface": "owner_surface",
133
+ });
134
+
135
+ const COLLECTION_KEYS = new Set(["issue_refs", "pr_refs"]);
136
+
137
+ function sleep(ms) {
138
+ return new Promise((resolve) => setTimeout(resolve, ms));
139
+ }
140
+
141
+ function maybeString(value) {
142
+ if (typeof value !== "string") return null;
143
+ const trimmed = value.trim();
144
+ return trimmed || null;
145
+ }
146
+
147
+ function toIsoTime(value) {
148
+ if (typeof value === "string" && value.trim()) return value;
149
+ if (value instanceof Date) return value.toISOString();
150
+ if (typeof value === "number" && Number.isFinite(value)) {
151
+ return new Date(value).toISOString();
152
+ }
153
+ return new Date().toISOString();
154
+ }
155
+
156
+ function shortHash(value) {
157
+ const str = String(value ?? "");
158
+ let h = 5381;
159
+ for (let i = 0; i < str.length; i += 1) {
160
+ h = (h * 33) ^ str.charCodeAt(i);
161
+ }
162
+ return (h >>> 0).toString(36);
163
+ }
164
+
165
+ function pathLabel(value) {
166
+ const str = String(value ?? "").replace(/[/\\]+$/u, "");
167
+ if (!str) return null;
168
+ const segments = str.split(/[/\\]+/u);
169
+ return segments[segments.length - 1] || null;
170
+ }
171
+
172
+ function normalizeNumericRefs(values) {
173
+ const rawValues = Array.isArray(values)
174
+ ? values
175
+ : values == null
176
+ ? []
177
+ : [values];
178
+ const refs = [];
179
+ for (const value of rawValues) {
180
+ const parsed =
181
+ typeof value === "number" && Number.isInteger(value)
182
+ ? value
183
+ : typeof value === "string"
184
+ ? Number(value.trim().replace(/^#/u, ""))
185
+ : NaN;
186
+ if (Number.isInteger(parsed) && parsed > 0 && !refs.includes(parsed)) {
187
+ refs.push(parsed);
188
+ }
189
+ }
190
+ return refs;
191
+ }
192
+
193
+ function normalizeActor(actor) {
194
+ if (!actor || typeof actor !== "object" || Array.isArray(actor)) return null;
195
+ const normalized = {};
196
+ for (const key of ["cli", "session_id", "agent_id", "host"]) {
197
+ const value = maybeString(actor[key]);
198
+ if (value) normalized[key] = value;
199
+ }
200
+ return Object.keys(normalized).length > 0 ? normalized : null;
201
+ }
202
+
203
+ function optionName(raw) {
204
+ return String(raw || "")
205
+ .replace(/^--?/u, "")
206
+ .trim();
207
+ }
208
+
209
+ function appendCollection(target, key, value) {
210
+ const values = String(value ?? "")
211
+ .split(",")
212
+ .map((item) => item.trim())
213
+ .filter(Boolean);
214
+ if (values.length === 0) return;
215
+ if (!Array.isArray(target[key])) target[key] = [];
216
+ target[key].push(...values);
217
+ }
218
+
219
+ function putPathValue(target, keyPath, value) {
220
+ if (keyPath.startsWith("actor.")) {
221
+ const key = keyPath.slice("actor.".length);
222
+ target.actor ||= {};
223
+ target.actor[key] = value;
224
+ return;
225
+ }
226
+ if (COLLECTION_KEYS.has(keyPath)) {
227
+ appendCollection(target, keyPath, value);
228
+ return;
229
+ }
230
+ target[keyPath] = value;
231
+ }
232
+
233
+ function parseEventArgs(args = []) {
234
+ const fields = {};
235
+ const positional = [];
236
+ let json = false;
237
+
238
+ for (let i = 0; i < args.length; i += 1) {
239
+ const arg = args[i];
240
+ if (arg === "--json") {
241
+ json = true;
242
+ continue;
243
+ }
244
+ if (!arg?.startsWith?.("--")) {
245
+ positional.push(arg);
246
+ continue;
247
+ }
248
+
249
+ const eqIndex = arg.indexOf("=");
250
+ const rawName = eqIndex >= 0 ? arg.slice(0, eqIndex) : arg;
251
+ const name = optionName(rawName);
252
+ const mapped = OPTION_KEYS[name];
253
+ if (!mapped) {
254
+ throw new Error(`unknown tfx cto event option: --${name}`);
255
+ }
256
+
257
+ let value = eqIndex >= 0 ? arg.slice(eqIndex + 1) : null;
258
+ if (value === null) {
259
+ const next = args[i + 1];
260
+ if (typeof next === "string" && !next.startsWith("--")) {
261
+ value = next;
262
+ i += 1;
263
+ } else {
264
+ value = "true";
265
+ }
266
+ }
267
+ putPathValue(fields, mapped, value);
268
+ }
269
+
270
+ return {
271
+ presetName: positional[0] || null,
272
+ extraPositionals: positional.slice(1),
273
+ fields,
274
+ json,
275
+ };
276
+ }
277
+
278
+ function mergeActor(defaultActor, overrideActor) {
279
+ const actor = { ...(defaultActor || {}), ...(overrideActor || {}) };
280
+ return Object.keys(actor).length > 0 ? actor : undefined;
281
+ }
282
+
283
+ function defaultEventSummary(input) {
284
+ if (input.event === "checkpoint_saved") {
285
+ return `context-save checkpoint ${input.checkpoint_id}`;
286
+ }
287
+ if (input.event === "checkpoint_restored") {
288
+ return `context-restore checkpoint ${input.checkpoint_id}`;
289
+ }
290
+ if (input.event === "pr_created") {
291
+ const [pr] = normalizeNumericRefs(input.pr_refs);
292
+ return pr ? `PR #${pr} created` : "PR created";
293
+ }
294
+ if (input.event === "pr_merged_or_closed") {
295
+ const [pr] = normalizeNumericRefs(input.pr_refs);
296
+ return pr ? `PR #${pr} merged or closed` : "PR merged or closed";
297
+ }
298
+ return undefined;
299
+ }
300
+
301
+ function validateRunEventInput(input) {
302
+ if (!input.event) {
303
+ throw new Error("tfx cto event requires a preset or --event <event_type>");
304
+ }
305
+
306
+ if (
307
+ ["checkpoint_saved", "checkpoint_restored"].includes(input.event) &&
308
+ !maybeString(input.checkpoint_id)
309
+ ) {
310
+ throw new Error(`tfx cto event ${input.event} requires --checkpoint-id`);
311
+ }
312
+
313
+ if (
314
+ ["pr_created", "pr_merged_or_closed"].includes(input.event) &&
315
+ normalizeNumericRefs(input.pr_refs).length === 0
316
+ ) {
317
+ throw new Error(`tfx cto event ${input.event} requires --pr`);
318
+ }
319
+ }
320
+
321
+ function putString(target, key, value) {
322
+ const normalized = maybeString(value);
323
+ if (normalized) target[key] = normalized;
324
+ }
325
+
326
+ function defaultSummary(eventType, ref) {
327
+ if (ref.task_id) return `${eventType} ${ref.task_id}`;
328
+ if (ref.checkpoint_id) return `${eventType} ${ref.checkpoint_id}`;
329
+ if (ref.session_id) return `${eventType} ${ref.session_id}`;
330
+ return eventType;
331
+ }
332
+
333
+ export function normalizeCtoEvent(input = {}) {
334
+ const eventType = maybeString(input.event ?? input.event_type);
335
+ if (!eventType || !EVENT_TYPE_SET.has(eventType)) {
336
+ throw new Error(`unknown CTO event: ${eventType || "<empty>"}`);
337
+ }
338
+
339
+ const status = maybeString(input.status);
340
+ if (status && !STATUS_SET.has(status)) {
341
+ throw new Error(`invalid CTO status: ${status}`);
342
+ }
343
+
344
+ const ts = toIsoTime(input.now ?? input.ts);
345
+ const ref = {
346
+ schema_version: CTO_EVENT_SCHEMA_VERSION,
347
+ event_type: eventType,
348
+ };
349
+
350
+ putString(ref, "session_id", input.session_id);
351
+ putString(ref, "parent_session_id", input.parent_session_id);
352
+ putString(ref, "restored_from_session_id", input.restored_from_session_id);
353
+ putString(ref, "checkpoint_id", input.checkpoint_id);
354
+ putString(ref, "artifact_path", input.artifact_path);
355
+ putString(ref, "task_id", input.task_id);
356
+ putString(ref, "branch", input.branch);
357
+ putString(ref, "last_seen_at", input.last_seen_at);
358
+ putString(ref, "stale_reason", input.stale_reason);
359
+ putString(ref, "hygiene_key", input.hygiene_key);
360
+ putString(ref, "hygiene_kind", input.hygiene_kind);
361
+ putString(ref, "hygiene_id", input.hygiene_id);
362
+ putString(ref, "hygiene_action", input.hygiene_action);
363
+ if (status) ref.status = status;
364
+
365
+ const projectRoot = maybeString(input.project_root);
366
+ if (projectRoot) {
367
+ ref.project_root_hash = shortHash(projectRoot);
368
+ ref.project_root_label = pathLabel(projectRoot) || basename(projectRoot);
369
+ }
370
+
371
+ const worktreePath = maybeString(input.worktree_path ?? input.worktreePath);
372
+ if (worktreePath) {
373
+ ref.worktree_path_hash = shortHash(worktreePath);
374
+ ref.worktree_label = pathLabel(worktreePath) || basename(worktreePath);
375
+ }
376
+
377
+ const issueRefs = normalizeNumericRefs(input.issue_refs ?? input.issueRefs);
378
+ if (issueRefs.length > 0) ref.issue_refs = issueRefs;
379
+ const prRefs = normalizeNumericRefs(input.pr_refs ?? input.prRefs);
380
+ if (prRefs.length > 0) ref.pr_refs = prRefs;
381
+
382
+ const actor = normalizeActor(input.actor);
383
+ if (actor) ref.actor = actor;
384
+
385
+ return {
386
+ ts,
387
+ event: eventType,
388
+ source: maybeString(input.source) || DEFAULT_CTO_EVENT_SOURCE,
389
+ summary: maybeString(input.summary) || defaultSummary(eventType, ref),
390
+ ref,
391
+ };
392
+ }
393
+
394
+ export async function appendCtoEvent(lakeRoot, input, opts = {}) {
395
+ const event = normalizeCtoEvent(input);
396
+ const stderr = opts.stderr || process.stderr;
397
+ const lockRetries = Number.isInteger(opts.lockRetries) ? opts.lockRetries : 3;
398
+ const lockRetryDelayMs = Number.isFinite(opts.lockRetryDelayMs)
399
+ ? opts.lockRetryDelayMs
400
+ : 100;
401
+
402
+ mkdirSync(lakeRoot, { recursive: true });
403
+ const ledgerPath = join(lakeRoot, "ledger.jsonl");
404
+ const lockPath = join(lakeRoot, "ledger.jsonl.lock");
405
+ let fd = null;
406
+
407
+ for (let attempt = 0; attempt < lockRetries; attempt += 1) {
408
+ try {
409
+ fd = openSync(lockPath, "wx", 0o600);
410
+ break;
411
+ } catch (error) {
412
+ if (error?.code !== "EEXIST") throw error;
413
+ if (attempt < lockRetries - 1) await sleep(lockRetryDelayMs);
414
+ }
415
+ }
416
+
417
+ if (fd === null) {
418
+ stderr.write(
419
+ "[tfx cto event] warning: ledger lock timeout; skipped ledger append\n",
420
+ );
421
+ return { appended: false, event };
422
+ }
423
+
424
+ try {
425
+ appendFileSync(ledgerPath, `${JSON.stringify(event)}\n`, "utf8");
426
+ return { appended: true, event };
427
+ } finally {
428
+ closeSync(fd);
429
+ try {
430
+ unlinkSync(lockPath);
431
+ } catch {}
432
+ }
433
+ }
434
+
435
+ export async function runEvent(args = [], opts = {}) {
436
+ const parsed = parseEventArgs(args);
437
+ if (parsed.extraPositionals.length > 0) {
438
+ throw new Error(
439
+ `unexpected tfx cto event argument: ${parsed.extraPositionals[0]}`,
440
+ );
441
+ }
442
+ const preset = parsed.presetName ? EVENT_PRESETS[parsed.presetName] : null;
443
+ if (parsed.presetName && !preset && !parsed.fields.event) {
444
+ throw new Error(`unknown tfx cto event preset: ${parsed.presetName}`);
445
+ }
446
+
447
+ const rootDir = opts.rootDir || resolveLakeRootDir(process.cwd());
448
+ const lakeRoot = opts.lakeRoot || join(rootDir, ".triflux", "lake");
449
+ const stdout = opts.stdout || process.stdout;
450
+ const jsonOut = opts.json === true || parsed.json;
451
+ const ownerSurface =
452
+ parsed.fields.owner_surface ||
453
+ preset?.ownerSurface ||
454
+ "external wrapper -> tfx cto event --event";
455
+
456
+ const input = {
457
+ ...(preset || {}),
458
+ ...parsed.fields,
459
+ actor: mergeActor(preset?.actor, parsed.fields.actor),
460
+ };
461
+ delete input.ownerSurface;
462
+ delete input.owner_surface;
463
+ if (!input.project_root) input.project_root = rootDir;
464
+ if (!input.summary) input.summary = defaultEventSummary(input);
465
+
466
+ validateRunEventInput(input);
467
+
468
+ const result = await appendCtoEvent(lakeRoot, input, {
469
+ stderr: opts.stderr,
470
+ lockRetries: opts.ledgerLockRetries,
471
+ lockRetryDelayMs: opts.ledgerLockRetryDelayMs,
472
+ });
473
+ const payload = {
474
+ appended: result.appended,
475
+ owner_surface: ownerSurface,
476
+ event: result.event,
477
+ };
478
+
479
+ if (jsonOut) {
480
+ stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
481
+ } else {
482
+ const status = result.appended ? "appended" : "skipped";
483
+ stdout.write(
484
+ `[tfx cto event] ${status} ${result.event.event} via ${ownerSurface}\n`,
485
+ );
486
+ }
487
+
488
+ return payload;
489
+ }