@mmerterden/multi-agent-pipeline 17.0.0 → 17.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.
- package/CHANGELOG.md +32 -0
- package/README.md +49 -4
- package/README.tr.md +50 -4
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/install/templates/multi-agent-autopilot.plist.template +79 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +64 -0
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +173 -0
- package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +74 -0
- package/pipeline/commands/multi-agent/channels/SKILL.md +41 -12
- package/pipeline/commands/multi-agent/help/SKILL.md +41 -35
- package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
- package/pipeline/lib/autopilot-activation.sh +117 -0
- package/pipeline/lib/autopilot-state.sh +150 -0
- package/pipeline/lib/issue-fetcher.sh +18 -1
- package/pipeline/lib/plan-todos.sh +18 -0
- package/pipeline/multi-agent-refs/channels/jira.md +80 -20
- package/pipeline/multi-agent-refs/channels/pr.md +65 -19
- package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -5
- package/pipeline/multi-agent-refs/features/visual-evidence.md +19 -7
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-2-planning.md +17 -15
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +1 -1
- package/pipeline/multi-agent-refs/readiness-review.md +7 -1
- package/pipeline/multi-agent-refs/rules.md +3 -11
- package/pipeline/multi-agent-refs/tracker-contract.md +32 -0
- package/pipeline/schemas/autopilot-config.schema.json +149 -0
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/scripts/autopilot-arming.mjs +147 -0
- package/pipeline/scripts/autopilot-intake.mjs +383 -0
- package/pipeline/scripts/autopilot-menubar.swift +361 -0
- package/pipeline/scripts/autopilot-runner.mjs +349 -0
- package/pipeline/scripts/autopilot-status.sh +212 -0
- package/pipeline/scripts/jira-search.sh +70 -0
- package/pipeline/scripts/phase-tracker.sh +134 -12
- package/pipeline/scripts/probe-evidence-capability.sh +27 -3
- package/pipeline/scripts/run-ui-tests.sh +113 -4
- package/pipeline/skills/.skill-manifest.json +16 -4
- package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +67 -0
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +146 -0
- package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +64 -0
- package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +62 -11
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -8
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* autopilot-runner.mjs - one tick. Recover, take one item, run it, record it.
|
|
4
|
+
*
|
|
5
|
+
* The loop is launchd, not `while true`. A `while true` that crashes is a dead
|
|
6
|
+
* queue with no signal; a tick that crashes is repeated `StartInterval` seconds
|
|
7
|
+
* later by something whose whole job is restarting it. That choice is also what
|
|
8
|
+
* makes step 1 the mutual exclusion: two overlapping ticks cannot take the same
|
|
9
|
+
* item, because the second one sees the first one's pid file and leaves.
|
|
10
|
+
*
|
|
11
|
+
* 1. RECOVER a previous runner's pid file decides whether anything is live.
|
|
12
|
+
* 2. INTAKE autopilot-intake.mjs writes the ordered queue. Read-only.
|
|
13
|
+
* 3. ARM autopilot-arming.mjs answers spend and credentials.
|
|
14
|
+
* 4. TAKE the head of the queue, and only if this repo is free.
|
|
15
|
+
* 5. RUN one `claude --bg` child, waited on. Stops at an open PR.
|
|
16
|
+
* 6. RECORD attempted.jsonl, clear the pid file, refresh status.json.
|
|
17
|
+
*
|
|
18
|
+
* LIVENESS WITHOUT A LEASE. Lease arithmetic exists to arbitrate between rival
|
|
19
|
+
* writers, and here there are none: one machine, one queue, one writer. The
|
|
20
|
+
* question is only "is the runner I recorded still alive", and three cheap facts
|
|
21
|
+
* answer it exactly - the pid responds to signal 0, the machine has not rebooted
|
|
22
|
+
* since the pid was recorded, and the session is in `claude agents --json`. The
|
|
23
|
+
* middle one is what makes a reboot safe without probing anything: after a
|
|
24
|
+
* restart pids start low, so a recorded 4711 may well belong to something real
|
|
25
|
+
* and unrelated, and a runner that trusted `kill -0` alone would decide another
|
|
26
|
+
* runner was live and do nothing, forever.
|
|
27
|
+
*
|
|
28
|
+
* WHAT IT WILL NOT DO. It does not merge. It does not remove a worktree holding
|
|
29
|
+
* uncommitted work - that is stashed to a branch and the worktree kept, because
|
|
30
|
+
* losing a day of edits is worse than 750 MB. It does not remove a worktree it
|
|
31
|
+
* cannot prove it created: the measured tree here had 19 worktrees with no state
|
|
32
|
+
* file, several of them hand-made and one in active use.
|
|
33
|
+
*
|
|
34
|
+
* Usage:
|
|
35
|
+
* autopilot-runner.mjs one tick
|
|
36
|
+
* autopilot-runner.mjs --dry-run everything except the spawn, printed
|
|
37
|
+
*
|
|
38
|
+
* Exit: 0 for every normal outcome INCLUDING "nothing to do" and "not armed".
|
|
39
|
+
* launchd logs a non-zero exit as a failure, and a quiet queue is not one.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
43
|
+
import {
|
|
44
|
+
existsSync,
|
|
45
|
+
readFileSync,
|
|
46
|
+
writeFileSync,
|
|
47
|
+
appendFileSync,
|
|
48
|
+
mkdirSync,
|
|
49
|
+
rmSync,
|
|
50
|
+
chmodSync,
|
|
51
|
+
} from "node:fs";
|
|
52
|
+
import { join } from "node:path";
|
|
53
|
+
import { homedir } from "node:os";
|
|
54
|
+
import { randomUUID } from "node:crypto";
|
|
55
|
+
|
|
56
|
+
const ROOT = process.env.MA_AUTOPILOT_ROOT || join(homedir(), ".claude", "autopilot");
|
|
57
|
+
const SCRIPTS = process.env.MA_AP_SCRIPTS || join(homedir(), ".claude", "scripts");
|
|
58
|
+
const CLAUDE_BIN = process.env.MA_AP_CLAUDE_BIN || "claude";
|
|
59
|
+
const DRY = process.argv.includes("--dry-run");
|
|
60
|
+
|
|
61
|
+
const log = (m) => process.stdout.write(`autopilot-runner: ${m}\n`);
|
|
62
|
+
|
|
63
|
+
function run(cmd, args, opts = {}) {
|
|
64
|
+
try {
|
|
65
|
+
return execFileSync(cmd, args, {
|
|
66
|
+
encoding: "utf-8",
|
|
67
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
68
|
+
...opts,
|
|
69
|
+
});
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return e.stdout || "";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readJson(p, fallback) {
|
|
76
|
+
if (!existsSync(p)) return fallback;
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
79
|
+
} catch {
|
|
80
|
+
return fallback;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ensureRoot() {
|
|
85
|
+
mkdirSync(ROOT, { recursive: true, mode: 0o700 });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function writeState(name, obj) {
|
|
89
|
+
ensureRoot();
|
|
90
|
+
const p = join(ROOT, name);
|
|
91
|
+
writeFileSync(p, JSON.stringify(obj, null, 2), { mode: 0o600 });
|
|
92
|
+
chmodSync(p, 0o600);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function record(entry) {
|
|
96
|
+
ensureRoot();
|
|
97
|
+
const p = join(ROOT, "attempted.jsonl");
|
|
98
|
+
appendFileSync(p, JSON.stringify({ at: Math.floor(Date.now() / 1000), ...entry }) + "\n", {
|
|
99
|
+
mode: 0o600,
|
|
100
|
+
});
|
|
101
|
+
chmodSync(p, 0o600);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Seconds since the epoch at which this machine booted. */
|
|
105
|
+
export function bootTime() {
|
|
106
|
+
const out = run("sysctl", ["-n", "kern.boottime"]);
|
|
107
|
+
const m = /sec\s*=\s*(\d+)/.exec(out);
|
|
108
|
+
return m ? Number(m[1]) : 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function pidAlive(pid) {
|
|
112
|
+
try {
|
|
113
|
+
process.kill(pid, 0);
|
|
114
|
+
return true;
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return e.code === "EPERM"; // alive but not ours
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sessionLive(sessionId) {
|
|
121
|
+
if (!sessionId) return false;
|
|
122
|
+
const out = process.env.MA_AP_AGENTS_JSON ?? run(CLAUDE_BIN, ["agents", "--json"]);
|
|
123
|
+
try {
|
|
124
|
+
const rows = JSON.parse(out || "[]");
|
|
125
|
+
const list = Array.isArray(rows) ? rows : rows.agents || [];
|
|
126
|
+
return list.some((a) => a.sessionId === sessionId);
|
|
127
|
+
} catch {
|
|
128
|
+
// Cannot tell. Treated as NOT live on purpose: the alternative is a queue
|
|
129
|
+
// that stops forever because one command's output changed shape.
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Is the recorded runner still running?
|
|
136
|
+
*
|
|
137
|
+
* All three conditions, and the boot check is the one that matters after a
|
|
138
|
+
* restart: a pid recorded before the current boot is stale by definition and
|
|
139
|
+
* needs no probe at all.
|
|
140
|
+
*/
|
|
141
|
+
export function holderIsLive(holder, { now = bootTime(), agentsJson } = {}) {
|
|
142
|
+
if (!holder || !holder.pid) return false;
|
|
143
|
+
if (holder.bootTime && now && holder.bootTime !== now) return false;
|
|
144
|
+
if (!pidAlive(holder.pid)) return false;
|
|
145
|
+
if (agentsJson !== undefined) {
|
|
146
|
+
try {
|
|
147
|
+
const rows = JSON.parse(agentsJson || "[]");
|
|
148
|
+
const list = Array.isArray(rows) ? rows : rows.agents || [];
|
|
149
|
+
return list.some((a) => a.sessionId === holder.sessionId);
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return sessionLive(holder.sessionId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Retire a run whose session is gone. The worktree is removed BY NAME, never by
|
|
159
|
+
* sweeping: gc-worktrees.sh deliberately skips registered worktrees and always
|
|
160
|
+
* will, and the runner is the only thing that knows which one it created.
|
|
161
|
+
*/
|
|
162
|
+
function retire(holder) {
|
|
163
|
+
const item = holder.item || {};
|
|
164
|
+
const wt = holder.worktree;
|
|
165
|
+
let stashed = null;
|
|
166
|
+
|
|
167
|
+
if (wt && existsSync(wt)) {
|
|
168
|
+
const dirty = run("git", ["-C", wt, "status", "--porcelain"]).trim();
|
|
169
|
+
if (dirty) {
|
|
170
|
+
// A day of edits is worth more than 750 MB. The branch name is derived
|
|
171
|
+
// from the task so it can be found without remembering this ran.
|
|
172
|
+
const branch = `autopilot/abandoned/${holder.taskId || item.id || "unknown"}`;
|
|
173
|
+
run("git", ["-C", wt, "stash", "push", "-u", "-m", branch]);
|
|
174
|
+
run("git", ["-C", wt, "branch", branch]);
|
|
175
|
+
stashed = branch;
|
|
176
|
+
log(`kept ${wt}: uncommitted work stashed to ${branch}`);
|
|
177
|
+
} else if (holder.createdWorktree) {
|
|
178
|
+
run("git", ["-C", wt, "worktree", "remove", "--force", wt]);
|
|
179
|
+
run("git", ["-C", holder.repoPath || wt, "worktree", "prune"]);
|
|
180
|
+
log(`removed ${wt}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (holder.statePath && existsSync(holder.statePath)) {
|
|
185
|
+
const st = readJson(holder.statePath, null);
|
|
186
|
+
if (st) {
|
|
187
|
+
st.status = "abandoned";
|
|
188
|
+
st.abandonedBy = "autopilot-runner";
|
|
189
|
+
writeFileSync(holder.statePath, JSON.stringify(st, null, 2));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
record({
|
|
194
|
+
source: item.source,
|
|
195
|
+
id: item.id,
|
|
196
|
+
taskId: holder.taskId,
|
|
197
|
+
outcome: "died",
|
|
198
|
+
stashedTo: stashed,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function refreshStatus() {
|
|
203
|
+
const s = join(SCRIPTS, "autopilot-status.sh");
|
|
204
|
+
if (existsSync(s)) run("bash", [s, "--write"]);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function main() {
|
|
208
|
+
const pidPath = join(ROOT, "runner.pid");
|
|
209
|
+
|
|
210
|
+
// ---- 1. RECOVER --------------------------------------------------------
|
|
211
|
+
const holder = readJson(pidPath, null);
|
|
212
|
+
if (holder) {
|
|
213
|
+
if (holderIsLive(holder)) {
|
|
214
|
+
log(`another runner is live (pid ${holder.pid}) - nothing to do`);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
log(`previous runner is gone (pid ${holder.pid}) - retiring its item`);
|
|
218
|
+
if (!DRY) {
|
|
219
|
+
retire(holder);
|
|
220
|
+
rmSync(pidPath, { force: true });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const config = readJson(join(ROOT, "config.json"), null);
|
|
225
|
+
if (!config) {
|
|
226
|
+
log("not configured on this machine - nothing to do");
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- 2. INTAKE ---------------------------------------------------------
|
|
231
|
+
const intake = join(SCRIPTS, "autopilot-intake.mjs");
|
|
232
|
+
if (existsSync(intake)) run(process.execPath, [intake]);
|
|
233
|
+
const queue = readJson(join(ROOT, "queue.json"), { queued: [], running: [] });
|
|
234
|
+
|
|
235
|
+
const running = queue.running || [];
|
|
236
|
+
const slots = Number(config.slots ?? 1);
|
|
237
|
+
if (running.length >= slots) {
|
|
238
|
+
log(`${running.length}/${slots} slots in use - nothing to take`);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Per-repo concurrency is always 1, whatever `slots` says: two runs in one
|
|
243
|
+
// checkout contend on .git/index.lock, and that is a named failure rather
|
|
244
|
+
// than a slow path. It also means the queue steps around a repo YOU are
|
|
245
|
+
// working in instead of competing with you for it.
|
|
246
|
+
const busyRepos = new Set(running.map((r) => r.repo));
|
|
247
|
+
const next = (queue.queued || []).find((i) => !busyRepos.has(i.repo));
|
|
248
|
+
if (!next) {
|
|
249
|
+
log(queue.emptyReason || "every queued item belongs to a repo already in flight");
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- 3. ARM ------------------------------------------------------------
|
|
254
|
+
const armScript = join(SCRIPTS, "autopilot-arming.mjs");
|
|
255
|
+
if (existsSync(armScript)) {
|
|
256
|
+
const out = run(process.execPath, [armScript, "check", "--source", next.source, "--json"]);
|
|
257
|
+
const arm = (() => {
|
|
258
|
+
try {
|
|
259
|
+
return JSON.parse(out);
|
|
260
|
+
} catch {
|
|
261
|
+
return { armed: true };
|
|
262
|
+
}
|
|
263
|
+
})();
|
|
264
|
+
if (!arm.armed) {
|
|
265
|
+
log(`not armed: ${arm.reason}`);
|
|
266
|
+
if (!DRY) {
|
|
267
|
+
record({ source: next.source, id: next.id, outcome: `blocked-${arm.blockedBy}` });
|
|
268
|
+
writeState("queue.json", { ...queue, blockedReason: arm.reason });
|
|
269
|
+
refreshStatus();
|
|
270
|
+
}
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- 4. TAKE -----------------------------------------------------------
|
|
276
|
+
const sessionId = randomUUID();
|
|
277
|
+
const taskId = `ap-${Date.now()}`;
|
|
278
|
+
const claim = {
|
|
279
|
+
pid: process.pid,
|
|
280
|
+
sessionId,
|
|
281
|
+
bootTime: bootTime(),
|
|
282
|
+
taskId,
|
|
283
|
+
item: next,
|
|
284
|
+
repoPath: next.localPath,
|
|
285
|
+
startedAt: Math.floor(Date.now() / 1000),
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
if (DRY) {
|
|
289
|
+
log(`would take ${next.source}:${next.id} (${next.repo}) as session ${sessionId}`);
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
writeState("runner.pid", claim);
|
|
294
|
+
writeState("queue.json", {
|
|
295
|
+
...queue,
|
|
296
|
+
running: [
|
|
297
|
+
...running,
|
|
298
|
+
{
|
|
299
|
+
id: next.id,
|
|
300
|
+
repo: next.repo,
|
|
301
|
+
depth: config.depthRouter === "auto" ? undefined : "full",
|
|
302
|
+
stack: next.stack,
|
|
303
|
+
startedAt: claim.startedAt,
|
|
304
|
+
state: "running",
|
|
305
|
+
url: next.url,
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
queued: (queue.queued || []).filter((i) => i.id !== next.id),
|
|
309
|
+
});
|
|
310
|
+
refreshStatus();
|
|
311
|
+
|
|
312
|
+
// ---- 5. RUN ------------------------------------------------------------
|
|
313
|
+
// The child is a normal pipeline run in the mode that already exists: one
|
|
314
|
+
// item, unattended, stopping at an open PR. Nothing about the per-item
|
|
315
|
+
// `autopilot` mode changes because a queue is calling it.
|
|
316
|
+
log(`running ${next.source}:${next.id}`);
|
|
317
|
+
const child = spawnSync(
|
|
318
|
+
CLAUDE_BIN,
|
|
319
|
+
[
|
|
320
|
+
"--bg",
|
|
321
|
+
"--session-id",
|
|
322
|
+
sessionId,
|
|
323
|
+
"--permission-prompts",
|
|
324
|
+
"none",
|
|
325
|
+
"-p",
|
|
326
|
+
`/multi-agent ${next.ref || next.id} autopilot`,
|
|
327
|
+
],
|
|
328
|
+
{ cwd: next.localPath || process.cwd(), encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// ---- 6. RECORD ---------------------------------------------------------
|
|
332
|
+
const outcome = child.status === 0 ? "pr-opened" : "build-failed";
|
|
333
|
+
const prUrl = /https:\/\/\S*\/pull(-requests)?\/\d+/.exec(child.stdout || "")?.[0] || null;
|
|
334
|
+
record({ source: next.source, id: next.id, taskId, outcome, prUrl, usd: 0 });
|
|
335
|
+
|
|
336
|
+
const after = readJson(join(ROOT, "queue.json"), queue);
|
|
337
|
+
writeState("queue.json", {
|
|
338
|
+
...after,
|
|
339
|
+
running: (after.running || []).filter((r) => r.id !== next.id),
|
|
340
|
+
});
|
|
341
|
+
rmSync(join(ROOT, "runner.pid"), { force: true });
|
|
342
|
+
refreshStatus();
|
|
343
|
+
log(`${next.id}: ${outcome}${prUrl ? ` ${prUrl}` : ""}`);
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
348
|
+
process.exit(main());
|
|
349
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# autopilot-status.sh - what continuous mode is doing, in one place.
|
|
3
|
+
#
|
|
4
|
+
# ONE producer, three renderings. The menu bar, the terminal and the session hook
|
|
5
|
+
# all describe the same thing, and the moment each computes its own answer they
|
|
6
|
+
# start disagreeing - which is exactly the failure this release spent its time on:
|
|
7
|
+
# a run reading `in_progress` while its PR was already open.
|
|
8
|
+
#
|
|
9
|
+
# (default) a paragraph for a human reading the terminal
|
|
10
|
+
# --short one line, for autopilot-off and the session hook
|
|
11
|
+
# --json status.json, which is what bin/menubar renders
|
|
12
|
+
# --write compute and write status.json, print nothing
|
|
13
|
+
# --subjects one line per in-flight item for the native task widget
|
|
14
|
+
#
|
|
15
|
+
# `--json` and `--write` produce the SAME bytes. A menu bar fed by a different
|
|
16
|
+
# code path than the terminal is a second source of truth with a nicer font.
|
|
17
|
+
#
|
|
18
|
+
# Exit: 0 always when it can read the state, 2 on usage. Never non-zero because
|
|
19
|
+
# the mode is off - "off" is an answer.
|
|
20
|
+
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
24
|
+
# shellcheck source=/dev/null
|
|
25
|
+
. "$HERE/../lib/autopilot-state.sh" 2>/dev/null || . "$HOME/.claude/lib/autopilot-state.sh"
|
|
26
|
+
|
|
27
|
+
MODE="human"
|
|
28
|
+
case "${1:-}" in
|
|
29
|
+
"") ;;
|
|
30
|
+
--short) MODE="short" ;;
|
|
31
|
+
--json) MODE="json" ;;
|
|
32
|
+
--write) MODE="write" ;;
|
|
33
|
+
--subjects) MODE="subjects" ;;
|
|
34
|
+
-h | --help)
|
|
35
|
+
grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
|
|
36
|
+
exit 0
|
|
37
|
+
;;
|
|
38
|
+
*)
|
|
39
|
+
echo "autopilot-status: unknown option $1" >&2
|
|
40
|
+
exit 2
|
|
41
|
+
;;
|
|
42
|
+
esac
|
|
43
|
+
|
|
44
|
+
command -v jq >/dev/null 2>&1 || { echo "autopilot-status: jq is required"; exit 0; }
|
|
45
|
+
|
|
46
|
+
ON=false; ma_ap_is_on && ON=true
|
|
47
|
+
CONFIGURED=false; ma_ap_is_configured && CONFIGURED=true
|
|
48
|
+
POWER=$(ma_ap_power)
|
|
49
|
+
SLOTS=$(ma_ap_cfg '.slots' 1)
|
|
50
|
+
CEILING=$(ma_ap_slot_ceiling)
|
|
51
|
+
INTERVAL=$(ma_ap_cfg '.scanIntervalSeconds' 120)
|
|
52
|
+
|
|
53
|
+
QUEUE=$(ma_ap_read queue.json 2>/dev/null || echo '{}')
|
|
54
|
+
RUNNING=$(printf '%s' "$QUEUE" | jq -c '[.running[]? | select((.state // "running") == "running")]' 2>/dev/null || echo '[]')
|
|
55
|
+
QUEUED=$(printf '%s' "$QUEUE" | jq -c '[.queued[]?]' 2>/dev/null || echo '[]')
|
|
56
|
+
AWAITING=$(printf '%s' "$QUEUE" | jq -c '[.awaiting[]?]' 2>/dev/null || echo '[]')
|
|
57
|
+
LAST_TICK=$(printf '%s' "$QUEUE" | jq -r '.lastTickAt // empty' 2>/dev/null)
|
|
58
|
+
EMPTY_REASON=$(printf '%s' "$QUEUE" | jq -r '.emptyReason // empty' 2>/dev/null)
|
|
59
|
+
BLOCKED=$(printf '%s' "$QUEUE" | jq -r '.blockedReason // empty' 2>/dev/null)
|
|
60
|
+
|
|
61
|
+
# Spend and the arming verdict come from the one script that computes them, so
|
|
62
|
+
# the terminal, the menu bar and the runner cannot disagree about whether the
|
|
63
|
+
# queue is allowed to take an item.
|
|
64
|
+
ARM='{}'
|
|
65
|
+
if [ "$CONFIGURED" = true ] && [ -f "$HOME/.claude/scripts/autopilot-arming.mjs" ]; then
|
|
66
|
+
ARM=$(node "$HOME/.claude/scripts/autopilot-arming.mjs" check --json 2>/dev/null || echo '{}')
|
|
67
|
+
fi
|
|
68
|
+
SPEND=$(printf '%s' "$ARM" | jq -r '.spend.usd // 0' 2>/dev/null || echo 0)
|
|
69
|
+
CEILING_USD=$(printf '%s' "$ARM" | jq -r '.ceiling // 0' 2>/dev/null || echo 0)
|
|
70
|
+
|
|
71
|
+
# Recently finished, from the append-only attempt log rather than from a second
|
|
72
|
+
# list somebody has to remember to prune. Bounded to a day: this answers "what
|
|
73
|
+
# happened while I was away", and an unbounded list stops answering that.
|
|
74
|
+
CUTOFF=$(($(date +%s) - 86400))
|
|
75
|
+
REPORTS='[]'
|
|
76
|
+
if [ -f "$(ma_ap_root)/attempted.jsonl" ]; then
|
|
77
|
+
REPORTS=$(jq -sc --argjson c "$CUTOFF" '
|
|
78
|
+
[ .[] | select((.at // 0) >= $c) | select(.prUrl != null and .prUrl != "")
|
|
79
|
+
| {id: .id, kind: "pr", url: .prUrl, outcome: (.outcome // "open"), finishedAt: .at} ]
|
|
80
|
+
| sort_by(-.finishedAt) | .[0:10]' "$(ma_ap_root)/attempted.jsonl" 2>/dev/null || echo '[]')
|
|
81
|
+
fi
|
|
82
|
+
TODAY_N=$(printf '%s' "$REPORTS" | jq 'length' 2>/dev/null || echo 0)
|
|
83
|
+
TODAY_USD=$(printf '%s' "$QUEUE" | jq -r '.todayUsd // 0' 2>/dev/null || echo 0)
|
|
84
|
+
|
|
85
|
+
NOTE=""
|
|
86
|
+
if [ "$ON" = true ]; then
|
|
87
|
+
if [ "$POWER" = "ac" ]; then NOTE="prizde, uyku engelleniyor"; else NOTE="bataryada, uyku serbest - prize takınca devam eder"; fi
|
|
88
|
+
fi
|
|
89
|
+
if ma_ap_is_orphaned; then
|
|
90
|
+
NOTE="config açık ama launchd'de iş yok - /multi-agent:autopilot-on ile yeniden kur"
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
build_json() {
|
|
94
|
+
jq -nc \
|
|
95
|
+
--argjson on "$ON" --argjson slots "$SLOTS" \
|
|
96
|
+
--argjson running "$RUNNING" --argjson queued "$QUEUED" --argjson awaiting "$AWAITING" \
|
|
97
|
+
--argjson reports "$REPORTS" --argjson todayCount "$TODAY_N" --argjson todayUsd "$TODAY_USD" \
|
|
98
|
+
--arg power "$POWER" --arg note "$NOTE" \
|
|
99
|
+
--arg emptyReason "$EMPTY_REASON" --arg blockedReason "$BLOCKED" \
|
|
100
|
+
--argjson interval "$INTERVAL" --argjson spendUsd "${SPEND:-0}" \
|
|
101
|
+
--argjson ceilingUsd "${CEILING_USD:-0}" \
|
|
102
|
+
--argjson lastTickAt "${LAST_TICK:-null}" \
|
|
103
|
+
'{on:$on, slots:$slots, running:$running, queued:$queued, awaiting:$awaiting,
|
|
104
|
+
reports:$reports, todayCount:$todayCount, todayUsd:$todayUsd,
|
|
105
|
+
lastTickAt:$lastTickAt, power:$power, note:$note,
|
|
106
|
+
scanIntervalSeconds:$interval, emptyReason:$emptyReason,
|
|
107
|
+
blockedReason:$blockedReason, spendUsd:$spendUsd, ceilingUsd:$ceilingUsd}'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case "$MODE" in
|
|
111
|
+
json)
|
|
112
|
+
build_json
|
|
113
|
+
exit 0
|
|
114
|
+
;;
|
|
115
|
+
write)
|
|
116
|
+
build_json | ma_ap_write status.json
|
|
117
|
+
exit 0
|
|
118
|
+
;;
|
|
119
|
+
esac
|
|
120
|
+
|
|
121
|
+
# Refresh status.json as a side effect of being asked, so the menu bar is current
|
|
122
|
+
# the moment anyone looks at the terminal - but ONLY when the mode is already
|
|
123
|
+
# configured. Asking a question may not answer it into existence: on a machine
|
|
124
|
+
# that never turned this on, `ma_ap_write` would create ~/.claude/autopilot/ and
|
|
125
|
+
# quietly break the guarantee that a clean install writes nothing. Caught by
|
|
126
|
+
# running this file on this machine, which then had the directory.
|
|
127
|
+
if [ "$CONFIGURED" = true ]; then
|
|
128
|
+
build_json | ma_ap_write status.json 2>/dev/null || true
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
NR=$(printf '%s' "$RUNNING" | jq 'length')
|
|
132
|
+
NQ=$(printf '%s' "$QUEUED" | jq 'length')
|
|
133
|
+
NA=$(printf '%s' "$AWAITING" | jq 'length')
|
|
134
|
+
|
|
135
|
+
if [ "$MODE" = "subjects" ]; then
|
|
136
|
+
# One line per item, with the numbers INSIDE the string: the widget takes
|
|
137
|
+
# exactly one string per row, so anything the reader needs has to travel in it.
|
|
138
|
+
printf '%s' "$RUNNING" | jq -r '.[] |
|
|
139
|
+
"\(.id) · \(.depth // "full" | ascii_upcase[0:1] + .[1:]) · Faz \(.phase // "?")\(if .phaseTotal then "/\(.phaseTotal)" else "" end) \(.phaseName // "") · \(.stack // "")"'
|
|
140
|
+
printf '%s' "$AWAITING" | jq -r '.[] | "\(.id) · cevap bekliyor"'
|
|
141
|
+
exit 0
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
if [ "$MODE" = "short" ]; then
|
|
145
|
+
if [ "$ON" != true ]; then
|
|
146
|
+
if [ "$CONFIGURED" = true ]; then
|
|
147
|
+
echo "autopilot: kapalı ($(ma_ap_cfg '.repos | length' 0) repo seçili)"
|
|
148
|
+
else
|
|
149
|
+
echo "autopilot: kurulu değil"
|
|
150
|
+
fi
|
|
151
|
+
exit 0
|
|
152
|
+
fi
|
|
153
|
+
echo "autopilot: açık · $NR koşuyor, $NQ sırada, $NA cevap bekliyor · $NOTE"
|
|
154
|
+
exit 0
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
# --- human -------------------------------------------------------------------
|
|
158
|
+
if [ "$CONFIGURED" != true ]; then
|
|
159
|
+
echo "autopilot: bu makinede kurulu değil."
|
|
160
|
+
echo " Açmak için: /multi-agent:autopilot-on"
|
|
161
|
+
exit 0
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
NREPOS=$(ma_ap_cfg '.repos | length' 0)
|
|
165
|
+
if [ "$ON" = true ]; then
|
|
166
|
+
echo "autopilot: AÇIK · $NREPOS repo · $SLOTS slot (bu makine $CEILING kaldırır) · her ${INTERVAL}sn tarıyor"
|
|
167
|
+
else
|
|
168
|
+
echo "autopilot: KAPALI · $NREPOS repo seçili, zamanlama yok"
|
|
169
|
+
echo " Açmak için: /multi-agent:autopilot-on"
|
|
170
|
+
fi
|
|
171
|
+
[ -n "$NOTE" ] && echo " $NOTE"
|
|
172
|
+
|
|
173
|
+
echo
|
|
174
|
+
if [ "$NR" -gt 0 ]; then
|
|
175
|
+
echo "Koşuyor ($NR):"
|
|
176
|
+
printf '%s' "$RUNNING" | jq -r '.[] | " \(.id) · \(.phaseName // .phase // "?") · \(.stack // "")"'
|
|
177
|
+
fi
|
|
178
|
+
if [ "$NQ" -gt 0 ]; then
|
|
179
|
+
echo "Sırada ($NQ):"
|
|
180
|
+
printf '%s' "$QUEUED" | jq -r '.[0:8][] | " \(.id)"'
|
|
181
|
+
fi
|
|
182
|
+
if [ "$NA" -gt 0 ]; then
|
|
183
|
+
echo "Cevap bekliyor ($NA) - açık soruları yorumladık, cevap gelince devam edecek:"
|
|
184
|
+
printf '%s' "$AWAITING" | jq -r '.[] | " \(.id)"'
|
|
185
|
+
fi
|
|
186
|
+
if [ "$NR" -eq 0 ] && [ "$NQ" -eq 0 ] && [ "$NA" -eq 0 ]; then
|
|
187
|
+
echo "Sırada iş yok."
|
|
188
|
+
# The single most common "why is nothing happening" answer, and it is not a
|
|
189
|
+
# fault: no item carries the label yet. When intake recorded a more specific
|
|
190
|
+
# reason, that one is truer and goes first.
|
|
191
|
+
if [ -n "$EMPTY_REASON" ]; then
|
|
192
|
+
echo " $EMPTY_REASON"
|
|
193
|
+
fi
|
|
194
|
+
echo " Bir madde almasi icin: GitHub issue'ya ya da sana atanmis Jira maddesine label ekle."
|
|
195
|
+
fi
|
|
196
|
+
|
|
197
|
+
# The queue refusing to take work is not the same as having none, and reading
|
|
198
|
+
# one as the other is the whole reason this line exists separately.
|
|
199
|
+
if [ -n "$BLOCKED" ]; then
|
|
200
|
+
echo
|
|
201
|
+
echo "Yeni madde alınmıyor: $BLOCKED"
|
|
202
|
+
fi
|
|
203
|
+
if [ "$CEILING_USD" != "0" ]; then
|
|
204
|
+
echo "Son 24 saat: \$$SPEND / \$$CEILING_USD"
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
if [ "$TODAY_N" -gt 0 ]; then
|
|
208
|
+
echo
|
|
209
|
+
echo "Son 24 saat ($TODAY_N PR):"
|
|
210
|
+
printf '%s' "$REPORTS" | jq -r '.[0:6][] | " \(.id) · \(.url)"'
|
|
211
|
+
fi
|
|
212
|
+
exit 0
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# jira-search.sh - run one JQL query and print Jira's own JSON.
|
|
4
|
+
#
|
|
5
|
+
# A thin front door onto `jira_search` in lib/issue-fetcher.sh, which owns the
|
|
6
|
+
# credential resolution, the host lookup and the rule that a token never reaches
|
|
7
|
+
# argv. This file adds exactly two things that function does not do for itself:
|
|
8
|
+
# it resolves the account from preferences, and it URL-encodes the JQL.
|
|
9
|
+
#
|
|
10
|
+
# The encoding is not a detail. A JQL carrying a quoted label, a space or an
|
|
11
|
+
# `=` reaches the server mangled otherwise, and the failure is a 400 that reads
|
|
12
|
+
# like a permissions problem. python3 does it correctly; the shell does not.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# jira-search.sh --jql '<JQL>' [--fields key,summary,priority,created] [--max 50]
|
|
16
|
+
#
|
|
17
|
+
# Exit: 0 with JSON on stdout, 1 when the query failed (a warning on stderr,
|
|
18
|
+
# never a token).
|
|
19
|
+
|
|
20
|
+
set -uo pipefail
|
|
21
|
+
|
|
22
|
+
JQL=""
|
|
23
|
+
FIELDS="key,summary,status,priority,created"
|
|
24
|
+
MAXR=50
|
|
25
|
+
while [ $# -gt 0 ]; do
|
|
26
|
+
case "$1" in
|
|
27
|
+
--jql) JQL="${2:-}"; shift 2 ;;
|
|
28
|
+
--fields) FIELDS="${2:-}"; shift 2 ;;
|
|
29
|
+
--max) MAXR="${2:-}"; shift 2 ;;
|
|
30
|
+
-h | --help) grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'; exit 0 ;;
|
|
31
|
+
*) echo "jira-search: unknown option $1" >&2; exit 2 ;;
|
|
32
|
+
esac
|
|
33
|
+
done
|
|
34
|
+
[ -n "$JQL" ] || { echo "jira-search: --jql is required" >&2; exit 2; }
|
|
35
|
+
|
|
36
|
+
PREFS="${MA_PREFS_FILE:-$HOME/.claude/multi-agent-preferences.json}"
|
|
37
|
+
[ -f "$PREFS" ] || { echo "jira-search: no preferences file" >&2; exit 1; }
|
|
38
|
+
|
|
39
|
+
# Host and the LOGICAL key name, never the literal Keychain service name: the
|
|
40
|
+
# mapping is per-user and resolving it here is what keeps the service name out
|
|
41
|
+
# of every synced file.
|
|
42
|
+
eval "$(python3 - "$PREFS" <<'PY'
|
|
43
|
+
import json, sys, shlex
|
|
44
|
+
try:
|
|
45
|
+
g = json.load(open(sys.argv[1]))["global"]
|
|
46
|
+
except Exception:
|
|
47
|
+
g = {}
|
|
48
|
+
host = (g.get("hosts") or {}).get("jira", "")
|
|
49
|
+
key = (g.get("keychainMapping") or {}).get("jira", "")
|
|
50
|
+
print(f"ACCOUNT_JIRA_HOST={shlex.quote(host)}")
|
|
51
|
+
print(f"ACCOUNT_JIRA_TOKEN_KEY={shlex.quote(key)}")
|
|
52
|
+
PY
|
|
53
|
+
)"
|
|
54
|
+
export ACCOUNT_JIRA_HOST ACCOUNT_JIRA_TOKEN_KEY
|
|
55
|
+
[ -n "$ACCOUNT_JIRA_HOST" ] && [ -n "$ACCOUNT_JIRA_TOKEN_KEY" ] || {
|
|
56
|
+
echo "jira-search: jira is not onboarded (host or keychainMapping.jira missing)" >&2
|
|
57
|
+
exit 1
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
61
|
+
for cand in "$HERE/../lib/issue-fetcher.sh" "$HOME/.claude/lib/issue-fetcher.sh"; do
|
|
62
|
+
[ -f "$cand" ] && { . "$cand"; break; }
|
|
63
|
+
done
|
|
64
|
+
command -v jira_search >/dev/null 2>&1 || {
|
|
65
|
+
echo "jira-search: issue-fetcher.sh not found" >&2
|
|
66
|
+
exit 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
ENCODED=$(printf '%s' "$JQL" | python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read()))')
|
|
70
|
+
jira_search "$ENCODED" "$FIELDS" "$MAXR"
|