@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,361 @@
1
+ // autopilot-menubar.swift - what is running, in the menu bar, while you are elsewhere.
2
+ //
3
+ // macOS has no Live Activity for a Mac app: ActivityKit ships in the macOS SDK
4
+ // but every entry point is `@available(macOS, unavailable)`, and the activities
5
+ // that appear in the menu bar on macOS 26 are mirrored from an iPhone. The Mac
6
+ // equivalent of "a thing that keeps showing progress in the corner" is an
7
+ // NSStatusItem, which is what this is.
8
+ //
9
+ // It is deliberately the dumbest program in the tree. It reads one JSON file and
10
+ // draws it. No polling of git, no shelling out, no network, no writes. Every
11
+ // decision - what is running, how long, what is queued - is made by the runner
12
+ // and lands in status.json; this only renders. That keeps the one compiled
13
+ // artifact we ship small enough to read in a sitting, and it means a bug here
14
+ // can misdraw but cannot affect a run.
15
+ //
16
+ // Unbundled on purpose. A .app means signing, notarization and a release
17
+ // artifact; a bare executable with `.accessory` activation owns a status item
18
+ // just as well, stays out of the Dock and the app switcher, and is built on
19
+ // demand from this source so nothing binary ever enters the package.
20
+ //
21
+ // Build: swiftc -O autopilot-menubar.swift -o ~/.claude/autopilot/bin/menubar
22
+ // Run: menubar [--status-file <path>] [--interval <seconds>]
23
+
24
+ import AppKit
25
+
26
+ // MARK: - What the runner writes
27
+
28
+ struct Item: Decodable {
29
+ let id: String // PROJ-1234, or my-app#316
30
+ let title: String? // the ticket's own summary, trimmed by the writer
31
+ let depth: String? // full | short - which pipeline this item is running
32
+ let phase: Loose? // "3" or 3 - jq passes the queue's own shape through
33
+ let phaseName: String? // "Dev"
34
+ let phaseTotal: Loose? // 8 for Full, fewer for Short
35
+ let repo: String?
36
+ let stack: String?
37
+ let startedAt: Double?
38
+ let state: String? // running | queued | awaiting-answer | blocked
39
+ let url: String? // the ticket, so a row is clickable
40
+ }
41
+
42
+ /// Something that finished and produced a link worth opening: a PR, a Jira
43
+ /// comment, a Confluence page. Unattended work is only useful if what it
44
+ /// produced is one click away - otherwise the queue runs and you still have to
45
+ /// go looking for the results.
46
+ struct Report: Decodable {
47
+ let id: String
48
+ let kind: String? // pr | jira | confluence | wiki
49
+ let url: String?
50
+ let finishedAt: Double?
51
+ let outcome: String? // merged | open | failed
52
+ }
53
+
54
+ /// A JSON value that may honestly arrive as a string or as a number.
55
+ ///
56
+ /// This is not defensive programming for its own sake. `phase` is written by
57
+ /// `autopilot-status.sh`, which passes the queue's own value through `jq`
58
+ /// untouched, so it is a number when the runner wrote a number and a string when
59
+ /// it wrote a string - and Swift's synthesized `Decodable` throws on the
60
+ /// mismatch. A throw here does not lose one field: `Status` fails to decode
61
+ /// entirely and the indicator reads "autopilot kapalı" while four items are in
62
+ /// flight. That is the exact failure this file's own comment calls worse than no
63
+ /// list, arriving through the type system instead of through the logic.
64
+ struct Loose: Decodable {
65
+ let text: String?
66
+ init(from decoder: Decoder) throws {
67
+ let c = try decoder.singleValueContainer()
68
+ if c.decodeNil() { text = nil }
69
+ else if let s = try? c.decode(String.self) { text = s }
70
+ else if let i = try? c.decode(Int.self) { text = String(i) }
71
+ else if let d = try? c.decode(Double.self) { text = String(Int(d)) }
72
+ else { text = nil }
73
+ }
74
+ }
75
+
76
+ struct Status: Decodable {
77
+ let on: Bool
78
+ let slots: Int?
79
+ /// Items in flight RIGHT NOW. The writer removes an item the moment it
80
+ /// finishes; it reappears under `reports` with whatever it produced. Nothing
81
+ /// finished is ever left here - a list that keeps showing a done item is
82
+ /// worse than no list, because you stop trusting the ones that are real.
83
+ let running: [Item]?
84
+ let queued: [Item]?
85
+ let awaiting: [Item]?
86
+ /// Recently finished, newest first, bounded by the writer to a short window
87
+ /// (a day). This is not a history log - it is "what happened while you were
88
+ /// away", and an unbounded list stops answering that.
89
+ let reports: [Report]?
90
+ let todayCount: Int?
91
+ let todayUsd: Double?
92
+ let lastTickAt: Double?
93
+ let power: String? // ac | battery
94
+ let note: String?
95
+ }
96
+
97
+ // MARK: - Rendering
98
+
99
+ /// Elapsed time as the user reads it, not as a duration library formats it.
100
+ /// Minutes up to an hour, then hours and minutes; a run that has been going for
101
+ /// three days should not say "4319dk".
102
+ func elapsed(since epoch: Double?) -> String {
103
+ guard let epoch, epoch > 0 else { return "-" }
104
+ let s = max(0, Date().timeIntervalSince1970 - epoch)
105
+ let m = Int(s / 60)
106
+ if m < 60 { return "\(m)dk" }
107
+ let h = m / 60
108
+ if h < 24 { return "\(h)sa \(m % 60)dk" }
109
+ return "\(h / 24)g \(h % 24)sa"
110
+ }
111
+
112
+ /// The one line in the corner. Short, because the menu bar is shared real estate
113
+ /// and a long title pushes other people's items off a laptop display.
114
+ func title(_ st: Status?) -> String {
115
+ guard let st, st.on else { return "○" }
116
+ let r = (st.running ?? []).filter { ($0.state ?? "running") == "running" }.count
117
+ let q = st.queued?.count ?? 0
118
+ let a = st.awaiting?.count ?? 0
119
+ if r == 0 && q == 0 && a == 0 { return "◌" }
120
+ var parts: [String] = []
121
+ if r > 0 { parts.append("◐ \(r)") }
122
+ if q > 0 { parts.append("· \(q)") }
123
+ if a > 0 { parts.append("? \(a)") }
124
+ return parts.joined(separator: " ")
125
+ }
126
+
127
+ final class Controller: NSObject {
128
+ private lazy var item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
129
+ private let path: String
130
+ private let interval: TimeInterval
131
+ private var timer: Timer?
132
+
133
+ init(path: String, interval: TimeInterval, headless: Bool = false) {
134
+ self.path = path
135
+ self.interval = interval
136
+ super.init()
137
+ if headless { return }
138
+ item.button?.title = "○"
139
+ let menu = NSMenu()
140
+ menu.delegate = self
141
+ item.menu = menu
142
+ refresh()
143
+ timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
144
+ self?.refresh()
145
+ }
146
+ }
147
+
148
+ private func read() -> Status? {
149
+ guard let data = FileManager.default.contents(atPath: path) else { return nil }
150
+ return try? JSONDecoder().decode(Status.self, from: data)
151
+ }
152
+
153
+ private func refresh() {
154
+ let st = read()
155
+ item.button?.title = title(st)
156
+ // A tooltip carries what does not fit in the title, for a glance that
157
+ // does not need a click.
158
+ if let st, st.on {
159
+ let power = st.power == "battery" ? " · bataryada, uyku serbest" : ""
160
+ item.button?.toolTip = "multi-agent autopilot: \(st.running?.count ?? 0) koşuyor, "
161
+ + "\(st.queued?.count ?? 0) sırada\(power)"
162
+ } else {
163
+ item.button?.toolTip = "multi-agent autopilot kapalı"
164
+ }
165
+ }
166
+
167
+ /// One item, as much as fits on one line, in the order you would ask:
168
+ /// which item, which pipeline, how far in, how long, what stack.
169
+ ///
170
+ /// ◐ PROJ-1234 · Full · 3/8 Dev · 22dk · ios
171
+ ///
172
+ /// The phase is a fraction rather than a name alone, because "Dev" says
173
+ /// nothing about how much is left and "3/8" says it without a second line.
174
+ private func row(_ i: Item, prefix: String) -> NSMenuItem {
175
+ var line = "\(prefix)\(i.id)"
176
+ if let d = i.depth, !d.isEmpty { line += " · \(d.prefix(1).uppercased() + d.dropFirst())" }
177
+ if let p = i.phase?.text, !p.isEmpty {
178
+ let total = i.phaseTotal?.text.map { "/\($0)" } ?? ""
179
+ line += " · \(p)\(total)"
180
+ if let n = i.phaseName, !n.isEmpty { line += " \(n)" }
181
+ } else if let n = i.phaseName {
182
+ line += " · \(n)"
183
+ }
184
+ if i.startedAt != nil { line += " · \(elapsed(since: i.startedAt))" }
185
+ if let s = i.stack, !s.isEmpty { line += " · \(s)" }
186
+
187
+ let m = NSMenuItem(title: line, action: nil, keyEquivalent: "")
188
+ // The ticket title is long and would push every row's shape around, so
189
+ // it lives in the tooltip where it costs nothing.
190
+ if let t = i.title, !t.isEmpty { m.toolTip = t }
191
+ if let u = i.url, let link = URL(string: u) {
192
+ m.action = #selector(open(_:))
193
+ m.target = self
194
+ m.representedObject = link
195
+ m.isEnabled = true
196
+ } else {
197
+ m.isEnabled = false
198
+ }
199
+ return m
200
+ }
201
+
202
+ /// Opening a URL is the only thing this program does besides draw. It cannot
203
+ /// start, stop or change a run - control stays in the terminal, where it is
204
+ /// confirmed.
205
+ @objc private func open(_ sender: NSMenuItem) {
206
+ guard let u = sender.representedObject as? URL else { return }
207
+ NSWorkspace.shared.open(u)
208
+ }
209
+
210
+ private func reportRow(_ r: Report) -> NSMenuItem {
211
+ var line = " \(r.id)"
212
+ if let k = r.kind { line += " · \(k.uppercased())" }
213
+ if let o = r.outcome { line += " · \(o)" }
214
+ if r.finishedAt != nil { line += " · \(elapsed(since: r.finishedAt)) önce" }
215
+ let m = NSMenuItem(title: line, action: nil, keyEquivalent: "")
216
+ if let u = r.url, let link = URL(string: u) {
217
+ m.action = #selector(open(_:))
218
+ m.target = self
219
+ m.representedObject = link
220
+ m.isEnabled = true
221
+ } else {
222
+ m.isEnabled = false
223
+ }
224
+ return m
225
+ }
226
+
227
+ func rebuild(_ menu: NSMenu) {
228
+ menu.removeAllItems()
229
+ guard let st = read() else {
230
+ menu.addItem(withTitle: "autopilot kapalı", action: nil, keyEquivalent: "").isEnabled = false
231
+ menu.addItem(.separator())
232
+ let q = NSMenuItem(title: "Göstergeyi kapat", action: #selector(quit), keyEquivalent: "q")
233
+ q.target = self
234
+ menu.addItem(q)
235
+ return
236
+ }
237
+ if !st.on {
238
+ menu.addItem(withTitle: "autopilot kapalı", action: nil, keyEquivalent: "").isEnabled = false
239
+ } else {
240
+ // Fixed order, running first: the thing you would interrupt for is
241
+ // always in the same place.
242
+ //
243
+ // Filtered on `state` as well as on which array it is in. A finished
244
+ // item belongs in `reports`, not in `running`, and the writer moves
245
+ // it - but a renderer that trusts the array alone will pin a finished
246
+ // item at the top of the menu until the next write, which is exactly
247
+ // the "it says it is still working when it is not" failure this whole
248
+ // release was about. Two conditions, one of them local.
249
+ for i in (st.running ?? []).filter({ ($0.state ?? "running") == "running" }) {
250
+ menu.addItem(row(i, prefix: "◐ "))
251
+ }
252
+ if let q = st.queued, !q.isEmpty {
253
+ menu.addItem(.separator())
254
+ menu.addItem(withTitle: "Sırada (\(q.count))", action: nil, keyEquivalent: "").isEnabled = false
255
+ for i in q.prefix(8) { menu.addItem(row(i, prefix: "· ")) }
256
+ if q.count > 8 {
257
+ menu.addItem(withTitle: " ... \(q.count - 8) tane daha", action: nil, keyEquivalent: "").isEnabled = false
258
+ }
259
+ }
260
+ if let a = st.awaiting, !a.isEmpty {
261
+ menu.addItem(.separator())
262
+ menu.addItem(withTitle: "Cevap bekliyor (\(a.count))", action: nil, keyEquivalent: "").isEnabled = false
263
+ for i in a.prefix(6) { menu.addItem(row(i, prefix: "? ")) }
264
+ }
265
+ if (st.running?.isEmpty ?? true) && (st.queued?.isEmpty ?? true) && (st.awaiting?.isEmpty ?? true) {
266
+ menu.addItem(withTitle: "açık, sırada iş yok", action: nil, keyEquivalent: "").isEnabled = false
267
+ }
268
+ }
269
+ // What it produced. Without this the menu answers "is it working" and
270
+ // never "what did it do", which is the question you actually have after
271
+ // being away.
272
+ if let rs = st.reports, !rs.isEmpty {
273
+ menu.addItem(.separator())
274
+ menu.addItem(withTitle: "Raporlar (\(rs.count))", action: nil, keyEquivalent: "").isEnabled = false
275
+ for r in rs.prefix(8) { menu.addItem(reportRow(r)) }
276
+ }
277
+
278
+ menu.addItem(.separator())
279
+ if let n = st.todayCount {
280
+ var line = "bugün \(n) madde"
281
+ if let u = st.todayUsd { line += String(format: " · $%.2f", u) }
282
+ menu.addItem(withTitle: line, action: nil, keyEquivalent: "").isEnabled = false
283
+ }
284
+ if let t = st.lastTickAt {
285
+ menu.addItem(withTitle: "son tarama \(elapsed(since: t)) önce", action: nil, keyEquivalent: "").isEnabled = false
286
+ }
287
+ if let n = st.note, !n.isEmpty {
288
+ menu.addItem(withTitle: n, action: nil, keyEquivalent: "").isEnabled = false
289
+ }
290
+ menu.addItem(.separator())
291
+ // Control lives in the terminal. This closes the INDICATOR only, and says
292
+ // so - a menu item that reads "Kapat" next to a queue would reasonably be
293
+ // taken for "stop the autopilot", and quietly stopping unattended work
294
+ // from a menu nobody confirmed is the wrong default.
295
+ menu.addItem(withTitle: "Durdurmak için: /multi-agent:autopilot-off", action: nil, keyEquivalent: "").isEnabled = false
296
+ let q = NSMenuItem(title: "Göstergeyi kapat", action: #selector(quit), keyEquivalent: "q")
297
+ q.target = self
298
+ menu.addItem(q)
299
+ }
300
+
301
+ @objc private func quit() { NSApplication.shared.terminate(nil) }
302
+
303
+ /// The title and every menu line, as text. Built by the same rebuild() the
304
+ /// live menu uses, so what a gate asserts is what a user sees.
305
+ func dump() -> String {
306
+ let st = read()
307
+ let menu = NSMenu()
308
+ rebuild(menu)
309
+ var out = ["title: \(title(st))"]
310
+ for i in 0..<menu.numberOfItems {
311
+ let mi = menu.item(at: i)!
312
+ out.append(mi.isSeparatorItem ? "---" : mi.title)
313
+ }
314
+ return out.joined(separator: "\n")
315
+ }
316
+ }
317
+
318
+ extension Controller: NSMenuDelegate {
319
+ func menuWillOpen(_ menu: NSMenu) { rebuild(menu) }
320
+ }
321
+
322
+ // MARK: - Entry
323
+
324
+ var statusPath = (NSHomeDirectory() as NSString).appendingPathComponent(".claude/autopilot/status.json")
325
+ var interval: TimeInterval = 5
326
+
327
+ var dumpOnly = false
328
+ var args = Array(CommandLine.arguments.dropFirst())
329
+ while let a = args.first {
330
+ args.removeFirst()
331
+ switch a {
332
+ case "--status-file":
333
+ if let v = args.first { statusPath = v; args.removeFirst() }
334
+ case "--interval":
335
+ if let v = args.first, let d = Double(v) { interval = max(1, d); args.removeFirst() }
336
+ case "--dump":
337
+ dumpOnly = true
338
+ case "--help", "-h":
339
+ print("usage: menubar [--status-file <path>] [--interval <seconds>] [--dump]")
340
+ exit(0)
341
+ default:
342
+ FileHandle.standardError.write("menubar: unknown option \(a)\n".data(using: .utf8)!)
343
+ exit(2)
344
+ }
345
+ }
346
+
347
+ // `--dump` prints the title and every menu line, then exits. It exists so the
348
+ // content can be asserted by a gate: a smoke test cannot click a status item, and
349
+ // a gate that only checks "the binary compiled" proves nothing about what the
350
+ // user reads. Same code path as the live menu, so the dump cannot drift from it.
351
+ if dumpOnly {
352
+ let c = Controller(path: statusPath, interval: interval, headless: true)
353
+ print(c.dump())
354
+ exit(0)
355
+ }
356
+
357
+ let app = NSApplication.shared
358
+ app.setActivationPolicy(.accessory) // no Dock tile, no app switcher entry
359
+ let controller = Controller(path: statusPath, interval: interval)
360
+ _ = controller
361
+ app.run()