@mmerterden/multi-agent-pipeline 17.0.0 → 17.3.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/README.md +56 -4
  3. package/README.tr.md +57 -4
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/token-budget-history.md +22 -0
  7. package/install/_dev-only-files.mjs +1 -0
  8. package/install/codex.mjs +18 -1
  9. package/install/copilot.mjs +17 -1
  10. package/install/templates/multi-agent-autopilot.plist.template +79 -0
  11. package/package.json +1 -1
  12. package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +64 -0
  13. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +181 -0
  14. package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +74 -0
  15. package/pipeline/commands/multi-agent/channels/SKILL.md +41 -12
  16. package/pipeline/commands/multi-agent/help/SKILL.md +41 -35
  17. package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
  18. package/pipeline/commands/multi-agent/sync/SKILL.md +10 -9
  19. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  20. package/pipeline/lib/autopilot-activation.sh +117 -0
  21. package/pipeline/lib/autopilot-state.sh +184 -0
  22. package/pipeline/lib/issue-fetcher.sh +18 -1
  23. package/pipeline/lib/plan-todos.sh +18 -0
  24. package/pipeline/multi-agent-refs/_dev-context.md +10 -0
  25. package/pipeline/multi-agent-refs/analysis/redesign.md +8 -0
  26. package/pipeline/multi-agent-refs/analysis/review.md +9 -0
  27. package/pipeline/multi-agent-refs/android-guide.md +14 -0
  28. package/pipeline/multi-agent-refs/audit-guide.md +12 -0
  29. package/pipeline/multi-agent-refs/backend-guide.md +10 -0
  30. package/pipeline/multi-agent-refs/channels/confluence.md +11 -0
  31. package/pipeline/multi-agent-refs/channels/issue-comment.md +12 -0
  32. package/pipeline/multi-agent-refs/channels/jira.md +90 -20
  33. package/pipeline/multi-agent-refs/channels/pr-review-actions.md +13 -0
  34. package/pipeline/multi-agent-refs/channels/pr.md +76 -19
  35. package/pipeline/multi-agent-refs/component-dispatch.md +11 -0
  36. package/pipeline/multi-agent-refs/component-generation.md +11 -0
  37. package/pipeline/multi-agent-refs/conventions-defaults.md +15 -0
  38. package/pipeline/multi-agent-refs/cross-cli-contract.md +49 -5
  39. package/pipeline/multi-agent-refs/features/analysis-jira.md +11 -0
  40. package/pipeline/multi-agent-refs/features/design-conformance.md +10 -0
  41. package/pipeline/multi-agent-refs/features/doctor.md +10 -0
  42. package/pipeline/multi-agent-refs/features/external-context-injection.md +7 -0
  43. package/pipeline/multi-agent-refs/features/jira-context.md +9 -0
  44. package/pipeline/multi-agent-refs/features/model-fallback.md +10 -0
  45. package/pipeline/multi-agent-refs/features/skill-conformance.md +13 -0
  46. package/pipeline/multi-agent-refs/features/url-enrichment.md +9 -0
  47. package/pipeline/multi-agent-refs/features/visual-evidence.md +61 -7
  48. package/pipeline/multi-agent-refs/generate-issue.md +7 -0
  49. package/pipeline/multi-agent-refs/issue-jira-triad.md +9 -0
  50. package/pipeline/multi-agent-refs/knowledge.md +6 -0
  51. package/pipeline/multi-agent-refs/multi-repo-integration-build.md +13 -0
  52. package/pipeline/multi-agent-refs/phases/modes.md +7 -0
  53. package/pipeline/multi-agent-refs/phases/operations.md +9 -0
  54. package/pipeline/multi-agent-refs/phases/phase-0-init.md +2 -2
  55. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +17 -15
  56. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +1 -1
  57. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +1 -1
  58. package/pipeline/multi-agent-refs/phases.md +11 -0
  59. package/pipeline/multi-agent-refs/picker-contract.md +12 -0
  60. package/pipeline/multi-agent-refs/platform-parity.md +10 -0
  61. package/pipeline/multi-agent-refs/progress-contract.md +10 -0
  62. package/pipeline/multi-agent-refs/readiness-review.md +7 -1
  63. package/pipeline/multi-agent-refs/rules.md +3 -11
  64. package/pipeline/multi-agent-refs/setup/firebase.md +9 -0
  65. package/pipeline/multi-agent-refs/swiftui-guide.md +17 -0
  66. package/pipeline/multi-agent-refs/tracker-contract.md +44 -0
  67. package/pipeline/multi-agent-refs/web-guide.md +10 -0
  68. package/pipeline/multi-agent-refs/wiki-capture.md +11 -0
  69. package/pipeline/schemas/autopilot-config.schema.json +149 -0
  70. package/pipeline/schemas/prefs.schema.json +4 -0
  71. package/pipeline/schemas/token-budget.json +10 -19
  72. package/pipeline/scripts/autopilot-arming.mjs +147 -0
  73. package/pipeline/scripts/autopilot-intake.mjs +387 -0
  74. package/pipeline/scripts/autopilot-menubar.swift +361 -0
  75. package/pipeline/scripts/autopilot-runner.mjs +354 -0
  76. package/pipeline/scripts/autopilot-status.sh +213 -0
  77. package/pipeline/scripts/capture-evidence.sh +79 -11
  78. package/pipeline/scripts/gen-ref-toc.mjs +279 -0
  79. package/pipeline/scripts/jira-search.sh +70 -0
  80. package/pipeline/scripts/phase-tracker.sh +134 -12
  81. package/pipeline/scripts/probe-evidence-capability.sh +27 -3
  82. package/pipeline/scripts/run-ui-tests.sh +113 -4
  83. package/pipeline/skills/.skill-manifest.json +16 -4
  84. package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +67 -0
  85. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +146 -0
  86. package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +64 -0
  87. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +62 -11
  88. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -8
