@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "17.6.0",
3
+ "version": "18.0.0",
4
4
  "description": "8-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "scripts": {
16
16
  "start": "node index.js",
17
- "test": "npm run format:check && node --test test/*.test.mjs && node pipeline/scripts/run-smokes.mjs && node pipeline/scripts/lint-skills.mjs && node pipeline/scripts/lint-personas.mjs && node pipeline/scripts/lint-mcp-refs.mjs && node pipeline/scripts/eval-triage.mjs && node pipeline/scripts/eval-golden-tasks.mjs && node pipeline/scripts/eval-intent.mjs && node pipeline/scripts/eval-recall.mjs && node pipeline/scripts/validate-schemas.mjs && node pipeline/scripts/validate-prefs.mjs && node pipeline/scripts/scorecard.mjs",
17
+ "test": "npm run format:check && npm run lint && node --test test/*.test.mjs && node pipeline/scripts/run-smokes.mjs && node pipeline/scripts/lint-skills.mjs && node pipeline/scripts/lint-personas.mjs && node pipeline/scripts/lint-mcp-refs.mjs && node pipeline/scripts/eval-triage.mjs && node pipeline/scripts/eval-golden-tasks.mjs && node pipeline/scripts/eval-intent.mjs && node pipeline/scripts/eval-recall.mjs && node pipeline/scripts/validate-schemas.mjs && node pipeline/scripts/validate-prefs.mjs && node pipeline/scripts/scorecard.mjs",
18
18
  "test:unit": "node --test test/*.test.mjs",
19
19
  "test:smoke": "node pipeline/scripts/run-smokes.mjs",
20
20
  "lint:skills": "node pipeline/scripts/lint-skills.mjs",
@@ -25,6 +25,7 @@
25
25
  "format": "prettier --write \"**/*.{js,mjs,json,yml}\" --ignore-path .gitignore --ignore-path .prettierignore",
26
26
  "format:check": "prettier --check \"**/*.{js,mjs,json,yml}\" --ignore-path .gitignore --ignore-path .prettierignore",
27
27
  "scorecard": "node pipeline/scripts/scorecard.mjs",
28
+ "prepack": "node pipeline/scripts/make-manifest.mjs",
28
29
  "gate": "bash pipeline/scripts/pre-push-check.sh --run"
29
30
  },
30
31
  "keywords": [
@@ -69,6 +70,8 @@
69
70
  ],
