@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +49 -4
  3. package/README.tr.md +50 -4
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/install/templates/multi-agent-autopilot.plist.template +79 -0
  7. package/package.json +1 -1
  8. package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +64 -0
  9. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +173 -0
  10. package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +74 -0
  11. package/pipeline/commands/multi-agent/channels/SKILL.md +41 -12
  12. package/pipeline/commands/multi-agent/help/SKILL.md +41 -35
  13. package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
  14. package/pipeline/commands/multi-agent/sync/SKILL.md +10 -9
  15. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  16. package/pipeline/lib/autopilot-activation.sh +117 -0
  17. package/pipeline/lib/autopilot-state.sh +150 -0
  18. package/pipeline/lib/issue-fetcher.sh +18 -1
  19. package/pipeline/lib/plan-todos.sh +18 -0
  20. package/pipeline/multi-agent-refs/channels/jira.md +80 -20
  21. package/pipeline/multi-agent-refs/channels/pr.md +65 -19
  22. package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -5
  23. package/pipeline/multi-agent-refs/features/visual-evidence.md +19 -7
  24. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  25. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +17 -15
  26. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +1 -1
  27. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +1 -1
  28. package/pipeline/multi-agent-refs/readiness-review.md +7 -1
  29. package/pipeline/multi-agent-refs/rules.md +3 -11
  30. package/pipeline/multi-agent-refs/tracker-contract.md +32 -0
  31. package/pipeline/schemas/autopilot-config.schema.json +149 -0
  32. package/pipeline/schemas/token-budget.json +2 -2
  33. package/pipeline/scripts/autopilot-arming.mjs +147 -0
  34. package/pipeline/scripts/autopilot-intake.mjs +383 -0
  35. package/pipeline/scripts/autopilot-menubar.swift +361 -0
  36. package/pipeline/scripts/autopilot-runner.mjs +349 -0
  37. package/pipeline/scripts/autopilot-status.sh +212 -0
  38. package/pipeline/scripts/jira-search.sh +70 -0
  39. package/pipeline/scripts/phase-tracker.sh +134 -12
  40. package/pipeline/scripts/probe-evidence-capability.sh +27 -3
  41. package/pipeline/scripts/run-ui-tests.sh +113 -4
  42. package/pipeline/skills/.skill-manifest.json +16 -4
  43. package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +67 -0
  44. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +146 -0
  45. package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +64 -0
  46. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +62 -11
  47. 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"