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.
- package/bin/tfx-live.mjs +61 -1
- package/bin/triflux.mjs +6 -1
- package/cto/dashboard.mjs +60 -0
- package/cto/events.mjs +489 -0
- package/cto/hygiene-notify.mjs +170 -0
- package/cto/hygiene.mjs +629 -0
- package/cto/index.mjs +12 -2
- package/cto/lake-root.mjs +47 -6
- package/cto/status.mjs +4 -0
- package/hooks/agy-session-hook.mjs +89 -29
- 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/hooks/subagent-tracker.mjs +16 -2
- package/hub/public/tray.html +36 -0
- package/hub/routing/q-learning.mjs +5 -2
- package/hub/team/claude-daemon-control.mjs +42 -9
- package/hub/team/conductor.mjs +54 -0
- package/hub/team/execution-mode.mjs +15 -1
- package/hub/team/notify.mjs +34 -11
- 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/providers/gemini.mjs +116 -31
- package/hud/renderers.mjs +2 -2
- package/package.json +1 -1
- package/scripts/__tests__/mcp-guard-engine.test.mjs +146 -0
- package/scripts/check-codex-config-stable.mjs +38 -11
- 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/test-lock.mjs +34 -29
- 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,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
|
+
}
|