@mmerterden/multi-agent-pipeline 17.5.1 → 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 (134) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +59 -1
  3. package/README.tr.md +57 -0
  4. package/docs/adr/0011-dormant-ci.md +25 -1
  5. package/docs/features.md +24 -0
  6. package/docs/server-readiness.md +188 -0
  7. package/docs/token-budget-history.md +1 -1
  8. package/index.js +16 -1
  9. package/install/_common.mjs +42 -17
  10. package/install/_dev-only-files.mjs +8 -0
  11. package/install/_unattended-profile.mjs +113 -0
  12. package/install/index.mjs +48 -0
  13. package/install/templates/claude-hooks.json +13 -1
  14. package/manifest.json +1049 -0
  15. package/package.json +5 -2
  16. package/pipeline/commands/multi-agent/SKILL.md +1 -1
  17. package/pipeline/commands/multi-agent/feedback/SKILL.md +7 -1
  18. package/pipeline/commands/multi-agent/graph/SKILL.md +1 -1
  19. package/pipeline/commands/multi-agent/issue/SKILL.md +13 -1
  20. package/pipeline/commands/multi-agent/jira/SKILL.md +13 -1
  21. package/pipeline/commands/multi-agent/resume/SKILL.md +16 -1
  22. package/pipeline/commands/multi-agent/setup/SKILL.md +14 -16
  23. package/pipeline/commands/multi-agent/status/SKILL.md +52 -21
  24. package/pipeline/commands/multi-agent/update/SKILL.md +13 -56
  25. package/pipeline/lib/_jira-auth.sh +8 -0
  26. package/pipeline/lib/analysis-jira-write.sh +32 -0
  27. package/pipeline/lib/ask-choice.sh +13 -2
  28. package/pipeline/lib/autopilot-state.sh +8 -0
  29. package/pipeline/lib/fatal.mjs +129 -0
  30. package/pipeline/lib/figma-mcp-refresh.sh +18 -0
  31. package/pipeline/lib/figma-screenshot.sh +18 -0
  32. package/pipeline/lib/invoked-directly.mjs +43 -0
  33. package/pipeline/lib/jira-publish.sh +42 -0
  34. package/pipeline/lib/md2confluence-v3.py +47 -0
  35. package/pipeline/lib/outbound-gate.mjs +175 -0
  36. package/pipeline/lib/plan-todos.sh +27 -6
  37. package/pipeline/lib/post-pr-review.sh +77 -8
  38. package/pipeline/lib/repo-hygiene.sh +8 -3
  39. package/pipeline/lib/require-jq.sh +40 -0
  40. package/pipeline/lib/run-paths.sh +335 -0
  41. package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +70 -0
  42. package/pipeline/multi-agent-refs/features/code-graph.md +20 -0
  43. package/pipeline/multi-agent-refs/features/cost-analysis.md +93 -0
  44. package/pipeline/multi-agent-refs/features/doctor.md +68 -0
  45. package/pipeline/multi-agent-refs/features/maturity-followup.md +166 -0
  46. package/pipeline/multi-agent-refs/features/package-manager.md +80 -0
  47. package/pipeline/multi-agent-refs/features/usage-reporting.md +79 -0
  48. package/pipeline/multi-agent-refs/features/verify-by-test.md +1 -1
  49. package/pipeline/multi-agent-refs/features/verify.md +83 -0
  50. package/pipeline/multi-agent-refs/phases/operations.md +13 -2
  51. package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -3
  52. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +8 -2
  53. package/pipeline/multi-agent-refs/phases/phase-4-review.md +1 -1
  54. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  55. package/pipeline/multi-agent-refs/unattended-contract.md +129 -0
  56. package/pipeline/preferences-template.json +1 -1
  57. package/pipeline/schemas/agent-state.schema.json +122 -11
  58. package/pipeline/schemas/prefs.schema.json +35 -0
  59. package/pipeline/schemas/token-budget.json +2 -2
  60. package/pipeline/scripts/_run-paths.mjs +372 -0
  61. package/pipeline/scripts/aggregate-metrics.mjs +64 -64
  62. package/pipeline/scripts/autopilot-arming.mjs +2 -1
  63. package/pipeline/scripts/autopilot-intake.mjs +2 -1
  64. package/pipeline/scripts/autopilot-runner.mjs +206 -2
  65. package/pipeline/scripts/build-references.mjs +2 -1
  66. package/pipeline/scripts/build-stack-plugins.mjs +10 -2
  67. package/pipeline/scripts/capture-evidence.sh +7 -2
  68. package/pipeline/scripts/classify-plan-safety.mjs +2 -1
  69. package/pipeline/scripts/cost-analyze.mjs +600 -0
  70. package/pipeline/scripts/cost-budget-check.mjs +4 -12
  71. package/pipeline/scripts/council-view.mjs +2 -1
  72. package/pipeline/scripts/crush-json.mjs +2 -1
  73. package/pipeline/scripts/diff-explain.mjs +6 -9
  74. package/pipeline/scripts/diff-risk-score.mjs +2 -1
  75. package/pipeline/scripts/doctor.mjs +203 -4
  76. package/pipeline/scripts/evidence-gate.mjs +9 -3
  77. package/pipeline/scripts/feedback-send.mjs +13 -3
  78. package/pipeline/scripts/gc-abandoned.sh +29 -13
  79. package/pipeline/scripts/gc-worktrees.sh +11 -4
  80. package/pipeline/scripts/github-ssh-setup.sh +64 -7
  81. package/pipeline/scripts/graph-mermaid.mjs +4 -2
  82. package/pipeline/scripts/graph-report.mjs +155 -1
  83. package/pipeline/scripts/keychain-save.sh +101 -30
  84. package/pipeline/scripts/learn-from-transcripts.mjs +2 -1
  85. package/pipeline/scripts/learning-curve.mjs +34 -29
  86. package/pipeline/scripts/make-manifest.mjs +199 -0
  87. package/pipeline/scripts/maturity-followup.mjs +294 -0
  88. package/pipeline/scripts/migrate-prefs.mjs +2 -1
  89. package/pipeline/scripts/migrate-state.mjs +94 -4
  90. package/pipeline/scripts/package-manager.mjs +310 -0
  91. package/pipeline/scripts/phase-banner.sh +6 -2
  92. package/pipeline/scripts/phase-tracker.sh +41 -3
  93. package/pipeline/scripts/plan-coverage-gate.mjs +6 -2
  94. package/pipeline/scripts/pre-commit-check.sh +7 -0
  95. package/pipeline/scripts/pre-push-check.sh +7 -0
  96. package/pipeline/scripts/purge.sh +23 -6
  97. package/pipeline/scripts/render-agent-log-cost.sh +9 -2
  98. package/pipeline/scripts/render-cost-summary.sh +9 -2
  99. package/pipeline/scripts/render-work-summary.sh +11 -4
  100. package/pipeline/scripts/review-file-filter.mjs +4 -2
  101. package/pipeline/scripts/review-scope.mjs +2 -1
  102. package/pipeline/scripts/routine-registry.mjs +2 -1
  103. package/pipeline/scripts/run-aggregator.mjs +13 -14
  104. package/pipeline/scripts/run-metrics.mjs +3 -1
  105. package/pipeline/scripts/runs-index.mjs +343 -0
  106. package/pipeline/scripts/scorecard-snapshot.mjs +178 -0
  107. package/pipeline/scripts/search-logs.sh +18 -0
  108. package/pipeline/scripts/test-gap-scan.mjs +2 -1
  109. package/pipeline/scripts/test-integrity-gate.mjs +2 -1
  110. package/pipeline/scripts/update-issue-progress.sh +56 -7
  111. package/pipeline/scripts/usage-register.mjs +271 -0
  112. package/pipeline/scripts/usage-report.mjs +14 -3
  113. package/pipeline/scripts/validate-analysis-doc.mjs +2 -1
  114. package/pipeline/scripts/validate-code-graph.mjs +6 -3
  115. package/pipeline/scripts/validate-complaint-doc.mjs +2 -1
  116. package/pipeline/scripts/validate-diff-risk.mjs +6 -3
  117. package/pipeline/scripts/validate-test-gap.mjs +6 -3
  118. package/pipeline/scripts/validate-triage.mjs +3 -1
  119. package/pipeline/scripts/verify-citations.mjs +4 -2
  120. package/pipeline/scripts/verify.mjs +327 -0
  121. package/pipeline/scripts/worktree-finalize.sh +13 -4
  122. package/pipeline/scripts/write-state.mjs +154 -15
  123. package/pipeline/skills/.skill-manifest.json +6 -6
  124. package/pipeline/skills/.skills-index.json +56 -1
  125. package/pipeline/skills/shared/README.md +8 -3
  126. package/pipeline/skills/shared/core/multi-agent-issue/SKILL.md +14 -0
  127. package/pipeline/skills/shared/core/multi-agent-jira/SKILL.md +14 -0
  128. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +13 -0
  129. package/pipeline/skills/shared/core/multi-agent-status/SKILL.md +33 -9
  130. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +6 -0
  131. package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/package_app.sh +4 -1
  132. package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/setup_dev_signing.sh +4 -1
  133. package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/sign-and-notarize.sh +2 -1
  134. package/pipeline/skills/skills-index.md +6 -1
