@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.
- package/CHANGELOG.md +276 -0
- package/README.md +59 -1
- package/README.tr.md +57 -0
- package/docs/adr/0011-dormant-ci.md +25 -1
- package/docs/features.md +24 -0
- package/docs/server-readiness.md +188 -0
- package/docs/token-budget-history.md +1 -1
- package/index.js +16 -1
- package/install/_common.mjs +42 -17
- package/install/_dev-only-files.mjs +8 -0
- package/install/_unattended-profile.mjs +113 -0
- package/install/index.mjs +48 -0
- package/install/templates/claude-hooks.json +13 -1
- package/manifest.json +1049 -0
- package/package.json +5 -2
- package/pipeline/commands/multi-agent/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/feedback/SKILL.md +7 -1
- package/pipeline/commands/multi-agent/graph/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/issue/SKILL.md +13 -1
- package/pipeline/commands/multi-agent/jira/SKILL.md +13 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +16 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +14 -16
- package/pipeline/commands/multi-agent/status/SKILL.md +52 -21
- package/pipeline/commands/multi-agent/update/SKILL.md +13 -56
- package/pipeline/lib/_jira-auth.sh +8 -0
- package/pipeline/lib/analysis-jira-write.sh +32 -0
- package/pipeline/lib/ask-choice.sh +13 -2
- package/pipeline/lib/autopilot-state.sh +8 -0
- package/pipeline/lib/fatal.mjs +129 -0
- package/pipeline/lib/figma-mcp-refresh.sh +18 -0
- package/pipeline/lib/figma-screenshot.sh +18 -0
- package/pipeline/lib/invoked-directly.mjs +43 -0
- package/pipeline/lib/jira-publish.sh +42 -0
- package/pipeline/lib/md2confluence-v3.py +47 -0
- package/pipeline/lib/outbound-gate.mjs +175 -0
- package/pipeline/lib/plan-todos.sh +27 -6
- package/pipeline/lib/post-pr-review.sh +77 -8
- package/pipeline/lib/repo-hygiene.sh +8 -3
- package/pipeline/lib/require-jq.sh +40 -0
- package/pipeline/lib/run-paths.sh +335 -0
- package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +70 -0
- package/pipeline/multi-agent-refs/features/code-graph.md +20 -0
- package/pipeline/multi-agent-refs/features/cost-analysis.md +93 -0
- package/pipeline/multi-agent-refs/features/doctor.md +68 -0
- package/pipeline/multi-agent-refs/features/maturity-followup.md +166 -0
- package/pipeline/multi-agent-refs/features/package-manager.md +80 -0
- package/pipeline/multi-agent-refs/features/usage-reporting.md +79 -0
- package/pipeline/multi-agent-refs/features/verify-by-test.md +1 -1
- package/pipeline/multi-agent-refs/features/verify.md +83 -0
- package/pipeline/multi-agent-refs/phases/operations.md +13 -2
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -3
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +8 -2
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +1 -1
- package/pipeline/multi-agent-refs/picker-contract.md +1 -1
- package/pipeline/multi-agent-refs/unattended-contract.md +129 -0
- package/pipeline/preferences-template.json +1 -1
- package/pipeline/schemas/agent-state.schema.json +122 -11
- package/pipeline/schemas/prefs.schema.json +35 -0
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/scripts/_run-paths.mjs +372 -0
- package/pipeline/scripts/aggregate-metrics.mjs +64 -64
- package/pipeline/scripts/autopilot-arming.mjs +2 -1
- package/pipeline/scripts/autopilot-intake.mjs +2 -1
- package/pipeline/scripts/autopilot-runner.mjs +206 -2
- package/pipeline/scripts/build-references.mjs +2 -1
- package/pipeline/scripts/build-stack-plugins.mjs +10 -2
- package/pipeline/scripts/capture-evidence.sh +7 -2
- package/pipeline/scripts/classify-plan-safety.mjs +2 -1
- package/pipeline/scripts/cost-analyze.mjs +600 -0
- package/pipeline/scripts/cost-budget-check.mjs +4 -12
- package/pipeline/scripts/council-view.mjs +2 -1
- package/pipeline/scripts/crush-json.mjs +2 -1
- package/pipeline/scripts/diff-explain.mjs +6 -9
- package/pipeline/scripts/diff-risk-score.mjs +2 -1
- package/pipeline/scripts/doctor.mjs +203 -4
- package/pipeline/scripts/evidence-gate.mjs +9 -3
- package/pipeline/scripts/feedback-send.mjs +13 -3
- package/pipeline/scripts/gc-abandoned.sh +29 -13
- package/pipeline/scripts/gc-worktrees.sh +11 -4
- package/pipeline/scripts/github-ssh-setup.sh +64 -7
- package/pipeline/scripts/graph-mermaid.mjs +4 -2
- package/pipeline/scripts/graph-report.mjs +155 -1
- package/pipeline/scripts/keychain-save.sh +101 -30
- package/pipeline/scripts/learn-from-transcripts.mjs +2 -1
- package/pipeline/scripts/learning-curve.mjs +34 -29
- package/pipeline/scripts/make-manifest.mjs +199 -0
- package/pipeline/scripts/maturity-followup.mjs +294 -0
- package/pipeline/scripts/migrate-prefs.mjs +2 -1
- package/pipeline/scripts/migrate-state.mjs +94 -4
- package/pipeline/scripts/package-manager.mjs +310 -0
- package/pipeline/scripts/phase-banner.sh +6 -2
- package/pipeline/scripts/phase-tracker.sh +41 -3
- package/pipeline/scripts/plan-coverage-gate.mjs +6 -2
- package/pipeline/scripts/pre-commit-check.sh +7 -0
- package/pipeline/scripts/pre-push-check.sh +7 -0
- package/pipeline/scripts/purge.sh +23 -6
- package/pipeline/scripts/render-agent-log-cost.sh +9 -2
- package/pipeline/scripts/render-cost-summary.sh +9 -2
- package/pipeline/scripts/render-work-summary.sh +11 -4
- package/pipeline/scripts/review-file-filter.mjs +4 -2
- package/pipeline/scripts/review-scope.mjs +2 -1
- package/pipeline/scripts/routine-registry.mjs +2 -1
- package/pipeline/scripts/run-aggregator.mjs +13 -14
- package/pipeline/scripts/run-metrics.mjs +3 -1
- package/pipeline/scripts/runs-index.mjs +343 -0
- package/pipeline/scripts/scorecard-snapshot.mjs +178 -0
- package/pipeline/scripts/search-logs.sh +18 -0
- package/pipeline/scripts/test-gap-scan.mjs +2 -1
- package/pipeline/scripts/test-integrity-gate.mjs +2 -1
- package/pipeline/scripts/update-issue-progress.sh +56 -7
- package/pipeline/scripts/usage-register.mjs +271 -0
- package/pipeline/scripts/usage-report.mjs +14 -3
- package/pipeline/scripts/validate-analysis-doc.mjs +2 -1
- package/pipeline/scripts/validate-code-graph.mjs +6 -3
- package/pipeline/scripts/validate-complaint-doc.mjs +2 -1
- package/pipeline/scripts/validate-diff-risk.mjs +6 -3
- package/pipeline/scripts/validate-test-gap.mjs +6 -3
- package/pipeline/scripts/validate-triage.mjs +3 -1
- package/pipeline/scripts/verify-citations.mjs +4 -2
- package/pipeline/scripts/verify.mjs +327 -0
- package/pipeline/scripts/worktree-finalize.sh +13 -4
- package/pipeline/scripts/write-state.mjs +154 -15
- package/pipeline/skills/.skill-manifest.json +6 -6
- package/pipeline/skills/.skills-index.json +56 -1
- package/pipeline/skills/shared/README.md +8 -3
- package/pipeline/skills/shared/core/multi-agent-issue/SKILL.md +14 -0
- package/pipeline/skills/shared/core/multi-agent-jira/SKILL.md +14 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +13 -0
- package/pipeline/skills/shared/core/multi-agent-status/SKILL.md +33 -9
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +6 -0
- package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/package_app.sh +4 -1
- package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/setup_dev_signing.sh +4 -1
- package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/sign-and-notarize.sh +2 -1
- 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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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)
|
|
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
|
+
}
|