70
71
  "files": [
71
72
  "index.js",
73
+ "manifest.json",
74
+ "manifest.sig",
72
75
  "install.js",
73
76
  "install/**/*",
74
77
  "pipeline/**/*",
@@ -9,36 +9,49 @@ Show every active and completed task as a table.
9
9
 
10
10
  ## Steps
11
11
 
12
- 1. **Detect the project** - find the repo root from cwd (otherwise scan every known repo):
13
- - `~/my-ios-app/.worktrees/`
14
- - `~/my-figma-app/.worktrees/`
15
- - `~/my-ui-components/.worktrees/`
12
+ 1. **Ask the producer, do not go looking.** One command answers the whole
13
+ question:
16
14
 
17
- 2. **Scan worktrees** - read `agent-state.json` in each worktree dir. **Also scan the log dir**, because a task whose PR is open has no worktree any more (Phase 6 removes it and salvages its state): `find $HOME/.claude/logs/multi-agent -maxdepth 4 -name agent-state.json -path '*/artifacts/*'`. (`-maxdepth 4`, not 3: Phase 6 always passes `--project`, so the salvaged copy lands at `<project>/<task-id>/artifacts/agent-state.json`, which a depth-3 scan can never reach.) Merge both sets by `taskId`, preferring the worktree copy when both exist, and render a finalized task with its `worktreeRemovedAt` rather than omitting it - a task that shipped should not vanish from status.
18
15
  ```bash
19
- find {repo}/.worktrees/ -name "agent-state.json" -maxdepth 2
16
+ node "$HOME/.claude/scripts/runs-index.mjs" # grouped table
17
+ node "$HOME/.claude/scripts/runs-index.mjs" --json # the same records
20
18
  ```
21
19
 
22
- 3. **Parse state** - for each task:
23
- - `taskId`, `branch`, `currentPhase`, `status`, `startedAt`, `autopilot`
24
-
25
- 3b. **Sort into three groups, and label them.** `in_progress` alone cannot tell
26
- a run that is waiting for you from one that died, and on this machine that
27
- difference covered 20 runs: 3 were holding at Phase 6/7 with their PR already
28
- open, 11 were left at a Phase 0 question with nothing built, 6 stopped
29
- mid-development. One "in progress" label for all three is what made a
30
- finished job and a crashed one share a row.
20
+ `runs-index.mjs` resolves through `lib/run-paths.sh` / `scripts/_run-paths.mjs`,
21
+ so it sees both directory layouts (`<project>/<id>/` and the flat `<id>/`),
22
+ the salvaged `artifacts/` copy Phase 6 leaves behind, and every spelling of a
23
+ task id - and it counts a run that exists in both layouts once. Do NOT
24
+ re-scan the tree by hand: the earlier instruction here listed three
25
+ hard-coded `.worktrees/` paths and a single `find` depth, and on a real
26
+ install that combination missed a quarter of the runs and double-counted
27
+ others. It also scanned worktrees for `agent-state.json`, which Phase 0 has
28
+ never written there ("never inside the worktree", `phases/phase-0-init.md`).
29
+
30
+ The JSON and the table are rendered from the same records, so a dashboard and
31
+ this command cannot disagree.
32
+
33
+ 2. **Fields per run** (already in the output): `taskId`, `project`, `branch`,
34
+ `currentPhase`, `status`, `startedAt`, `worktreePath`, `prUrl`, `autopilot`,
35
+ `phases[]`, `tokens`, `estUsd`, `group`, plus `duplicateOf` when the run also
36
+ exists in the other layout and `salvaged` when its state is the Phase 6 copy.
37
+
38
+ 3. **Groups are computed, not judged.** `runs-index.mjs` assigns `group` by the
39
+ table below; report what it returns rather than re-deriving it. `in_progress`
40
+ alone cannot tell a run that is waiting for you from one that died, and on
41
+ this machine that difference covered 21 runs.
31
42
 
32
43
  | Group | Test | Action offered |
33
44
  |---|---|---|
34
- | Waiting on you | `status == "awaiting_input"`, or a `pr.url`, or phase 6/7 | `resume #N` - the work landed, it needs your answer |
35
- | Stopped mid-development | anything else past phase 0 | `resume #N` or `kill #N` |
36
- | Left at a question | phase 0 | `garbage-collect --abandoned` - nothing was built |
45
+ | `waiting` - Waiting on you | `status == "awaiting_input"`, or a `pr.url`, or phase >= 6 | `resume #N` - the work landed, it needs your answer |
46
+ | `stopped` - Stopped mid-development | anything else past phase 0 | `resume #N` or `kill #N` |
47
+ | `question` - Left at a question | phase 0 | `garbage-collect --abandoned` - nothing was built |
48
+ | `unknown` - Status not recorded | no `status` field | say so; offer nothing |
37
49
 
38
- A run with no `status` is **not** placed in any group. Unknown is not a
39
- finding, and calling it dead is the same false claim in the other direction.
50
+ A run with no `status` is **not** placed in an actionable group. Unknown is
51
+ not a finding, and calling it dead is the same false claim in the other
52
+ direction.
40
53
 
41
- 4. **Render as a table**, grouped per 3b, with the group as a section heading:
54
+ 4. **Render as a table**, grouped per step 3, with the group as a section heading:
42
55
  ```
43
56
  🤖 Multi-Agent Tasks
44
57
 
@@ -59,6 +72,24 @@ Show every active and completed task as a table.
59
72
 
60
73
  Report `N lesson(s) minable from transcripts - /multi-agent:refactor to review` and `N open pipeline observation(s)` when either count is above zero, and print nothing when both are zero. Neither runs a model and neither writes anything: the miner is dry-run by default. Zero candidates alongside a non-zero `toolResultsExamined` means there is nothing to find; zero of both means the read is broken, and that is worth saying rather than reporting a clean queue.
61
74
 
75
+ 7. **What the spend is doing** (one line, and only when there is something to
76
+ say). `cost-budget-check.mjs` watches ONE run against ONE ceiling, which is
77
+ blind to the two ways a budget actually empties: a drift no single run trips,
78
+ and one pathological session that burns a week in an hour while every run
79
+ stays under its cap.
80
+
81
+ ```bash
82
+ node "$HOME/.claude/scripts/cost-analyze.mjs" burn --json 2>/dev/null
83
+ node "$HOME/.claude/scripts/cost-analyze.mjs" anomaly --days 30 --json 2>/dev/null
84
+ ```
85
+
86
+ Report a line only when either exits 10 - an accelerating day, or a day out
87
+ of family - and say nothing otherwise. The figures are estimates priced from
88
+ `cost-table.json` at LIST price, so on a subscription they are the right
89
+ number for comparing days to each other and the wrong number to call a bill;
90
+ say which when quoting one. `UNMEASURED` means the transcripts could not be
91
+ read, and it is reported as that rather than as zero.
92
+
62
93
  5. **Quick command hints** - based on state:
63
94
  - Paused task → suggest `resume #N`
64
95
  - Done task → suggest `log #N`
@@ -40,6 +40,14 @@ JIRA_AUTH_PREFS="${JIRA_AUTH_PREFS:-$HOME/.claude/multi-agent-preferences.json}"
40
40
 
41
41
  _jira_auth_pref() { # _jira_auth_pref <jq path> -> value or empty
42
42
  [ -f "$JIRA_AUTH_PREFS" ] || { printf ''; return 0; }
43
+ # Without jq this printed empty and returned 0, so "no jq" and "key not set"
44
+ # were the same answer and the caller went on to authenticate with nothing -
45
+ # surfacing much later as a 401 that blames the credential.
46
+ if ! command -v jq >/dev/null 2>&1; then
47
+ echo "jq not found - cannot read $JIRA_AUTH_PREFS (install: brew install jq)" >&2
48
+ printf ''
49
+ return 3
50
+ fi
43
51
  jq -r "$1 // empty" "$JIRA_AUTH_PREFS" 2>/dev/null || printf ''
44
52
  }
45
53
 
@@ -125,6 +125,30 @@ fi
125
125
  # back in CREATED_KEY. Returning the key on stdout too meant a caller using
126
126
  # command substitution swallowed the report - the first dry run printed a header
127
127
  # and nothing else, and the tree looked empty.
128
+ # Same gate, same five candidate paths, same refusal as jira-publish.sh and
129
+ # post-pr-review.sh. This file is the only ISSUE-CREATION path in the tree, and
130
+ # a summary plus a description is outbound text like any other - it was the one
131
+ # writer the gate did not cover.
132
+ ma_outbound_gate_text() {
133
+ local text="$1" og="" tmp rc
134
+ for c in "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/outbound-gate.mjs" \
135
+ "$HOME/.claude/lib/outbound-gate.mjs" \
136
+ "$HOME/.copilot/lib/outbound-gate.mjs" \
137
+ "$HOME/.codex/lib/outbound-gate.mjs"; do
138
+ [ -f "$c" ] && { og="$c"; break; }
139
+ done
140
+ if [ -z "$og" ]; then
141
+ echo "outbound-gate.mjs not found - refusing to create unchecked issues." >&2
142
+ return 7
143
+ fi
144
+ tmp="$(mktemp)"
145
+ printf '%s' "$text" > "$tmp"
146
+ node "$og" --file "$tmp"
147
+ rc=$?
148
+ rm -f "$tmp"
149
+ return $rc
150
+ }
151
+
128
152
  CREATED_KEY=""
129
153
  create_issue() { # create_issue <label> <summary> <issuetype> <parentKey|""> <description>
130
154
  local label="$1" summary="$2" itype="$3" parent="$4" desc="$5" body key existing
@@ -146,6 +170,14 @@ create_issue() { # create_issue <label> <summary> <issuetype> <parentKey|""> <d
146
170
  echo " create $itype $summary [$label]"
147
171
  return 0
148
172
  fi
173
+ # After the dry-run branch, because a dry run publishes nothing, and before
174
+ # the ledger line, because an intent recorded for a POST that never happens
175
+ # is a false entry in the only record of what was attempted.
176
+ if ! ma_outbound_gate_text "$summary
177
+ $desc"; then
178
+ echo "ERR: outbound gate refused '$summary'; nothing was created" >&2
179
+ return 1
180
+ fi
149
181
  ledger intent "$label"
150
182
  local resp
151
183
  resp="$(printf '%s' "$body" | jira_api POST "/rest/api/2/issue" --data @- || echo "")"
@@ -14,6 +14,9 @@
14
14
  #
15
15
  # Non-interactive / autopilot / CI:
16
16
  # - ASK_CHOICE_DEFAULT=<label-or-1based-index> picks without prompting.
17
+ # - MULTI_AGENT_UNATTENDED=1 means nobody is watching even if a terminal is
18
+ # attached (screen, tmux, a login shell on a server). Same resolution as
19
+ # the no-TTY case.
17
20
  # - If stdin is not a TTY and no default is set, the FIRST option is chosen
18
21
  # and a notice is written to stderr (never blocks an automated run).
19
22
  #
@@ -64,8 +67,16 @@ if [ -n "${ASK_CHOICE_DEFAULT:-}" ]; then
64
67
  fi
65
68
 
66
69
  # Non-interactive with no usable default: pick the first option, don't block.
67
- if [ ! -t 0 ]; then
68
- echo "ask-choice: no TTY and no ASK_CHOICE_DEFAULT - selecting first option '${OPTIONS[0]}'" >&2
70
+ #
71
+ # "No TTY" is the usual shape of that, but it is not the only one. A server run
72
+ # under screen, tmux or a login shell HAS a terminal and still has nobody in
73
+ # front of it, and there the TTY test says "ask" and the process waits forever.
74
+ # MULTI_AGENT_UNATTENDED=1 is the operator saying so out loud; see
75
+ # refs/unattended-contract.md. Unset, nothing below changes.
76
+ if [ ! -t 0 ] || [ "${MULTI_AGENT_UNATTENDED:-}" = "1" ]; then
77
+ why="no TTY"
78
+ [ "${MULTI_AGENT_UNATTENDED:-}" = "1" ] && why="MULTI_AGENT_UNATTENDED=1"
79
+ echo "ask-choice: $why and no ASK_CHOICE_DEFAULT - selecting first option '${OPTIONS[0]}'" >&2
69
80
  printf '%s\n' "${OPTIONS[0]}"
70
81
  exit 0
71
82
  fi
@@ -133,6 +133,14 @@ ma_ap_read() { # $1 = filename; empty and exit 1 when absent
133
133
  # jq with a default, so a caller never has to distinguish "key absent" from
134
134
  # "file absent" from "file unparseable" - all three mean "use the default".
135
135
  ma_ap_cfg() { # $1 = jq path, $2 = default
136
+ # An unreadable queue must not read as an EMPTY queue. Without this, a
137
+ # machine with no jq made the runner conclude there was no work and go
138
+ # quiet - the worst failure this mode can have, because it looks like
139
+ # success.
140
+ if ! command -v jq >/dev/null 2>&1; then
141
+ echo "jq not found - cannot read the autopilot config (install: brew install jq)" >&2
142
+ return 3
143
+ fi
136
144
  local v
137
145
  v=$(ma_ap_read config.json 2>/dev/null | jq -r "$1 // empty" 2>/dev/null)
138
146
  [ -n "$v" ] && printf '%s\n' "$v" || printf '%s\n' "$2"
@@ -0,0 +1,129 @@
1
+ /**
2
+ * fatal.mjs - one line instead of a stack dump, and whatever was held gets
3
+ * released.
4
+ *
5
+ * Node's default for an uncaught throw or a rejected promise nobody awaited is
6
+ * a stack trace on stderr and exit 1. That is the right default for a library
7
+ * and the wrong one here for two reasons.
8
+ *
9
+ * The first is legibility. Every script in this tree reports its own failures
10
+ * as `name: what went wrong`, one line, and a caller - a shell gate, a phase
11
+ * step, a person reading a run log - is written against that shape. A stack
12
+ * dump in the middle of it reads as a crash of the pipeline rather than as a
13
+ * failure of one step, and the actual message sits four frames down.
14
+ *
15
+ * The second is state, and it is the one that costs something. A script that
16
+ * dies between acquiring an advisory lock and releasing it leaves the lock on
17
+ * disk. The next writer then waits out the full acquire window before deciding
18
+ * the holder is stale - and if it judges wrong it takes a lock a LIVE writer
19
+ * holds. `cleanup` exists for exactly that: the handler releases before it
20
+ * exits, so the abnormal path leaves the same state behind as the normal one.
21
+ *
22
+ * What this does NOT do is swallow anything. Every path still exits non-zero,
23
+ * and the message still names the error; only the stack goes, and only when
24
+ * the error carries a message worth reading on its own.
25
+ *
26
+ * Usage:
27
+ * import { runMain } from "../lib/fatal.mjs";
28
+ * runMain("write-state", main, { cleanup: releaseLock });
29
+ *
30
+ * @module pipeline/lib/fatal
31
+ */
32
+
33
+ /** Guards against a cleanup that runs twice when a throw follows a rejection. */
34
+ let cleanedUp = false;
35
+
36
+ /**
37
+ * Run `cleanup` at most once, and never let it become the failure it is
38
+ * cleaning up after: a cleanup that throws inside a fatal handler replaces the
39
+ * original error with its own, which is how a lock bug ends up reported as a
40
+ * permissions bug.
41
+ *
42
+ * @param {(() => void)|undefined} cleanup
43
+ */
44
+ function runCleanup(cleanup) {
45
+ if (cleanedUp || typeof cleanup !== "function") return;
46
+ cleanedUp = true;
47
+ try {
48
+ cleanup();
49
+ } catch {
50
+ // Deliberately silent. The caller is already exiting with the real error.
51
+ }
52
+ }
53
+
54
+ /**
55
+ * The message a human should see. An Error with a message prints the message;
56
+ * anything else (a thrown string, a rejected non-Error) prints its own
57
+ * stringification, because dropping it would leave the line with nothing in it.
58
+ *
59
+ * @param {unknown} err
60
+ * @returns {string}
61
+ */
62
+ function describe(err) {
63
+ if (err instanceof Error && err.message) return err.message;
64
+ if (typeof err === "string" && err) return err;
65
+ try {
66
+ return JSON.stringify(err);
67
+ } catch {
68
+ return String(err);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Catch what escapes: a rejected promise nobody awaited, and a throw from a
74
+ * callback that no try/catch surrounds. Both are invisible to a try/catch
75
+ * around main(), which is why wrapping main() alone is not enough.
76
+ *
77
+ * MULTI_AGENT_FATAL_STACK=1 restores the stack for debugging. It is off by
78
+ * default because the line is for the person running the pipeline, not for the
79
+ * person maintaining it.
80
+ *
81
+ * @param {{name:string, cleanup?:()=>void, code?:number}} opts
82
+ */
83
+ export function installFatalHandlers({ name, cleanup, code = 1 }) {
84
+ const report = (kind, err) => {
85
+ runCleanup(cleanup);
86
+ // `runs-index.mjs --json | head` is not a failure: head closes the pipe and
87
+ // the next write raises EPIPE. Reporting it as a fatal would turn the most
88
+ // ordinary way of looking at a large output into a red line and a non-zero
89
+ // exit, and the reader that went away is not listening to the complaint
90
+ // anyway. Quiet, zero, the way every other CLI treats it.
91
+ if (err && err.code === "EPIPE") process.exit(0);
92
+ process.stderr.write(`${name}: ${kind} - ${describe(err)}\n`);
93
+ if (process.env.MULTI_AGENT_FATAL_STACK === "1" && err instanceof Error && err.stack) {
94
+ process.stderr.write(`${err.stack}\n`);
95
+ }
96
+ // exit(), not exitCode: the event loop may hold a listener that would keep
97
+ // the process alive after the failure, and a script that has already
98
+ // reported a fatal must not go on to do more work.
99
+ process.exit(code);
100
+ };
101
+ process.on("unhandledRejection", (err) => report("unhandled rejection", err));
102
+ process.on("uncaughtException", (err) => report("uncaught exception", err));
103
+ }
104
+
105
+ /**
106
+ * Install the handlers and run main, sync or async, with the same contract for
107
+ * both. A successful run is untouched: no exit call, so `process.exitCode` set
108
+ * inside main survives, and stdout drains the way it would have.
109
+ *
110
+ * @param {string} name script name as it should appear in the message
111
+ * @param {() => unknown} main
112
+ * @param {{cleanup?:()=>void, code?:number}} [opts]
113
+ * @returns {Promise<void>}
114
+ */
115
+ export async function runMain(name, main, opts = {}) {
116
+ const { cleanup, code = 1 } = opts;
117
+ installFatalHandlers({ name, cleanup, code });
118
+ try {
119
+ await main();
120
+ } catch (err) {
121
+ runCleanup(cleanup);
122
+ if (err && err.code === "EPIPE") process.exit(0);
123
+ process.stderr.write(`${name}: ${describe(err)}\n`);
124
+ if (process.env.MULTI_AGENT_FATAL_STACK === "1" && err instanceof Error && err.stack) {
125
+ process.stderr.write(`${err.stack}\n`);
126
+ }
127
+ process.exit(code);
128
+ }
129
+ }
@@ -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)