@mmerterden/multi-agent-pipeline 17.6.0 → 18.0.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 (103) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +43 -1
  3. package/README.tr.md +41 -0
  4. package/docs/adr/0011-dormant-ci.md +25 -1
  5. package/docs/server-readiness.md +188 -0
  6. package/index.js +16 -1
  7. package/install/_common.mjs +42 -17
  8. package/install/_dev-only-files.mjs +8 -0
  9. package/install/_unattended-profile.mjs +113 -0
  10. package/install/index.mjs +48 -0
  11. package/manifest.json +1049 -0
  12. package/package.json +5 -2
  13. package/pipeline/commands/multi-agent/status/SKILL.md +52 -21
  14. package/pipeline/lib/_jira-auth.sh +8 -0
  15. package/pipeline/lib/analysis-jira-write.sh +32 -0
  16. package/pipeline/lib/ask-choice.sh +13 -2
  17. package/pipeline/lib/autopilot-state.sh +8 -0
  18. package/pipeline/lib/fatal.mjs +129 -0
  19. package/pipeline/lib/figma-mcp-refresh.sh +18 -0
  20. package/pipeline/lib/figma-screenshot.sh +18 -0
  21. package/pipeline/lib/invoked-directly.mjs +43 -0
  22. package/pipeline/lib/jira-publish.sh +42 -0
  23. package/pipeline/lib/md2confluence-v3.py +47 -0
  24. package/pipeline/lib/outbound-gate.mjs +175 -0
  25. package/pipeline/lib/plan-todos.sh +27 -6
  26. package/pipeline/lib/post-pr-review.sh +77 -8
  27. package/pipeline/lib/repo-hygiene.sh +8 -3
  28. package/pipeline/lib/require-jq.sh +40 -0
  29. package/pipeline/lib/run-paths.sh +335 -0
  30. package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +70 -0
  31. package/pipeline/multi-agent-refs/features/cost-analysis.md +93 -0
  32. package/pipeline/multi-agent-refs/features/doctor.md +45 -0
  33. package/pipeline/multi-agent-refs/features/verify.md +83 -0
  34. package/pipeline/multi-agent-refs/phases/operations.md +13 -2
  35. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  36. package/pipeline/multi-agent-refs/unattended-contract.md +129 -0
  37. package/pipeline/scripts/_run-paths.mjs +372 -0
  38. package/pipeline/scripts/aggregate-metrics.mjs +64 -64
  39. package/pipeline/scripts/autopilot-arming.mjs +2 -1
  40. package/pipeline/scripts/autopilot-intake.mjs +2 -1
  41. package/pipeline/scripts/autopilot-runner.mjs +206 -2
  42. package/pipeline/scripts/build-references.mjs +2 -1
  43. package/pipeline/scripts/build-stack-plugins.mjs +10 -2
  44. package/pipeline/scripts/capture-evidence.sh +7 -2
  45. package/pipeline/scripts/classify-plan-safety.mjs +2 -1
  46. package/pipeline/scripts/cost-analyze.mjs +600 -0
  47. package/pipeline/scripts/cost-budget-check.mjs +4 -12
  48. package/pipeline/scripts/council-view.mjs +2 -1
  49. package/pipeline/scripts/crush-json.mjs +2 -1
  50. package/pipeline/scripts/diff-explain.mjs +6 -9
  51. package/pipeline/scripts/diff-risk-score.mjs +2 -1
  52. package/pipeline/scripts/doctor.mjs +138 -4
  53. package/pipeline/scripts/evidence-gate.mjs +9 -3
  54. package/pipeline/scripts/feedback-send.mjs +12 -2
  55. package/pipeline/scripts/gc-abandoned.sh +29 -13
  56. package/pipeline/scripts/gc-worktrees.sh +11 -4
  57. package/pipeline/scripts/github-ssh-setup.sh +64 -7
  58. package/pipeline/scripts/graph-mermaid.mjs +4 -2
  59. package/pipeline/scripts/keychain-save.sh +101 -30
  60. package/pipeline/scripts/learn-from-transcripts.mjs +2 -1
  61. package/pipeline/scripts/learning-curve.mjs +34 -29
  62. package/pipeline/scripts/make-manifest.mjs +199 -0
  63. package/pipeline/scripts/migrate-prefs.mjs +2 -1
  64. package/pipeline/scripts/migrate-state.mjs +94 -4
  65. package/pipeline/scripts/phase-banner.sh +6 -2
  66. package/pipeline/scripts/phase-tracker.sh +41 -3
  67. package/pipeline/scripts/plan-coverage-gate.mjs +6 -2
  68. package/pipeline/scripts/pre-commit-check.sh +7 -0
  69. package/pipeline/scripts/pre-push-check.sh +7 -0
  70. package/pipeline/scripts/purge.sh +23 -6
  71. package/pipeline/scripts/render-agent-log-cost.sh +9 -2
  72. package/pipeline/scripts/render-cost-summary.sh +9 -2
  73. package/pipeline/scripts/render-work-summary.sh +11 -4
  74. package/pipeline/scripts/review-file-filter.mjs +4 -2
  75. package/pipeline/scripts/review-scope.mjs +2 -1
  76. package/pipeline/scripts/routine-registry.mjs +2 -1
  77. package/pipeline/scripts/run-aggregator.mjs +13 -14
  78. package/pipeline/scripts/run-metrics.mjs +3 -1
  79. package/pipeline/scripts/runs-index.mjs +343 -0
  80. package/pipeline/scripts/scorecard-snapshot.mjs +178 -0
  81. package/pipeline/scripts/search-logs.sh +18 -0
  82. package/pipeline/scripts/test-gap-scan.mjs +2 -1
  83. package/pipeline/scripts/test-integrity-gate.mjs +2 -1
  84. package/pipeline/scripts/update-issue-progress.sh +56 -7
  85. package/pipeline/scripts/usage-report.mjs +12 -1
  86. package/pipeline/scripts/validate-analysis-doc.mjs +2 -1
  87. package/pipeline/scripts/validate-code-graph.mjs +6 -3
  88. package/pipeline/scripts/validate-complaint-doc.mjs +2 -1
  89. package/pipeline/scripts/validate-diff-risk.mjs +6 -3
  90. package/pipeline/scripts/validate-test-gap.mjs +6 -3
  91. package/pipeline/scripts/validate-triage.mjs +3 -1
  92. package/pipeline/scripts/verify-citations.mjs +4 -2
  93. package/pipeline/scripts/verify.mjs +327 -0
  94. package/pipeline/scripts/worktree-finalize.sh +13 -4
  95. package/pipeline/scripts/write-state.mjs +154 -15
  96. package/pipeline/skills/.skill-manifest.json +2 -2
  97. package/pipeline/skills/.skills-index.json +56 -1
  98. package/pipeline/skills/shared/README.md +8 -3
  99. package/pipeline/skills/shared/core/multi-agent-status/SKILL.md +33 -9
  100. package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/package_app.sh +4 -1
  101. package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/setup_dev_signing.sh +4 -1
  102. package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/sign-and-notarize.sh +2 -1
  103. package/pipeline/skills/skills-index.md +6 -1
