triflux 10.20.0 → 10.20.2
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-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +34 -42
- package/README.md +30 -36
- package/bin/triflux.mjs +13 -4
- package/hooks/hook-orchestrator.mjs +1 -1
- package/hooks/hook-registry.json +76 -2
- package/hooks/hooks.json +49 -1
- package/hooks/permission-safe-allow.mjs +233 -0
- package/hooks/pipeline-stop.mjs +355 -16
- package/hooks/post-tool-tips.mjs +306 -0
- package/hooks/pre-compact-snapshot.mjs +181 -0
- package/hooks/session-end-cleanup.mjs +201 -0
- package/hooks/subagent-tracker.mjs +267 -0
- package/hooks/subagent-verifier.mjs +4 -0
- package/hub/account-broker.mjs +297 -8
- package/hub/codex-adapter.mjs +62 -2
- package/hub/server.mjs +51 -4
- package/hub/team/cli/services/native-control.mjs +1 -0
- package/hub/team/conductor.mjs +94 -29
- package/hub/team/native-supervisor.mjs +20 -7
- package/hub/team/swarm-cli.mjs +98 -4
- package/hub/team/swarm-hypervisor.mjs +112 -10
- package/hub/team/swarm-locks.mjs +7 -2
- package/hub/team/swarm-preflight.mjs +317 -0
- package/hub/team/synapse-cli.mjs +243 -6
- package/hub/team/worker-sandbox.mjs +93 -0
- package/hub/team/worktree-lifecycle.mjs +49 -8
- package/hub/workers/delegator-mcp.mjs +14 -1
- package/package.json +1 -1
- package/scripts/__tests__/gen-skill-docs.test.mjs +4 -0
- package/scripts/__tests__/skill-surface.test.mjs +61 -0
- package/scripts/gen-skill-manifest.mjs +3 -0
- package/scripts/lib/skill-template.mjs +2 -0
- package/scripts/session-stale-cleanup.mjs +111 -2
- package/skills/star-prompt/skill.json +1 -1
- package/skills/tfx-analysis/skill.json +11 -3
- package/skills/tfx-auto/skill.json +2 -2
- package/skills/tfx-autopilot/skill.json +9 -4
- package/skills/tfx-consensus/skill.json +4 -3
- package/skills/tfx-debate/skill.json +11 -4
- package/skills/tfx-doctor/skill.json +3 -1
- package/skills/tfx-fullcycle/skill.json +10 -4
- package/skills/tfx-hooks/skill.json +3 -1
- package/skills/tfx-hub/skill.json +5 -3
- package/skills/tfx-index/skill.json +6 -1
- package/skills/tfx-interview/skill.json +6 -1
- package/skills/tfx-multi/skill.json +7 -3
- package/skills/tfx-panel/skill.json +11 -4
- package/skills/tfx-persist/skill.json +11 -4
- package/skills/tfx-plan/skill.json +13 -3
- package/skills/tfx-profile/skill.json +3 -1
- package/skills/tfx-prune/skill.json +7 -1
- package/skills/tfx-psmux-rules/SKILL.md +2 -0
- package/skills/tfx-psmux-rules/SKILL.md.tmpl +22 -307
- package/skills/tfx-psmux-rules/skill.json +7 -2
- package/skills/tfx-qa/skill.json +12 -3
- package/skills/tfx-ralph/skill.json +4 -1
- package/skills/tfx-remote/skill.json +8 -0
- package/skills/tfx-remote-setup/SKILL.md +2 -0
- package/skills/tfx-remote-setup/SKILL.md.tmpl +23 -570
- package/skills/tfx-remote-setup/skill.json +7 -3
- package/skills/tfx-remote-spawn/SKILL.md +2 -0
- package/skills/tfx-remote-spawn/SKILL.md.tmpl +31 -242
- package/skills/tfx-remote-spawn/skill.json +7 -3
- package/skills/tfx-research/skill.json +11 -4
- package/skills/tfx-review/skill.json +12 -3
- package/skills/tfx-setup/skill.json +3 -1
- package/skills/tfx-ship/skill.json +13 -0
- package/skills/tfx-swarm/skill.json +13 -0
- package/skills/tfx-wt/skill.json +12 -0
package/hub/team/synapse-cli.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// so the CLI works even when Hub is offline.
|
|
5
5
|
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { join, resolve } from "node:path";
|
|
10
10
|
|
|
@@ -15,6 +15,15 @@ const DEFAULT_REGISTRY_CANDIDATES = [
|
|
|
15
15
|
".triflux/synapse/registry.json",
|
|
16
16
|
join(homedir(), ".claude", "cache", "tfx-hub", "synapse-registry.json"),
|
|
17
17
|
];
|
|
18
|
+
const DEFAULT_SWARM_LOGS_DIR = ".triflux/swarm-logs";
|
|
19
|
+
const STALE_AFTER_MS = 30 * 60 * 1000;
|
|
20
|
+
const STATUS_PRIORITY = {
|
|
21
|
+
active: 3,
|
|
22
|
+
running: 3,
|
|
23
|
+
completed: 2,
|
|
24
|
+
failed: 2,
|
|
25
|
+
stale: 1,
|
|
26
|
+
};
|
|
18
27
|
|
|
19
28
|
function gitExec(args, cwd) {
|
|
20
29
|
return new Promise((res, rej) => {
|
|
@@ -55,10 +64,228 @@ function loadRegistrySnapshot(path) {
|
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
function parseJsonLines(path) {
|
|
68
|
+
try {
|
|
69
|
+
return readFileSync(path, "utf8")
|
|
70
|
+
.split(/\r?\n/)
|
|
71
|
+
.map((line) => line.trim())
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.flatMap((line) => {
|
|
74
|
+
try {
|
|
75
|
+
return [JSON.parse(line)];
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeRunId(name) {
|
|
86
|
+
return String(name || "").replace(/^run-/, "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function eventTimeMs(event) {
|
|
90
|
+
const ms = Date.parse(String(event?.ts || ""));
|
|
91
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function deriveLogStatus(events, lastHeartbeatMs, nowMs) {
|
|
95
|
+
const stateEvents = events.filter((e) => e.event === "swarm_state");
|
|
96
|
+
const lastState = stateEvents[stateEvents.length - 1]?.to;
|
|
97
|
+
if (lastState === "completed" || lastState === "failed") return lastState;
|
|
98
|
+
if (events.some((e) => e.event === "integration_complete")) {
|
|
99
|
+
const lastIntegration = [...events]
|
|
100
|
+
.reverse()
|
|
101
|
+
.find((e) => e.event === "integration_complete");
|
|
102
|
+
if (
|
|
103
|
+
Array.isArray(lastIntegration?.failed) &&
|
|
104
|
+
lastIntegration.failed.length
|
|
105
|
+
) {
|
|
106
|
+
return "failed";
|
|
107
|
+
}
|
|
108
|
+
return "completed";
|
|
109
|
+
}
|
|
110
|
+
if (nowMs - lastHeartbeatMs > STALE_AFTER_MS) return "stale";
|
|
111
|
+
return "active";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildLogSession(runDir, nowMs = Date.now()) {
|
|
115
|
+
const eventPath = join(runDir, "swarm-events.jsonl");
|
|
116
|
+
if (!existsSync(eventPath)) return null;
|
|
117
|
+
const events = parseJsonLines(eventPath);
|
|
118
|
+
if (events.length === 0) return null;
|
|
119
|
+
|
|
120
|
+
let statMs = 0;
|
|
121
|
+
try {
|
|
122
|
+
statMs = statSync(eventPath).mtimeMs;
|
|
123
|
+
} catch {}
|
|
124
|
+
|
|
125
|
+
const eventMs = events.map(eventTimeMs).filter((ms) => ms > 0);
|
|
126
|
+
const lastEventMs = eventMs.length ? Math.max(...eventMs) : statMs;
|
|
127
|
+
const launched = new Set();
|
|
128
|
+
const completed = new Set();
|
|
129
|
+
const failed = new Set();
|
|
130
|
+
for (const event of events) {
|
|
131
|
+
if (event.event === "shard_launched" && event.shard) {
|
|
132
|
+
launched.add(event.shard);
|
|
133
|
+
} else if (event.event === "shard_completed" && event.shard) {
|
|
134
|
+
completed.add(event.shard);
|
|
135
|
+
} else if (
|
|
136
|
+
(event.event === "shard_failed" ||
|
|
137
|
+
event.event === "shard_launch_failed") &&
|
|
138
|
+
event.shard
|
|
139
|
+
) {
|
|
140
|
+
failed.add(event.shard);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const runId = normalizeRunId(runDir.split(/[\\/]/).pop());
|
|
145
|
+
const totalShards = launched.size || completed.size + failed.size;
|
|
146
|
+
const aliveWorkers = [...launched].filter(
|
|
147
|
+
(name) => !completed.has(name) && !failed.has(name),
|
|
148
|
+
).length;
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
sessionId: runId,
|
|
152
|
+
runId,
|
|
153
|
+
host: "-",
|
|
154
|
+
branch: "-",
|
|
155
|
+
dirtyFiles: [],
|
|
156
|
+
status: deriveLogStatus(events, lastEventMs || nowMs, nowMs),
|
|
157
|
+
taskSummary: "swarm log run",
|
|
158
|
+
source: "logs",
|
|
159
|
+
shards: totalShards ? `${completed.size}/${totalShards}` : "-",
|
|
160
|
+
workers: totalShards ? `${aliveWorkers} alive` : "-",
|
|
161
|
+
lastHeartbeat: lastEventMs || null,
|
|
162
|
+
logDir: runDir,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function loadSwarmLogSessions(logsDir, opts = {}) {
|
|
167
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
168
|
+
const root = logsDir || DEFAULT_SWARM_LOGS_DIR;
|
|
169
|
+
if (!existsSync(root)) return [];
|
|
170
|
+
try {
|
|
171
|
+
return readdirSync(root, { withFileTypes: true })
|
|
172
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith("run-"))
|
|
173
|
+
.map((entry) => buildLogSession(join(root, entry.name), nowMs))
|
|
174
|
+
.filter(Boolean);
|
|
175
|
+
} catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function statusPriority(status) {
|
|
181
|
+
return STATUS_PRIORITY[status] || 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function mergeSession(existing, next) {
|
|
185
|
+
const status =
|
|
186
|
+
statusPriority(next.status) > statusPriority(existing.status)
|
|
187
|
+
? next.status
|
|
188
|
+
: existing.status;
|
|
189
|
+
const source =
|
|
190
|
+
existing.source && next.source && existing.source !== next.source
|
|
191
|
+
? "synapse + logs"
|
|
192
|
+
: existing.source || next.source || "synapse";
|
|
193
|
+
return {
|
|
194
|
+
...next,
|
|
195
|
+
...existing,
|
|
196
|
+
status,
|
|
197
|
+
source,
|
|
198
|
+
shards: next.shards || existing.shards,
|
|
199
|
+
workers: next.workers || existing.workers,
|
|
200
|
+
lastHeartbeat: Math.max(
|
|
201
|
+
Number(existing.lastHeartbeat || 0),
|
|
202
|
+
Number(next.lastHeartbeat || 0),
|
|
203
|
+
),
|
|
204
|
+
logDir: existing.logDir || next.logDir,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function mergeRegistryAndLogSessions(registrySessions, logSessions) {
|
|
209
|
+
const merged = new Map();
|
|
210
|
+
for (const session of registrySessions) {
|
|
211
|
+
const key = String(session.runId || session.sessionId || "");
|
|
212
|
+
if (!key) continue;
|
|
213
|
+
merged.set(key, {
|
|
214
|
+
...session,
|
|
215
|
+
source: session.source || "synapse",
|
|
216
|
+
lastHeartbeat: session.lastHeartbeat || null,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
for (const session of logSessions) {
|
|
220
|
+
const key = String(session.runId || session.sessionId || "");
|
|
221
|
+
if (!key) continue;
|
|
222
|
+
const existing = merged.get(key);
|
|
223
|
+
merged.set(key, existing ? mergeSession(existing, session) : session);
|
|
224
|
+
}
|
|
225
|
+
return [...merged.values()].sort((left, right) => {
|
|
226
|
+
const rightTs = Number(right.lastHeartbeat || 0);
|
|
227
|
+
const leftTs = Number(left.lastHeartbeat || 0);
|
|
228
|
+
return rightTs - leftTs;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function formatAge(ts, nowMs = Date.now()) {
|
|
233
|
+
if (!Number.isFinite(Number(ts)) || Number(ts) <= 0) return "-";
|
|
234
|
+
const seconds = Math.max(0, Math.round((nowMs - Number(ts)) / 1000));
|
|
235
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
236
|
+
const minutes = Math.round(seconds / 60);
|
|
237
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
238
|
+
const hours = Math.round(minutes / 60);
|
|
239
|
+
return `${hours}h ago`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function hasSwarmLogFields(sessions) {
|
|
243
|
+
return sessions.some(
|
|
244
|
+
(s) =>
|
|
245
|
+
s.source === "logs" ||
|
|
246
|
+
s.source === "synapse + logs" ||
|
|
247
|
+
s.runId ||
|
|
248
|
+
s.shards ||
|
|
249
|
+
s.workers,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function formatSwarmStatus(sessions) {
|
|
254
|
+
const nowMs = Date.now();
|
|
255
|
+
const rows = [];
|
|
256
|
+
rows.push(
|
|
257
|
+
"RUN_ID STATUS SHARDS WORKERS LAST_HEARTBEAT SOURCE",
|
|
258
|
+
);
|
|
259
|
+
rows.push(
|
|
260
|
+
"───────────────────── ───────── ─────── ─────── ────────────── ──────────────",
|
|
261
|
+
);
|
|
262
|
+
for (const s of sessions) {
|
|
263
|
+
const id = String(s.runId || s.sessionId || "?")
|
|
264
|
+
.padEnd(21)
|
|
265
|
+
.slice(0, 21);
|
|
266
|
+
const status = String(s.status || "active")
|
|
267
|
+
.padEnd(9)
|
|
268
|
+
.slice(0, 9);
|
|
269
|
+
const shards = String(s.shards || "-")
|
|
270
|
+
.padEnd(7)
|
|
271
|
+
.slice(0, 7);
|
|
272
|
+
const workers = String(s.workers || "-")
|
|
273
|
+
.padEnd(7)
|
|
274
|
+
.slice(0, 7);
|
|
275
|
+
const heartbeat = formatAge(s.lastHeartbeat, nowMs).padEnd(14).slice(0, 14);
|
|
276
|
+
const source = String(s.source || "synapse").slice(0, 20);
|
|
277
|
+
rows.push(
|
|
278
|
+
`${id} ${status} ${shards} ${workers} ${heartbeat} ${source}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return rows.join("\n");
|
|
282
|
+
}
|
|
283
|
+
|
|
58
284
|
function formatStatus(sessions) {
|
|
59
285
|
if (!sessions.length) {
|
|
60
286
|
return "no active sessions (synapse-registry empty)";
|
|
61
287
|
}
|
|
288
|
+
if (hasSwarmLogFields(sessions)) return formatSwarmStatus(sessions);
|
|
62
289
|
const rows = [];
|
|
63
290
|
rows.push("SESSION HOST BRANCH DIRTY STATE TASK");
|
|
64
291
|
rows.push(
|
|
@@ -84,13 +311,22 @@ function formatStatus(sessions) {
|
|
|
84
311
|
export async function cmdSynapseStatus(args = [], opts = {}) {
|
|
85
312
|
const jsonOut = args.includes("--json") || opts.json;
|
|
86
313
|
const explicit = extractFlag(args, "--registry");
|
|
314
|
+
const explicitLogsDir = extractFlag(args, "--logs-dir");
|
|
87
315
|
const path = locateRegistryPath(explicit);
|
|
88
|
-
const
|
|
316
|
+
const registrySessions = loadRegistrySnapshot(path);
|
|
317
|
+
const logsDir = explicitLogsDir || DEFAULT_SWARM_LOGS_DIR;
|
|
318
|
+
const logSessions = loadSwarmLogSessions(logsDir);
|
|
319
|
+
const sessions = mergeRegistryAndLogSessions(registrySessions, logSessions);
|
|
89
320
|
|
|
90
321
|
if (jsonOut) {
|
|
91
322
|
process.stdout.write(
|
|
92
323
|
JSON.stringify(
|
|
93
|
-
{
|
|
324
|
+
{
|
|
325
|
+
registry: path,
|
|
326
|
+
logsDir: existsSync(logsDir) ? logsDir : null,
|
|
327
|
+
count: sessions.length,
|
|
328
|
+
sessions,
|
|
329
|
+
},
|
|
94
330
|
null,
|
|
95
331
|
2,
|
|
96
332
|
) + "\n",
|
|
@@ -98,13 +334,14 @@ export async function cmdSynapseStatus(args = [], opts = {}) {
|
|
|
98
334
|
return;
|
|
99
335
|
}
|
|
100
336
|
|
|
101
|
-
if (!path) {
|
|
337
|
+
if (!path && logSessions.length === 0) {
|
|
102
338
|
process.stdout.write(
|
|
103
|
-
"no registry
|
|
339
|
+
"no registry or swarm logs found (looked for .triflux/synapse-registry.json and .triflux/swarm-logs)\n",
|
|
104
340
|
);
|
|
105
341
|
return;
|
|
106
342
|
}
|
|
107
|
-
process.stdout.write(`registry: ${path}\n`);
|
|
343
|
+
if (path) process.stdout.write(`registry: ${path}\n`);
|
|
344
|
+
if (logSessions.length > 0) process.stdout.write(`swarm logs: ${logsDir}\n`);
|
|
108
345
|
process.stdout.write(`${formatStatus(sessions)}\n`);
|
|
109
346
|
}
|
|
110
347
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// hub/team/worker-sandbox.mjs — local worker HOME/APPDATA isolation helpers
|
|
2
|
+
//
|
|
3
|
+
// Team/swarm workers run untrusted task-specific CLIs. Keep their mutable user
|
|
4
|
+
// state under the worker worktree instead of the operator's real home/appdata,
|
|
5
|
+
// while preserving explicitly brokered auth homes such as CODEX_HOME.
|
|
6
|
+
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
import { join, resolve, win32 } from "node:path";
|
|
9
|
+
|
|
10
|
+
const SAFE_SEGMENT_RE = /[^a-zA-Z0-9._-]/g;
|
|
11
|
+
|
|
12
|
+
function safeSegment(value, fallback = "worker") {
|
|
13
|
+
const raw = String(value ?? "").trim();
|
|
14
|
+
const cleaned = raw.replace(SAFE_SEGMENT_RE, "_").replace(/^_+|_+$/g, "");
|
|
15
|
+
return cleaned || fallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isDisabled(env = {}) {
|
|
19
|
+
return /^(0|false|no|off)$/i.test(String(env.TFX_WORKER_SANDBOX ?? ""));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function windowsHomeParts(home) {
|
|
23
|
+
const normalized = String(home || "").replace(/\//g, "\\");
|
|
24
|
+
const match = normalized.match(/^([a-zA-Z]:)(\\.*)$/);
|
|
25
|
+
if (!match) return null;
|
|
26
|
+
return { drive: match[1], path: match[2] || "\\" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mkdirAll(paths) {
|
|
30
|
+
for (const path of paths) mkdirSync(path, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a deterministic sandbox home for a local team/swarm worker.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {string} [opts.cwd] Worktree/current working directory for the worker.
|
|
38
|
+
* @param {string} [opts.sessionId] Stable session/member id.
|
|
39
|
+
* @param {object} [opts.env] Existing env used for opt-out and explicit root.
|
|
40
|
+
* @param {boolean} [opts.create=true] Create directories eagerly.
|
|
41
|
+
* @returns {{env: object, root: string|null, home: string|null, disabled: boolean}}
|
|
42
|
+
*/
|
|
43
|
+
export function buildWorkerSandboxEnv(opts = {}) {
|
|
44
|
+
const env = opts.env && typeof opts.env === "object" ? opts.env : {};
|
|
45
|
+
if (isDisabled(env)) {
|
|
46
|
+
return { env: {}, root: null, home: null, disabled: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cwd = resolve(opts.cwd || process.cwd());
|
|
50
|
+
const sessionId = safeSegment(opts.sessionId, "worker");
|
|
51
|
+
const home = resolve(
|
|
52
|
+
env.TFX_WORKER_HOME || join(cwd, ".triflux", "worker-home", sessionId),
|
|
53
|
+
);
|
|
54
|
+
const appData = join(home, "AppData", "Roaming");
|
|
55
|
+
const localAppData = join(home, "AppData", "Local");
|
|
56
|
+
const xdgConfig = join(home, ".config");
|
|
57
|
+
const xdgCache = join(home, ".cache");
|
|
58
|
+
const xdgState = join(home, ".local", "state");
|
|
59
|
+
|
|
60
|
+
if (opts.create !== false) {
|
|
61
|
+
mkdirAll([home, appData, localAppData, xdgConfig, xdgCache, xdgState]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const overlay = {
|
|
65
|
+
TFX_WORKER_HOME: home,
|
|
66
|
+
HOME: home,
|
|
67
|
+
USERPROFILE: home,
|
|
68
|
+
APPDATA: appData,
|
|
69
|
+
LOCALAPPDATA: localAppData,
|
|
70
|
+
XDG_CONFIG_HOME: xdgConfig,
|
|
71
|
+
XDG_CACHE_HOME: xdgCache,
|
|
72
|
+
XDG_STATE_HOME: xdgState,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const winParts =
|
|
76
|
+
windowsHomeParts(home) ||
|
|
77
|
+
(process.platform === "win32"
|
|
78
|
+
? {
|
|
79
|
+
drive: win32.parse(home).root.replace(/[\\/]$/, "") || "C:",
|
|
80
|
+
path:
|
|
81
|
+
win32
|
|
82
|
+
.relative(win32.parse(home).root, home)
|
|
83
|
+
.replace(/\//g, "\\")
|
|
84
|
+
.replace(/^/, "\\") || "\\",
|
|
85
|
+
}
|
|
86
|
+
: null);
|
|
87
|
+
if (winParts) {
|
|
88
|
+
overlay.HOMEDRIVE = winParts.drive;
|
|
89
|
+
overlay.HOMEPATH = winParts.path;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { env: overlay, root: home, home, disabled: false };
|
|
93
|
+
}
|
|
@@ -314,7 +314,7 @@ export async function prepareIntegrationBranch({
|
|
|
314
314
|
* @param {string} [opts.rootDir=process.cwd()]
|
|
315
315
|
* @param {{ checkRebase: Function } | null} [opts.preflight=null] — git-preflight instance
|
|
316
316
|
* @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
|
|
317
|
-
* @returns {Promise<{ ok: boolean, headCommit?: string, error?: string, preflight?: object }>}
|
|
317
|
+
* @returns {Promise<{ ok: boolean, headCommit?: string, strategy?: string, commits?: number, notes?: string, fallbackReason?: string, error?: string, preflight?: object }>}
|
|
318
318
|
*/
|
|
319
319
|
export async function rebaseShardOntoIntegration({
|
|
320
320
|
shardBranch,
|
|
@@ -355,10 +355,9 @@ export async function rebaseShardOntoIntegration({
|
|
|
355
355
|
// Backup integration HEAD for rollback
|
|
356
356
|
const backupCommit = await git(["rev-parse", integrationBranch], rootDir);
|
|
357
357
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
// so worktree contention disappears. Memory: feedback_swarm_cherry_pick.
|
|
358
|
+
let shaList = [];
|
|
359
|
+
let ffError = null;
|
|
360
|
+
|
|
362
361
|
try {
|
|
363
362
|
const log = await git(
|
|
364
363
|
[
|
|
@@ -369,19 +368,55 @@ export async function rebaseShardOntoIntegration({
|
|
|
369
368
|
],
|
|
370
369
|
rootDir,
|
|
371
370
|
);
|
|
372
|
-
|
|
371
|
+
shaList = log
|
|
373
372
|
.split("\n")
|
|
374
373
|
.map((s) => s.trim())
|
|
375
374
|
.filter(Boolean);
|
|
376
375
|
|
|
377
376
|
await git(["checkout", integrationBranch], rootDir);
|
|
378
377
|
|
|
378
|
+
try {
|
|
379
|
+
await git(["merge", "--ff-only", shardBranch], rootDir);
|
|
380
|
+
const headCommit = await git(["rev-parse", "HEAD"], rootDir);
|
|
381
|
+
return {
|
|
382
|
+
ok: true,
|
|
383
|
+
headCommit,
|
|
384
|
+
strategy: "auto-ff",
|
|
385
|
+
commits: shaList.length,
|
|
386
|
+
notes: "linear (merge --ff-only)",
|
|
387
|
+
};
|
|
388
|
+
} catch (err) {
|
|
389
|
+
ffError = err;
|
|
390
|
+
try {
|
|
391
|
+
await git(["merge", "--abort"], rootDir);
|
|
392
|
+
} catch {
|
|
393
|
+
/* ff-only usually leaves no merge state */
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
await git(["reset", "--hard", backupCommit], rootDir);
|
|
397
|
+
} catch {
|
|
398
|
+
/* fallback below will report if cherry-pick also fails */
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// #127: cherry-pick fallback instead of branch merge. Rebase/ff paths can
|
|
403
|
+
// fail when histories diverge or a shard branch is checked out by a swarm
|
|
404
|
+
// worktree. Cherry-pick reads commits without moving the shard branch ref.
|
|
379
405
|
for (const sha of shaList) {
|
|
380
406
|
await git(["cherry-pick", sha], rootDir);
|
|
381
407
|
}
|
|
382
408
|
|
|
383
409
|
const headCommit = await git(["rev-parse", "HEAD"], rootDir);
|
|
384
|
-
return {
|
|
410
|
+
return {
|
|
411
|
+
ok: true,
|
|
412
|
+
headCommit,
|
|
413
|
+
strategy: "cherry-pick",
|
|
414
|
+
commits: shaList.length,
|
|
415
|
+
fallbackReason: ffError?.message || null,
|
|
416
|
+
notes: ffError
|
|
417
|
+
? `auto-ff failed: ${ffError.message}`
|
|
418
|
+
: "cherry-pick fallback",
|
|
419
|
+
};
|
|
385
420
|
} catch (err) {
|
|
386
421
|
try {
|
|
387
422
|
await git(["cherry-pick", "--abort"], rootDir);
|
|
@@ -422,7 +457,13 @@ export async function rebaseShardOntoIntegration({
|
|
|
422
457
|
}
|
|
423
458
|
}
|
|
424
459
|
|
|
425
|
-
return {
|
|
460
|
+
return {
|
|
461
|
+
ok: false,
|
|
462
|
+
error: err.message,
|
|
463
|
+
strategy: "failed",
|
|
464
|
+
commits: shaList.length,
|
|
465
|
+
fallbackReason: ffError?.message || null,
|
|
466
|
+
};
|
|
426
467
|
} finally {
|
|
427
468
|
// Always restore caller's branch. Skip if originalBranch is null
|
|
428
469
|
// (detached HEAD case) or already on target.
|
|
@@ -14,6 +14,7 @@ import * as z from "zod";
|
|
|
14
14
|
import { resolveBashExecutable } from "../lib/bash-path.mjs";
|
|
15
15
|
import { whichCommand } from "../platform.mjs";
|
|
16
16
|
import { runHeadlessWithCleanup } from "../team/headless.mjs";
|
|
17
|
+
import { buildWorkerSandboxEnv } from "../team/worker-sandbox.mjs";
|
|
17
18
|
import { CodexMcpWorker } from "./codex-mcp.mjs";
|
|
18
19
|
import { GeminiWorker } from "./gemini-worker.mjs";
|
|
19
20
|
|
|
@@ -1182,8 +1183,20 @@ export class DelegatorMcpWorker {
|
|
|
1182
1183
|
}
|
|
1183
1184
|
|
|
1184
1185
|
_buildRouteEnv(args) {
|
|
1185
|
-
const
|
|
1186
|
+
const baseEnv = cloneEnv(this.env);
|
|
1187
|
+
const sandbox = buildWorkerSandboxEnv({
|
|
1188
|
+
cwd: args.cwd || this.cwd,
|
|
1189
|
+
sessionId:
|
|
1190
|
+
args.teamTaskId ||
|
|
1191
|
+
args.teamAgentName ||
|
|
1192
|
+
args.workerIndex ||
|
|
1193
|
+
args.agentType ||
|
|
1194
|
+
"route",
|
|
1195
|
+
env: baseEnv,
|
|
1196
|
+
});
|
|
1197
|
+
const env = { ...baseEnv, ...sandbox.env };
|
|
1186
1198
|
env.TFX_CLI_MODE = pickRouteMode(args.provider);
|
|
1199
|
+
if (sandbox.root) env.TFX_WORKER_SANDBOX_SCOPE = "delegator-route";
|
|
1187
1200
|
|
|
1188
1201
|
if (args.codexTransport) {
|
|
1189
1202
|
env.TFX_CODEX_TRANSPORT = args.codexTransport;
|
package/package.json
CHANGED
|
@@ -163,6 +163,8 @@ describe("gen-skill-manifest", () => {
|
|
|
163
163
|
" - manifest",
|
|
164
164
|
"argument-hint: <arg>",
|
|
165
165
|
"internal: true",
|
|
166
|
+
"deprecated: true",
|
|
167
|
+
"superseded-by: tfx-auto",
|
|
166
168
|
"---",
|
|
167
169
|
"body",
|
|
168
170
|
].join("\n"),
|
|
@@ -180,6 +182,8 @@ describe("gen-skill-manifest", () => {
|
|
|
180
182
|
assert.deepEqual(manifest.triggers, ["test", "manifest"]);
|
|
181
183
|
assert.equal(manifest.argument_hint, "<arg>");
|
|
182
184
|
assert.equal(manifest.internal, true);
|
|
185
|
+
assert.equal(manifest.deprecated, true);
|
|
186
|
+
assert.equal(manifest.superseded_by, "tfx-auto");
|
|
183
187
|
} finally {
|
|
184
188
|
rmSync(root, { recursive: true, force: true });
|
|
185
189
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
|
|
6
|
+
import { parseFrontmatter } from "../lib/skill-template.mjs";
|
|
7
|
+
|
|
8
|
+
const repoRoot = join(import.meta.dirname, "..", "..");
|
|
9
|
+
const skillsDir = join(repoRoot, "skills");
|
|
10
|
+
|
|
11
|
+
function readSkillFrontmatter(name) {
|
|
12
|
+
const skillPath = join(skillsDir, name, "SKILL.md");
|
|
13
|
+
assert.equal(existsSync(skillPath), true, `${name} SKILL.md missing`);
|
|
14
|
+
return parseFrontmatter(readFileSync(skillPath, "utf8")).data;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function listSkillFrontmatter() {
|
|
18
|
+
return readdirSync(skillsDir, { withFileTypes: true })
|
|
19
|
+
.filter((entry) => entry.isDirectory())
|
|
20
|
+
.filter((entry) => existsSync(join(skillsDir, entry.name, "SKILL.md")))
|
|
21
|
+
.map((entry) => readSkillFrontmatter(entry.name))
|
|
22
|
+
.filter((data) => data.name);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("skill surface consolidation (#112)", () => {
|
|
26
|
+
it("keeps the public core skill surface at or below the target size", () => {
|
|
27
|
+
const coreSkills = listSkillFrontmatter().filter(
|
|
28
|
+
(data) => data.internal !== true && data.deprecated !== true,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
assert.ok(
|
|
32
|
+
coreSkills.length <= 15,
|
|
33
|
+
`expected <=15 public core skills, got ${coreSkills.length}: ${coreSkills
|
|
34
|
+
.map((skill) => skill.name)
|
|
35
|
+
.sort()
|
|
36
|
+
.join(", ")}`,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("marks legacy compatibility aliases as deprecated and superseded", () => {
|
|
41
|
+
const expectedAliases = [
|
|
42
|
+
"tfx-autopilot",
|
|
43
|
+
"tfx-consensus",
|
|
44
|
+
"tfx-debate",
|
|
45
|
+
"tfx-fullcycle",
|
|
46
|
+
"tfx-multi",
|
|
47
|
+
"tfx-panel",
|
|
48
|
+
"tfx-persist",
|
|
49
|
+
"tfx-psmux-rules",
|
|
50
|
+
"tfx-remote-setup",
|
|
51
|
+
"tfx-remote-spawn",
|
|
52
|
+
"tfx-swarm",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
for (const name of expectedAliases) {
|
|
56
|
+
const data = readSkillFrontmatter(name);
|
|
57
|
+
assert.equal(data.deprecated, true, `${name} must be deprecated`);
|
|
58
|
+
assert.ok(data["superseded-by"], `${name} must declare superseded-by`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -25,6 +25,9 @@ function extractManifest(skillMdContent) {
|
|
|
25
25
|
if (data["argument-hint"]) manifest.argument_hint = data["argument-hint"];
|
|
26
26
|
if (data.internal === true || data.internal === "true")
|
|
27
27
|
manifest.internal = true;
|
|
28
|
+
if (data.deprecated === true || data.deprecated === "true")
|
|
29
|
+
manifest.deprecated = true;
|
|
30
|
+
if (data["superseded-by"]) manifest.superseded_by = data["superseded-by"];
|
|
28
31
|
|
|
29
32
|
return manifest;
|
|
30
33
|
}
|
|
@@ -360,6 +360,8 @@ export function parseFrontmatterWithManifest(source, skillDir) {
|
|
|
360
360
|
if (manifest.triggers) merged.triggers = manifest.triggers;
|
|
361
361
|
if (manifest.argument_hint) merged["argument-hint"] = manifest.argument_hint;
|
|
362
362
|
if (manifest.internal != null) merged.internal = manifest.internal;
|
|
363
|
+
if (manifest.deprecated != null) merged.deprecated = manifest.deprecated;
|
|
364
|
+
if (manifest.superseded_by) merged["superseded-by"] = manifest.superseded_by;
|
|
363
365
|
|
|
364
366
|
return { data: merged, body };
|
|
365
367
|
}
|