@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
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * outbound-gate.mjs - nothing leaves this machine carrying a credential.
4
+ *
5
+ * The repo already has a leak scanner, and it looks at the wrong thing for
6
+ * this purpose: `smoke-personal-data.sh` audits the FILES IN THE REPO so the
7
+ * published pipeline stays generic. It never sees a Jira comment, a PR body or
8
+ * an issue update - text that a run composes at runtime out of command output,
9
+ * error messages and file excerpts, any of which can contain a token that was
10
+ * on the machine a second earlier.
11
+ *
12
+ * SCOPE, and the part the plan for this got wrong: corporate names are NOT
13
+ * checked here. A Jira comment on a corporate ticket legitimately contains the
14
+ * corporate project key, the repo name and the hostname - refusing those would
15
+ * make the gate fire on every correct publish, and a gate that cries wolf is
16
+ * turned off. Repo genericity and run-output hygiene are different jobs with
17
+ * different lists, and only the second one ships.
18
+ *
19
+ * WIRED INTO, and the honest limit: the three SHELL publishers -
20
+ * lib/jira-publish.sh (which is also how `channels` posts to Jira),
21
+ * lib/post-pr-review.sh (per-finding comments and the request-changes body),
22
+ * and scripts/update-issue-progress.sh. That is every publish this repo makes
23
+ * from a script.
24
+ *
25
+ * It is NOT every publish. `channels` also writes a Confluence page and a PR
26
+ * description, and those are composed and posted by the agent following the
27
+ * skill rather than by a script, so nothing in shell can intercept them. A
28
+ * gate that claimed four paths while covering three would be worse than one
29
+ * that covers three and says so.
30
+ *
31
+ * WHAT IT NEVER DOES: print the thing it found. A gate that echoes the secret
32
+ * into a terminal, a log and a CI transcript has moved it somewhere new.
33
+ * Findings are reported as rule name plus line number, nothing else.
34
+ *
35
+ * Usage:
36
+ * cat body.md | outbound-gate.mjs # exit 0 clean, 7 if blocked
37
+ * outbound-gate.mjs --file body.md
38
+ * cat body.md | outbound-gate.mjs --json
39
+ * cat body.md | outbound-gate.mjs --warn # report, never block
40
+ *
41
+ * Exit codes:
42
+ * 0 - nothing found
43
+ * 7 - at least one finding; the caller must not publish
44
+ * 2 - usage error (no input)
45
+ */
46
+
47
+ import { readFileSync } from "node:fs";
48
+ import { invokedDirectly } from "./invoked-directly.mjs";
49
+
50
+ /**
51
+ * Each rule is a SHAPE, not a dictionary. A list of known token prefixes goes
52
+ * stale the moment a provider adds one; a shape keeps working.
53
+ *
54
+ * `confidence` separates the two kinds of finding. A GitHub PAT has a fixed
55
+ * prefix and length and is never a false positive - that blocks. An email
56
+ * address is a shape that legitimate prose contains, so it is reported and the
57
+ * caller decides. Mixing them would mean either blocking valid publishes or
58
+ * letting credentials through, and there is no threshold that does both.
59
+ */
60
+ export const RULES = [
61
+ // --- credentials: a match is a match -------------------------------------
62
+ { name: "github-pat", certain: true, re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
63
+ { name: "github-fine-grained-pat", certain: true, re: /\bgithub_pat_[A-Za-z0-9_]{50,}\b/g },
64
+ { name: "slack-token", certain: true, re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
65
+ { name: "aws-access-key-id", certain: true, re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
66
+ { name: "google-api-key", certain: true, re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
67
+ { name: "openai-key", certain: true, re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b/g },
68
+ { name: "anthropic-key", certain: true, re: /\bsk-ant-[A-Za-z0-9_-]{32,}\b/g },
69
+ { name: "figma-token", certain: true, re: /\bfig[a-z]_[A-Za-z0-9_-]{20,}\b/g },
70
+ { name: "npm-token", certain: true, re: /\bnpm_[A-Za-z0-9]{36}\b/g },
71
+ {
72
+ name: "jwt",
73
+ certain: true,
74
+ re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
75
+ },
76
+ { name: "private-key-block", certain: true, re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
77
+ // A URL carrying credentials. This is the one that leaks by accident most
78
+ // often: a `git remote -v` pasted into a comment.
79
+ {
80
+ name: "url-with-credentials",
81
+ certain: true,
82
+ re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@/g,
83
+ },
84
+ // `Authorization: Bearer <something long>` copied out of a curl trace.
85
+ {
86
+ name: "bearer-header",
87
+ certain: true,
88
+ re: /\bAuthorization:\s*(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/gi,
89
+ },
90
+
91
+ // --- PII and environment: a shape, so reported rather than certain -------
92
+ {
93
+ name: "email-address",
94
+ certain: false,
95
+ re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
96
+ },
97
+ // An absolute path under /Users or /home names the operator.
98
+ { name: "home-directory-path", certain: false, re: /\/(?:Users|home)\/[A-Za-z0-9_.-]+\//g },
99
+ {
100
+ name: "private-ip",
101
+ certain: false,
102
+ re: /\b(?:10\.\d{1,3}|192\.168|172\.(?:1[6-9]|2\d|3[01]))\.\d{1,3}\.\d{1,3}\b/g,
103
+ },
104
+ ];
105
+
106
+ /**
107
+ * @param {string} text
108
+ * @returns {{rule: string, certain: boolean, line: number}[]}
109
+ */
110
+ export function scan(text) {
111
+ const findings = [];
112
+ const lines = String(text).split("\n");
113
+ for (let i = 0; i < lines.length; i++) {
114
+ for (const rule of RULES) {
115
+ // `lastIndex` survives between calls on a /g regex and would make every
116
+ // second line skip its start. Reset per line.
117
+ rule.re.lastIndex = 0;
118
+ if (rule.re.test(lines[i])) {
119
+ findings.push({ rule: rule.name, certain: rule.certain, line: i + 1 });
120
+ }
121
+ }
122
+ }
123
+ return findings;
124
+ }
125
+
126
+ function readInput(args) {
127
+ const fileFlag = args.indexOf("--file");
128
+ if (fileFlag !== -1) {
129
+ const p = args[fileFlag + 1];
130
+ if (!p) {
131
+ process.stderr.write("outbound-gate: --file needs a path\n");
132
+ process.exitCode = 2;
133
+ return null;
134
+ }
135
+ return readFileSync(p, "utf-8");
136
+ }
137
+ try {
138
+ return readFileSync(0, "utf-8");
139
+ } catch {
140
+ process.stderr.write("outbound-gate: nothing on stdin and no --file\n");
141
+ process.exitCode = 2;
142
+ return null;
143
+ }
144
+ }
145
+
146
+ function main() {
147
+ const args = process.argv.slice(2);
148
+ const text = readInput(args);
149
+ if (text === null) return;
150
+
151
+ const findings = scan(text);
152
+ const blocking = findings.filter((f) => f.certain);
153
+
154
+ if (args.includes("--json")) {
155
+ process.stdout.write(
156
+ JSON.stringify({ findings, blocked: blocking.length > 0 }, null, 2) + "\n",
157
+ );
158
+ } else if (findings.length) {
159
+ // Rule name and line only. The value is deliberately absent: writing it
160
+ // here would put the secret into a terminal, a log and a CI transcript.
161
+ const shown = findings.map(
162
+ (f) => ` ${f.certain ? "BLOCK" : "check"} ${f.rule} line ${f.line}`,
163
+ );
164
+ process.stderr.write(
165
+ `outbound-gate: ${findings.length} finding(s) in the text about to be published\n` +
166
+ shown.join("\n") +
167
+ "\n (values are not printed - reading them out would move the secret here)\n",
168
+ );
169
+ }
170
+
171
+ if (blocking.length && !args.includes("--warn")) process.exitCode = 7;
172
+ }
173
+
174
+ const isDirectRun = invokedDirectly(import.meta.url);
175
+ if (isDirectRun) main();
@@ -30,6 +30,31 @@
30
30
 
31
31
  set -euo pipefail
32
32
 
33
+ # jq is not optional on this path. Without the guard below a missing binary
34
+ # renders as EMPTY DATA and the work continues on it; see lib/require-jq.sh.
35
+ for _rq in "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/require-jq.sh" \
36
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/require-jq.sh" \
37
+ "$HOME/.claude/lib/require-jq.sh" \
38
+ "$HOME/.copilot/lib/require-jq.sh" \
39
+ "$HOME/.codex/lib/require-jq.sh"; do
40
+ [ -f "$_rq" ] || continue
41
+ # shellcheck source=/dev/null
42
+ . "$_rq" && break
43
+ done
44
+ unset _rq
45
+ if ! command -v ma_require_jq >/dev/null 2>&1; then
46
+ # The helper itself is missing, which is an install problem, not a jq one.
47
+ ma_require_jq() { command -v jq >/dev/null 2>&1 || { echo "jq not found - cannot ${1:-continue}." >&2; return 1; }; }
48
+ fi
49
+ ma_require_jq "read or write the plan" || exit 3
50
+
51
+ # Run-state path resolution: pipeline/lib/run-paths.sh owns the two layouts
52
+ # (nested <root>/<project>/<id>/ and flat <root>/<id>/) and every id spelling.
53
+ _MA_RP_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
54
+ # shellcheck source=/dev/null
55
+ . "$_MA_RP_HERE/run-paths.sh" 2>/dev/null || . "$HOME/.claude/lib/run-paths.sh"
56
+
57
+
33
58
  CMD="${1:-}"
34
59
  shift || true
35
60
 
@@ -49,12 +74,8 @@ resolve_state() {
49
74
  # directory ($HOME/.claude/logs/multi-agent/{project}/{taskId}/) - not the
50
75
  # ~/.claude/projects/*/state/ path this used to glob, which no writer in
51
76
  # the pipeline ever populates.
52
- for candidate in "$HOME"/.claude/logs/multi-agent/*/"$task_id"/agent-state.json; do
53
- if [ -f "$candidate" ]; then
54
- found="$candidate"
55
- break
56
- fi
57
- done
77
+ # This globbed ONLY the nested layout; flat-layout runs resolved to nothing.
78
+ found="$(ma_resolve_run_file "$task_id" agent-state.json 2>/dev/null || true)"
58
79
  if [ -z "$found" ]; then
59
80
  echo "plan-todos: agent-state.json not found for task=$task_id" >&2
60
81
  exit 1
@@ -34,6 +34,65 @@
34
34
 
35
35
  set -euo pipefail
36
36
 
37
+ # jq is not optional on this path. Without the guard below a missing binary
38
+ # renders as EMPTY DATA and the work continues on it; see lib/require-jq.sh.
39
+ for _rq in "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/require-jq.sh" \
40
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/require-jq.sh" \
41
+ "$HOME/.claude/lib/require-jq.sh" \
42
+ "$HOME/.copilot/lib/require-jq.sh" \
43
+ "$HOME/.codex/lib/require-jq.sh"; do
44
+ [ -f "$_rq" ] || continue
45
+ # shellcheck source=/dev/null
46
+ . "$_rq" && break
47
+ done
48
+ unset _rq
49
+ if ! command -v ma_require_jq >/dev/null 2>&1; then
50
+ # The helper itself is missing, which is an install problem, not a jq one.
51
+ ma_require_jq() { command -v jq >/dev/null 2>&1 || { echo "jq not found - cannot ${1:-continue}." >&2; return 1; }; }
52
+ fi
53
+ ma_require_jq "parse the review findings" || exit 3
54
+
55
+ # Outbound leak gate. Every byte below is composed at runtime out of command
56
+ # output, error text and file excerpts, any of which can carry a token that was
57
+ # on this machine a second earlier - and once it is in a comment it is in
58
+ # someone else's database. The repo's own leak scanner looks at FILES IN THE
59
+ # REPO and never sees this text. See lib/outbound-gate.mjs.
60
+ ma_outbound_gate() {
61
+ local body_file="$1" og=""
62
+ for c in "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/outbound-gate.mjs" \
63
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/outbound-gate.mjs" \
64
+ "$HOME/.claude/lib/outbound-gate.mjs" \
65
+ "$HOME/.copilot/lib/outbound-gate.mjs" \
66
+ "$HOME/.codex/lib/outbound-gate.mjs"; do
67
+ [ -f "$c" ] && { og="$c"; break; }
68
+ done
69
+ # Missing gate is NOT an open door: refusing to publish beats publishing
70
+ # unchecked, and the only way this file is absent is a broken install.
71
+ if [ -z "$og" ]; then
72
+ echo "outbound-gate.mjs not found - refusing to publish unchecked text." >&2
73
+ return 7
74
+ fi
75
+ node "$og" --file "$body_file"
76
+ }
77
+
78
+ # Same gate, for the bodies this file keeps in a variable rather than a file.
79
+ ma_outbound_gate_text() {
80
+ local tmp
81
+ tmp=$(mktemp) || return 7
82
+ printf '%s' "$1" > "$tmp"
83
+ ma_outbound_gate "$tmp"
84
+ local rc=$?
85
+ rm -f "$tmp"
86
+ return $rc
87
+ }
88
+
89
+ # Run-state path resolution: pipeline/lib/run-paths.sh owns the two layouts
90
+ # (nested <root>/<project>/<id>/ and flat <root>/<id>/) and every id spelling.
91
+ _MA_RP_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
92
+ # shellcheck source=/dev/null
93
+ . "$_MA_RP_HERE/run-paths.sh" 2>/dev/null || . "$HOME/.claude/lib/run-paths.sh"
94
+
95
+
37
96
  # Basic-auth via a curl config fed through process substitution so the
38
97
  # credential never appears in argv (argv is visible to `ps` / process audit).
39
98
  bb_auth_cfg() { printf 'user = "%s:%s"\n' "$1" "$2"; }
@@ -49,13 +108,10 @@ fi
49
108
  # per-project directory ($HOME/.claude/logs/multi-agent/{project}/{taskId}/)
50
109
  # - not the ~/.claude/projects/*/state/ path this used to glob, which no
51
110
  # writer in the pipeline ever populates.
52
- AGENT_STATE=""
53
- for candidate in "$HOME"/.claude/logs/multi-agent/*/"$TASK_ID"/agent-state.json; do
54
- if [ -f "$candidate" ]; then
55
- AGENT_STATE="$candidate"
56
- break
57
- fi
58
- done
111
+ # This globbed ONLY the nested layout. phase-tracker.sh and most Phase 0 paths
112
+ # write flat, so on a real machine the majority of runs resolved to nothing and
113
+ # this script reported "no agent-state.json" for a run whose state existed.
114
+ AGENT_STATE="$(ma_resolve_run_file "$TASK_ID" agent-state.json 2>/dev/null || true)"
59
115
 
60
116
  if [ -z "$AGENT_STATE" ]; then
61
117
  echo "post-pr-review: agent-state.json not found for task=$TASK_ID" >&2
@@ -271,6 +327,13 @@ post_github() {
271
327
  continue
272
328
  fi
273
329
 
330
+ # A finding's body quotes the diff and the reviewer's prose, so it is
331
+ # the most likely of the three to carry something from the machine.
332
+ if ! ma_outbound_gate_text "$body"; then
333
+ echo "post-pr-review: outbound gate refused the comment for $path:$line - not posted" >&2
334
+ continue
335
+ fi
336
+
274
337
  local inline_ok=0
275
338
  if [ -n "$head_sha" ] && [ "$line" -gt 0 ] 2>/dev/null; then
276
339
  if gh api -X POST "/repos/$org_repo/pulls/$PR_NUM/comments" \
@@ -290,7 +353,13 @@ post_github() {
290
353
 
291
354
  case "$DECISION" in
292
355
  approve) gh pr review "$PR_NUM" --repo "$org_repo" --approve ;;
293
- needs_work) gh pr review "$PR_NUM" --repo "$org_repo" --request-changes --body "$rc_body" ;;
356
+ needs_work)
357
+ if ! ma_outbound_gate_text "$rc_body"; then
358
+ echo "post-pr-review: outbound gate refused the request-changes body; no verdict posted" >&2
359
+ return 7
360
+ fi
361
+ gh pr review "$PR_NUM" --repo "$org_repo" --request-changes --body "$rc_body"
362
+ ;;
294
363
  esac
295
364
  }
296
365
 
@@ -141,11 +141,16 @@ ma_hygiene_local_gitignore() {
141
141
  # rmdir, never rm -rf: it fails harmlessly when anything is left, which is the
142
142
  # behaviour we want. An empty .worktrees/ and .pipeline/evidence/ survive a
143
143
  # clean run today and read as leftovers to anyone looking at the checkout.
144
+ #
145
+ # "Harmlessly" is only true while the CALLER does not run under `set -e`, and
146
+ # this file is sourced, so that is the caller's choice and not ours. Every
147
+ # rmdir here is therefore explicitly forgiven: a non-empty directory is the
148
+ # ordinary case, and it must not abort a teardown half-way.
144
149
  ma_hygiene_prune_empty() {
145
150
  local root d
146
151
  root=$(ma_hygiene_safe_root "${1:-}") || return 0
147
152
  for d in "$root/.pipeline/evidence" "$root/.pipeline" "$root/.worktrees/.archive" "$root/.worktrees" "$root/.multi-agent/refs"; do
148
- [ -d "$d" ] && rmdir "$d" 2>/dev/null
153
+ [ -d "$d" ] && { rmdir "$d" 2>/dev/null || true; }
149
154
  done
150
155
 
151
156
  # .multi-agent survives the loop above because it still holds the .gitignore
@@ -156,8 +161,8 @@ ma_hygiene_prune_empty() {
156
161
  local leftover
157
162
  leftover=$(ls -A "$root/.multi-agent" 2>/dev/null)
158
163
  if [ "$leftover" = ".gitignore" ]; then
159
- rm -f "$root/.multi-agent/.gitignore" 2>/dev/null
160
- rmdir "$root/.multi-agent" 2>/dev/null
164
+ rm -f "$root/.multi-agent/.gitignore" 2>/dev/null || true
165
+ rmdir "$root/.multi-agent" 2>/dev/null || true
161
166
  fi
162
167
  fi
163
168
  return 0
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ # require-jq.sh - one place to refuse when `jq` is missing.
3
+ #
4
+ # 78 shell files in this repo call `jq`. 46 of them called it with no check at
5
+ # all, and the README described that as helpers which "skip in silence" - which
6
+ # is the defect stated as if it were the design. A bare `jq` on a machine
7
+ # without it does not skip. It writes "command not found" to stderr, produces
8
+ # an empty string on stdout, and the caller carries on with that empty string
9
+ # as if it were the answer.
10
+ #
11
+ # The nine production paths are where that matters:
12
+ #
13
+ # autopilot-state.sh an unreadable queue reads as an EMPTY queue, so the
14
+ # runner concludes there is no work and goes quiet
15
+ # jira-publish.sh posts a comment built from empty fields
16
+ # post-pr-review.sh publishes a review whose findings did not parse
17
+ # update-issue-progress.sh same, on an issue
18
+ # _jira-auth.sh no credential, and the failure surfaces later as 401
19
+ # figma-mcp-refresh.sh / figma-screenshot.sh silent no-op on a design fetch
20
+ # plan-todos.sh an empty plan looks like a plan with no work in it
21
+ # search-logs.sh "no results" instead of "could not search"
22
+ #
23
+ # Every one of those is the same shape: absence of a tool rendered as absence
24
+ # of data. Refusing is the only honest answer, and the exit code is distinct
25
+ # so a caller can tell "jq missing" from "the work failed".
26
+ #
27
+ # Usage:
28
+ # . "$(dirname "$0")/require-jq.sh"
29
+ # ma_require_jq "read the autopilot queue" || exit 3
30
+ #
31
+ # It returns rather than exits: this file is SOURCED, and a sourced file that
32
+ # exits kills the caller's shell in ways the caller did not write down.
33
+
34
+ # shellcheck disable=SC2329 # sourced by other scripts, not called here
35
+ ma_require_jq() {
36
+ command -v jq >/dev/null 2>&1 && return 0
37
+ printf 'jq not found - cannot %s.\n' "${1:-continue}" >&2
38
+ printf ' install it: brew install jq\n' >&2
39
+ return 1
40
+ }