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.
@@ -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
+ ];