playmakerlabs 0.0.0-stage → 0.1.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,422 @@
1
+ #!/usr/bin/env node
2
+ // The manager's board: tasks, dependencies, collision-free batches, worker lifecycle and integration.
3
+ // It is the deterministic half of a parallel run: what can start, what collides, what merged.
4
+ // Judgement (is the diff good, what to tell the user) stays with the manager agent.
5
+ //
6
+ // board.mjs validate
7
+ // board.mjs show [--json]
8
+ // board.mjs ready [--json] ready set and the batch that could start now
9
+ // board.mjs start [--dry-run] start the batch (one worktree + branch + worker per task)
10
+ // board.mjs wait [--timeout-min N] block until a worker delivers or fails (run it in the background)
11
+ // board.mjs integrate <task> [--no-merge] rebase on base, run gates, fast-forward merge, clean up
12
+ // board.mjs set <task> <status> [--note T] [--confirmed-by-user]
13
+ // board.mjs report [--json]
14
+ //
15
+ // Board file: <repo>/.playmaker/board.json, format in skills/orchestrate/references/board.md
16
+ import path from 'node:path';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { makeWorker, removeWorktree, startWorker, checkFree, ID_RE } from './lib/launch.mjs';
19
+ import { PROVIDER_IDS, PROVIDERS } from './lib/providers.mjs';
20
+ import { git, pidAlive, readJson, repoRoot, tryGit, writeJsonAtomic } from './lib/util.mjs';
21
+ import { commandLine, stamp } from './spawn.mjs';
22
+
23
+ const STATUSES = ['not_started', 'in_progress', 'delivered', 'rework', 'to_test', 'refix', 'done', 'postponed'];
24
+ const MERGED = new Set(['to_test', 'done']);
25
+ const MAX_ATTEMPTS = 2;
26
+
27
+ const argv = process.argv.slice(2);
28
+ const cmd = argv[0];
29
+ const rest = argv.slice(1);
30
+ const flag = (name) => rest.includes(name);
31
+ const flagValue = (name) => {
32
+ const i = rest.indexOf(name);
33
+ return i >= 0 ? rest[i + 1] : undefined;
34
+ };
35
+ const positional = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && ['--note', '--timeout-min'].includes(rest[i - 1])));
36
+
37
+ function die(message, code = 1) {
38
+ process.stderr.write(`playmaker: ${message}\n`);
39
+ process.exit(code);
40
+ }
41
+
42
+ const root = repoRoot();
43
+ if (!root) die('run this from the main worktree of a git repository.');
44
+ const boardFile = path.join(root, '.playmaker', 'board.json');
45
+
46
+ // ---------------------------------------------------------------- model
47
+
48
+ function load() {
49
+ const b = readJson(boardFile);
50
+ if (!b) die(`no board at ${boardFile}. Write it first (format: skills/orchestrate/references/board.md).`);
51
+ b.base = b.base || 'main';
52
+ b.cap = b.cap || 4;
53
+ b.gates = b.gates || [];
54
+ b.sharedFiles = b.sharedFiles || [];
55
+ b.timeoutMin = b.timeoutMin || 30;
56
+ for (const t of b.tasks || []) {
57
+ t.status = t.status || 'not_started';
58
+ t.deps = t.deps || [];
59
+ t.owns = t.owns || [];
60
+ t.shared = t.shared || [];
61
+ t.resources = t.resources || [];
62
+ t.attempts = t.attempts || 0;
63
+ t.round = t.round || 0;
64
+ t.branches = t.branches || [];
65
+ t.history = t.history || [];
66
+ }
67
+ return b;
68
+ }
69
+
70
+ const save = (b) => writeJsonAtomic(boardFile, b);
71
+ const norm = (p) => String(p).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
72
+ const pathsOverlap = (x, y) => x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
73
+
74
+ function collides(a, b) {
75
+ for (const x of a.owns) for (const y of b.owns) if (pathsOverlap(norm(x), norm(y))) return `both edit ${norm(x)}`;
76
+ for (const s of a.shared) if (b.shared.includes(s)) return `both need shared file ${s}`;
77
+ for (const r of a.resources) if (b.resources.includes(r)) return `both need resource ${r}`;
78
+ return null;
79
+ }
80
+
81
+ function validate(b) {
82
+ const problems = [];
83
+ if (!Array.isArray(b.tasks) || b.tasks.length === 0) return ['board needs a non-empty "tasks" array.'];
84
+ if (!tryGit(root, 'rev-parse', '--verify', b.base)) problems.push(`base "${b.base}" is not a branch or commit.`);
85
+ if (!Number.isInteger(b.cap) || b.cap < 1 || b.cap > 8) problems.push('cap must be an integer from 1 to 8.');
86
+ const ids = new Set();
87
+ for (const t of b.tasks) {
88
+ if (!ID_RE.test(t.id || '')) problems.push(`task id "${t.id}" is invalid. Use lowercase letters, digits and dashes.`);
89
+ else if (/-r\d+$/.test(t.id)) problems.push(`task id "${t.id}" ends in -rN, which is reserved for retries.`);
90
+ if (ids.has(t.id)) problems.push(`duplicate task id "${t.id}".`);
91
+ ids.add(t.id);
92
+ if (!PROVIDER_IDS.includes(t.provider)) problems.push(`task "${t.id}": unknown provider "${t.provider}". Known: ${PROVIDER_IDS.join(', ')}.`);
93
+ if (!t.brief || !String(t.brief).trim()) problems.push(`task "${t.id}": empty brief.`);
94
+ if (!STATUSES.includes(t.status)) problems.push(`task "${t.id}": unknown status "${t.status}".`);
95
+ if (!t.owns.length) problems.push(`task "${t.id}": "owns" is empty. Name the files or folders it edits so collisions can be checked.`);
96
+ for (const s of t.shared) if (!b.sharedFiles.includes(s)) problems.push(`task "${t.id}": shared file "${s}" is not in board.sharedFiles.`);
97
+ }
98
+ for (const t of b.tasks) {
99
+ for (const d of t.deps) {
100
+ if (!ids.has(d)) problems.push(`task "${t.id}": depends on unknown task "${d}".`);
101
+ if (d === t.id) problems.push(`task "${t.id}": depends on itself.`);
102
+ }
103
+ }
104
+ // cycles
105
+ const byId = new Map(b.tasks.map((t) => [t.id, t]));
106
+ const state = new Map();
107
+ const visit = (id, trail) => {
108
+ if (state.get(id) === 2) return;
109
+ if (state.get(id) === 1) {
110
+ problems.push(`dependency cycle: ${[...trail, id].join(' -> ')}`);
111
+ return;
112
+ }
113
+ state.set(id, 1);
114
+ for (const d of byId.get(id)?.deps || []) if (byId.has(d)) visit(d, [...trail, id]);
115
+ state.set(id, 2);
116
+ };
117
+ for (const t of b.tasks) visit(t.id, []);
118
+ return problems;
119
+ }
120
+
121
+ function computeReady(b) {
122
+ const byId = new Map(b.tasks.map((t) => [t.id, t]));
123
+ const running = b.tasks.filter((t) => t.status === 'in_progress');
124
+ const candidates = [];
125
+ const blocked = [];
126
+ for (const t of b.tasks) {
127
+ if (!['not_started', 'rework'].includes(t.status)) continue;
128
+ if (t.blockedOnUser) {
129
+ blocked.push({ id: t.id, why: 'waiting for something only the user can do' });
130
+ continue;
131
+ }
132
+ const unmet = t.deps.filter((d) => !MERGED.has(byId.get(d)?.status));
133
+ if (unmet.length) {
134
+ const parked = unmet.filter((d) => ['refix', 'postponed'].includes(byId.get(d)?.status));
135
+ blocked.push({ id: t.id, why: parked.length ? `dependency parked: ${parked.join(', ')}` : `waiting for ${unmet.join(', ')} to be merged` });
136
+ continue;
137
+ }
138
+ candidates.push(t);
139
+ }
140
+ const batch = [];
141
+ const slots = b.cap - running.length;
142
+ for (const c of candidates) {
143
+ if (batch.length >= slots) {
144
+ blocked.push({ id: c.id, why: 'no free slot (cap reached)' });
145
+ continue;
146
+ }
147
+ let reason = null;
148
+ for (const r of [...running, ...batch]) {
149
+ reason = collides(r, c);
150
+ if (reason) {
151
+ reason = `${reason} with ${r.id}`;
152
+ break;
153
+ }
154
+ }
155
+ if (reason) blocked.push({ id: c.id, why: reason });
156
+ else batch.push(c);
157
+ }
158
+ return { ready: candidates.map((t) => t.id), batch: batch.map((t) => t.id), running: running.map((t) => t.id), blocked };
159
+ }
160
+
161
+ function failTask(t, reason) {
162
+ t.attempts += 1;
163
+ t.feedback = reason;
164
+ t.status = t.attempts >= MAX_ATTEMPTS ? 'refix' : 'rework';
165
+ t.history.push({ at: new Date().toISOString(), event: 'failed', status: t.status, reason: reason.slice(0, 500) });
166
+ }
167
+
168
+ /** Fold worker outcomes into the board. Returns what changed. */
169
+ function sync(b) {
170
+ const out = { changed: false, delivered: [], failed: [] };
171
+ for (const t of b.tasks) {
172
+ if (t.status !== 'in_progress' || !t.worker) continue;
173
+ const dir = path.join(root, '.playmaker', 'runs', b.run, t.worker);
174
+ const st = readJson(path.join(dir, 'status.json'));
175
+ if (!st) continue;
176
+ let state = st.state;
177
+ if (state === 'running' && !pidAlive(st.supervisorPid)) state = 'stale';
178
+ if (['queued', 'running'].includes(state)) continue;
179
+
180
+ out.changed = true;
181
+ if (state === 'done' && st.filesChanged > 0) {
182
+ t.status = 'delivered';
183
+ t.head = st.head;
184
+ t.history.push({ at: new Date().toISOString(), event: 'delivered', worker: t.worker, head: st.head });
185
+ out.delivered.push(t.id);
186
+ } else {
187
+ const why =
188
+ state === 'done'
189
+ ? 'the worker finished without committing any change'
190
+ : `worker ${t.worker} ended as "${state}"${st.error ? `: ${st.error}` : ''}${st.commitError ? ` (commit error: ${st.commitError})` : ''}`;
191
+ failTask(t, `${why}. See ${path.join(dir, 'log.txt')}`);
192
+ out.failed.push({ id: t.id, reason: t.feedback, status: t.status });
193
+ }
194
+ }
195
+ return out;
196
+ }
197
+
198
+ const tail = (s, n = 4000) => (s.length > n ? `...${s.slice(-n)}` : s);
199
+
200
+ // ---------------------------------------------------------------- commands
201
+
202
+ function cmdValidate() {
203
+ const problems = validate(load());
204
+ if (problems.length) {
205
+ for (const p of problems) console.error(`- ${p}`);
206
+ process.exit(1);
207
+ }
208
+ console.log('Board is valid.');
209
+ }
210
+
211
+ function cmdShow() {
212
+ const b = load();
213
+ if (flag('--json')) return console.log(JSON.stringify(b, null, 2));
214
+ const pad = (s, n) => String(s).padEnd(n);
215
+ console.log(`base ${b.base} cap ${b.cap} gates ${b.gates.length} run ${b.run || '-'}`);
216
+ console.log(`${pad('TASK', 20)}${pad('STATUS', 13)}${pad('PROVIDER', 10)}${pad('ROUND', 6)}DEPS`);
217
+ for (const t of b.tasks) console.log(`${pad(t.id, 20)}${pad(t.status, 13)}${pad(t.provider, 10)}${pad(t.round, 6)}${t.deps.join(',') || '-'}`);
218
+ }
219
+
220
+ function cmdReady() {
221
+ const b = load();
222
+ const problems = validate(b);
223
+ if (problems.length) die(`board is invalid:\n- ${problems.join('\n- ')}`);
224
+ const r = computeReady(b);
225
+ if (flag('--json')) return console.log(JSON.stringify(r, null, 2));
226
+ console.log(`running: ${r.running.join(', ') || '-'}`);
227
+ console.log(`batch: ${r.batch.join(', ') || '-'}`);
228
+ for (const x of r.blocked) console.log(`held: ${x.id} (${x.why})`);
229
+ }
230
+
231
+ function cmdStart() {
232
+ const b = load();
233
+ const problems = validate(b);
234
+ if (problems.length) die(`board is invalid:\n- ${problems.join('\n- ')}`);
235
+ if (sync(b).changed) save(b);
236
+
237
+ const { batch } = computeReady(b);
238
+ if (!batch.length) {
239
+ console.log('Nothing can start right now.');
240
+ return;
241
+ }
242
+ const run = b.run || stamp('b');
243
+ const byId = new Map(b.tasks.map((t) => [t.id, t]));
244
+ const plans = batch.map((id) => {
245
+ const t = byId.get(id);
246
+ const round = t.round + 1;
247
+ const workerId = round === 1 ? t.id : `${t.id}-r${round}`;
248
+ const prevBranch = t.branch && tryGit(root, 'rev-parse', '--verify', '--quiet', `refs/heads/${t.branch}`) ? t.branch : null;
249
+ const retry = round > 1 && t.feedback;
250
+ const brief =
251
+ t.brief +
252
+ (retry
253
+ ? `\n\n## Previous attempt failed integration\n${t.feedback}\n\nYour branch already contains the commits of that attempt. Fix what is described above, keep the rest. If the failure was a rebase conflict, run "git rebase ${b.base}" in your worktree and resolve it.\n`
254
+ : '');
255
+ const w = makeWorker(root, {
256
+ run,
257
+ id: workerId,
258
+ provider: t.provider,
259
+ brief,
260
+ model: t.model,
261
+ allow: t.allow,
262
+ extraArgs: t.extraArgs,
263
+ timeoutMin: t.timeoutMin || b.timeoutMin,
264
+ startPoint: round > 1 && prevBranch ? prevBranch : b.base,
265
+ });
266
+ return { t, w, round };
267
+ });
268
+
269
+ for (const { t, w } of plans) {
270
+ console.log(`- ${t.id}${w.id !== t.id ? ` (round ${w.id.split('-r').pop()})` : ''}: ${PROVIDERS[t.provider].label}${w.installed ? '' : ' (NOT FOUND ON PATH)'}`);
271
+ console.log(` branch ${w.branch}`);
272
+ console.log(` worktree ${w.worktree}`);
273
+ console.log(` command ${commandLine(w.command, w.args)}`);
274
+ }
275
+ if (flag('--dry-run')) return console.log('\nDry run only. Nothing was started.');
276
+ const missing = plans.filter((p) => !p.w.installed);
277
+ if (missing.length) die(`not installed: ${missing.map((p) => `${p.t.id} (${p.t.provider})`).join(', ')}.`);
278
+
279
+ for (const { t, w, round } of plans) {
280
+ if (round > 1 && t.worktree) removeWorktree(root, t.worktree);
281
+ checkFree(root, w);
282
+ }
283
+ b.run = run;
284
+ for (const { t, w, round } of plans) {
285
+ startWorker(root, w, { gates: b.gates });
286
+ t.status = 'in_progress';
287
+ t.worker = w.id;
288
+ t.round = round;
289
+ t.branch = w.branch;
290
+ t.worktree = w.worktree;
291
+ t.branches.push(w.branch);
292
+ t.history.push({ at: new Date().toISOString(), event: 'started', worker: w.id, provider: t.provider });
293
+ save(b);
294
+ }
295
+ console.log(`\nStarted ${plans.length} worker(s). Wait for deliveries with:\n node "${process.argv[1]}" wait`);
296
+ }
297
+
298
+ async function cmdWait() {
299
+ const timeoutMin = Number(flagValue('--timeout-min')) || 120;
300
+ const deadline = Date.now() + timeoutMin * 60000;
301
+ const newlyFailed = [];
302
+ for (;;) {
303
+ const b = load();
304
+ const ch = sync(b);
305
+ if (ch.changed) save(b);
306
+ newlyFailed.push(...ch.failed);
307
+ const running = b.tasks.filter((t) => t.status === 'in_progress').map((t) => t.id);
308
+ const delivered = b.tasks.filter((t) => t.status === 'delivered').map((t) => t.id);
309
+ const timedOut = Date.now() > deadline;
310
+ if (delivered.length || newlyFailed.length || running.length === 0 || timedOut) {
311
+ console.log(JSON.stringify({ delivered, failed: newlyFailed, running, timedOut: timedOut || undefined }, null, 2));
312
+ return;
313
+ }
314
+ await new Promise((r) => setTimeout(r, 2000));
315
+ }
316
+ }
317
+
318
+ function runGate(gate, cwd) {
319
+ const res = spawnSync(gate, { cwd, shell: true, encoding: 'utf8', timeout: 10 * 60 * 1000, windowsHide: true });
320
+ return { ok: res.status === 0, output: `${res.stdout || ''}${res.stderr || ''}`.trim(), status: res.status, signal: res.signal };
321
+ }
322
+
323
+ function cmdIntegrate() {
324
+ const id = positional[0];
325
+ if (!id) die('usage: integrate <task> [--no-merge]');
326
+ const b = load();
327
+ const t = b.tasks.find((x) => x.id === id);
328
+ if (!t) die(`no task "${id}".`);
329
+ if (t.status !== 'delivered') die(`task "${id}" is "${t.status}", not "delivered".`, 2);
330
+
331
+ const current = tryGit(root, 'branch', '--show-current');
332
+ if (current !== b.base) die(`the main worktree is on "${current}", not on base "${b.base}". Switch it first.`, 2);
333
+ if (git(root, 'status', '--porcelain')) die('the main worktree has uncommitted changes. Commit or stash them first.', 2);
334
+
335
+ const fail = (stage, detail) => {
336
+ failTask(t, `${stage}\n${tail(detail)}`);
337
+ save(b);
338
+ console.log(JSON.stringify({ task: id, ok: false, stage, status: t.status, attempts: t.attempts, detail: tail(detail, 2000) }, null, 2));
339
+ process.exit(3);
340
+ };
341
+
342
+ // 1. rebase the worker's branch on the current base
343
+ const rebase = spawnSync('git', ['rebase', b.base], { cwd: t.worktree, encoding: 'utf8' });
344
+ if (rebase.status !== 0) {
345
+ spawnSync('git', ['rebase', '--abort'], { cwd: t.worktree });
346
+ fail(`rebase onto ${b.base} failed`, `${rebase.stdout || ''}${rebase.stderr || ''}`);
347
+ }
348
+
349
+ // 2. gates, run by the manager, never taken from the worker's word
350
+ const gateResults = [];
351
+ for (const gate of b.gates) {
352
+ const r = runGate(gate, t.worktree);
353
+ gateResults.push({ gate, ok: r.ok });
354
+ if (!r.ok) fail(`gate failed: ${gate}`, r.output || `exit ${r.status} ${r.signal || ''}`);
355
+ }
356
+
357
+ if (flag('--no-merge')) {
358
+ console.log(JSON.stringify({ task: id, ok: true, merged: false, gates: gateResults }, null, 2));
359
+ return;
360
+ }
361
+
362
+ // 3. fast-forward merge, never force
363
+ const merge = spawnSync('git', ['merge', '--ff-only', t.branch], { cwd: root, encoding: 'utf8' });
364
+ if (merge.status !== 0) fail('fast-forward merge failed', `${merge.stdout || ''}${merge.stderr || ''}`);
365
+
366
+ t.status = 'to_test';
367
+ t.merged = git(root, 'rev-parse', 'HEAD');
368
+ t.history.push({ at: new Date().toISOString(), event: 'merged', commit: t.merged });
369
+ removeWorktree(root, t.worktree);
370
+ for (const br of t.branches) tryGit(root, 'branch', '-D', br);
371
+ save(b);
372
+ console.log(JSON.stringify({ task: id, ok: true, merged: true, commit: t.merged, status: t.status, gates: gateResults }, null, 2));
373
+ }
374
+
375
+ function cmdSet() {
376
+ const [id, status] = positional;
377
+ if (!id || !status) die('usage: set <task> <status> [--note text] [--confirmed-by-user]');
378
+ if (!STATUSES.includes(status)) die(`unknown status "${status}". Known: ${STATUSES.join(', ')}.`);
379
+ const b = load();
380
+ const t = b.tasks.find((x) => x.id === id);
381
+ if (!t) die(`no task "${id}".`);
382
+ if (status === 'done' && !flag('--confirmed-by-user')) {
383
+ die('only the user promotes a task to "done". Pass --confirmed-by-user once they have said so.', 2);
384
+ }
385
+ t.status = status;
386
+ if (status === 'refix' || status === 'postponed') t.note = flagValue('--note') || t.note;
387
+ t.history.push({ at: new Date().toISOString(), event: `set:${status}`, note: flagValue('--note') });
388
+ save(b);
389
+ console.log(`${id} -> ${status}`);
390
+ }
391
+
392
+ function cmdReport() {
393
+ const b = load();
394
+ const groups = {};
395
+ for (const t of b.tasks) (groups[t.status] ||= []).push(t);
396
+ if (flag('--json')) {
397
+ return console.log(JSON.stringify(Object.fromEntries(Object.entries(groups).map(([k, v]) => [k, v.map((t) => ({ id: t.id, commit: t.merged, attempts: t.attempts, note: t.note, feedback: t.feedback }))])), null, 2));
398
+ }
399
+ const order = ['to_test', 'done', 'delivered', 'in_progress', 'rework', 'refix', 'postponed', 'not_started'];
400
+ for (const s of order) {
401
+ if (!groups[s]) continue;
402
+ console.log(`${s} (${groups[s].length})`);
403
+ for (const t of groups[s]) {
404
+ const extra = [t.merged ? `commit ${t.merged.slice(0, 8)}` : null, t.attempts ? `${t.attempts} failed attempt(s)` : null, t.note, s === 'refix' || s === 'rework' ? t.feedback?.split('\n')[0] : null]
405
+ .filter(Boolean)
406
+ .join('; ');
407
+ console.log(` - ${t.id}${extra ? ` (${extra})` : ''}`);
408
+ }
409
+ }
410
+ const r = computeReady(b);
411
+ if (r.blocked.length) {
412
+ console.log('held');
413
+ for (const x of r.blocked) console.log(` - ${x.id} (${x.why})`);
414
+ }
415
+ }
416
+
417
+ const commands = { validate: cmdValidate, show: cmdShow, ready: cmdReady, start: cmdStart, wait: cmdWait, integrate: cmdIntegrate, set: cmdSet, report: cmdReport };
418
+ if (!commands[cmd]) {
419
+ console.log('usage: board.mjs validate | show | ready | start | wait | integrate <task> | set <task> <status> | report');
420
+ process.exit(cmd ? 1 : 0);
421
+ }
422
+ await commands[cmd]();
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ // Detects which agent CLIs are installed and whether Playmaker runs are in flight.
3
+ // --hook SessionStart hook: reads stdin, prints hook JSON (only when there is something useful to say)
4
+ // --print prints the detection result as JSON
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { PROVIDERS } from './lib/providers.mjs';
9
+ import { dataDir, hookOutput, option, pidAlive, readJson, readStdinJson, repoRoot, which, writeJsonAtomic } from './lib/util.mjs';
10
+
11
+ function agentTeamsEnabled() {
12
+ if (process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === '1') return true;
13
+ const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json'), {});
14
+ return settings?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === '1';
15
+ }
16
+
17
+ function runningWorkers(root) {
18
+ if (!root) return 0;
19
+ const runsDir = path.join(root, '.playmaker', 'runs');
20
+ let count = 0;
21
+ try {
22
+ for (const run of fs.readdirSync(runsDir)) {
23
+ for (const worker of fs.readdirSync(path.join(runsDir, run))) {
24
+ const status = readJson(path.join(runsDir, run, worker, 'status.json'));
25
+ if (status?.state === 'running' && pidAlive(status.supervisorPid)) count += 1;
26
+ }
27
+ }
28
+ } catch {
29
+ /* no runs yet */
30
+ }
31
+ return count;
32
+ }
33
+
34
+ export function detect(cwd = process.cwd()) {
35
+ const providers = {};
36
+ for (const [id, p] of Object.entries(PROVIDERS)) {
37
+ const found = which(p.bin);
38
+ if (found) providers[id] = { label: p.label, path: found };
39
+ }
40
+ const root = repoRoot(cwd);
41
+ return {
42
+ detectedAt: new Date().toISOString(),
43
+ providers,
44
+ agentTeams: agentTeamsEnabled(),
45
+ gitRepo: Boolean(root),
46
+ running: runningWorkers(root),
47
+ };
48
+ }
49
+
50
+ const args = process.argv.slice(2);
51
+ if (args.includes('--hook')) {
52
+ const input = await readStdinJson();
53
+ const result = detect(input.cwd || process.cwd());
54
+ try {
55
+ writeJsonAtomic(path.join(dataDir(), 'providers.json'), result);
56
+ } catch {
57
+ /* cache is optional */
58
+ }
59
+ const ids = Object.keys(result.providers);
60
+ const worthSaying = option('proactive', 'true') !== 'false' && (ids.length >= 2 || result.running > 0 || result.agentTeams);
61
+ if (worthSaying) {
62
+ const parts = [
63
+ `Playmaker: agent CLIs on PATH: ${ids.join(', ') || 'none'}.`,
64
+ `Agent teams: ${result.agentTeams ? 'enabled' : 'off'}.`,
65
+ `Playmaker workers running in this repo: ${result.running}.`,
66
+ 'The playmaker:orchestrate and playmaker:run skills cover parallel execution.',
67
+ ];
68
+ process.stdout.write(hookOutput('SessionStart', parts.join(' ')));
69
+ }
70
+ } else if (args.includes('--print')) {
71
+ process.stdout.write(JSON.stringify(detect(), null, 2) + '\n');
72
+ } else {
73
+ process.stdout.write('usage: detect-agents.mjs --hook | --print\n');
74
+ }
@@ -0,0 +1,114 @@
1
+ // Creating and starting one worker: git worktree, branch, brief, detached supervisor.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { PROVIDERS, PROVIDER_IDS, WORKER_PROMPT } from './providers.mjs';
7
+ import { git, tryGit, which, writeJsonAtomic } from './util.mjs';
8
+
9
+ export const ID_RE = /^[a-z0-9][a-z0-9-]{0,30}$/;
10
+ const SUPERVISOR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'spawn.mjs');
11
+
12
+ export function ensureExcluded(root) {
13
+ const common = git(root, 'rev-parse', '--git-common-dir');
14
+ const excludeFile = path.join(path.resolve(root, common), 'info', 'exclude');
15
+ fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
16
+ const current = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, 'utf8') : '';
17
+ if (!current.split(/\r?\n/).includes('.playmaker/')) {
18
+ fs.appendFileSync(excludeFile, `${current && !current.endsWith('\n') ? '\n' : ''}.playmaker/\n`);
19
+ }
20
+ }
21
+
22
+ export function workerFooter(w, gates = []) {
23
+ const lines = [
24
+ '',
25
+ '---',
26
+ 'Playmaker rules (added automatically)',
27
+ `- You are worker "${w.id}" of run "${w.run}", in a git worktree on branch ${w.branch}. Work only there.`,
28
+ '- Do one task: the one above. Do not pick up other tasks and do not start other agents.',
29
+ '- Commit your work on this branch with a conventional commit message (feat:, fix:, test:, docs:, refactor:). A final commit by the supervisor picks up anything you leave uncommitted.',
30
+ '- Do not merge, push, checkout, switch or reset, and do not touch any other worktree.',
31
+ '- Do not edit files outside the scope above. If the task needs that, stop and say so in your report instead.',
32
+ ];
33
+ if (gates.length) {
34
+ lines.push('- Gates you must run and pass before reporting:');
35
+ for (const g of gates) lines.push(` ${g}`);
36
+ }
37
+ lines.push(
38
+ '- Finish with a report that states: what you built, the commit hash, each gate you ran with a summary of its real output, anything you could not do or did not verify, and any decision you took that the user would normally take.',
39
+ ''
40
+ );
41
+ return lines.join('\n');
42
+ }
43
+
44
+ /** Build the full record for one worker without touching the filesystem or git state. */
45
+ export function makeWorker(root, spec) {
46
+ const provider = PROVIDERS[spec.provider];
47
+ if (!provider) throw new Error(`unknown provider "${spec.provider}" for "${spec.id}". Known: ${PROVIDER_IDS.join(', ')}.`);
48
+ if (!ID_RE.test(spec.id || '')) throw new Error(`invalid worker id "${spec.id}". Use lowercase letters, digits and dashes.`);
49
+ if (!spec.brief || !spec.brief.trim()) throw new Error(`worker "${spec.id}" has no brief.`);
50
+
51
+ const startPoint = spec.startPoint || 'HEAD';
52
+ const baseSha = tryGit(root, 'rev-parse', '--verify', startPoint);
53
+ if (!baseSha) throw new Error(`cannot resolve "${startPoint}".`);
54
+
55
+ const workerDir = path.join(root, '.playmaker', 'runs', spec.run, spec.id);
56
+ const w = {
57
+ id: spec.id,
58
+ run: spec.run,
59
+ provider: spec.provider,
60
+ branch: `playmaker/${spec.run}/${spec.id}`,
61
+ worktree: path.join(path.dirname(root), `${path.basename(root)}-playmaker`, spec.run, spec.id),
62
+ workerDir,
63
+ base: startPoint,
64
+ baseSha,
65
+ timeoutMin: spec.timeoutMin || 30,
66
+ commit: spec.commit !== false,
67
+ brief: spec.brief,
68
+ installed: Boolean(which(provider.bin)),
69
+ command: provider.bin,
70
+ };
71
+ w.args = provider.build({
72
+ prompt: WORKER_PROMPT,
73
+ model: spec.model || '',
74
+ allow: spec.allow || [],
75
+ extra: spec.extraArgs || [],
76
+ resultPath: path.join(workerDir, 'result.md'),
77
+ });
78
+ return w;
79
+ }
80
+
81
+ /** Refuse to start when the branch or worktree path is already taken. */
82
+ export function checkFree(root, w) {
83
+ if (tryGit(root, 'rev-parse', '--verify', '--quiet', `refs/heads/${w.branch}`)) throw new Error(`branch ${w.branch} already exists.`);
84
+ if (fs.existsSync(w.worktree)) throw new Error(`${w.worktree} already exists.`);
85
+ }
86
+
87
+ /** Create the worktree and branch, write the brief, start the detached supervisor. */
88
+ export function startWorker(root, w, { gates = [] } = {}) {
89
+ checkFree(root, w);
90
+ ensureExcluded(root);
91
+ fs.mkdirSync(path.dirname(w.worktree), { recursive: true });
92
+ git(root, 'worktree', 'add', '-b', w.branch, w.worktree, w.baseSha);
93
+ fs.mkdirSync(path.join(w.worktree, '.playmaker'), { recursive: true });
94
+ const brief = w.brief.trimEnd() + '\n' + workerFooter(w, gates);
95
+ fs.writeFileSync(path.join(w.worktree, '.playmaker', 'brief.md'), brief);
96
+
97
+ fs.mkdirSync(w.workerDir, { recursive: true });
98
+ fs.writeFileSync(path.join(w.workerDir, 'brief.md'), brief);
99
+ const { brief: _brief, installed: _installed, ...record } = w;
100
+ writeJsonAtomic(path.join(w.workerDir, 'worker.json'), { ...record, createdAt: new Date().toISOString() });
101
+ writeJsonAtomic(path.join(w.workerDir, 'status.json'), { state: 'queued' });
102
+
103
+ const sup = spawn(process.execPath, [SUPERVISOR, '--supervise', w.workerDir], {
104
+ detached: true,
105
+ stdio: 'ignore',
106
+ windowsHide: true,
107
+ });
108
+ sup.unref();
109
+ }
110
+
111
+ export function removeWorktree(root, worktree) {
112
+ if (fs.existsSync(worktree)) tryGit(root, 'worktree', 'remove', '--force', worktree);
113
+ tryGit(root, 'worktree', 'prune');
114
+ }
@@ -0,0 +1,76 @@
1
+ // Headless invocation of each agent CLI. Flags checked against each vendor's docs on 2026-10-26;
2
+ // see skills/orchestrate/references/providers.md for sources and caveats.
3
+ //
4
+ // Safety stance: every provider runs in its "edit files, ask before anything riskier" mode.
5
+ // No provider is ever started with a bypass/yolo flag. A plan can add flags through `extraArgs`,
6
+ // which is an explicit choice by the user, never a default.
7
+
8
+ export const PROVIDERS = {
9
+ claude: {
10
+ label: 'Claude Code',
11
+ bin: 'claude',
12
+ // `allow` entries are Claude permission rules, for example "Bash(npm test)".
13
+ build: ({ prompt, model, allow, extra }) => [
14
+ '-p', prompt,
15
+ '--permission-mode', 'acceptEdits',
16
+ ...(model ? ['--model', model] : []),
17
+ ...extra,
18
+ // variadic flag goes last so it cannot swallow other arguments
19
+ ...(allow.length ? ['--allowedTools', ...allow] : []),
20
+ ],
21
+ },
22
+ codex: {
23
+ label: 'Codex CLI',
24
+ bin: 'codex',
25
+ resultFile: true,
26
+ // workspace-write sandbox lets Codex edit and run commands inside the worktree, without network.
27
+ build: ({ prompt, model, extra, resultPath }) => [
28
+ 'exec',
29
+ '--sandbox', 'workspace-write',
30
+ ...(model ? ['-m', model] : []),
31
+ ...extra,
32
+ '-o', resultPath,
33
+ prompt,
34
+ ],
35
+ },
36
+ gemini: {
37
+ label: 'Gemini CLI',
38
+ bin: 'gemini',
39
+ build: ({ prompt, model, extra }) => [
40
+ '-p', prompt,
41
+ '--approval-mode', 'auto_edit',
42
+ ...(model ? ['-m', model] : []),
43
+ ...extra,
44
+ ],
45
+ },
46
+ agy: {
47
+ label: 'Antigravity CLI',
48
+ bin: 'agy',
49
+ // Shell tools stay denied in headless runs unless the user's settings.json pre-approves them.
50
+ build: ({ prompt, extra }) => [
51
+ '-p', prompt,
52
+ '--mode', 'accept-edits',
53
+ '--output-format', 'text',
54
+ ...extra,
55
+ ],
56
+ },
57
+ copilot: {
58
+ label: 'GitHub Copilot CLI',
59
+ bin: 'copilot',
60
+ // `allow` entries are Copilot tool rules, for example "shell(npm test)". Writing is always allowed.
61
+ build: ({ prompt, model, allow, extra }) => [
62
+ '-p', prompt,
63
+ '-s',
64
+ '--no-ask-user',
65
+ `--allow-tool=${['write', ...allow].join(', ')}`,
66
+ ...(model ? ['--model', model] : []),
67
+ ...extra,
68
+ ],
69
+ },
70
+ };
71
+
72
+ export const PROVIDER_IDS = Object.keys(PROVIDERS);
73
+
74
+ export const WORKER_PROMPT =
75
+ 'Read the file .playmaker/brief.md in the current directory and carry out the task it describes. ' +
76
+ 'Stay inside the scope it defines and finish with the report format it asks for.';