traintrack 2.0.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/LICENSE +201 -0
- package/README.md +158 -0
- package/dist/channel/channel.js +64 -0
- package/dist/channel/resolve.js +43 -0
- package/dist/cli.js +164 -0
- package/dist/index.js +6 -0
- package/dist/mcp/server.js +198 -0
- package/dist/mcp/tools.js +207 -0
- package/dist/mcp-server.js +8 -0
- package/dist/onboarding/briefing.js +24 -0
- package/dist/runner/argv.js +47 -0
- package/dist/runner/event-parser.js +165 -0
- package/dist/runner/turn-runner.js +81 -0
- package/dist/setup/blocks.js +251 -0
- package/dist/setup/configure.js +179 -0
- package/dist/setup/detect.js +53 -0
- package/dist/setup/harness.js +61 -0
- package/dist/setup/prompt.js +218 -0
- package/dist/setup/setup.js +106 -0
- package/dist/setup/types.js +9 -0
- package/dist/spawn/spawn.js +90 -0
- package/dist/ui/banner.js +76 -0
- package/dist/worker/worker.js +190 -0
- package/hooks/hooks-codex.json +16 -0
- package/hooks/hooks-cursor.json +10 -0
- package/hooks/hooks.json +16 -0
- package/hooks/run-hook.cmd +46 -0
- package/hooks/session-start +44 -0
- package/hooks/session-start-codex +28 -0
- package/package.json +52 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Why: run ONE headless agent turn as a fresh child process and resolve its
|
|
2
|
+
// structured result. This is the reliable replacement for PTY injection — no
|
|
3
|
+
// idle-guessing: we spawn, stream stdout line-by-line through the event-parser,
|
|
4
|
+
// and the provider's own turn-end event (claude `result` / codex `turn.completed`)
|
|
5
|
+
// is the in-band ACK. Ported from the rust conductor's run_process/run_turn.
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { createTurnParseState, reduceLine, finalizeTurn } from './event-parser.js';
|
|
8
|
+
// Local passthrough: Windows packaging is out of scope for traintrack v1 (macOS/Linux only).
|
|
9
|
+
function getSpawnArgsForWindows(cmd, args) {
|
|
10
|
+
return { spawnCmd: cmd, spawnArgs: args };
|
|
11
|
+
}
|
|
12
|
+
const MAX_STDERR_CHARS = 64_000;
|
|
13
|
+
/** Spawn a headless turn, stream-parse its stdout, and resolve the turn result + exit code. */
|
|
14
|
+
export function runHeadlessTurn(input) {
|
|
15
|
+
const { provider, command, args, cwd, env, onDelta } = input;
|
|
16
|
+
const spawnImpl = input.spawnImpl ?? spawn;
|
|
17
|
+
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args);
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
let child;
|
|
20
|
+
try {
|
|
21
|
+
child = spawnImpl(spawnCmd, spawnArgs, {
|
|
22
|
+
// Why: stdin MUST be ignored (= /dev/null). codex exec HANGS FOREVER on a
|
|
23
|
+
// non-TTY stdin pipe waiting for input (openai/codex #20919); the rust
|
|
24
|
+
// conductor used Stdio::null for exactly this.
|
|
25
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
cwd,
|
|
28
|
+
env: env ?? process.env
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const state = createTurnParseState(provider);
|
|
36
|
+
let stdoutBuf = '';
|
|
37
|
+
let stderr = '';
|
|
38
|
+
let settled = false;
|
|
39
|
+
const consumeLine = (line) => {
|
|
40
|
+
const { delta } = reduceLine(state, line);
|
|
41
|
+
if (delta && onDelta) {
|
|
42
|
+
onDelta(delta);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
child.stdout?.on('data', (chunk) => {
|
|
46
|
+
stdoutBuf += chunk.toString();
|
|
47
|
+
let nl = stdoutBuf.indexOf('\n');
|
|
48
|
+
while (nl >= 0) {
|
|
49
|
+
consumeLine(stdoutBuf.slice(0, nl));
|
|
50
|
+
stdoutBuf = stdoutBuf.slice(nl + 1);
|
|
51
|
+
nl = stdoutBuf.indexOf('\n');
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
// Why: drain stderr concurrently so a chatty stderr can't fill the OS pipe
|
|
55
|
+
// buffer and deadlock the child (the rust run_process deadlock fix).
|
|
56
|
+
child.stderr?.on('data', (chunk) => {
|
|
57
|
+
stderr += chunk.toString();
|
|
58
|
+
if (stderr.length > MAX_STDERR_CHARS) {
|
|
59
|
+
stderr = stderr.slice(-MAX_STDERR_CHARS);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
child.on('error', (err) => {
|
|
63
|
+
if (settled) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
settled = true;
|
|
67
|
+
reject(err);
|
|
68
|
+
});
|
|
69
|
+
child.on('close', (code) => {
|
|
70
|
+
if (settled) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
settled = true;
|
|
74
|
+
// Flush a trailing partial line that had no terminating newline.
|
|
75
|
+
if (stdoutBuf.trim()) {
|
|
76
|
+
consumeLine(stdoutBuf);
|
|
77
|
+
}
|
|
78
|
+
resolve({ ...finalizeTurn(state), exitCode: code, stderr });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// The idempotency engine (pure, fs-only; no harness logic).
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
function ensureDir(file) {
|
|
5
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
/** Build the canonical marker block: start, body, end on their own lines. */
|
|
8
|
+
function renderBlock(start, end, body) {
|
|
9
|
+
return `${start}\n${body}\n${end}`;
|
|
10
|
+
}
|
|
11
|
+
/** Find the [start, end] span (inclusive of markers) in text at or after `at`, or null. */
|
|
12
|
+
function findSpanFrom(text, start, end, at) {
|
|
13
|
+
const from = text.indexOf(start, at);
|
|
14
|
+
if (from === -1)
|
|
15
|
+
return null;
|
|
16
|
+
const endStart = text.indexOf(end, from + start.length);
|
|
17
|
+
if (endStart === -1)
|
|
18
|
+
return null;
|
|
19
|
+
return { from, to: endStart + end.length };
|
|
20
|
+
}
|
|
21
|
+
/** Find the first [start, end] span (inclusive of markers) in text, or null. */
|
|
22
|
+
function findSpan(text, start, end) {
|
|
23
|
+
return findSpanFrom(text, start, end, 0);
|
|
24
|
+
}
|
|
25
|
+
/** Cut a span out of text, collapsing the seam so removal leaves no blank-line
|
|
26
|
+
* scar and never drops the file's POSIX trailing newline. */
|
|
27
|
+
function spliceSeam(text, span) {
|
|
28
|
+
let before = text.slice(0, span.from);
|
|
29
|
+
let after = text.slice(span.to);
|
|
30
|
+
// Drop a single leading newline on `after` (the newline that followed the end
|
|
31
|
+
// marker), then drop a trailing newline on `before` ONLY when a real blank-line
|
|
32
|
+
// scar is present (i.e. `after` still starts with a newline). When the block was
|
|
33
|
+
// the last thing in the file, `after` becomes '' and we keep `before`'s trailing
|
|
34
|
+
// newline so the file stays POSIX-clean.
|
|
35
|
+
if (after.startsWith('\n'))
|
|
36
|
+
after = after.slice(1);
|
|
37
|
+
if (before.endsWith('\n') && after.startsWith('\n')) {
|
|
38
|
+
before = before.slice(0, -1);
|
|
39
|
+
}
|
|
40
|
+
return before + after;
|
|
41
|
+
}
|
|
42
|
+
/** Insert or replace a marker-delimited block in a text file. Creates the file
|
|
43
|
+
* (and parent dirs) if missing. Returns 'added' | 'updated' | 'unchanged'. */
|
|
44
|
+
export function upsertBlock(file, start, end, body) {
|
|
45
|
+
const block = renderBlock(start, end, body);
|
|
46
|
+
if (!existsSync(file)) {
|
|
47
|
+
ensureDir(file);
|
|
48
|
+
writeFileSync(file, block + '\n', 'utf8');
|
|
49
|
+
return 'added';
|
|
50
|
+
}
|
|
51
|
+
const text = readFileSync(file, 'utf8');
|
|
52
|
+
const span = findSpan(text, start, end);
|
|
53
|
+
if (!span) {
|
|
54
|
+
// No existing block — append, keeping a clean separation from prior content.
|
|
55
|
+
const sep = text.length === 0 || text.endsWith('\n') ? '' : '\n';
|
|
56
|
+
writeFileSync(file, text + sep + block + '\n', 'utf8');
|
|
57
|
+
return 'added';
|
|
58
|
+
}
|
|
59
|
+
// Replace the first span, then strip any further spans of the same markers so
|
|
60
|
+
// the file converges to EXACTLY one block even if a prior buggy run, a merge,
|
|
61
|
+
// or a copy/paste left duplicates behind.
|
|
62
|
+
const existing = text.slice(span.from, span.to);
|
|
63
|
+
let next = text.slice(0, span.from) + block + text.slice(span.to);
|
|
64
|
+
const collapsed = collapseExtraSpans(next, start, end);
|
|
65
|
+
next = collapsed.text;
|
|
66
|
+
if (existing === block && !collapsed.removedAny)
|
|
67
|
+
return 'unchanged';
|
|
68
|
+
writeFileSync(file, next, 'utf8');
|
|
69
|
+
return 'updated';
|
|
70
|
+
}
|
|
71
|
+
/** Remove every span AFTER the first (the first is assumed already canonical),
|
|
72
|
+
* collapsing the seam each time. Returns the new text and whether any were removed. */
|
|
73
|
+
function collapseExtraSpans(text, start, end) {
|
|
74
|
+
const first = findSpan(text, start, end);
|
|
75
|
+
if (!first)
|
|
76
|
+
return { text, removedAny: false };
|
|
77
|
+
let removedAny = false;
|
|
78
|
+
// Search only the region after the first block's end for further spans.
|
|
79
|
+
let cur = text;
|
|
80
|
+
let searchFrom = first.to;
|
|
81
|
+
for (;;) {
|
|
82
|
+
const span = findSpanFrom(cur, start, end, searchFrom);
|
|
83
|
+
if (!span)
|
|
84
|
+
break;
|
|
85
|
+
cur = spliceSeam(cur, span);
|
|
86
|
+
removedAny = true;
|
|
87
|
+
// Continue scanning from where the removed block began.
|
|
88
|
+
searchFrom = span.from;
|
|
89
|
+
}
|
|
90
|
+
return { text: cur, removedAny };
|
|
91
|
+
}
|
|
92
|
+
/** Remove a marker-delimited block if present; preserves the rest of the file.
|
|
93
|
+
* Returns 'removed' | 'unchanged'. */
|
|
94
|
+
export function removeBlock(file, start, end) {
|
|
95
|
+
if (!existsSync(file))
|
|
96
|
+
return 'unchanged';
|
|
97
|
+
let text = readFileSync(file, 'utf8');
|
|
98
|
+
let removedAny = false;
|
|
99
|
+
// Loop until no span remains so ALL traintrack blocks are removed — a doubled
|
|
100
|
+
// block (from a prior buggy run, a copy/paste, or a merge) is fully collapsed,
|
|
101
|
+
// not left orphaned. uninstall then truly removes everything it claims to.
|
|
102
|
+
for (;;) {
|
|
103
|
+
const span = findSpan(text, start, end);
|
|
104
|
+
if (!span)
|
|
105
|
+
break;
|
|
106
|
+
text = spliceSeam(text, span);
|
|
107
|
+
removedAny = true;
|
|
108
|
+
}
|
|
109
|
+
if (!removedAny)
|
|
110
|
+
return 'unchanged';
|
|
111
|
+
writeFileSync(file, text, 'utf8');
|
|
112
|
+
return 'removed';
|
|
113
|
+
}
|
|
114
|
+
/** Structural (key-order-INSENSITIVE) deep equality. Exported so dry-run planning
|
|
115
|
+
* in configure.ts compares JSON values exactly the way the real upsertJson does. */
|
|
116
|
+
export function deepEqual(a, b) {
|
|
117
|
+
if (a === b)
|
|
118
|
+
return true;
|
|
119
|
+
if (typeof a !== typeof b)
|
|
120
|
+
return false;
|
|
121
|
+
if (a === null || b === null)
|
|
122
|
+
return a === b;
|
|
123
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
124
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
125
|
+
return false;
|
|
126
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
127
|
+
}
|
|
128
|
+
if (typeof a === 'object' && typeof b === 'object') {
|
|
129
|
+
const ao = a;
|
|
130
|
+
const bo = b;
|
|
131
|
+
const ak = Object.keys(ao);
|
|
132
|
+
const bk = Object.keys(bo);
|
|
133
|
+
if (ak.length !== bk.length)
|
|
134
|
+
return false;
|
|
135
|
+
return ak.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && deepEqual(ao[k], bo[k]));
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
function readJson(file) {
|
|
140
|
+
if (!existsSync(file))
|
|
141
|
+
return {};
|
|
142
|
+
const text = readFileSync(file, 'utf8').trim();
|
|
143
|
+
if (text === '')
|
|
144
|
+
return {};
|
|
145
|
+
const parsed = JSON.parse(text);
|
|
146
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
147
|
+
? parsed
|
|
148
|
+
: {};
|
|
149
|
+
}
|
|
150
|
+
function writeJson(file, obj) {
|
|
151
|
+
ensureDir(file);
|
|
152
|
+
writeFileSync(file, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
|
153
|
+
}
|
|
154
|
+
/** Read a JSON file (or {} if missing), set obj[...path] = value (deep), write
|
|
155
|
+
* back pretty-printed. Returns 'added' | 'updated' | 'unchanged'. Creates dirs. */
|
|
156
|
+
export function upsertJson(file, path, value) {
|
|
157
|
+
const obj = readJson(file);
|
|
158
|
+
let cursor = obj;
|
|
159
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
160
|
+
const key = path[i];
|
|
161
|
+
const child = cursor[key];
|
|
162
|
+
if (child === null || typeof child !== 'object' || Array.isArray(child)) {
|
|
163
|
+
cursor[key] = {};
|
|
164
|
+
}
|
|
165
|
+
cursor = cursor[key];
|
|
166
|
+
}
|
|
167
|
+
const leaf = path[path.length - 1];
|
|
168
|
+
const had = Object.prototype.hasOwnProperty.call(cursor, leaf);
|
|
169
|
+
if (had && deepEqual(cursor[leaf], value))
|
|
170
|
+
return 'unchanged';
|
|
171
|
+
cursor[leaf] = value;
|
|
172
|
+
writeJson(file, obj);
|
|
173
|
+
return had ? 'updated' : 'added';
|
|
174
|
+
}
|
|
175
|
+
/** Delete obj[...path] if present; write back. Returns 'removed' | 'unchanged'. */
|
|
176
|
+
export function removeJson(file, path) {
|
|
177
|
+
if (!existsSync(file))
|
|
178
|
+
return 'unchanged';
|
|
179
|
+
const obj = readJson(file);
|
|
180
|
+
let cursor = obj;
|
|
181
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
182
|
+
const child = cursor[path[i]];
|
|
183
|
+
if (child === null || typeof child !== 'object' || Array.isArray(child))
|
|
184
|
+
return 'unchanged';
|
|
185
|
+
cursor = child;
|
|
186
|
+
}
|
|
187
|
+
const leaf = path[path.length - 1];
|
|
188
|
+
if (!Object.prototype.hasOwnProperty.call(cursor, leaf))
|
|
189
|
+
return 'unchanged';
|
|
190
|
+
delete cursor[leaf];
|
|
191
|
+
writeJson(file, obj);
|
|
192
|
+
return 'removed';
|
|
193
|
+
}
|
|
194
|
+
// ─── Dry-run planners ────────────────────────────────────────────────────────
|
|
195
|
+
// Single source of truth for `--dry-run`: each planX answers exactly the Action
|
|
196
|
+
// the matching mutator WOULD return, using the SAME comparison logic, so dry-run
|
|
197
|
+
// can never disagree with a real run (structural parity, not coincidental).
|
|
198
|
+
/** What upsertBlock WOULD return for these inputs, without writing. */
|
|
199
|
+
export function planBlock(file, start, end, body) {
|
|
200
|
+
const block = renderBlock(start, end, body);
|
|
201
|
+
if (!existsSync(file))
|
|
202
|
+
return 'added';
|
|
203
|
+
const text = readFileSync(file, 'utf8');
|
|
204
|
+
const span = findSpan(text, start, end);
|
|
205
|
+
if (!span)
|
|
206
|
+
return 'added';
|
|
207
|
+
const existing = text.slice(span.from, span.to);
|
|
208
|
+
// A doubled block would be reconciled to one → 'updated' even if the first matches.
|
|
209
|
+
const next = text.slice(0, span.from) + block + text.slice(span.to);
|
|
210
|
+
const { removedAny } = collapseExtraSpans(next, start, end);
|
|
211
|
+
return existing === block && !removedAny ? 'unchanged' : 'updated';
|
|
212
|
+
}
|
|
213
|
+
/** What upsertJson WOULD return for these inputs, without writing. */
|
|
214
|
+
export function planJson(file, path, value) {
|
|
215
|
+
if (!existsSync(file))
|
|
216
|
+
return 'added';
|
|
217
|
+
const obj = readJson(file);
|
|
218
|
+
let cursor = obj;
|
|
219
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
220
|
+
const child = cursor[path[i]];
|
|
221
|
+
if (child === null || typeof child !== 'object' || Array.isArray(child))
|
|
222
|
+
return 'added';
|
|
223
|
+
cursor = child;
|
|
224
|
+
}
|
|
225
|
+
const leaf = path[path.length - 1];
|
|
226
|
+
if (!Object.prototype.hasOwnProperty.call(cursor, leaf))
|
|
227
|
+
return 'added';
|
|
228
|
+
return deepEqual(cursor[leaf], value) ? 'unchanged' : 'updated';
|
|
229
|
+
}
|
|
230
|
+
/** What removeBlock WOULD return for these inputs, without writing. */
|
|
231
|
+
export function planRemoveBlock(file, start, end) {
|
|
232
|
+
if (!existsSync(file))
|
|
233
|
+
return 'unchanged';
|
|
234
|
+
const text = readFileSync(file, 'utf8');
|
|
235
|
+
return findSpan(text, start, end) ? 'removed' : 'unchanged';
|
|
236
|
+
}
|
|
237
|
+
/** What removeJson WOULD return for these inputs, without writing. */
|
|
238
|
+
export function planRemoveJson(file, path) {
|
|
239
|
+
if (!existsSync(file))
|
|
240
|
+
return 'unchanged';
|
|
241
|
+
const obj = readJson(file);
|
|
242
|
+
let cursor = obj;
|
|
243
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
244
|
+
const child = cursor[path[i]];
|
|
245
|
+
if (child === null || typeof child !== 'object' || Array.isArray(child))
|
|
246
|
+
return 'unchanged';
|
|
247
|
+
cursor = child;
|
|
248
|
+
}
|
|
249
|
+
const leaf = path[path.length - 1];
|
|
250
|
+
return Object.prototype.hasOwnProperty.call(cursor, leaf) ? 'removed' : 'unchanged';
|
|
251
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Apply/remove the MCP entry + awareness block for one harness.
|
|
2
|
+
// Pure orchestration over blocks.ts — honors ctx.dryRun and ctx.injectAwareness.
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { upsertBlock, removeBlock, upsertJson, removeJson, planBlock, planJson, planRemoveBlock, planRemoveJson, } from './blocks.js';
|
|
6
|
+
// Marker conventions (per the plan). Re-runs replace, never duplicate; uninstall removes cleanly.
|
|
7
|
+
const MD_START = '<!-- >>> traintrack >>> -->';
|
|
8
|
+
const MD_END = '<!-- <<< traintrack <<< -->';
|
|
9
|
+
const TOML_START = '# >>> traintrack >>>';
|
|
10
|
+
const TOML_END = '# <<< traintrack <<<';
|
|
11
|
+
/** The awareness body — the auto-injected "coordinating-a-team" methodology,
|
|
12
|
+
* identical text on every harness, wrapped in the file's markers. */
|
|
13
|
+
export const AWARENESS_BODY = [
|
|
14
|
+
'You are part of a traintrack agent team. Every coding-agent session (Claude Code, Codex, Cursor, OpenCode) opened in this project auto-joins the SAME team over a shared local channel, and you have these tools: list_team, check_messages, send_message, spawn_worker, delegate_task, await_results, join_team.',
|
|
15
|
+
'',
|
|
16
|
+
'Working with teammates (the mesh):',
|
|
17
|
+
'- Run check_messages at the START of each turn, and whenever you see a "📨 N unread" nudge on a tool result — that is a teammate writing to you.',
|
|
18
|
+
'- list_team shows who is online. send_message(to, body) messages a teammate by handle; they read it on their next turn (you cannot interrupt them mid-task).',
|
|
19
|
+
'',
|
|
20
|
+
'When the user asks you to build several things (you act as LEAD):',
|
|
21
|
+
'1. Propose a short split — which part goes to which worker (claude or codex) — and confirm before spawning, unless told to just go.',
|
|
22
|
+
'2. spawn_worker(agent, role, task) for each part; workers run headless in their own git worktree and auto-reply.',
|
|
23
|
+
'3. await_results() to collect, delegate_task(handle, task) for follow-ups, then synthesize. Keep the team small; do simple work yourself.',
|
|
24
|
+
'',
|
|
25
|
+
'If a human adds your session to a running team, you are already auto-joined — just check_messages and help.',
|
|
26
|
+
].join('\n');
|
|
27
|
+
/** Pick the marker pair for a comment style. */
|
|
28
|
+
function markers(style) {
|
|
29
|
+
return style === 'toml' ? { start: TOML_START, end: TOML_END } : { start: MD_START, end: MD_END };
|
|
30
|
+
}
|
|
31
|
+
/** Encode a string as a TOML basic string: wrap in double quotes and escape
|
|
32
|
+
* backslashes and double quotes (the two chars that would otherwise break a
|
|
33
|
+
* TOML basic string). Without this, a nodePath/serverPath containing a `"` or
|
|
34
|
+
* `\` would emit invalid TOML and Codex would silently fail to load the server. */
|
|
35
|
+
function tomlStr(s) {
|
|
36
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
37
|
+
}
|
|
38
|
+
/** The toml MCP block body (node/server substituted, properly TOML-escaped). The
|
|
39
|
+
* env table tags this session's agent type so auto-presence registers it as the
|
|
40
|
+
* right kind (claude/codex/…). */
|
|
41
|
+
function tomlMcpBody(ctx, agentId) {
|
|
42
|
+
return (`[mcp_servers.traintrack]\n` +
|
|
43
|
+
`command = ${tomlStr(ctx.nodePath)}\n` +
|
|
44
|
+
`args = [${tomlStr(ctx.serverPath)}]\n` +
|
|
45
|
+
`env = { TRAINTRACK_AGENT = ${tomlStr(agentId)} }`);
|
|
46
|
+
}
|
|
47
|
+
/** Did this action actually mutate a file? files[] should list only real writes:
|
|
48
|
+
* never on a dry-run, and never for 'unchanged'/'error'/'skipped'. */
|
|
49
|
+
function wrote(action, ctx) {
|
|
50
|
+
return !ctx.dryRun && (action === 'added' || action === 'updated');
|
|
51
|
+
}
|
|
52
|
+
/** Ensure `file` opens with exactly `frontmatter` (e.g. Cursor MDC `---\n...\n---`).
|
|
53
|
+
* Idempotent: if the file already starts with the frontmatter, it is a no-op; if
|
|
54
|
+
* it exists without it, the frontmatter is prepended (a blank line separates it
|
|
55
|
+
* from existing content). Honors dryRun (never writes). The frontmatter MUST be
|
|
56
|
+
* the first bytes of the file for the host to recognize the rule, so it is added
|
|
57
|
+
* ahead of the marker-delimited awareness block rather than inside it. */
|
|
58
|
+
function ensureFrontmatter(file, frontmatter, dryRun) {
|
|
59
|
+
if (dryRun)
|
|
60
|
+
return;
|
|
61
|
+
if (!existsSync(file))
|
|
62
|
+
return; // upsertBlock will create it; prepended on the next pass below
|
|
63
|
+
const text = readFileSync(file, 'utf8');
|
|
64
|
+
if (text.startsWith(frontmatter))
|
|
65
|
+
return;
|
|
66
|
+
const sep = text.length === 0 || text.startsWith('\n') ? '' : '\n';
|
|
67
|
+
writeFileSync(file, frontmatter + '\n' + sep + text, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
/** Strip a leading `frontmatter` block from `file` (the inverse of ensureFrontmatter),
|
|
70
|
+
* used on uninstall so we leave no orphaned `---\nalwaysApply: true\n---` behind.
|
|
71
|
+
* Only removes it when it is the literal head of the file. Honors dryRun. */
|
|
72
|
+
function removeFrontmatter(file, frontmatter, dryRun) {
|
|
73
|
+
if (dryRun || !existsSync(file))
|
|
74
|
+
return;
|
|
75
|
+
const text = readFileSync(file, 'utf8');
|
|
76
|
+
if (!text.startsWith(frontmatter))
|
|
77
|
+
return;
|
|
78
|
+
let rest = text.slice(frontmatter.length);
|
|
79
|
+
if (rest.startsWith('\n'))
|
|
80
|
+
rest = rest.slice(1);
|
|
81
|
+
if (rest.startsWith('\n'))
|
|
82
|
+
rest = rest.slice(1);
|
|
83
|
+
writeFileSync(file, rest, 'utf8');
|
|
84
|
+
}
|
|
85
|
+
/** Apply the MCP entry + (optionally) awareness for one harness. */
|
|
86
|
+
export function configureHarness(spec, ctx) {
|
|
87
|
+
const mcpFile = join(ctx.home, spec.mcp.file);
|
|
88
|
+
const awarenessFile = join(ctx.home, spec.awarenessFile);
|
|
89
|
+
const files = [];
|
|
90
|
+
// --- MCP ---
|
|
91
|
+
let mcp;
|
|
92
|
+
if (spec.mcp.kind === 'toml') {
|
|
93
|
+
mcp = ctx.dryRun
|
|
94
|
+
? planBlock(mcpFile, TOML_START, TOML_END, tomlMcpBody(ctx, spec.id))
|
|
95
|
+
: upsertBlock(mcpFile, TOML_START, TOML_END, tomlMcpBody(ctx, spec.id));
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const path = spec.mcp.jsonPath ?? ['mcpServers', 'traintrack'];
|
|
99
|
+
// OpenCode reads a `mcp` map of local-server objects; everyone else uses the
|
|
100
|
+
// `mcpServers` {command, args, env} shape.
|
|
101
|
+
const value = spec.mcp.kind === 'json-opencode'
|
|
102
|
+
? {
|
|
103
|
+
type: 'local',
|
|
104
|
+
command: [ctx.nodePath, ctx.serverPath],
|
|
105
|
+
enabled: true,
|
|
106
|
+
environment: { TRAINTRACK_AGENT: spec.id },
|
|
107
|
+
}
|
|
108
|
+
: { command: ctx.nodePath, args: [ctx.serverPath], env: { TRAINTRACK_AGENT: spec.id } };
|
|
109
|
+
mcp = ctx.dryRun ? planJson(mcpFile, path, value) : upsertJson(mcpFile, path, value);
|
|
110
|
+
}
|
|
111
|
+
// files[] = paths actually WRITTEN: skip on dry-run, on error, and on 'unchanged'.
|
|
112
|
+
if (wrote(mcp, ctx))
|
|
113
|
+
files.push(mcpFile);
|
|
114
|
+
// --- Awareness ---
|
|
115
|
+
let awareness;
|
|
116
|
+
if (!ctx.injectAwareness) {
|
|
117
|
+
awareness = 'skipped';
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
const m = markers(spec.awarenessStyle);
|
|
121
|
+
awareness = ctx.dryRun
|
|
122
|
+
? planBlock(awarenessFile, m.start, m.end, AWARENESS_BODY)
|
|
123
|
+
: upsertBlock(awarenessFile, m.start, m.end, AWARENESS_BODY);
|
|
124
|
+
// Cursor (and any harness with awarenessFrontmatter) needs MDC frontmatter at
|
|
125
|
+
// the file head or the rule is never auto-applied. Prepend it after the block
|
|
126
|
+
// is written so it sits ABOVE the markers. Idempotent on re-run.
|
|
127
|
+
if (spec.awarenessFrontmatter && !ctx.dryRun) {
|
|
128
|
+
ensureFrontmatter(awarenessFile, spec.awarenessFrontmatter, ctx.dryRun);
|
|
129
|
+
}
|
|
130
|
+
if (wrote(awareness, ctx))
|
|
131
|
+
files.push(awarenessFile);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
harness: spec.id,
|
|
135
|
+
mcp,
|
|
136
|
+
awareness,
|
|
137
|
+
files,
|
|
138
|
+
detail: ctx.dryRun ? 'dry-run: no files written' : undefined,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Remove the MCP entry + awareness for one harness (the inverse of configureHarness). */
|
|
142
|
+
export function unconfigureHarness(spec, ctx) {
|
|
143
|
+
const mcpFile = join(ctx.home, spec.mcp.file);
|
|
144
|
+
const awarenessFile = join(ctx.home, spec.awarenessFile);
|
|
145
|
+
const files = [];
|
|
146
|
+
// --- MCP ---
|
|
147
|
+
let mcp;
|
|
148
|
+
if (spec.mcp.kind === 'toml') {
|
|
149
|
+
mcp = ctx.dryRun
|
|
150
|
+
? planRemoveBlock(mcpFile, TOML_START, TOML_END)
|
|
151
|
+
: removeBlock(mcpFile, TOML_START, TOML_END);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const path = spec.mcp.jsonPath ?? ['mcpServers', 'traintrack'];
|
|
155
|
+
mcp = ctx.dryRun ? planRemoveJson(mcpFile, path) : removeJson(mcpFile, path);
|
|
156
|
+
}
|
|
157
|
+
// files[] = paths actually WRITTEN: a real removal only (never on dry-run).
|
|
158
|
+
if (!ctx.dryRun && mcp === 'removed')
|
|
159
|
+
files.push(mcpFile);
|
|
160
|
+
// --- Awareness ---
|
|
161
|
+
const m = markers(spec.awarenessStyle);
|
|
162
|
+
const awareness = ctx.dryRun
|
|
163
|
+
? planRemoveBlock(awarenessFile, m.start, m.end)
|
|
164
|
+
: removeBlock(awarenessFile, m.start, m.end);
|
|
165
|
+
// Strip the MDC frontmatter we prepended on install (Cursor) so uninstall leaves
|
|
166
|
+
// no orphaned `---\nalwaysApply: true\n---` head behind.
|
|
167
|
+
if (spec.awarenessFrontmatter && !ctx.dryRun && awareness === 'removed') {
|
|
168
|
+
removeFrontmatter(awarenessFile, spec.awarenessFrontmatter, ctx.dryRun);
|
|
169
|
+
}
|
|
170
|
+
if (!ctx.dryRun && awareness === 'removed')
|
|
171
|
+
files.push(awarenessFile);
|
|
172
|
+
return {
|
|
173
|
+
harness: spec.id,
|
|
174
|
+
mcp,
|
|
175
|
+
awareness,
|
|
176
|
+
files,
|
|
177
|
+
detail: ctx.dryRun ? 'dry-run: no files written' : undefined,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, delimiter } from 'node:path';
|
|
3
|
+
import { HARNESSES } from './harness.js';
|
|
4
|
+
/** Detect which harnesses are available.
|
|
5
|
+
*
|
|
6
|
+
* @param args.home - The user's home directory to probe configHints under.
|
|
7
|
+
* @param args.onPath - A function that returns true if a binary is executable on PATH.
|
|
8
|
+
* Injected so tests can stub without touching the real filesystem or PATH.
|
|
9
|
+
*/
|
|
10
|
+
export function detectHarnesses(args) {
|
|
11
|
+
const { home, onPath } = args;
|
|
12
|
+
return HARNESSES.map((spec) => {
|
|
13
|
+
// 1. Check bins on PATH
|
|
14
|
+
for (const bin of spec.bins) {
|
|
15
|
+
if (onPath(bin)) {
|
|
16
|
+
return { spec, present: true, reason: `found '${bin}' on PATH` };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// 2. Check configHints under home
|
|
20
|
+
for (const hint of spec.configHints) {
|
|
21
|
+
const abs = join(home, hint);
|
|
22
|
+
if (existsSync(abs)) {
|
|
23
|
+
return { spec, present: true, reason: `config hint found: ${abs}` };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { spec, present: false, reason: 'not found on PATH and no config hints present' };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Real implementation: scan process.env.PATH directories for an executable named `bin`.
|
|
30
|
+
* No shelling out — pure Node.js fs checks. */
|
|
31
|
+
export function defaultOnPath(bin) {
|
|
32
|
+
const pathEnv = process.env['PATH'] ?? '';
|
|
33
|
+
// Use the platform PATH separator (':' on POSIX, ';' on Windows). traintrack
|
|
34
|
+
// targets macOS/Linux (see package.json "os"), but splitting on path.delimiter
|
|
35
|
+
// keeps this correct rather than relying on a hardcoded ':'.
|
|
36
|
+
const dirs = pathEnv.split(delimiter).filter((d) => d.length > 0);
|
|
37
|
+
for (const dir of dirs) {
|
|
38
|
+
const candidate = join(dir, bin);
|
|
39
|
+
try {
|
|
40
|
+
const st = statSync(candidate);
|
|
41
|
+
if (st.isFile()) {
|
|
42
|
+
// Check executable bit: owner (0o100), group (0o010), or other (0o001)
|
|
43
|
+
const mode = st.mode;
|
|
44
|
+
if (mode & 0o111)
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// ENOENT or EACCES — not found in this dir, continue
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** The canonical per-harness target table.
|
|
2
|
+
* Every field must match the plan exactly — downstream tasks consume this verbatim. */
|
|
3
|
+
export const HARNESSES = [
|
|
4
|
+
{
|
|
5
|
+
id: 'claude',
|
|
6
|
+
displayName: 'Claude Code',
|
|
7
|
+
bins: ['claude'],
|
|
8
|
+
configHints: ['.claude.json', '.claude'],
|
|
9
|
+
mcp: {
|
|
10
|
+
kind: 'json',
|
|
11
|
+
file: '.claude.json',
|
|
12
|
+
jsonPath: ['mcpServers', 'traintrack'],
|
|
13
|
+
},
|
|
14
|
+
awarenessFile: '.claude/CLAUDE.md',
|
|
15
|
+
awarenessStyle: 'md',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'codex',
|
|
19
|
+
displayName: 'Codex',
|
|
20
|
+
bins: ['codex'],
|
|
21
|
+
configHints: ['.codex', '.codex/config.toml'],
|
|
22
|
+
mcp: {
|
|
23
|
+
kind: 'toml',
|
|
24
|
+
file: '.codex/config.toml',
|
|
25
|
+
},
|
|
26
|
+
awarenessFile: '.codex/AGENTS.md',
|
|
27
|
+
awarenessStyle: 'toml',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'cursor',
|
|
31
|
+
displayName: 'Cursor',
|
|
32
|
+
bins: ['cursor'],
|
|
33
|
+
configHints: ['.cursor'],
|
|
34
|
+
mcp: {
|
|
35
|
+
kind: 'json',
|
|
36
|
+
file: '.cursor/mcp.json',
|
|
37
|
+
jsonPath: ['mcpServers', 'traintrack'],
|
|
38
|
+
},
|
|
39
|
+
awarenessFile: '.cursor/rules/traintrack.md',
|
|
40
|
+
awarenessStyle: 'md',
|
|
41
|
+
// Cursor only auto-attaches a rule to the model context when it carries MDC
|
|
42
|
+
// frontmatter. Without `alwaysApply: true` the rule defaults to a non-applied
|
|
43
|
+
// type and the lead never learns it can run a team. Emit it at the file head.
|
|
44
|
+
awarenessFrontmatter: '---\nalwaysApply: true\n---',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'opencode',
|
|
48
|
+
displayName: 'OpenCode',
|
|
49
|
+
bins: ['opencode'],
|
|
50
|
+
configHints: ['.config/opencode', '.opencode'],
|
|
51
|
+
mcp: {
|
|
52
|
+
// OpenCode's config uses a `mcp` map with local-server objects, not the
|
|
53
|
+
// `mcpServers` {command,args} shape — see configure.ts for the value built.
|
|
54
|
+
kind: 'json-opencode',
|
|
55
|
+
file: '.config/opencode/opencode.json',
|
|
56
|
+
jsonPath: ['mcp', 'traintrack'],
|
|
57
|
+
},
|
|
58
|
+
awarenessFile: '.config/opencode/AGENTS.md',
|
|
59
|
+
awarenessStyle: 'md',
|
|
60
|
+
},
|
|
61
|
+
];
|