@triflux/remote 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/cto/events.mjs +489 -0
- package/cto/hygiene-notify.mjs +170 -0
- package/cto/hygiene.mjs +629 -0
- package/cto/lake-root.mjs +47 -6
- package/cto/status.mjs +4 -0
- package/hub/public/tray.html +36 -0
- 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/package.json +1 -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/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
|
+
}
|
|
@@ -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
|
+
}
|