triflux 10.41.0 → 10.42.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/CLAUDE.md +233 -0
- package/bin/tfx-doctor.mjs +6 -1
- package/bin/tfx-live.mjs +13 -4
- package/bin/tfx-setup.mjs +6 -1
- package/bin/triflux.mjs +48 -1
- package/cto/hygiene-actions.mjs +355 -0
- package/cto/hygiene.mjs +79 -9
- package/cto/index.mjs +14 -2
- package/cto/steward.mjs +362 -0
- package/hooks/keyword-rules.json +39 -5
- package/hub/lib/cto-env.mjs +48 -0
- package/hub/lib/memory-store.mjs +9 -0
- package/hub/log-retention.mjs +726 -0
- package/hub/pipe.mjs +2 -1
- package/hub/public/tray.html +50 -53
- package/hub/router.mjs +24 -1
- package/hub/server.mjs +1 -1
- package/hub/store.mjs +15 -2
- package/hub/tools.mjs +1 -4
- package/package.json +1 -1
- package/scripts/__tests__/release-governance.test.mjs +49 -2
- package/scripts/pack.mjs +1 -0
- package/scripts/release/publish.mjs +90 -5
- package/skills/tfx-harness/SKILL.md +89 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
cpSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import {
|
|
14
|
+
basename,
|
|
15
|
+
dirname,
|
|
16
|
+
isAbsolute,
|
|
17
|
+
join,
|
|
18
|
+
relative,
|
|
19
|
+
resolve,
|
|
20
|
+
} from "node:path";
|
|
21
|
+
|
|
22
|
+
const ARCHIVABLE_ACTIONS = new Set([
|
|
23
|
+
"archive_or_resume_session",
|
|
24
|
+
"prune_superseded_checkpoint",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function shortHash(value) {
|
|
28
|
+
const str = String(value ?? "");
|
|
29
|
+
let h = 5381;
|
|
30
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
31
|
+
h = (h * 33) ^ str.charCodeAt(i);
|
|
32
|
+
}
|
|
33
|
+
return (h >>> 0).toString(36);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stewardRootHash(rootDir) {
|
|
37
|
+
return createHash("sha256")
|
|
38
|
+
.update(String(rootDir || ""))
|
|
39
|
+
.digest("hex")
|
|
40
|
+
.slice(0, 12);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hygieneKeyForRow(row) {
|
|
44
|
+
return `${row.kind}:${row.id}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function yyyymmdd(value) {
|
|
48
|
+
const date = value ? new Date(value) : new Date();
|
|
49
|
+
if (Number.isNaN(date.getTime())) return yyyymmdd(new Date().toISOString());
|
|
50
|
+
return date.toISOString().slice(0, 10).replace(/-/gu, "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function safeLabel(value) {
|
|
54
|
+
return (
|
|
55
|
+
String(value || "item")
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9._-]+/gu, "-")
|
|
58
|
+
.replace(/^-+|-+$/gu, "")
|
|
59
|
+
.slice(0, 80) || "item"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isInside(parent, child) {
|
|
64
|
+
const rel = relative(resolve(parent), resolve(child));
|
|
65
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeActiveSessions(active) {
|
|
69
|
+
const sessions = Array.isArray(active)
|
|
70
|
+
? active
|
|
71
|
+
: Array.isArray(active?.sessions)
|
|
72
|
+
? active.sessions
|
|
73
|
+
: Array.isArray(active?.live_sessions)
|
|
74
|
+
? active.live_sessions
|
|
75
|
+
: [];
|
|
76
|
+
return sessions
|
|
77
|
+
.map((session) => ({
|
|
78
|
+
sessionId: String(session?.sessionId || session?.session_id || "").trim(),
|
|
79
|
+
phase: String(
|
|
80
|
+
session?.phase || session?.status || "active",
|
|
81
|
+
).toLowerCase(),
|
|
82
|
+
}))
|
|
83
|
+
.filter((session) => session.sessionId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isLiveOwned(row, activeSessions) {
|
|
87
|
+
const rowSession = String(row?.session_id || row?.id || "").trim();
|
|
88
|
+
if (!rowSession) return false;
|
|
89
|
+
return activeSessions.some(
|
|
90
|
+
(session) => session.sessionId === rowSession && session.phase !== "stale",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertProjectHash(row, rootDir) {
|
|
95
|
+
if (!row?.project_root_hash) return;
|
|
96
|
+
const allowed = new Set([shortHash(rootDir), stewardRootHash(rootDir)]);
|
|
97
|
+
if (!allowed.has(String(row.project_root_hash))) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`project_root_hash mismatch for ${hygieneKeyForRow(row)}: ${row.project_root_hash}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function assertLakePath(lakeRoot, path, label) {
|
|
105
|
+
if (!isInside(lakeRoot, path)) {
|
|
106
|
+
throw new Error(`${label} must be under lakeRoot: ${path}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sourceBytes(path) {
|
|
111
|
+
const info = statSync(path);
|
|
112
|
+
if (info.isFile()) return info.size;
|
|
113
|
+
if (!info.isDirectory()) return info.size || 0;
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function targetPathFor({ lakeRoot, row, sourcePath, now }) {
|
|
118
|
+
const archiveDir = join(lakeRoot, "archive", yyyymmdd(now));
|
|
119
|
+
const label = safeLabel(hygieneKeyForRow(row));
|
|
120
|
+
return join(archiveDir, `${label}-${basename(sourcePath)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function hasSecondActorApproval(opts = {}) {
|
|
124
|
+
if (opts.humanAck === true || opts.humanGate === true) return true;
|
|
125
|
+
const actorSessionId = String(opts.actorSessionId || "").trim();
|
|
126
|
+
if (!actorSessionId) return false;
|
|
127
|
+
const hygieneKey = opts.hygieneKey;
|
|
128
|
+
const approvals = Array.isArray(opts.approvals) ? opts.approvals : [];
|
|
129
|
+
return approvals.some((entry) => {
|
|
130
|
+
const ref = entry?.ref && typeof entry.ref === "object" ? entry.ref : {};
|
|
131
|
+
if (ref.hygiene_key !== hygieneKey) return false;
|
|
132
|
+
const approverSession = String(ref?.actor?.session_id || "").trim();
|
|
133
|
+
return Boolean(approverSession && approverSession !== actorSessionId);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function movePath(sourcePath, targetPath, fsOps = {}) {
|
|
138
|
+
const ops = {
|
|
139
|
+
copyFileSync,
|
|
140
|
+
cpSync,
|
|
141
|
+
existsSync,
|
|
142
|
+
lstatSync,
|
|
143
|
+
mkdirSync,
|
|
144
|
+
renameSync,
|
|
145
|
+
rmSync,
|
|
146
|
+
unlinkSync,
|
|
147
|
+
...fsOps,
|
|
148
|
+
};
|
|
149
|
+
ops.mkdirSync(dirname(targetPath), { recursive: true });
|
|
150
|
+
try {
|
|
151
|
+
ops.renameSync(sourcePath, targetPath);
|
|
152
|
+
return "rename";
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error?.code !== "EXDEV") throw error;
|
|
155
|
+
const info = ops.lstatSync(sourcePath);
|
|
156
|
+
if (info.isDirectory()) {
|
|
157
|
+
ops.cpSync(sourcePath, targetPath, { recursive: true, force: false });
|
|
158
|
+
ops.rmSync(sourcePath, { recursive: true, force: false });
|
|
159
|
+
} else {
|
|
160
|
+
ops.copyFileSync(sourcePath, targetPath);
|
|
161
|
+
ops.unlinkSync(sourcePath);
|
|
162
|
+
}
|
|
163
|
+
return "copy_unlink";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function digestOperations(operations) {
|
|
168
|
+
const payload = operations.map((operation) => ({
|
|
169
|
+
hygiene_key: operation.hygiene_key,
|
|
170
|
+
status: operation.status,
|
|
171
|
+
source_path: operation.source_path,
|
|
172
|
+
target_path: operation.target_path,
|
|
173
|
+
bytes: operation.bytes,
|
|
174
|
+
reason: operation.reason,
|
|
175
|
+
}));
|
|
176
|
+
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function rowsFromProjection(projection) {
|
|
180
|
+
return Array.isArray(projection?.rows) ? projection.rows : [];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function applyHygieneArchiveActions({
|
|
184
|
+
rootDir,
|
|
185
|
+
lakeRoot,
|
|
186
|
+
projection,
|
|
187
|
+
dryRun = true,
|
|
188
|
+
now = new Date().toISOString(),
|
|
189
|
+
applyMode = "off",
|
|
190
|
+
active = {},
|
|
191
|
+
humanAck = false,
|
|
192
|
+
humanGate = false,
|
|
193
|
+
actorSessionId = null,
|
|
194
|
+
approvals = [],
|
|
195
|
+
fsOps = {},
|
|
196
|
+
} = {}) {
|
|
197
|
+
const archiveRoot = join(lakeRoot, "archive", yyyymmdd(now));
|
|
198
|
+
assertLakePath(lakeRoot, archiveRoot, "archive target");
|
|
199
|
+
|
|
200
|
+
const activeSessions = normalizeActiveSessions(active);
|
|
201
|
+
const operations = [];
|
|
202
|
+
|
|
203
|
+
for (const row of rowsFromProjection(projection)) {
|
|
204
|
+
const hygieneKey = hygieneKeyForRow(row);
|
|
205
|
+
if (row?.kind === "worktree" && row?.status === "orphaned") {
|
|
206
|
+
operations.push({
|
|
207
|
+
hygiene_key: hygieneKey,
|
|
208
|
+
kind: row.kind,
|
|
209
|
+
id: row.id,
|
|
210
|
+
action: row.action,
|
|
211
|
+
status: "blocked",
|
|
212
|
+
reason: "orphan_worktree_requires_human",
|
|
213
|
+
bytes: 0,
|
|
214
|
+
});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!ARCHIVABLE_ACTIONS.has(row?.action)) continue;
|
|
219
|
+
|
|
220
|
+
assertProjectHash(row, rootDir);
|
|
221
|
+
|
|
222
|
+
if (isLiveOwned(row, activeSessions)) {
|
|
223
|
+
operations.push({
|
|
224
|
+
hygiene_key: hygieneKey,
|
|
225
|
+
kind: row.kind,
|
|
226
|
+
id: row.id,
|
|
227
|
+
action: row.action,
|
|
228
|
+
status: "skipped",
|
|
229
|
+
reason: "live_session_owned",
|
|
230
|
+
bytes: 0,
|
|
231
|
+
});
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const sourcePath = row.artifact_path ? resolve(row.artifact_path) : null;
|
|
236
|
+
if (!sourcePath) {
|
|
237
|
+
operations.push({
|
|
238
|
+
hygiene_key: hygieneKey,
|
|
239
|
+
kind: row.kind,
|
|
240
|
+
id: row.id,
|
|
241
|
+
action: row.action,
|
|
242
|
+
status: "skipped",
|
|
243
|
+
reason: "missing_artifact_path",
|
|
244
|
+
bytes: 0,
|
|
245
|
+
});
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// lake 밖 산출물은 자동 이동 대상이 아니다 — 크래시 대신 skip으로 보고한다.
|
|
249
|
+
// (checkpoint 등 실제 산출물은 대부분 lake 밖에 있으므로 throw 하면 apply 전체가 죽는다.)
|
|
250
|
+
if (!isInside(lakeRoot, sourcePath)) {
|
|
251
|
+
operations.push({
|
|
252
|
+
hygiene_key: hygieneKey,
|
|
253
|
+
kind: row.kind,
|
|
254
|
+
id: row.id,
|
|
255
|
+
action: row.action,
|
|
256
|
+
source_path: sourcePath,
|
|
257
|
+
status: "skipped",
|
|
258
|
+
reason: "source_outside_lake",
|
|
259
|
+
bytes: 0,
|
|
260
|
+
});
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const targetPath = targetPathFor({ lakeRoot, row, sourcePath, now });
|
|
265
|
+
assertLakePath(lakeRoot, targetPath, "archive target");
|
|
266
|
+
|
|
267
|
+
if (!existsSync(sourcePath)) {
|
|
268
|
+
operations.push({
|
|
269
|
+
hygiene_key: hygieneKey,
|
|
270
|
+
kind: row.kind,
|
|
271
|
+
id: row.id,
|
|
272
|
+
action: row.action,
|
|
273
|
+
source_path: sourcePath,
|
|
274
|
+
target_path: targetPath,
|
|
275
|
+
status: existsSync(targetPath) ? "already_archived" : "skipped",
|
|
276
|
+
reason: existsSync(targetPath) ? undefined : "source_missing",
|
|
277
|
+
bytes: 0,
|
|
278
|
+
});
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
operations.push({
|
|
283
|
+
hygiene_key: hygieneKey,
|
|
284
|
+
kind: row.kind,
|
|
285
|
+
id: row.id,
|
|
286
|
+
action: row.action,
|
|
287
|
+
source_path: sourcePath,
|
|
288
|
+
target_path: targetPath,
|
|
289
|
+
status: "planned",
|
|
290
|
+
bytes: sourceBytes(sourcePath),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!dryRun) {
|
|
295
|
+
for (const operation of operations) {
|
|
296
|
+
if (operation.status !== "planned") continue;
|
|
297
|
+
if (applyMode !== "archive") {
|
|
298
|
+
operation.status = "blocked";
|
|
299
|
+
operation.reason = "apply_mode_off";
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (
|
|
303
|
+
!hasSecondActorApproval({
|
|
304
|
+
humanAck,
|
|
305
|
+
humanGate,
|
|
306
|
+
actorSessionId,
|
|
307
|
+
approvals,
|
|
308
|
+
hygieneKey: operation.hygiene_key,
|
|
309
|
+
})
|
|
310
|
+
) {
|
|
311
|
+
operation.status = "blocked";
|
|
312
|
+
operation.reason = "second_actor_ack_required";
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
operation.move_method = movePath(
|
|
316
|
+
operation.source_path,
|
|
317
|
+
operation.target_path,
|
|
318
|
+
fsOps,
|
|
319
|
+
);
|
|
320
|
+
operation.status = "archived";
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const plannedCount = operations.filter((operation) =>
|
|
325
|
+
["planned", "archived", "already_archived"].includes(operation.status),
|
|
326
|
+
).length;
|
|
327
|
+
const appliedCount = operations.filter(
|
|
328
|
+
(operation) => operation.status === "archived",
|
|
329
|
+
).length;
|
|
330
|
+
const blockedCount = operations.filter(
|
|
331
|
+
(operation) => operation.status === "blocked",
|
|
332
|
+
).length;
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
schema_version: "cto-hygiene-actions.v1",
|
|
336
|
+
dry_run: Boolean(dryRun),
|
|
337
|
+
apply_mode: applyMode,
|
|
338
|
+
archive_root: archiveRoot,
|
|
339
|
+
planned_count: plannedCount,
|
|
340
|
+
applied_count: appliedCount,
|
|
341
|
+
blocked_count: blockedCount,
|
|
342
|
+
total_bytes: operations.reduce(
|
|
343
|
+
(sum, operation) => sum + Number(operation.bytes || 0),
|
|
344
|
+
0,
|
|
345
|
+
),
|
|
346
|
+
digest: digestOperations(operations),
|
|
347
|
+
live_safety: {
|
|
348
|
+
active_session_count: activeSessions.length,
|
|
349
|
+
skipped_live_count: operations.filter(
|
|
350
|
+
(operation) => operation.reason === "live_session_owned",
|
|
351
|
+
).length,
|
|
352
|
+
},
|
|
353
|
+
operations,
|
|
354
|
+
};
|
|
355
|
+
}
|
package/cto/hygiene.mjs
CHANGED
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
} from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { basename, dirname, join } from "node:path";
|
|
14
|
-
|
|
14
|
+
import { getCtoHygieneApplyMode } from "../hub/lib/cto-env.mjs";
|
|
15
15
|
import { appendCtoEvent } from "./events.mjs";
|
|
16
|
+
import { applyHygieneArchiveActions } from "./hygiene-actions.mjs";
|
|
16
17
|
import { resolveLakeRootDir } from "./lake-root.mjs";
|
|
17
18
|
|
|
18
19
|
const COUNT_KEYS = [
|
|
@@ -178,6 +179,18 @@ function rowFrom(kind, id, status, entry, ref, action) {
|
|
|
178
179
|
if (entry?.summary) row.summary = String(entry.summary);
|
|
179
180
|
if (action) row.action = action;
|
|
180
181
|
if (action && !hasKnownOwner(ref)) row.owner = "unknown";
|
|
182
|
+
if (typeof ref?.artifact_path === "string" && ref.artifact_path.trim()) {
|
|
183
|
+
row.artifact_path = ref.artifact_path.trim();
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
typeof ref?.project_root_hash === "string" &&
|
|
187
|
+
ref.project_root_hash.trim()
|
|
188
|
+
) {
|
|
189
|
+
row.project_root_hash = ref.project_root_hash.trim();
|
|
190
|
+
}
|
|
191
|
+
if (typeof ref?.session_id === "string" && ref.session_id.trim()) {
|
|
192
|
+
row.session_id = ref.session_id.trim();
|
|
193
|
+
}
|
|
181
194
|
return row;
|
|
182
195
|
}
|
|
183
196
|
|
|
@@ -517,17 +530,52 @@ function isApplicableRow(row) {
|
|
|
517
530
|
return Boolean(row?.action) || APPLICABLE_STATUSES.has(row?.status);
|
|
518
531
|
}
|
|
519
532
|
|
|
533
|
+
const ARCHIVE_ACK_STATUSES = new Set(["archived", "already_archived"]);
|
|
534
|
+
|
|
535
|
+
function hygieneApplyActor(opts = {}) {
|
|
536
|
+
const actor = { cli: "tfx cto hygiene --apply" };
|
|
537
|
+
if (typeof opts.actorSessionId === "string" && opts.actorSessionId.trim()) {
|
|
538
|
+
actor.session_id = opts.actorSessionId.trim();
|
|
539
|
+
}
|
|
540
|
+
return actor;
|
|
541
|
+
}
|
|
542
|
+
|
|
520
543
|
async function applyHygieneRows({
|
|
521
544
|
rootDir,
|
|
522
545
|
lakeRoot,
|
|
523
546
|
projection,
|
|
524
547
|
steward,
|
|
548
|
+
active,
|
|
549
|
+
approvals,
|
|
525
550
|
opts,
|
|
526
551
|
}) {
|
|
527
552
|
const rows = projection.rows.filter(isApplicableRow);
|
|
528
553
|
const appended = [];
|
|
529
554
|
const now = opts.now || new Date().toISOString();
|
|
555
|
+
const actions = await applyHygieneArchiveActions({
|
|
556
|
+
rootDir,
|
|
557
|
+
lakeRoot,
|
|
558
|
+
projection: { ...projection, rows },
|
|
559
|
+
dryRun: false,
|
|
560
|
+
now,
|
|
561
|
+
applyMode: opts.applyMode || getCtoHygieneApplyMode(),
|
|
562
|
+
active,
|
|
563
|
+
humanAck: opts.humanAck,
|
|
564
|
+
humanGate: opts.humanGate,
|
|
565
|
+
actorSessionId: opts.actorSessionId,
|
|
566
|
+
approvals,
|
|
567
|
+
fsOps: opts.fsOps,
|
|
568
|
+
});
|
|
569
|
+
const operationByKey = new Map(
|
|
570
|
+
(actions.operations || []).map((operation) => [
|
|
571
|
+
operation.hygiene_key,
|
|
572
|
+
operation,
|
|
573
|
+
]),
|
|
574
|
+
);
|
|
530
575
|
for (const row of rows) {
|
|
576
|
+
const hygieneKey = hygieneKeyForRow(row);
|
|
577
|
+
const operation = operationByKey.get(hygieneKey);
|
|
578
|
+
if (operation && !ARCHIVE_ACK_STATUSES.has(operation.status)) continue;
|
|
531
579
|
const result = await appendCtoEvent(
|
|
532
580
|
lakeRoot,
|
|
533
581
|
{
|
|
@@ -536,12 +584,12 @@ async function applyHygieneRows({
|
|
|
536
584
|
source: "tfx_cto_hygiene_apply",
|
|
537
585
|
project_root: rootDir,
|
|
538
586
|
status: row.status,
|
|
539
|
-
hygiene_key:
|
|
587
|
+
hygiene_key: hygieneKey,
|
|
540
588
|
hygiene_kind: row.kind,
|
|
541
589
|
hygiene_id: row.id,
|
|
542
590
|
hygiene_action: row.action || `acknowledge_${row.status}`,
|
|
543
591
|
summary: `hygiene apply ${row.kind}:${row.id} ${row.status}`,
|
|
544
|
-
actor:
|
|
592
|
+
actor: hygieneApplyActor(opts),
|
|
545
593
|
},
|
|
546
594
|
{
|
|
547
595
|
stderr: opts.stderr,
|
|
@@ -559,6 +607,7 @@ async function applyHygieneRows({
|
|
|
559
607
|
},
|
|
560
608
|
applicable_count: rows.length,
|
|
561
609
|
applied_count: appended.length,
|
|
610
|
+
actions,
|
|
562
611
|
events: appended,
|
|
563
612
|
};
|
|
564
613
|
}
|
|
@@ -582,15 +631,21 @@ export async function runHygiene(args = [], opts = {}) {
|
|
|
582
631
|
const current = existsSync(currentPath) ? readJson(currentPath) : {};
|
|
583
632
|
const ledger = readJsonLines(join(lakeRoot, "ledger.jsonl"));
|
|
584
633
|
const overlay = await readLiveOverlay({ ...opts, rootDir, lakeRoot });
|
|
585
|
-
return
|
|
586
|
-
|
|
587
|
-
ledger: ledger.length > 0 ? ledger : null,
|
|
634
|
+
return {
|
|
635
|
+
ledger,
|
|
588
636
|
overlay,
|
|
589
|
-
|
|
637
|
+
projection: projectCtoHygiene({
|
|
638
|
+
current,
|
|
639
|
+
ledger: ledger.length > 0 ? ledger : null,
|
|
640
|
+
overlay,
|
|
641
|
+
}),
|
|
642
|
+
};
|
|
590
643
|
};
|
|
591
644
|
|
|
592
645
|
let steward = null;
|
|
593
646
|
let projection;
|
|
647
|
+
let overlay = { live_sessions: [] };
|
|
648
|
+
let ledger = [];
|
|
594
649
|
let applyResult = null;
|
|
595
650
|
if (apply) {
|
|
596
651
|
steward = await acquireCtoHygieneStewardLock(rootDir, lakeRoot, {
|
|
@@ -600,12 +655,14 @@ export async function runHygiene(args = [], opts = {}) {
|
|
|
600
655
|
lockPath: opts.stewardLockPath,
|
|
601
656
|
});
|
|
602
657
|
try {
|
|
603
|
-
projection = await buildProjection();
|
|
658
|
+
({ projection, overlay, ledger } = await buildProjection());
|
|
604
659
|
applyResult = await applyHygieneRows({
|
|
605
660
|
rootDir,
|
|
606
661
|
lakeRoot,
|
|
607
662
|
projection,
|
|
608
663
|
steward,
|
|
664
|
+
active: overlay,
|
|
665
|
+
approvals: ledger.filter((entry) => entry?.event === "hygiene_applied"),
|
|
609
666
|
opts,
|
|
610
667
|
});
|
|
611
668
|
projection = { ...projection, dry_run: false, apply: applyResult };
|
|
@@ -613,7 +670,20 @@ export async function runHygiene(args = [], opts = {}) {
|
|
|
613
670
|
steward.release();
|
|
614
671
|
}
|
|
615
672
|
} else {
|
|
616
|
-
projection = await buildProjection();
|
|
673
|
+
({ projection, overlay } = await buildProjection());
|
|
674
|
+
projection = {
|
|
675
|
+
...projection,
|
|
676
|
+
actions: await applyHygieneArchiveActions({
|
|
677
|
+
rootDir,
|
|
678
|
+
lakeRoot,
|
|
679
|
+
projection,
|
|
680
|
+
dryRun: true,
|
|
681
|
+
now: opts.now || new Date().toISOString(),
|
|
682
|
+
applyMode: opts.applyMode || getCtoHygieneApplyMode(),
|
|
683
|
+
active: overlay,
|
|
684
|
+
fsOps: opts.fsOps,
|
|
685
|
+
}),
|
|
686
|
+
};
|
|
617
687
|
}
|
|
618
688
|
|
|
619
689
|
if (jsonOut) writeJson(stdout, projection);
|
package/cto/index.mjs
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
const SUBCOMMANDS = [
|
|
1
|
+
const SUBCOMMANDS = [
|
|
2
|
+
"collect",
|
|
3
|
+
"status",
|
|
4
|
+
"dashboard",
|
|
5
|
+
"hygiene",
|
|
6
|
+
"steward",
|
|
7
|
+
"event",
|
|
8
|
+
];
|
|
2
9
|
|
|
3
10
|
function printUsage(subcommand) {
|
|
4
11
|
if (subcommand) {
|
|
@@ -6,13 +13,14 @@ function printUsage(subcommand) {
|
|
|
6
13
|
}
|
|
7
14
|
console.log(`
|
|
8
15
|
Usage
|
|
9
|
-
tfx cto <collect|status|dashboard|hygiene|event> [options]
|
|
16
|
+
tfx cto <collect|status|dashboard|hygiene|steward|event> [options]
|
|
10
17
|
|
|
11
18
|
Subcommands
|
|
12
19
|
collect Refresh .triflux/lake/current.json from repo-local authority sources
|
|
13
20
|
status Print the current authority summary
|
|
14
21
|
dashboard Render the CTO console dashboard, optionally with --watch
|
|
15
22
|
hygiene Project CTO hygiene counts and actionable dry-run rows
|
|
23
|
+
steward Periodically collect and run one-shot CTO hygiene (TFX_CTO=0 disables)
|
|
16
24
|
event Append normalized wrapper lifecycle events to the CTO ledger
|
|
17
25
|
`);
|
|
18
26
|
}
|
|
@@ -37,6 +45,10 @@ export async function cmdCto(cmdArgs, opts = {}) {
|
|
|
37
45
|
const { runHygiene } = await import("./hygiene.mjs");
|
|
38
46
|
return runHygiene(rest, opts);
|
|
39
47
|
}
|
|
48
|
+
case "steward": {
|
|
49
|
+
const { runSteward } = await import("./steward.mjs");
|
|
50
|
+
return runSteward(rest, opts);
|
|
51
|
+
}
|
|
40
52
|
case "event": {
|
|
41
53
|
const { runEvent } = await import("./events.mjs");
|
|
42
54
|
return runEvent(rest, opts);
|