@@ -18,6 +18,24 @@
18
18
 
19
19
  set -euo pipefail
20
20
 
21
+ # jq is not optional on this path. Without the guard below a missing binary
22
+ # renders as EMPTY DATA and the work continues on it; see lib/require-jq.sh.
23
+ for _rq in "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/require-jq.sh" \
24
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/require-jq.sh" \
25
+ "$HOME/.claude/lib/require-jq.sh" \
26
+ "$HOME/.copilot/lib/require-jq.sh" \
27
+ "$HOME/.codex/lib/require-jq.sh"; do
28
+ [ -f "$_rq" ] || continue
29
+ # shellcheck source=/dev/null
30
+ . "$_rq" && break
31
+ done
32
+ unset _rq
33
+ if ! command -v ma_require_jq >/dev/null 2>&1; then
34
+ # The helper itself is missing, which is an install problem, not a jq one.
35
+ ma_require_jq() { command -v jq >/dev/null 2>&1 || { echo "jq not found - cannot ${1:-continue}." >&2; return 1; }; }
36
+ fi
37
+ ma_require_jq "refresh the Figma MCP token" || exit 3
38
+
21
39
  PREFS_FILE="${PREFS_FILE:-$HOME/.claude/multi-agent-preferences.json}"
22
40
  TOKEN_ENDPOINT="https://api.figma.com/v1/oauth/token"