@@ -39,6 +39,7 @@ import { execFileSync } from "node:child_process";
39
39
  import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, chmodSync } from "node:fs";
40
40
  import { join, dirname } from "node:path";
41
41
  import { homedir } from "node:os";
42
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
42
43
 
43
44
  const ROOT = process.env.MA_AUTOPILOT_ROOT || join(homedir(), ".claude", "autopilot");
44
45
 
@@ -382,6 +383,6 @@ function main(argv) {
382
383
  return 0;
383
384
  }
384
385
 
385
- if (import.meta.url === `file://${process.argv[1]}`) {
386
+ if (invokedDirectly(import.meta.url)) {
386
387
  process.exit(main(process.argv));
387
388
  }
@@ -51,10 +51,16 @@ import {
51
51
  chmodSync,
52
52
  readdirSync,
53
53
  statSync,
54
+ openSync,
55
+ readSync,
56
+ closeSync,
57
+ truncateSync,
54
58
  } from "node:fs";
55
59
  import { join } from "node:path";
56
60
  import { homedir } from "node:os";
57
61
  import { randomUUID } from "node:crypto";
62
+ import { runMain } from "../lib/fatal.mjs";
63
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
58
64
 
59
65
  const ROOT = process.env.MA_AUTOPILOT_ROOT || join(homedir(), ".claude", "autopilot");
60
66
  // Siblings resolve from THIS file's own directory, not from ~/.claude/scripts.
@@ -76,6 +82,25 @@ const REGISTER_GRACE_MS = Number(process.env.MA_AP_GRACE_MS || 60000);
76
82
  // A session in one of these is still working. Anything else - and a row with
77
83
  // no status at all is not "anything else" - ends supervision.
78
84
  const LIVE_STATUSES = new Set(["busy", "running", "waiting"]);
85
+ // launchd appends this process's stdout to runner.log forever. On a laptop that
86
+ // is a file nobody ever looks at; on a machine that ticks every few minutes for
87
+ // months it is the thing that fills the disk, and a full disk stops the runs it
88
+ // was logging.
89
+ const LOG_MAX_BYTES = Number(process.env.MA_AP_LOG_MAX_BYTES || 5 * 1024 * 1024);
90
+ // Consecutive failed attempts before the runner stops taking NEW work. The
91
+ // failure this guards against is a machine-level one - an expired token, a full
92
+ // disk, a `claude` that no longer launches - where every item fails the same
93
+ // way and the queue is consumed one worthless run at a time. Zero disables it.
94
+ const BREAKER_LIMIT = Number(process.env.MA_AP_BREAKER_LIMIT || 3);
95
+ // How long an open breaker waits before letting ONE attempt through. Long
96
+ // enough that a broken machine is not burning the queue (default 30 min, so at
97
+ // a 15-minute tick it is every other tick at most), short enough that a machine
98
+ // fixed at 3am is working again by morning without anyone touching it.
99
+ const BREAKER_COOLDOWN_SEC = Number(process.env.MA_AP_BREAKER_COOLDOWN_SEC || 1800);
100
+ // Outcomes that mean the attempt produced nothing. `needs-input` is NOT here:
101
+ // a run parked on a question did work and is waiting for a person, which is the
102
+ // system behaving correctly.
103
+ const FAILED_OUTCOMES = new Set(["launch-failed", "timed-out", "no-state", "failed", "unknown"]);
79
104
 
80
105
  const log = (m) => process.stdout.write(`autopilot-runner: ${m}\n`);
81
106
 
@@ -120,6 +145,111 @@ function record(entry) {
120
145
  chmodSync(p, 0o600);
121
146
  }
122
147
 
148
+ /**
149
+ * Keep runner.log bounded without breaking the writer.
150
+ *
151
+ * launchd holds the file open with O_APPEND, so RENAMING the log leaves that fd
152
+ * writing to the renamed inode and the new file stays empty forever - the
153
+ * rotation that looks right is the one that silently stops logging. Truncating
154
+ * the SAME inode is what works: the open fd keeps writing, at offset zero.
155
+ * The tail is copied aside first, because the last few hundred lines are the
156
+ * ones somebody is about to need.
157
+ */
158
+ function rotateLog() {
159
+ const p = join(ROOT, "runner.log");
160
+ let size;
161
+ try {
162
+ size = statSync(p).size;
163
+ } catch {
164
+ // No log yet (a runner that has never been started by launchd), or it is
165
+ // unreadable. Nothing to rotate either way.
166
+ return;
167
+ }
168
+ if (size <= LOG_MAX_BYTES) return;
169
+ try {
170
+ const keep = Math.min(size, 256 * 1024);
171
+ const fd = openSync(p, "r");
172
+ const buf = Buffer.alloc(keep);
173
+ readSync(fd, buf, 0, keep, size - keep);
174
+ closeSync(fd);
175
+ writeFileSync(join(ROOT, "runner.log.1"), buf, { mode: 0o600 });
176
+ // Truncate in place. Not unlink, not rename.
177
+ truncateSync(p, 0);
178
+ log(
179
+ `runner.log reached ${Math.round(size / 1048576)}MB - tail kept in runner.log.1, log truncated`,
180
+ );
181
+ } catch {
182
+ // A log that cannot be rotated is not a reason to skip the tick.
183
+ }
184
+ }
185
+
186
+ /**
187
+ * One line per tick, machine-readable, next to the human log. The human log
188
+ * answers "what happened just now"; this answers "how has it been behaving for
189
+ * a week", which is the question a server actually raises and the one prose
190
+ * cannot be asked.
191
+ */
192
+ function tick(entry) {
193
+ try {
194
+ ensureRoot();
195
+ const p = join(ROOT, "ticks.jsonl");
196
+ appendFileSync(p, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n", {
197
+ mode: 0o600,
198
+ });
199
+ } catch {
200
+ // Telemetry never fails a tick.
201
+ }
202
+ }
203
+
204
+ /**
205
+ * How many attempts in a row produced nothing.
206
+ *
207
+ * Read from attempted.jsonl rather than kept as a counter, because a counter is
208
+ * a second piece of state that can disagree with the ledger - and the ledger is
209
+ * what a person reads when they ask why the runner stopped.
210
+ *
211
+ * @returns {{count:number, last:string|null}}
212
+ */
213
+ export function consecutiveFailures(lines) {
214
+ let count = 0;
215
+ let last = null;
216
+ for (let i = lines.length - 1; i >= 0; i--) {
217
+ const row = lines[i];
218
+ if (!row || typeof row.outcome !== "string") continue;
219
+ // A blocked item never ran: arming refused it, which says nothing about
220
+ // whether a run would have worked.
221
+ if (row.outcome.startsWith("blocked-")) continue;
222
+ if (FAILED_OUTCOMES.has(row.outcome)) {
223
+ count++;
224
+ if (!last) last = row.outcome;
225
+ continue;
226
+ }
227
+ break;
228
+ }
229
+ return { count, last };
230
+ }
231
+
232
+ function readAttempted() {
233
+ const p = join(ROOT, "attempted.jsonl");
234
+ if (!existsSync(p)) return [];
235
+ try {
236
+ return readFileSync(p, "utf-8")
237
+ .split("\n")
238
+ .filter(Boolean)
239
+ .slice(-50)
240
+ .map((l) => {
241
+ try {
242
+ return JSON.parse(l);
243
+ } catch {
244
+ return null;
245
+ }
246
+ })
247
+ .filter(Boolean);
248
+ } catch {
249
+ return [];
250
+ }
251
+ }
252
+
123
253
  /** Seconds since the epoch at which this machine booted. */
