triflux 10.41.1 → 10.43.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-live.mjs +3 -1
- package/bin/triflux.mjs +48 -1
- package/config/routing-policy.json +1 -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/hooks/pipeline-stop.mjs +1 -1
- package/hub/bridge.mjs +80 -1
- package/hub/cli-adapter-base.mjs +100 -19
- package/hub/codex-adapter.mjs +5 -1
- package/hub/dynamic-routing-engine.mjs +1 -1
- package/hub/gemini-adapter.mjs +3 -1
- package/hub/intent.mjs +13 -9
- package/hub/lib/cto-env.mjs +94 -0
- package/hub/lib/memory-store.mjs +34 -13
- package/hub/lib/timeout-defaults.mjs +45 -0
- package/hub/lib/worker-lifecycle.mjs +101 -0
- package/hub/log-retention.mjs +726 -0
- package/hub/middleware/request-logger.mjs +4 -1
- package/hub/pipe.mjs +11 -1
- package/hub/public/tray.html +50 -53
- package/hub/role-contract.mjs +314 -0
- package/hub/role-control-events.mjs +113 -0
- package/hub/router.mjs +135 -30
- package/hub/schema.sql +1 -0
- package/hub/server.mjs +89 -44
- package/hub/store.mjs +50 -19
- package/hub/team/claude-daemon-control.mjs +28 -15
- package/hub/team/cli/commands/start/parse-args.mjs +2 -2
- package/hub/team/headless.mjs +295 -24
- package/hub/team/intervention.mjs +530 -0
- package/hub/team/retry-state-machine.mjs +6 -2
- package/hub/team/swarm-hypervisor.mjs +20 -2
- package/hub/team/swarm-locks.mjs +17 -1
- package/hub/team/uds-orchestrator.mjs +62 -9
- package/hub/tools.mjs +9 -7
- package/hub/workers/codex-app-server-worker.mjs +54 -5
- package/hub/workers/codex-mcp.mjs +1 -1
- package/hub/workers/delegator-mcp.mjs +18 -18
- package/hud/constants.mjs +21 -0
- package/hud/context-monitor.mjs +18 -21
- package/hud/hud-qos-status.mjs +1 -1
- package/hud/providers/codex-probe.mjs +216 -0
- package/hud/providers/codex.mjs +206 -29
- package/hud/renderers.mjs +6 -9
- package/package.json +1 -1
- package/scripts/__tests__/tfx-route-bash-node-parity.test.mjs +1 -1
- package/scripts/__tests__/tfx-route-node-entry.test.mjs +6 -6
- package/scripts/headless-guard.mjs +70 -23
- package/scripts/lib/cli-agy.mjs +1 -0
- package/scripts/lib/cli-claude.mjs +1 -0
- package/scripts/lib/cli-codex.mjs +22 -21
- package/scripts/lib/codex-profile-config.mjs +2 -2
- package/scripts/pack.mjs +1 -0
- package/scripts/setup.mjs +15 -11
- package/scripts/tfx-gate-activate.mjs +1 -1
- package/scripts/tfx-route-worker.mjs +5 -0
- package/scripts/tfx-route.sh +136 -84
- package/skills/tfx-analysis/SKILL.md +3 -1
- package/skills/tfx-harness/SKILL.md +89 -0
- package/skills/tfx-hub/SKILL.md +1 -1
- package/skills/tfx-plan/SKILL.md +3 -1
- package/skills/tfx-profile/SKILL.md +9 -9
- package/skills/tfx-prune/SKILL.md +4 -2
- package/skills/tfx-qa/SKILL.md +6 -2
- package/skills/tfx-research/SKILL.md +3 -1
- package/skills/tfx-review/SKILL.md +5 -3
- package/skills/tfx-setup/SKILL.md +1 -1
- package/tui/codex-profile.mjs +3 -1
- package/tui/setup.mjs +4 -4
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);
|
package/cto/steward.mjs
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// cto/steward.mjs — collect + hygiene 주기 루프 (resident-cto-manager PRD T2 선행 슬라이스)
|
|
2
|
+
// 가드레일: TFX_CTO kill-switch 매 cycle 확인 + watch 루프는 lake당 단일 인스턴스 락.
|
|
3
|
+
import { utimesSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { isCtoDisabled } from "../hub/lib/cto-env.mjs";
|
|
7
|
+
import { runCollect } from "./collect.mjs";
|
|
8
|
+
import { acquireCtoHygieneStewardLock, runHygiene } from "./hygiene.mjs";
|
|
9
|
+
import { resolveLakeRootDir } from "./lake-root.mjs";
|
|
10
|
+
|
|
11
|
+
const SCHEMA_VERSION = "cto-steward.v1";
|
|
12
|
+
const DEFAULT_INTERVAL_MS = 60_000;
|
|
13
|
+
const LOOP_LOCK_STALE_MS = 10 * 60_000;
|
|
14
|
+
const KNOWN_FLAGS_WITH_VALUES = new Set(["--max-runs", "--interval-ms"]);
|
|
15
|
+
const KNOWN_BOOLEAN_FLAGS = new Set([
|
|
16
|
+
"--watch",
|
|
17
|
+
"--apply",
|
|
18
|
+
"--dry-run",
|
|
19
|
+
"--json",
|
|
20
|
+
"--no-collect",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function writeJson(stdout, payload) {
|
|
24
|
+
stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parsePositiveInteger(name, value) {
|
|
28
|
+
if (value === undefined || String(value).startsWith("--")) {
|
|
29
|
+
throw new Error(`tfx cto steward ${name} requires a value`);
|
|
30
|
+
}
|
|
31
|
+
if (!/^\d+$/u.test(String(value))) {
|
|
32
|
+
throw new Error(`tfx cto steward ${name} must be a positive integer`);
|
|
33
|
+
}
|
|
34
|
+
const parsed = Number(value);
|
|
35
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
36
|
+
throw new Error(`tfx cto steward ${name} must be a positive integer`);
|
|
37
|
+
}
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseNonNegativeInteger(name, value) {
|
|
42
|
+
if (value === undefined || String(value).startsWith("--")) {
|
|
43
|
+
throw new Error(`tfx cto steward ${name} requires a value`);
|
|
44
|
+
}
|
|
45
|
+
if (!/^\d+$/u.test(String(value))) {
|
|
46
|
+
throw new Error(`tfx cto steward ${name} must be a non-negative integer`);
|
|
47
|
+
}
|
|
48
|
+
const parsed = Number(value);
|
|
49
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
50
|
+
throw new Error(`tfx cto steward ${name} must be a non-negative integer`);
|
|
51
|
+
}
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseStewardArgs(args = []) {
|
|
56
|
+
const parsed = {
|
|
57
|
+
watch: false,
|
|
58
|
+
maxRuns: null,
|
|
59
|
+
intervalMs: DEFAULT_INTERVAL_MS,
|
|
60
|
+
apply: false,
|
|
61
|
+
dryRun: false,
|
|
62
|
+
json: false,
|
|
63
|
+
collectEnabled: true,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
67
|
+
const arg = args[index];
|
|
68
|
+
if (KNOWN_FLAGS_WITH_VALUES.has(arg)) {
|
|
69
|
+
const value = args[index + 1];
|
|
70
|
+
if (arg === "--max-runs")
|
|
71
|
+
parsed.maxRuns = parsePositiveInteger(arg, value);
|
|
72
|
+
else parsed.intervalMs = parseNonNegativeInteger(arg, value);
|
|
73
|
+
index += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (KNOWN_BOOLEAN_FLAGS.has(arg)) {
|
|
77
|
+
if (arg === "--watch") parsed.watch = true;
|
|
78
|
+
else if (arg === "--apply") parsed.apply = true;
|
|
79
|
+
else if (arg === "--dry-run") parsed.dryRun = true;
|
|
80
|
+
else if (arg === "--json") parsed.json = true;
|
|
81
|
+
else if (arg === "--no-collect") parsed.collectEnabled = false;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`unknown tfx cto steward flag: ${arg}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (parsed.apply && parsed.dryRun) {
|
|
88
|
+
throw new Error("tfx cto steward accepts only one of --apply or --dry-run");
|
|
89
|
+
}
|
|
90
|
+
return parsed;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function createSilentStdout() {
|
|
94
|
+
return {
|
|
95
|
+
write() {
|
|
96
|
+
return true;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function compactRun(index, collectResult, hygieneResult) {
|
|
102
|
+
return {
|
|
103
|
+
index,
|
|
104
|
+
collected: collectResult !== null,
|
|
105
|
+
collect: collectResult,
|
|
106
|
+
hygiene: hygieneResult,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isAbortError(error) {
|
|
111
|
+
return error?.name === "AbortError" || error?.code === "ABORT_ERR";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function stewardLoopLockPath(lakeRoot) {
|
|
115
|
+
return join(lakeRoot, "stewards", "steward-loop.lock");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function runSteward(args = [], opts = {}) {
|
|
119
|
+
const parsed = parseStewardArgs(args);
|
|
120
|
+
const jsonRequested = parsed.json || opts.json === true;
|
|
121
|
+
if (parsed.watch && parsed.maxRuns === null && jsonRequested) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
"tfx cto steward --watch --json requires --max-runs for a finite JSON summary",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (!parsed.watch && parsed.maxRuns !== null) {
|
|
127
|
+
(opts.stderr || process.stderr).write(
|
|
128
|
+
"tfx cto steward: --max-runs is ignored without --watch\n",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// steward 전용 opts 는 여기서 걸러내고, 나머지(overlay, synapseReader 등)만
|
|
133
|
+
// collect/hygiene 로 전달한다.
|
|
134
|
+
const {
|
|
135
|
+
collectFn: injectedCollectFn,
|
|
136
|
+
hygieneFn: injectedHygieneFn,
|
|
137
|
+
sleepFn: injectedSleepFn,
|
|
138
|
+
onRun,
|
|
139
|
+
json: _jsonOpt,
|
|
140
|
+
stdout: injectedStdout,
|
|
141
|
+
stderr: _stderrOpt,
|
|
142
|
+
signal: externalSignal,
|
|
143
|
+
isCtoDisabledFn: injectedGate,
|
|
144
|
+
acquireLoopLockFn: injectedAcquireLoopLock,
|
|
145
|
+
installSignalHandler,
|
|
146
|
+
rootDir: _rootDirOpt,
|
|
147
|
+
lakeRoot: _lakeRootOpt,
|
|
148
|
+
...innerOpts
|
|
149
|
+
} = opts;
|
|
150
|
+
|
|
151
|
+
const rootDir = opts.rootDir || resolveLakeRootDir(process.cwd());
|
|
152
|
+
const lakeRoot = opts.lakeRoot || join(rootDir, ".triflux", "lake");
|
|
153
|
+
const stdout = injectedStdout || process.stdout;
|
|
154
|
+
const collectFn = injectedCollectFn || runCollect;
|
|
155
|
+
const hygieneFn = injectedHygieneFn || runHygiene;
|
|
156
|
+
const ctoDisabled = injectedGate || isCtoDisabled;
|
|
157
|
+
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
const signal = controller.signal;
|
|
160
|
+
if (externalSignal) {
|
|
161
|
+
if (externalSignal.aborted) controller.abort();
|
|
162
|
+
else
|
|
163
|
+
externalSignal.addEventListener("abort", () => controller.abort(), {
|
|
164
|
+
once: true,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const sleepFn =
|
|
169
|
+
injectedSleepFn ||
|
|
170
|
+
((ms) =>
|
|
171
|
+
new Promise((resolve) => {
|
|
172
|
+
if (signal.aborted) {
|
|
173
|
+
resolve();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const timer = setTimeout(resolve, ms);
|
|
177
|
+
signal.addEventListener(
|
|
178
|
+
"abort",
|
|
179
|
+
() => {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
resolve();
|
|
182
|
+
},
|
|
183
|
+
{ once: true },
|
|
184
|
+
);
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
const mode = parsed.apply ? "apply" : "dry-run";
|
|
188
|
+
const silentStdout = createSilentStdout();
|
|
189
|
+
const runLimit = parsed.watch ? parsed.maxRuns : 1;
|
|
190
|
+
const retainRuns = runLimit !== null;
|
|
191
|
+
const runs = [];
|
|
192
|
+
let latestRun = null;
|
|
193
|
+
let lastCollect = null;
|
|
194
|
+
let lastHygiene = null;
|
|
195
|
+
let runCount = 0;
|
|
196
|
+
let stoppedReason = null;
|
|
197
|
+
|
|
198
|
+
// 가드레일 1 (PRD kill-switch): 루프 진입 전에도, 매 cycle에도 확인한다.
|
|
199
|
+
if (ctoDisabled()) stoppedReason = "disabled";
|
|
200
|
+
|
|
201
|
+
// 가드레일 2 (PRD single-instance): watch 루프는 lake당 하나만 돈다.
|
|
202
|
+
const loopLockPath = stewardLoopLockPath(lakeRoot);
|
|
203
|
+
const loopLockStaleMs = Math.max(LOOP_LOCK_STALE_MS, parsed.intervalMs * 3);
|
|
204
|
+
let loopLock = null;
|
|
205
|
+
let loopLockHeartbeat = null;
|
|
206
|
+
if (!stoppedReason && parsed.watch) {
|
|
207
|
+
const acquireLock =
|
|
208
|
+
injectedAcquireLoopLock ||
|
|
209
|
+
((lockPath) =>
|
|
210
|
+
acquireCtoHygieneStewardLock(rootDir, lakeRoot, {
|
|
211
|
+
lockPath,
|
|
212
|
+
timeoutMs: 0,
|
|
213
|
+
staleMs: loopLockStaleMs,
|
|
214
|
+
}));
|
|
215
|
+
loopLock = await acquireLock(loopLockPath);
|
|
216
|
+
// 한 cycle이 staleMs보다 길어져도 다른 인스턴스가 락을 stale로 오판해
|
|
217
|
+
// 훔치지 못하도록, 작업 진행 중에도 주기적으로 mtime을 갱신한다.
|
|
218
|
+
if (loopLock?.path) {
|
|
219
|
+
const heartbeatMs = Math.max(30_000, Math.floor(loopLockStaleMs / 3));
|
|
220
|
+
loopLockHeartbeat = setInterval(() => {
|
|
221
|
+
try {
|
|
222
|
+
const touch = new Date();
|
|
223
|
+
utimesSync(loopLock.path, touch, touch);
|
|
224
|
+
} catch {}
|
|
225
|
+
}, heartbeatMs);
|
|
226
|
+
loopLockHeartbeat.unref?.();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let sigintHandler = null;
|
|
231
|
+
if (!stoppedReason && parsed.watch && installSignalHandler !== false) {
|
|
232
|
+
sigintHandler = () => controller.abort();
|
|
233
|
+
process.once("SIGINT", sigintHandler);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
while (!stoppedReason && (runLimit === null || runCount < runLimit)) {
|
|
238
|
+
if (ctoDisabled()) {
|
|
239
|
+
stoppedReason = "disabled";
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
if (signal.aborted) {
|
|
243
|
+
stoppedReason = "signal";
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const runIndex = runCount + 1;
|
|
248
|
+
let collectResult = null;
|
|
249
|
+
|
|
250
|
+
if (parsed.collectEnabled) {
|
|
251
|
+
collectResult = await collectFn([], {
|
|
252
|
+
...innerOpts,
|
|
253
|
+
rootDir,
|
|
254
|
+
lakeRoot,
|
|
255
|
+
stdout: silentStdout,
|
|
256
|
+
});
|
|
257
|
+
lastCollect = collectResult;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// collect 도중 중단/kill-switch가 들어왔으면 이번 cycle의 hygiene(특히
|
|
261
|
+
// apply)을 시작하지 않는다 — 중단 요청 이후의 fs-mutation 금지.
|
|
262
|
+
if (ctoDisabled()) {
|
|
263
|
+
stoppedReason = "disabled";
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
if (signal.aborted) {
|
|
267
|
+
stoppedReason = "signal";
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const hygieneArgs = parsed.apply
|
|
272
|
+
? ["--apply", "--json"]
|
|
273
|
+
: ["--dry-run", "--json"];
|
|
274
|
+
const hygieneResult = await hygieneFn(hygieneArgs, {
|
|
275
|
+
...innerOpts,
|
|
276
|
+
rootDir,
|
|
277
|
+
lakeRoot,
|
|
278
|
+
stdout: silentStdout,
|
|
279
|
+
apply: parsed.apply,
|
|
280
|
+
dryRun: !parsed.apply,
|
|
281
|
+
json: true,
|
|
282
|
+
});
|
|
283
|
+
lastHygiene = hygieneResult;
|
|
284
|
+
|
|
285
|
+
const run = compactRun(runIndex, collectResult, hygieneResult);
|
|
286
|
+
latestRun = run;
|
|
287
|
+
if (retainRuns) runs.push(run);
|
|
288
|
+
runCount += 1;
|
|
289
|
+
|
|
290
|
+
if (parsed.watch && !jsonRequested) {
|
|
291
|
+
stdout.write(
|
|
292
|
+
`cto steward run ${runIndex} (${mode}): collect=${
|
|
293
|
+
collectResult !== null ? "ok" : "skipped"
|
|
294
|
+
} hygiene=ok\n`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
if (typeof onRun === "function") {
|
|
298
|
+
await onRun(run, {
|
|
299
|
+
run_count: runCount,
|
|
300
|
+
rootDir,
|
|
301
|
+
lakeRoot,
|
|
302
|
+
mode,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!parsed.watch) break;
|
|
307
|
+
if (runLimit !== null && runCount >= runLimit) break;
|
|
308
|
+
try {
|
|
309
|
+
await sleepFn(parsed.intervalMs);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (isAbortError(error)) {
|
|
312
|
+
stoppedReason = "signal";
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
if (signal.aborted) {
|
|
318
|
+
stoppedReason = "signal";
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} finally {
|
|
323
|
+
if (sigintHandler) process.off("SIGINT", sigintHandler);
|
|
324
|
+
if (loopLockHeartbeat) clearInterval(loopLockHeartbeat);
|
|
325
|
+
loopLock?.release?.();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!stoppedReason) stoppedReason = parsed.watch ? "max-runs" : "single-run";
|
|
329
|
+
|
|
330
|
+
const summary = {
|
|
331
|
+
schema_version: SCHEMA_VERSION,
|
|
332
|
+
mode,
|
|
333
|
+
watch: parsed.watch,
|
|
334
|
+
interval_ms: parsed.intervalMs,
|
|
335
|
+
max_runs: parsed.maxRuns,
|
|
336
|
+
collect_enabled: parsed.collectEnabled,
|
|
337
|
+
disabled: stoppedReason === "disabled",
|
|
338
|
+
stopped_reason: stoppedReason,
|
|
339
|
+
loop_lock_path: parsed.watch ? loopLockPath : null,
|
|
340
|
+
run_count: runCount,
|
|
341
|
+
retained_run_count: runs.length,
|
|
342
|
+
runs,
|
|
343
|
+
latest_run: latestRun,
|
|
344
|
+
last_collect: lastCollect,
|
|
345
|
+
last_hygiene: lastHygiene,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
if (jsonRequested) writeJson(stdout, summary);
|
|
349
|
+
else if (stoppedReason === "disabled") {
|
|
350
|
+
stdout.write(
|
|
351
|
+
`cto steward disabled by TFX_CTO kill-switch after ${runCount} run(s)\n`,
|
|
352
|
+
);
|
|
353
|
+
} else {
|
|
354
|
+
stdout.write(
|
|
355
|
+
`cto steward ${mode}: ${runCount} run(s), collect ${
|
|
356
|
+
parsed.collectEnabled ? "enabled" : "disabled"
|
|
357
|
+
}, stopped=${stoppedReason}\n`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return summary;
|
|
362
|
+
}
|
package/hooks/keyword-rules.json
CHANGED
|
@@ -62,6 +62,40 @@
|
|
|
62
62
|
"state": null,
|
|
63
63
|
"mcp_route": null
|
|
64
64
|
},
|
|
65
|
+
{
|
|
66
|
+
"id": "tfx-harness",
|
|
67
|
+
"patterns": [
|
|
68
|
+
{
|
|
69
|
+
"source": "\\btfx[\\s-]?harness\\b",
|
|
70
|
+
"flags": "i"
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"skill": "tfx-harness",
|
|
74
|
+
"priority": 1,
|
|
75
|
+
"supersedes": ["tfx-unified"],
|
|
76
|
+
"exclusive": false,
|
|
77
|
+
"state": null,
|
|
78
|
+
"mcp_route": null
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "tfx-harness-meta",
|
|
82
|
+
"patterns": [
|
|
83
|
+
{
|
|
84
|
+
"source": "(?:어떤\\s*스킬|스킬\\s*추천|스킬\\s*라우팅|작업\\s*라우팅|메타\\s*라우팅)",
|
|
85
|
+
"flags": "i"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"source": "\\bwhich\\s+skill\\b",
|
|
89
|
+
"flags": "i"
|
|
90
|
+
}
|
|
91
|
+
],
|
|
92
|
+
"skill": "tfx-harness",
|
|
93
|
+
"priority": 2,
|
|
94
|
+
"supersedes": ["tfx-unified"],
|
|
95
|
+
"exclusive": false,
|
|
96
|
+
"state": null,
|
|
97
|
+
"mcp_route": null
|
|
98
|
+
},
|
|
65
99
|
{
|
|
66
100
|
"id": "tfx-unified",
|
|
67
101
|
"patterns": [
|
|
@@ -160,7 +194,7 @@
|
|
|
160
194
|
}
|
|
161
195
|
],
|
|
162
196
|
"skill": "tfx-hub",
|
|
163
|
-
"priority":
|
|
197
|
+
"priority": 1,
|
|
164
198
|
"supersedes": [],
|
|
165
199
|
"exclusive": false,
|
|
166
200
|
"state": null,
|
|
@@ -175,7 +209,7 @@
|
|
|
175
209
|
}
|
|
176
210
|
],
|
|
177
211
|
"skill": "tfx-doctor",
|
|
178
|
-
"priority":
|
|
212
|
+
"priority": 1,
|
|
179
213
|
"supersedes": [],
|
|
180
214
|
"exclusive": false,
|
|
181
215
|
"state": null,
|
|
@@ -190,7 +224,7 @@
|
|
|
190
224
|
}
|
|
191
225
|
],
|
|
192
226
|
"skill": "tfx-setup",
|
|
193
|
-
"priority":
|
|
227
|
+
"priority": 1,
|
|
194
228
|
"supersedes": [],
|
|
195
229
|
"exclusive": false,
|
|
196
230
|
"state": null,
|
|
@@ -205,7 +239,7 @@
|
|
|
205
239
|
}
|
|
206
240
|
],
|
|
207
241
|
"skill": "tfx-research",
|
|
208
|
-
"priority":
|
|
242
|
+
"priority": 1,
|
|
209
243
|
"supersedes": [],
|
|
210
244
|
"exclusive": false,
|
|
211
245
|
"state": null,
|
|
@@ -220,7 +254,7 @@
|
|
|
220
254
|
}
|
|
221
255
|
],
|
|
222
256
|
"skill": "tfx-interview",
|
|
223
|
-
"priority":
|
|
257
|
+
"priority": 1,
|
|
224
258
|
"supersedes": [],
|
|
225
259
|
"exclusive": false,
|
|
226
260
|
"state": null,
|
package/hooks/pipeline-stop.mjs
CHANGED
|
@@ -152,7 +152,7 @@ function contextPercentsFromObject(value) {
|
|
|
152
152
|
value.total_tokens ??
|
|
153
153
|
value.totalTokens;
|
|
154
154
|
// Fall back to a 1M context window when the payload omits one — Opus 4.x
|
|
155
|
-
// [1M] and Codex
|
|
155
|
+
// [1M] and Codex GPT-5.6 family run on a 1M window, and assuming 200K there
|
|
156
156
|
// false-positives at ~15% real usage. Override via
|
|
157
157
|
// TFX_CONTEXT_DEFAULT_MAX_TOKENS for narrower setups.
|
|
158
158
|
const envFallback = Number(process.env.TFX_CONTEXT_DEFAULT_MAX_TOKENS);
|