23
41
 
@@ -39,6 +39,24 @@
39
39
 
40
40
  set -euo pipefail
41
41
 
42
+ # jq is not optional on this path. Without the guard below a missing binary
43
+ # renders as EMPTY DATA and the work continues on it; see lib/require-jq.sh.
44
+ for _rq in "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/require-jq.sh" \
45
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/require-jq.sh" \
46
+ "$HOME/.claude/lib/require-jq.sh" \
47
+ "$HOME/.copilot/lib/require-jq.sh" \
48
+ "$HOME/.codex/lib/require-jq.sh"; do
49
+ [ -f "$_rq" ] || continue
50
+ # shellcheck source=/dev/null
51
+ . "$_rq" && break
52
+ done
53
+ unset _rq
54
+ if ! command -v ma_require_jq >/dev/null 2>&1; then
55
+ # The helper itself is missing, which is an install problem, not a jq one.
56
+ ma_require_jq() { command -v jq >/dev/null 2>&1 || { echo "jq not found - cannot ${1:-continue}." >&2; return 1; }; }
57
+ fi
58
+ ma_require_jq "fetch a Figma screenshot" || exit 3
59
+
42
60
  # --- Globals ----------------------------------------------------------------
43
61
 
44
62
  # Locate the resolver with an existence check, not a `.`-chain: sourcing a missing file