@@ -0,0 +1,354 @@
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
+ // Siblings resolve from THIS file's own directory, not from ~/.claude/scripts.
58
+ // All five autopilot scripts install into whichever host tree took them, so a
59
+ // runner living in ~/.codex/scripts must find the intake beside it - naming one
60
+ // host's path means a Codex-only or Copilot-only tick finds nothing and does
61
+ // nothing, with no error anywhere. smoke-autopilot-hosts.sh covers this.
62
+ const SCRIPTS = process.env.MA_AP_SCRIPTS || import.meta.dirname;
63
+ const CLAUDE_BIN = process.env.MA_AP_CLAUDE_BIN || "claude";
64
+ const DRY = process.argv.includes("--dry-run");
65
+
66
+ const log = (m) => process.stdout.write(`autopilot-runner: ${m}\n`);
67
+
68
+ function run(cmd, args, opts = {}) {
69
+ try {
70
+ return execFileSync(cmd, args, {
71
+ encoding: "utf-8",
72
+ stdio: ["ignore", "pipe", "pipe"],
73
+ ...opts,
74
+ });
75
+ } catch (e) {
76
+ return e.stdout || "";
77
+ }
78
+ }
79
+
80
+ function readJson(p, fallback) {
81
+ if (!existsSync(p)) return fallback;
82
+ try {
83
+ return JSON.parse(readFileSync(p, "utf-8"));
84
+ } catch {
85
+ return fallback;
86
+ }
87
+ }
88
+
89
+ function ensureRoot() {
90
+ mkdirSync(ROOT, { recursive: true, mode: 0o700 });
91
+ }
92
+
93
+ function writeState(name, obj) {
94
+ ensureRoot();
95
+ const p = join(ROOT, name);
96
+ writeFileSync(p, JSON.stringify(obj, null, 2), { mode: 0o600 });
97
+ chmodSync(p, 0o600);
98
+ }
99
+
100
+ function record(entry) {
101
+ ensureRoot();
102
+ const p = join(ROOT, "attempted.jsonl");
103
+ appendFileSync(p, JSON.stringify({ at: Math.floor(Date.now() / 1000), ...entry }) + "\n", {
104
+ mode: 0o600,
105
+ });
106
+ chmodSync(p, 0o600);
107
+ }
108
+
109
+ /** Seconds since the epoch at which this machine booted. */
110
+ export function bootTime() {
111
+ const out = run("sysctl", ["-n", "kern.boottime"]);
112
+ const m = /sec\s*=\s*(\d+)/.exec(out);
113
+ return m ? Number(m[1]) : 0;
114
+ }
115
+
116
+ function pidAlive(pid) {
117
+ try {
118
+ process.kill(pid, 0);
119
+ return true;
120
+ } catch (e) {
121
+ return e.code === "EPERM"; // alive but not ours
122
+ }
123
+ }
124
+
125
+ function sessionLive(sessionId) {
126
+ if (!sessionId) return false;
127
+ const out = process.env.MA_AP_AGENTS_JSON ?? run(CLAUDE_BIN, ["agents", "--json"]);
128
+ try {
129
+ const rows = JSON.parse(out || "[]");
130
+ const list = Array.isArray(rows) ? rows : rows.agents || [];
131
+ return list.some((a) => a.sessionId === sessionId);
132
+ } catch {
133
+ // Cannot tell. Treated as NOT live on purpose: the alternative is a queue
134
+ // that stops forever because one command's output changed shape.
135
+ return false;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Is the recorded runner still running?
141
+ *
142
+ * All three conditions, and the boot check is the one that matters after a
143
+ * restart: a pid recorded before the current boot is stale by definition and
144
+ * needs no probe at all.
145
+ */
146
+ export function holderIsLive(holder, { now = bootTime(), agentsJson } = {}) {
147
+ if (!holder || !holder.pid) return false;
148
+ if (holder.bootTime && now && holder.bootTime !== now) return false;
149
+ if (!pidAlive(holder.pid)) return false;
150
+ if (agentsJson !== undefined) {
151
+ try {
152
+ const rows = JSON.parse(agentsJson || "[]");
153
+ const list = Array.isArray(rows) ? rows : rows.agents || [];
154
+ return list.some((a) => a.sessionId === holder.sessionId);
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+ return sessionLive(holder.sessionId);
160
+ }
161
+
162
+ /**
163
+ * Retire a run whose session is gone. The worktree is removed BY NAME, never by
164
+ * sweeping: gc-worktrees.sh deliberately skips registered worktrees and always
165
+ * will, and the runner is the only thing that knows which one it created.
166
+ */
167
+ function retire(holder) {
168
+ const item = holder.item || {};
169
+ const wt = holder.worktree;
170
+ let stashed = null;
171
+
172
+ if (wt && existsSync(wt)) {
173
+ const dirty = run("git", ["-C", wt, "status", "--porcelain"]).trim();
174
+ if (dirty) {
175
+ // A day of edits is worth more than 750 MB. The branch name is derived
176
+ // from the task so it can be found without remembering this ran.
177
+ const branch = `autopilot/abandoned/${holder.taskId || item.id || "unknown"}`;
178
+ run("git", ["-C", wt, "stash", "push", "-u", "-m", branch]);
179
+ run("git", ["-C", wt, "branch", branch]);
180
+ stashed = branch;
181
+ log(`kept ${wt}: uncommitted work stashed to ${branch}`);
182
+ } else if (holder.createdWorktree) {
183
+ run("git", ["-C", wt, "worktree", "remove", "--force", wt]);
184
+ run("git", ["-C", holder.repoPath || wt, "worktree", "prune"]);
185
+ log(`removed ${wt}`);
186
+ }
187
+ }
188
+
189
+ if (holder.statePath && existsSync(holder.statePath)) {
190
+ const st = readJson(holder.statePath, null);
191
+ if (st) {
192
+ st.status = "abandoned";
193
+ st.abandonedBy = "autopilot-runner";
194
+ writeFileSync(holder.statePath, JSON.stringify(st, null, 2));
195
+ }
196
+ }
197
+
198
+ record({
199
+ source: item.source,
200
+ id: item.id,
201
+ taskId: holder.taskId,
202
+ outcome: "died",
203
+ stashedTo: stashed,
204
+ });
205
+ }
206
+
207
+ function refreshStatus() {
208
+ const s = join(SCRIPTS, "autopilot-status.sh");
209
+ if (existsSync(s)) run("bash", [s, "--write"]);
210
+ }
211
+
212
+ function main() {
213
+ const pidPath = join(ROOT, "runner.pid");
214
+
215
+ // ---- 1. RECOVER --------------------------------------------------------
216
+ const holder = readJson(pidPath, null);
217
+ if (holder) {
218
+ if (holderIsLive(holder)) {
219
+ log(`another runner is live (pid ${holder.pid}) - nothing to do`);
220
+ return 0;
221
+ }
222
+ log(`previous runner is gone (pid ${holder.pid}) - retiring its item`);
223
+ if (!DRY) {
224
+ retire(holder);
225
+ rmSync(pidPath, { force: true });
226
+ }
227
+ }
228
+
229
+ const config = readJson(join(ROOT, "config.json"), null);
230
+ if (!config) {
231
+ log("not configured on this machine - nothing to do");
232
+ return 0;
233
+ }
234
+
235
+ // ---- 2. INTAKE ---------------------------------------------------------
236
+ const intake = join(SCRIPTS, "autopilot-intake.mjs");
237
+ if (existsSync(intake)) run(process.execPath, [intake]);
238
+ const queue = readJson(join(ROOT, "queue.json"), { queued: [], running: [] });
239
+
240
+ const running = queue.running || [];
241
+ const slots = Number(config.slots ?? 1);
242
+ if (running.length >= slots) {
243
+ log(`${running.length}/${slots} slots in use - nothing to take`);
244
+ return 0;
245
+ }
246
+
247
+ // Per-repo concurrency is always 1, whatever `slots` says: two runs in one
248
+ // checkout contend on .git/index.lock, and that is a named failure rather
249
+ // than a slow path. It also means the queue steps around a repo YOU are
250
+ // working in instead of competing with you for it.
251
+ const busyRepos = new Set(running.map((r) => r.repo));
252
+ const next = (queue.queued || []).find((i) => !busyRepos.has(i.repo));
253
+ if (!next) {
254
+ log(queue.emptyReason || "every queued item belongs to a repo already in flight");
255
+ return 0;
256
+ }
257
+
258
+ // ---- 3. ARM ------------------------------------------------------------
259
+ const armScript = join(SCRIPTS, "autopilot-arming.mjs");
260
+ if (existsSync(armScript)) {
261
+ const out = run(process.execPath, [armScript, "check", "--source", next.source, "--json"]);
262
+ const arm = (() => {
263
+ try {
264
+ return JSON.parse(out);
265
+ } catch {
266
+ return { armed: true };
267
+ }
268
+ })();
269
+ if (!arm.armed) {
270
+ log(`not armed: ${arm.reason}`);
271
+ if (!DRY) {
272
+ record({ source: next.source, id: next.id, outcome: `blocked-${arm.blockedBy}` });
273
+ writeState("queue.json", { ...queue, blockedReason: arm.reason });
274
+ refreshStatus();
275
+ }
276
+ return 0;
277
+ }
278
+ }
279
+
280
+ // ---- 4. TAKE -----------------------------------------------------------
281
+ const sessionId = randomUUID();
282
+ const taskId = `ap-${Date.now()}`;
283
+ const claim = {
284
+ pid: process.pid,
285
+ sessionId,
286
+ bootTime: bootTime(),
287
+ taskId,
288
+ item: next,
289
+ repoPath: next.localPath,
290
+ startedAt: Math.floor(Date.now() / 1000),
291
+ };
292
+
293
+ if (DRY) {
294
+ log(`would take ${next.source}:${next.id} (${next.repo}) as session ${sessionId}`);
295
+ return 0;
296
+ }
297
+
298
+ writeState("runner.pid", claim);
299
+ writeState("queue.json", {
300
+ ...queue,
301
+ running: [
302
+ ...running,
303
+ {
304
+ id: next.id,
305
+ repo: next.repo,
306
+ depth: config.depthRouter === "auto" ? undefined : "full",
307
+ stack: next.stack,
308
+ startedAt: claim.startedAt,
309
+ state: "running",
310
+ url: next.url,
311
+ },
312
+ ],
313
+ queued: (queue.queued || []).filter((i) => i.id !== next.id),
314
+ });
315
+ refreshStatus();
316
+
317
+ // ---- 5. RUN ------------------------------------------------------------
318
+ // The child is a normal pipeline run in the mode that already exists: one
319
+ // item, unattended, stopping at an open PR. Nothing about the per-item
320
+ // `autopilot` mode changes because a queue is calling it.
321
+ log(`running ${next.source}:${next.id}`);
322
+ const child = spawnSync(
323
+ CLAUDE_BIN,
324
+ [
325
+ "--bg",
326
+ "--session-id",
327
+ sessionId,
328
+ "--permission-prompts",
329
+ "none",
330
+ "-p",
331
+ `/multi-agent ${next.ref || next.id} autopilot`,
332
+ ],
333
+ { cwd: next.localPath || process.cwd(), encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
334
+ );
335
+
336
+ // ---- 6. RECORD ---------------------------------------------------------
337
+ const outcome = child.status === 0 ? "pr-opened" : "build-failed";
338
+ const prUrl = /https:\/\/\S*\/pull(-requests)?\/\d+/.exec(child.stdout || "")?.[0] || null;
339
+ record({ source: next.source, id: next.id, taskId, outcome, prUrl, usd: 0 });
340
+
341
+ const after = readJson(join(ROOT, "queue.json"), queue);
342
+ writeState("queue.json", {
343
+ ...after,
344
+ running: (after.running || []).filter((r) => r.id !== next.id),
345
+ });
346
+ rmSync(join(ROOT, "runner.pid"), { force: true });
347
+ refreshStatus();
348
+ log(`${next.id}: ${outcome}${prUrl ? ` ${prUrl}` : ""}`);
349
+ return 0;
350
+ }
351
+
352
+ if (import.meta.url === `file://${process.argv[1]}`) {
353
+ process.exit(main());
354
+ }
@@ -0,0 +1,213 @@
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
+ ARMING=$(ma_ap_asset scripts/autopilot-arming.mjs 2>/dev/null || true)
66
+ if [ "$CONFIGURED" = true ] && [ -n "$ARMING" ]; then
67
+ ARM=$(node "$ARMING" check --json 2>/dev/null || echo '{}')
68
+ fi
69
+ SPEND=$(printf '%s' "$ARM" | jq -r '.spend.usd // 0' 2>/dev/null || echo 0)
70
+ CEILING_USD=$(printf '%s' "$ARM" | jq -r '.ceiling // 0' 2>/dev/null || echo 0)
71
+
72
+ # Recently finished, from the append-only attempt log rather than from a second
73
+ # list somebody has to remember to prune. Bounded to a day: this answers "what
74
+ # happened while I was away", and an unbounded list stops answering that.
75
+ CUTOFF=$(($(date +%s) - 86400))
76
+ REPORTS='[]'
77
+ if [ -f "$(ma_ap_root)/attempted.jsonl" ]; then
78
+ REPORTS=$(jq -sc --argjson c "$CUTOFF" '
79
+ [ .[] | select((.at // 0) >= $c) | select(.prUrl != null and .prUrl != "")
80
+ | {id: .id, kind: "pr", url: .prUrl, outcome: (.outcome // "open"), finishedAt: .at} ]
81
+ | sort_by(-.finishedAt) | .[0:10]' "$(ma_ap_root)/attempted.jsonl" 2>/dev/null || echo '[]')
82
+ fi
83
+ TODAY_N=$(printf '%s' "$REPORTS" | jq 'length' 2>/dev/null || echo 0)
84
+ TODAY_USD=$(printf '%s' "$QUEUE" | jq -r '.todayUsd // 0' 2>/dev/null || echo 0)
85
+
86
+ NOTE=""
87
+ if [ "$ON" = true ]; then
88
+ if [ "$POWER" = "ac" ]; then NOTE="prizde, uyku engelleniyor"; else NOTE="bataryada, uyku serbest - prize takınca devam eder"; fi
89
+ fi
90
+ if ma_ap_is_orphaned; then
91
+ NOTE="config açık ama launchd'de iş yok - /multi-agent:autopilot-on ile yeniden kur"
92
+ fi
93
+
94
+ build_json() {
95
+ jq -nc \
96
+ --argjson on "$ON" --argjson slots "$SLOTS" \
97
+ --argjson running "$RUNNING" --argjson queued "$QUEUED" --argjson awaiting "$AWAITING" \
98
+ --argjson reports "$REPORTS" --argjson todayCount "$TODAY_N" --argjson todayUsd "$TODAY_USD" \
99
+ --arg power "$POWER" --arg note "$NOTE" \
100
+ --arg emptyReason "$EMPTY_REASON" --arg blockedReason "$BLOCKED" \
101
+ --argjson interval "$INTERVAL" --argjson spendUsd "${SPEND:-0}" \
102
+ --argjson ceilingUsd "${CEILING_USD:-0}" \
103
+ --argjson lastTickAt "${LAST_TICK:-null}" \
104
+ '{on:$on, slots:$slots, running:$running, queued:$queued, awaiting:$awaiting,
105
+ reports:$reports, todayCount:$todayCount, todayUsd:$todayUsd,
106
+ lastTickAt:$lastTickAt, power:$power, note:$note,
107
+ scanIntervalSeconds:$interval, emptyReason:$emptyReason,
108
+ blockedReason:$blockedReason, spendUsd:$spendUsd, ceilingUsd:$ceilingUsd}'
109
+ }
110
+
111
+ case "$MODE" in
112
+ json)
113
+ build_json
114
+ exit 0
115
+ ;;
116
+ write)
117
+ build_json | ma_ap_write status.json
118
+ exit 0
119
+ ;;
120
+ esac
121
+
122
+ # Refresh status.json as a side effect of being asked, so the menu bar is current
123
+ # the moment anyone looks at the terminal - but ONLY when the mode is already
124
+ # configured. Asking a question may not answer it into existence: on a machine
125
+ # that never turned this on, `ma_ap_write` would create ~/.claude/autopilot/ and
126
+ # quietly break the guarantee that a clean install writes nothing. Caught by
127
+ # running this file on this machine, which then had the directory.
128
+ if [ "$CONFIGURED" = true ]; then
129
+ build_json | ma_ap_write status.json 2>/dev/null || true
130
+ fi
131
+
132
+ NR=$(printf '%s' "$RUNNING" | jq 'length')
133
+ NQ=$(printf '%s' "$QUEUED" | jq 'length')
134
+ NA=$(printf '%s' "$AWAITING" | jq 'length')
135
+
136
+ if [ "$MODE" = "subjects" ]; then
137
+ # One line per item, with the numbers INSIDE the string: the widget takes
138
+ # exactly one string per row, so anything the reader needs has to travel in it.
139
+ printf '%s' "$RUNNING" | jq -r '.[] |
140
+ "\(.id) · \(.depth // "full" | ascii_upcase[0:1] + .[1:]) · Faz \(.phase // "?")\(if .phaseTotal then "/\(.phaseTotal)" else "" end) \(.phaseName // "") · \(.stack // "")"'
141
+ printf '%s' "$AWAITING" | jq -r '.[] | "\(.id) · cevap bekliyor"'
142
+ exit 0
143
+ fi
144
+
145
+ if [ "$MODE" = "short" ]; then
146
+ if [ "$ON" != true ]; then
147
+ if [ "$CONFIGURED" = true ]; then
148
+ echo "autopilot: kapalı ($(ma_ap_cfg '.repos | length' 0) repo seçili)"
149
+ else
150
+ echo "autopilot: kurulu değil"
151
+ fi
152
+ exit 0
153
+ fi
154
+ echo "autopilot: açık · $NR koşuyor, $NQ sırada, $NA cevap bekliyor · $NOTE"
155
+ exit 0
156
+ fi
157
+
158
+ # --- human -------------------------------------------------------------------
159
+ if [ "$CONFIGURED" != true ]; then
160
+ echo "autopilot: bu makinede kurulu değil."
161
+ echo " Açmak için: /multi-agent:autopilot-on"
162
+ exit 0
163
+ fi
164
+
165
+ NREPOS=$(ma_ap_cfg '.repos | length' 0)
166
+ if [ "$ON" = true ]; then
167
+ echo "autopilot: AÇIK · $NREPOS repo · $SLOTS slot (bu makine $CEILING kaldırır) · her ${INTERVAL}sn tarıyor"
168
+ else
169
+ echo "autopilot: KAPALI · $NREPOS repo seçili, zamanlama yok"
170
+ echo " Açmak için: /multi-agent:autopilot-on"
171
+ fi
172
+ [ -n "$NOTE" ] && echo " $NOTE"
173
+
174
+ echo
175
+ if [ "$NR" -gt 0 ]; then
176
+ echo "Koşuyor ($NR):"
177
+ printf '%s' "$RUNNING" | jq -r '.[] | " \(.id) · \(.phaseName // .phase // "?") · \(.stack // "")"'
178
+ fi
179
+ if [ "$NQ" -gt 0 ]; then
180
+ echo "Sırada ($NQ):"
181
+ printf '%s' "$QUEUED" | jq -r '.[0:8][] | " \(.id)"'
182
+ fi
183
+ if [ "$NA" -gt 0 ]; then
184
+ echo "Cevap bekliyor ($NA) - açık soruları yorumladık, cevap gelince devam edecek:"
185
+ printf '%s' "$AWAITING" | jq -r '.[] | " \(.id)"'
186
+ fi
187
+ if [ "$NR" -eq 0 ] && [ "$NQ" -eq 0 ] && [ "$NA" -eq 0 ]; then
188
+ echo "Sırada iş yok."
189
+ # The single most common "why is nothing happening" answer, and it is not a
190
+ # fault: no item carries the label yet. When intake recorded a more specific
191
+ # reason, that one is truer and goes first.
192
+ if [ -n "$EMPTY_REASON" ]; then
193
+ echo " $EMPTY_REASON"
194
+ fi
195
+ echo " Bir madde almasi icin: GitHub issue'ya ya da sana atanmis Jira maddesine label ekle."
196
+ fi
197
+
198
+ # The queue refusing to take work is not the same as having none, and reading
199
+ # one as the other is the whole reason this line exists separately.
200
+ if [ -n "$BLOCKED" ]; then
201
+ echo
202
+ echo "Yeni madde alınmıyor: $BLOCKED"
203
+ fi
204
+ if [ "$CEILING_USD" != "0" ]; then
205
+ echo "Son 24 saat: \$$SPEND / \$$CEILING_USD"
206
+ fi
207
+
208
+ if [ "$TODAY_N" -gt 0 ]; then
209
+ echo
210
+ echo "Son 24 saat ($TODAY_N PR):"
211
+ printf '%s' "$REPORTS" | jq -r '.[0:6][] | " \(.id) · \(.url)"'
212
+ fi
213
+ exit 0