124
254
  export function bootTime() {
125
255
  const out = run("sysctl", ["-n", "kern.boottime"]);
@@ -446,6 +576,61 @@ function main() {
446
576
  return 0;
447
577
  }
448
578
 
579
+ rotateLog();
580
+
581
+ // ---- 1b. BREAKER -------------------------------------------------------
582
+ // Three failures in a row is not three unlucky items, it is one broken
583
+ // machine: an expired token, a `claude` that no longer launches, a full disk.
584
+ // Left alone the runner eats the whole queue at a few minutes an item and
585
+ // records a failure for each, which destroys the evidence of WHICH item was
586
+ // first and leaves nothing to resume. Stopping keeps the queue intact.
587
+ const attempts = readAttempted();
588
+ const breaker = consecutiveFailures(attempts);
589
+ if (BREAKER_LIMIT > 0 && breaker.count >= BREAKER_LIMIT) {
590
+ // HALF-OPEN, not latched. The first version of this returned here on every
591
+ // tick, and the only writer of attempted.jsonl is downstream of this
592
+ // return - so once it opened, no new attempt could ever be recorded, the
593
+ // consecutive count could never fall, and the runner was stopped for good.
594
+ // The log line said "until an attempt succeeds" and the feature doc said
595
+ // "one successful attempt clears it": both described a state the code made
596
+ // unreachable.
597
+ //
598
+ // A breaker that cannot re-close is not a breaker, it is an off switch with
599
+ // a misleading label. So after a cooldown one probe is let through: if the
600
+ // machine recovered it succeeds and the count resets on its own; if it did
601
+ // not, that probe fails, becomes the new most recent attempt, and the
602
+ // breaker closes again for another cooldown. One wasted run per cooldown is
603
+ // the price of not needing a human to notice.
604
+ const lastAt = attempts.length ? Number(attempts[attempts.length - 1].at || 0) : 0;
605
+ const sinceSec = lastAt ? Math.floor(Date.now() / 1000) - lastAt : Infinity;
606
+ const probeDue = sinceSec >= BREAKER_COOLDOWN_SEC;
607
+ if (!probeDue) {
608
+ const why = `${breaker.count} attempts in a row produced nothing (last: ${breaker.last})`;
609
+ const waitMin = Math.ceil((BREAKER_COOLDOWN_SEC - sinceSec) / 60);
610
+ log(`circuit breaker open - ${why}`);
611
+ log(
612
+ ` one probe run is allowed in ${waitMin} min; check the token, disk and \`claude\` binary`,
613
+ );
614
+ log(" to override for one tick: MA_AP_BREAKER_LIMIT=0");
615
+ if (!DRY) {
616
+ writeState("queue.json", {
617
+ ...readJson(join(ROOT, "queue.json"), { queued: [], running: [] }),
618
+ blockedReason: `circuit breaker: ${why} - probe in ${waitMin} min`,
619
+ });
620
+ refreshStatus();
621
+ }
622
+ tick({
623
+ action: "breaker-open",
624
+ failures: breaker.count,
625
+ lastOutcome: breaker.last,
626
+ probeInSec: BREAKER_COOLDOWN_SEC - sinceSec,
627
+ });
628
+ return 0;
629
+ }
630
+ log(`circuit breaker half-open - ${breaker.count} failures, letting one probe run through`);
631
+ tick({ action: "breaker-probe", failures: breaker.count, lastOutcome: breaker.last });
632
+ }
633
+
449
634
  // ---- 2. INTAKE ---------------------------------------------------------
450
635
  const intake = join(SCRIPTS, "autopilot-intake.mjs");
451
636
  if (existsSync(intake)) run(process.execPath, [intake]);
@@ -455,6 +640,7 @@ function main() {
455
640
  const slots = Number(config.slots ?? 1);
456
641
  if (running.length >= slots) {
457
642
  log(`${running.length}/${slots} slots in use - nothing to take`);
643
+ tick({ action: "slots-full", running: running.length, slots });
458
644
  return 0;
459
645
  }
460
646
 
@@ -466,6 +652,11 @@ function main() {
466
652
  const next = (queue.queued || []).find((i) => !busyRepos.has(i.repo));
467
653
  if (!next) {
468
654
  log(queue.emptyReason || "every queued item belongs to a repo already in flight");
655
+ tick({
656
+ action: "nothing-to-take",
657
+ queued: (queue.queued || []).length,
658
+ running: running.length,
659
+ });
469
660
  return 0;
470
661
  }
471
662
 
@@ -593,9 +784,22 @@ function main() {
593
784
  log(`${next.id}: claim kept for the next tick (${supervised.reason})`);
594
785
  }
595
786
  log(`${next.id}: ${outcome}${prUrl ? ` ${prUrl}` : ""} (${supervised.waitedSec}s)`);
787
+ tick({
788
+ action: "ran",
789
+ source: next.source,
790
+ id: next.id,
791
+ taskId,
792
+ outcome,
793
+ reason: supervised.reason,
794
+ waitedSec: supervised.waitedSec,
795
+ prOpened: Boolean(prUrl),
796
+ consecutiveFailuresBefore: breaker.count,
797
+ });
596
798
  return 0;
597
799
  }
598
800
 
599
- if (import.meta.url === `file://${process.argv[1]}`) {
600
- process.exit(main());
801
+ if (invokedDirectly(import.meta.url)) {
802
+ runMain("autopilot-runner", () => {
803
+ process.exit(main());
804
+ });
601
805
  }
@@ -22,6 +22,7 @@
22
22
  // Exit codes: 0 ok, 1 coverage failure, 2 usage / parse error.
23
23
 
24
24
  import { readFileSync } from "node:fs";
25
+ import { runMain } from "../lib/fatal.mjs";
25
26
 
26
27
  const HEADERS = {
27
28
  tr: ["Tür", "Kaynak", "URL / Yol", "Sürüm / Ref", "Rol", "Erişim", "Notlar"],
@@ -385,4 +386,4 @@ function main() {
385
386
  process.stdout.write(`${renderTable(rows, lang)}\n`);
386
387
  }
387
388
 
388
- main();
389
+ runMain("build-references", main);
@@ -33,7 +33,8 @@ import {
33
33
  mkdirSync,
34
34
  statSync,
35
35
  } from "node:fs";
36
- import { join } from "node:path";
36
+ import { dirname, join } from "node:path";
37
+ import { fileURLToPath } from "node:url";
37
38
  import { spawnSync } from "node:child_process";
38
39
  import { createHash } from "node:crypto";
39
40
 
@@ -65,7 +66,14 @@ if (!HOME) {
65
66
  process.exit(2);
66
67
  }
67
68
  const PLUGINS_REPO = getArg("--plugins-repo", join(HOME, "multi-agent-plugins"));
68
- const PIPE_ROOT = getArg("--pipeline", join(HOME, "multi-agent-pipeline"));
69
+ // The pipeline root is THIS file's repo, found by walking up from the script,
70
+ // not a guess at where the checkout lives. It defaulted to
71
+ // $HOME/multi-agent-pipeline, which is true on the maintainer's machine and
72
+ // nowhere else: on a CI runner the checkout is under the workspace directory,
73
+ // so --check-routing reported "source not found" and three tests failed on
74
+ // every clean host while passing locally. That is the whole class of bug CI
75
+ // exists to catch, and it survived because CI was asleep.
76
+ const PIPE_ROOT = getArg("--pipeline", join(dirname(fileURLToPath(import.meta.url)), "..", ".."));
69
77
  const EXTERNAL = join(PIPE_ROOT, "pipeline/skills/shared/external");
70
78
  const DRY = args.includes("--dry-run");
71
79
 
@@ -207,7 +207,10 @@ case "$MODE" in
207
207
  # stale video from yesterday's run attached as today's evidence is worse
208
208
  # than no video, because nobody re-checks an artefact that is present.
209
209
  SRC=""
210
- for cand in $(find test-results cypress/videos -type f \( -name '*.webm' -o -name '*.mp4' \) 2>/dev/null); do
210
+ # `while read`, not `for cand in $(find ...)`: word splitting turns one
211
+ # path with a space into two candidates, both of which fail `[ -f ]`, and
212
+ # the evidence goes missing without a word said.
213
+ while IFS= read -r cand; do
211
214
  [ -f "$cand" ] || continue
212
215
  MT=$(date -r "$cand" +%s 2>/dev/null || echo 0)
213
216
  [ "$MT" -ge "$SINCE" ] 2>/dev/null || continue
@@ -215,7 +218,9 @@ case "$MODE" in
215
218
  PREV=$(date -r "$SRC" +%s 2>/dev/null || echo 0)
216
219
  [ "$MT" -gt "$PREV" ] 2>/dev/null && SRC="$cand"
217
220
  fi
218
- done
221
+ done <<EOF
222
+ $(find test-results cypress/videos -type f \( -name '*.webm' -o -name '*.mp4' \) 2>/dev/null)
223
+ EOF
219
224
  [ -n "$SRC" ] || { echo "capture-evidence: the suite recorded no video after the marker (is video enabled in the project's runner config?)" >&2; exit 4; }
220
225
  case "$SRC" in
221
226
  *.webm)
@@ -197,5 +197,6 @@ if (recommendPause) {
197
197
  summary = `Low-risk plan - autopilot safe`;
198
198
  }
199
199
 
200
+ // See run-metrics.mjs: exiting here would truncate this write when stdout is a
201
+ // pipe, and 0 is the default code anyway.
200
202
  process.stdout.write(JSON.stringify({ score, recommendPause, reasons, summary }, null, 2) + "\n");
201
- process.exit(0);