@@ -0,0 +1,43 @@
1
+ /**
2
+ * invoked-directly.mjs - "was this file run, or imported", answered correctly.
3
+ *
4
+ * The idiom this replaces was in nine files:
5
+ *
6
+ * if (import.meta.url === `file://${process.argv[1]}`) main();
7
+ *
8
+ * It compares a RESOLVED, percent-encoded URL against a raw path, and it is
9
+ * false in three situations that are not exotic:
10
+ *
11
+ * - a symlink. `install --link` symlinks whole directories into ~/.claude, so
12
+ * every script a linked install runs is reached through one;
13
+ * - a path that resolves elsewhere. On macOS /var is a symlink to /private/var,
14
+ * which is where mktemp puts everything;
15
+ * - a path containing a space, or any character a URL escapes.
16
+ *
17
+ * In all three the guard is false and the script does nothing, silently, exit 0.
18
+ * On `outbound-gate.mjs` that was a security hole rather than an inconvenience:
19
+ * the callers read exit 0 as "this text is clean", so a body carrying a token
20
+ * would have been published unscanned by any install that used --link.
21
+ *
22
+ * @module pipeline/lib/invoked-directly
23
+ */
24
+
25
+ import { realpathSync } from "node:fs";
26
+ import { pathToFileURL } from "node:url";
27
+
28
+ /**
29
+ * True when `metaUrl` names the file Node was asked to run.
30
+ *
31
+ * @param {string} metaUrl the caller's own `import.meta.url`
32
+ * @returns {boolean}
33
+ */
34
+ export function invokedDirectly(metaUrl) {
35
+ const entry = process.argv[1];
36
+ if (!entry) return false;
37
+ try {
38
+ return metaUrl === pathToFileURL(realpathSync(entry)).href;
39
+ } catch {
40
+ // An argv[1] that cannot be resolved is not this file.
41
+ return false;
42
+ }
43
+ }
@@ -40,6 +40,47 @@
40
40
 
41
41
  set -euo pipefail
42
42
 
43
+ # jq is not optional on this path. Without the guard below a missing binary
44
+ # renders as EMPTY DATA and the work continues on it; see lib/require-jq.sh.
45
+ for _rq in "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/require-jq.sh" \
46
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/require-jq.sh" \
47
+ "$HOME/.claude/lib/require-jq.sh" \
48
+ "$HOME/.copilot/lib/require-jq.sh" \
49
+ "$HOME/.codex/lib/require-jq.sh"; do
50
+ [ -f "$_rq" ] || continue
51
+ # shellcheck source=/dev/null
52
+ . "$_rq" && break
53
+ done
54
+ unset _rq
55
+ if ! command -v ma_require_jq >/dev/null 2>&1; then
56
+ # The helper itself is missing, which is an install problem, not a jq one.
57
+ ma_require_jq() { command -v jq >/dev/null 2>&1 || { echo "jq not found - cannot ${1:-continue}." >&2; return 1; }; }
58
+ fi
59
+ ma_require_jq "build the Jira comment" || exit 3
60
+
61
+ # Outbound leak gate. Every byte below is composed at runtime out of command
62
+ # output, error text and file excerpts, any of which can carry a token that was
63
+ # on this machine a second earlier - and once it is in a comment it is in
64
+ # someone else's database. The repo's own leak scanner looks at FILES IN THE
65
+ # REPO and never sees this text. See lib/outbound-gate.mjs.
66
+ ma_outbound_gate() {
67
+ local body_file="$1" og=""
68
+ for c in "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/outbound-gate.mjs" \
69
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/outbound-gate.mjs" \
70
+ "$HOME/.claude/lib/outbound-gate.mjs" \
71
+ "$HOME/.copilot/lib/outbound-gate.mjs" \
72
+ "$HOME/.codex/lib/outbound-gate.mjs"; do
73
+ [ -f "$c" ] && { og="$c"; break; }
74
+ done
75
+ # Missing gate is NOT an open door: refusing to publish beats publishing
76
+ # unchecked, and the only way this file is absent is a broken install.
77
+ if [ -z "$og" ]; then
78
+ echo "outbound-gate.mjs not found - refusing to publish unchecked text." >&2
79
+ return 7
80
+ fi
81
+ node "$og" --file "$body_file"
82
+ }
83
+
43
84
  SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
44
85
  PREFS="${MULTI_AGENT_PREFS:-$HOME/.claude/multi-agent-preferences.json}"
45
86
  CURL="${JIRA_PUBLISH_CURL:-curl}"
@@ -73,6 +114,7 @@ done
73
114
  [ -n "$ISSUE" ] || die "usage: jira-publish.sh --issue KEY --body-file FILE [--target comment|description]"
74
115
  [ -n "$BODY_FILE" ] || die "usage: jira-publish.sh --issue KEY --body-file FILE [--target comment|description]"
75
116
  [ -f "$BODY_FILE" ] || die "body file not found: $BODY_FILE"
117
+ ma_outbound_gate "$BODY_FILE" || die "outbound gate refused this comment; nothing was posted"
76
118
  case "$TARGET" in comment|description) ;; *) die "--target must be comment or description, got: $TARGET" ;; esac
77
119
  case "$MODE" in append|replace) ;; *) die "--mode must be append or replace, got: $MODE" ;; esac
78
120
 
@@ -35,6 +35,7 @@ import argparse
35
35
  import html
36
36
  import io
37
37
  import json
38
+ import tempfile
38
39
  import mimetypes
39
40
  import os
40
41
  import re
@@ -962,6 +963,10 @@ def cmd_create(args: argparse.Namespace) -> int:
962
963
  sys.stderr.write(json.dumps(envelope, indent=2, ensure_ascii=False) + "\n")
963
964
  return 0
964
965
 
966
+ # Every byte below this line leaves the machine. The dry-run branch above
967
+ # returns before it, which is why the gate sits here and not at the top.
968
+ outbound_gate(md_text)
969
+
965
970
  auth = resolve_auth()
966
971
  warnings = list(result.warnings)
967
972
 
@@ -1194,6 +1199,48 @@ def build_parser() -> argparse.ArgumentParser:
1194
1199
  return parser
1195
1200
 
1196
1201
 
1202
+ def outbound_gate(text: str) -> None:
1203
+ """Refuse to publish text the leak gate has not cleared.
1204
+
1205
+ This is the choke point for the Confluence half of the pipeline's outbound
1206
+ surface: a page is created or updated only from here, and everything above
1207
+ is rendering. The gate itself is the same one the Jira and PR paths use -
1208
+ one set of patterns, one verdict, rather than a second copy that drifts.
1209
+
1210
+ A missing gate file REFUSES. The only way it is absent is a broken install,
1211
+ and publishing unchecked because the check could not be loaded is the single
1212
+ failure this exists to prevent.
1213
+ """
1214
+ candidates = [
1215
+ Path(__file__).resolve().parent / "outbound-gate.mjs",
1216
+ Path.home() / ".claude" / "lib" / "outbound-gate.mjs",
1217
+ Path.home() / ".copilot" / "lib" / "outbound-gate.mjs",
1218
+ Path.home() / ".codex" / "lib" / "outbound-gate.mjs",
1219
+ ]
1220
+ gate = next((c for c in candidates if c.is_file()), None)
1221
+ if gate is None:
1222
+ sys.stderr.write("outbound-gate.mjs not found - refusing to publish unchecked text.\n")
1223
+ raise SystemExit(2)
1224
+
1225
+ with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as fh:
1226
+ fh.write(text)
1227
+ tmp = fh.name
1228
+ try:
1229
+ proc = subprocess.run(
1230
+ ["node", str(gate), "--file", tmp],
1231
+ capture_output=True,
1232
+ text=True,
1233
+ check=False,
1234
+ )
1235
+ finally:
1236
+ os.unlink(tmp)
1237
+ if proc.returncode != 0:
1238
+ sys.stderr.write(proc.stdout)
1239
+ sys.stderr.write(proc.stderr)
1240
+ sys.stderr.write("outbound gate refused this page; nothing was published.\n")
1241
+ raise SystemExit(2)
1242
+
1243
+
1197
1244
  def main(argv: list[str] | None = None) -> int:
1198
1245
  parser = build_parser()
1199
1246
  args = parser.parse_args(argv)
@@ -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
+ }