@mmerterden/multi-agent-pipeline 17.0.0 → 17.3.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 +159 -0
- package/README.md +56 -4
- package/README.tr.md +57 -4
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/token-budget-history.md +22 -0
- package/install/_dev-only-files.mjs +1 -0
- package/install/codex.mjs +18 -1
- package/install/copilot.mjs +17 -1
- package/install/templates/multi-agent-autopilot.plist.template +79 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +64 -0
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +181 -0
- package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +74 -0
- package/pipeline/commands/multi-agent/channels/SKILL.md +41 -12
- package/pipeline/commands/multi-agent/help/SKILL.md +41 -35
- package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
- package/pipeline/lib/autopilot-activation.sh +117 -0
- package/pipeline/lib/autopilot-state.sh +184 -0
- package/pipeline/lib/issue-fetcher.sh +18 -1
- package/pipeline/lib/plan-todos.sh +18 -0
- package/pipeline/multi-agent-refs/_dev-context.md +10 -0
- package/pipeline/multi-agent-refs/analysis/redesign.md +8 -0
- package/pipeline/multi-agent-refs/analysis/review.md +9 -0
- package/pipeline/multi-agent-refs/android-guide.md +14 -0
- package/pipeline/multi-agent-refs/audit-guide.md +12 -0
- package/pipeline/multi-agent-refs/backend-guide.md +10 -0
- package/pipeline/multi-agent-refs/channels/confluence.md +11 -0
- package/pipeline/multi-agent-refs/channels/issue-comment.md +12 -0
- package/pipeline/multi-agent-refs/channels/jira.md +90 -20
- package/pipeline/multi-agent-refs/channels/pr-review-actions.md +13 -0
- package/pipeline/multi-agent-refs/channels/pr.md +76 -19
- package/pipeline/multi-agent-refs/component-dispatch.md +11 -0
- package/pipeline/multi-agent-refs/component-generation.md +11 -0
- package/pipeline/multi-agent-refs/conventions-defaults.md +15 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +49 -5
- package/pipeline/multi-agent-refs/features/analysis-jira.md +11 -0
- package/pipeline/multi-agent-refs/features/design-conformance.md +10 -0
- package/pipeline/multi-agent-refs/features/doctor.md +10 -0
- package/pipeline/multi-agent-refs/features/external-context-injection.md +7 -0
- package/pipeline/multi-agent-refs/features/jira-context.md +9 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +10 -0
- package/pipeline/multi-agent-refs/features/skill-conformance.md +13 -0
- package/pipeline/multi-agent-refs/features/url-enrichment.md +9 -0
- package/pipeline/multi-agent-refs/features/visual-evidence.md +61 -7
- package/pipeline/multi-agent-refs/generate-issue.md +7 -0
- package/pipeline/multi-agent-refs/issue-jira-triad.md +9 -0
- package/pipeline/multi-agent-refs/knowledge.md +6 -0
- package/pipeline/multi-agent-refs/multi-repo-integration-build.md +13 -0
- package/pipeline/multi-agent-refs/phases/modes.md +7 -0
- package/pipeline/multi-agent-refs/phases/operations.md +9 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-2-planning.md +17 -15
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +1 -1
- package/pipeline/multi-agent-refs/phases.md +11 -0
- package/pipeline/multi-agent-refs/picker-contract.md +12 -0
- package/pipeline/multi-agent-refs/platform-parity.md +10 -0
- package/pipeline/multi-agent-refs/progress-contract.md +10 -0
- package/pipeline/multi-agent-refs/readiness-review.md +7 -1
- package/pipeline/multi-agent-refs/rules.md +3 -11
- package/pipeline/multi-agent-refs/setup/firebase.md +9 -0
- package/pipeline/multi-agent-refs/swiftui-guide.md +17 -0
- package/pipeline/multi-agent-refs/tracker-contract.md +44 -0
- package/pipeline/multi-agent-refs/web-guide.md +10 -0
- package/pipeline/multi-agent-refs/wiki-capture.md +11 -0
- package/pipeline/schemas/autopilot-config.schema.json +149 -0
- package/pipeline/schemas/prefs.schema.json +4 -0
- package/pipeline/schemas/token-budget.json +10 -19
- package/pipeline/scripts/autopilot-arming.mjs +147 -0
- package/pipeline/scripts/autopilot-intake.mjs +387 -0
- package/pipeline/scripts/autopilot-menubar.swift +361 -0
- package/pipeline/scripts/autopilot-runner.mjs +354 -0
- package/pipeline/scripts/autopilot-status.sh +213 -0
- package/pipeline/scripts/capture-evidence.sh +79 -11
- package/pipeline/scripts/gen-ref-toc.mjs +279 -0
- package/pipeline/scripts/jira-search.sh +70 -0
- package/pipeline/scripts/phase-tracker.sh +134 -12
- package/pipeline/scripts/probe-evidence-capability.sh +27 -3
- package/pipeline/scripts/run-ui-tests.sh +113 -4
- package/pipeline/skills/.skill-manifest.json +16 -4
- package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +67 -0
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +146 -0
- package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +64 -0
- package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +62 -11
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -8
|
@@ -38,20 +38,24 @@ MODE="${1:-}"; shift 2>/dev/null || true
|
|
|
38
38
|
EVIDENCE_DIR="${EVIDENCE_DIR:-$PWD/.pipeline/evidence}"
|
|
39
39
|
PREFS="$HOME/.claude/multi-agent-preferences.json"
|
|
40
40
|
|
|
41
|
-
# Reads visualEvidence.enabled,
|
|
42
|
-
#
|
|
43
|
-
#
|
|
41
|
+
# Reads visualEvidence.enabled, .maxAttachmentMb, .maxVideoSeconds and
|
|
42
|
+
# .webBaseUrl. One node call: separate ones cost a process start each on every
|
|
43
|
+
# phase boundary. webBaseUrl is shell-quoted because it is the only value here
|
|
44
|
+
# that is free text a user typed.
|
|
44
45
|
prefs_visual() {
|
|
45
46
|
node -e '
|
|
46
47
|
const fs=require("fs");
|
|
47
48
|
let v={};
|
|
48
49
|
try{v=((JSON.parse(fs.readFileSync(process.argv[1],"utf8")).global||{}).visualEvidence)||{}}catch{}
|
|
49
50
|
const num=(x,d)=>Number.isFinite(x)&&x>0?x:d;
|
|
51
|
+
const Q=String.fromCharCode(39);
|
|
52
|
+
const q=(x)=>String(x==null?"":x).split(Q).join(Q+"\\"+Q+Q);
|
|
50
53
|
process.stdout.write(
|
|
51
54
|
"VISUAL_EVIDENCE_ENABLED="+(v.enabled===false?"false":"true")+"\n"+
|
|
52
55
|
"MAX_ATTACHMENT_MB="+num(v.maxAttachmentMb,10)+"\n"+
|
|
53
|
-
"MAX_VIDEO_SECONDS="+num(v.maxVideoSeconds,60)+"\n"
|
|
54
|
-
|
|
56
|
+
"MAX_VIDEO_SECONDS="+num(v.maxVideoSeconds,60)+"\n"+
|
|
57
|
+
"WEB_BASE_URL="+Q+q(v.webBaseUrl)+Q+"\n");
|
|
58
|
+
' "$PREFS" 2>/dev/null || printf "VISUAL_EVIDENCE_ENABLED=true\nMAX_ATTACHMENT_MB=10\nMAX_VIDEO_SECONDS=60\nWEB_BASE_URL=''\n"
|
|
55
59
|
}
|
|
56
60
|
eval "$(prefs_visual)"
|
|
57
61
|
MAX_ATTACH_MB="${MAX_ATTACH_MB:-$MAX_ATTACHMENT_MB}"
|
|
@@ -77,17 +81,18 @@ file_bytes() {
|
|
|
77
81
|
|
|
78
82
|
case "$MODE" in
|
|
79
83
|
after)
|
|
80
|
-
TASK=""; PLATFORM=""; LABEL="screen"
|
|
84
|
+
TASK=""; PLATFORM=""; LABEL="screen"; URL=""
|
|
81
85
|
while [ "$#" -gt 0 ]; do
|
|
82
86
|
case "$1" in
|
|
83
87
|
--task) TASK="${2:-}"; shift 2 ;;
|
|
84
88
|
--platform) PLATFORM="${2:-}"; shift 2 ;;
|
|
85
89
|
--label) LABEL="${2:-screen}"; shift 2 ;;
|
|
90
|
+
--url) URL="${2:-}"; shift 2 ;;
|
|
86
91
|
*) echo "capture-evidence: unknown option $1" >&2; exit 2 ;;
|
|
87
92
|
esac
|
|
88
93
|
done
|
|
89
94
|
[ -n "$TASK" ] && [ -n "$PLATFORM" ] || {
|
|
90
|
-
echo "usage: capture-evidence.sh after --task <id> --platform <ios|android> [--label <slug>]" >&2
|
|
95
|
+
echo "usage: capture-evidence.sh after --task <id> --platform <ios|android|web> [--label <slug>] [--url <addr>]" >&2
|
|
91
96
|
exit 2
|
|
92
97
|
}
|
|
93
98
|
mkdir -p "$EVIDENCE_DIR"
|
|
@@ -112,6 +117,21 @@ case "$MODE" in
|
|
|
112
117
|
adb exec-out screencap -p > "$OUT" 2>/dev/null || { echo "capture-evidence: no attached device" >&2; exit 4; }
|
|
113
118
|
[ -s "$OUT" ] || { echo "capture-evidence: empty capture" >&2; exit 4; }
|
|
114
119
|
;;
|
|
120
|
+
web)
|
|
121
|
+
# The "device" here is the browser the test runner already drives, which
|
|
122
|
+
# is why the probe calls the runner the device for web. A URL is the one
|
|
123
|
+
# thing this platform needs and the other two do not: a simulator is
|
|
124
|
+
# already showing something, a dev server has to be pointed at.
|
|
125
|
+
[ -n "$URL" ] || URL="${WEB_BASE_URL:-}"
|
|
126
|
+
[ -n "$URL" ] || { echo "capture-evidence: no dev-server URL for the web capture (pass --url or set prefs.global.visualEvidence.webBaseUrl)" >&2; exit 4; }
|
|
127
|
+
npx --no-install playwright --version >/dev/null 2>&1 || { echo "capture-evidence: playwright is not installed in this project" >&2; exit 4; }
|
|
128
|
+
# --no-install, never a bare npx: a bare one DOWNLOADS the browser stack,
|
|
129
|
+
# turning "this project has no browser tooling" into a silent network
|
|
130
|
+
# fetch. run-ui-tests.sh carries the same rule and the same reason.
|
|
131
|
+
npx --no-install playwright screenshot --full-page --wait-for-timeout=1000 \
|
|
132
|
+
"$URL" "$OUT" >/dev/null 2>&1 || { echo "capture-evidence: playwright could not reach $URL" >&2; exit 4; }
|
|
133
|
+
[ -s "$OUT" ] || { echo "capture-evidence: empty capture" >&2; exit 4; }
|
|
134
|
+
;;
|
|
115
135
|
*) echo "capture-evidence: unsupported platform '$PLATFORM'" >&2; exit 2 ;;
|
|
116
136
|
esac
|
|
117
137
|
|
|
@@ -150,14 +170,14 @@ case "$MODE" in
|
|
|
150
170
|
esac
|
|
151
171
|
done
|
|
152
172
|
case "$ACTION" in start | stop) ;; *)
|
|
153
|
-
echo "usage: capture-evidence.sh video start|stop --task <id> --platform <ios|android> [--label <slug>]" >&2
|
|
173
|
+
echo "usage: capture-evidence.sh video start|stop --task <id> --platform <ios|android|web> [--label <slug>]" >&2
|
|
154
174
|
exit 2 ;;
|
|
155
175
|
esac
|
|
156
176
|
[ -n "$TASK" ] && [ -n "$PLATFORM" ] || {
|
|
157
|
-
echo "usage: capture-evidence.sh video start|stop --task <id> --platform <ios|android> [--label <slug>]" >&2
|
|
177
|
+
echo "usage: capture-evidence.sh video start|stop --task <id> --platform <ios|android|web> [--label <slug>]" >&2
|
|
158
178
|
exit 2
|
|
159
179
|
}
|
|
160
|
-
case "$PLATFORM" in ios | android) ;; *)
|
|
180
|
+
case "$PLATFORM" in ios | android | web) ;; *)
|
|
161
181
|
echo "capture-evidence: unsupported platform '$PLATFORM'" >&2; exit 2 ;;
|
|
162
182
|
esac
|
|
163
183
|
|
|
@@ -168,6 +188,54 @@ case "$MODE" in
|
|
|
168
188
|
WATCHFILE="$EVIDENCE_DIR/.${SLUG}.watchpid"
|
|
169
189
|
REMOTE="/sdcard/_ma_${SLUG}.mp4"
|
|
170
190
|
|
|
191
|
+
# Web does not use start/stop at all, and the reason is already written in
|
|
192
|
+
# probe-evidence-capability.sh: "the recorder IS the runner". Playwright and
|
|
193
|
+
# Cypress write the video themselves, so spawning a second recorder would
|
|
194
|
+
# record the same run twice. start marks the moment; stop harvests whatever
|
|
195
|
+
# the suite produced after that moment.
|
|
196
|
+
if [ "$PLATFORM" = "web" ]; then
|
|
197
|
+
MARKER="$EVIDENCE_DIR/.${SLUG}.webrec"
|
|
198
|
+
if [ "$ACTION" = "start" ]; then
|
|
199
|
+
date +%s > "$MARKER"
|
|
200
|
+
printf '%s\n' "$OUT"
|
|
201
|
+
exit 0
|
|
202
|
+
fi
|
|
203
|
+
[ -f "$MARKER" ] || { echo "capture-evidence: no web recording was started for $SLUG" >&2; exit 4; }
|
|
204
|
+
SINCE=$(cat "$MARKER" 2>/dev/null || echo 0)
|
|
205
|
+
rm -f "$MARKER"
|
|
206
|
+
# Both runners, newest first, and only files written after the marker: a
|
|
207
|
+
# stale video from yesterday's run attached as today's evidence is worse
|
|
208
|
+
# than no video, because nobody re-checks an artefact that is present.
|
|
209
|
+
SRC=""
|
|
210
|
+
for cand in $(find test-results cypress/videos -type f \( -name '*.webm' -o -name '*.mp4' \) 2>/dev/null); do
|
|
211
|
+
[ -f "$cand" ] || continue
|
|
212
|
+
MT=$(date -r "$cand" +%s 2>/dev/null || echo 0)
|
|
213
|
+
[ "$MT" -ge "$SINCE" ] 2>/dev/null || continue
|
|
214
|
+
if [ -z "$SRC" ]; then SRC="$cand"; else
|
|
215
|
+
PREV=$(date -r "$SRC" +%s 2>/dev/null || echo 0)
|
|
216
|
+
[ "$MT" -gt "$PREV" ] 2>/dev/null && SRC="$cand"
|
|
217
|
+
fi
|
|
218
|
+
done
|
|
219
|
+
[ -n "$SRC" ] || { echo "capture-evidence: the suite recorded no video after the marker (is video enabled in the project's runner config?)" >&2; exit 4; }
|
|
220
|
+
case "$SRC" in
|
|
221
|
+
*.webm)
|
|
222
|
+
# h264 because that is what the Jira preview and the PR body can play;
|
|
223
|
+
# the iOS arm re-encodes for the same reason and says so there too.
|
|
224
|
+
if command -v ffmpeg >/dev/null 2>&1; then
|
|
225
|
+
ffmpeg -y -i "$SRC" -c:v libx264 -preset veryfast -crf 28 -an "$OUT" >/dev/null 2>&1 \
|
|
226
|
+
|| { echo "capture-evidence: ffmpeg could not transcode $SRC" >&2; exit 4; }
|
|
227
|
+
else
|
|
228
|
+
OUT="${OUT%.mp4}.webm"
|
|
229
|
+
cp "$SRC" "$OUT"
|
|
230
|
+
echo "capture-evidence: ffmpeg unavailable, keeping webm - some viewers will not play it" >&2
|
|
231
|
+
fi
|
|
232
|
+
;;
|
|
233
|
+
*) cp "$SRC" "$OUT" ;;
|
|
234
|
+
esac
|
|
235
|
+
printf '%s\n' "$OUT"
|
|
236
|
+
exit 0
|
|
237
|
+
fi
|
|
238
|
+
|
|
171
239
|
# screenrecord's own ceiling is 180s and it is not negotiable, so a
|
|
172
240
|
# maxVideoSeconds above it would silently become 180 on Android and stay
|
|
173
241
|
# honoured on iOS - two platforms disagreeing about one preference. Clamp in
|
|
@@ -300,7 +368,7 @@ case "$MODE" in
|
|
|
300
368
|
|
|
301
369
|
BASE="${FILE%.*}"; EXT="${FILE##*.}"
|
|
302
370
|
case "$EXT" in
|
|
303
|
-
mp4|mov|m4v)
|
|
371
|
+
mp4|mov|m4v|webm)
|
|
304
372
|
# Quality down, artefact kept. 720p + a lower bitrate clears an order of
|
|
305
373
|
# magnitude; a flow video is watched for what moves, not for its pixels.
|
|
306
374
|
if command -v ffmpeg >/dev/null 2>&1; then
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gen-ref-toc.mjs - a table of contents on every long reference file.
|
|
3
|
+
//
|
|
4
|
+
// Anthropic's published Agent Skills guidance: "For reference files longer than
|
|
5
|
+
// 100 lines, include a table of contents". The reason is the same one behind
|
|
6
|
+
// the one-level-deep rule - an agent that reads a long file partially needs to
|
|
7
|
+
// know from the top what is further down, and without that it answers from the
|
|
8
|
+
// first screen and stops.
|
|
9
|
+
//
|
|
10
|
+
// Generated, never hand-written. Fifty-six files' worth of hand-maintained
|
|
11
|
+
// contents lists would be wrong by the second edit, and a wrong ToC is worse
|
|
12
|
+
// than none: it tells a reader a section exists where it does not.
|
|
13
|
+
//
|
|
14
|
+
// Section level is DERIVED, not assumed. These files are not consistent: some
|
|
15
|
+
// open at `##` and section at `###` (keychain.md), others open at `###` and
|
|
16
|
+
// section at `##` (phases/phase-3-dev.md). The rule is the shallowest heading
|
|
17
|
+
// depth that occurs more than once - a depth used exactly once is a title, not
|
|
18
|
+
// a section level.
|
|
19
|
+
//
|
|
20
|
+
// Not every long file is eligible, and the exclusion is measured rather than
|
|
21
|
+
// preferred. A contents list helps a file that is OPENED AND SKIMMED. It is
|
|
22
|
+
// pure cost on a file that is loaded whole by contract, because there is no
|
|
23
|
+
// partial read for it to rescue - and those files are already at their limit:
|
|
24
|
+
//
|
|
25
|
+
// the 8 phase docs a ToC each puts 5 of 8 over their per-phase max and the
|
|
26
|
+
// aggregate 1,533 tokens over 62,700
|
|
27
|
+
// rules.md always loaded; 59,908 of a 60,000 byte ceiling, and a
|
|
28
|
+
// ToC takes it to 60,997
|
|
29
|
+
// the analysis refs mounted as one set per analysis run; 158,500 byte
|
|
30
|
+
// ceiling, and the ToCs put them 2,298 over
|
|
31
|
+
//
|
|
32
|
+
// So the two published rules genuinely conflict there, and the ToC is the one
|
|
33
|
+
// that loses: raising either ceiling to buy navigation nobody navigates is the
|
|
34
|
+
// move this repo spent a release removing.
|
|
35
|
+
//
|
|
36
|
+
// The exclusion is DERIVED from those two budgets, never hand-listed. A
|
|
37
|
+
// document entering or leaving a budget changes its ToC status by itself, which
|
|
38
|
+
// is the difference between a rule and a list somebody has to remember.
|
|
39
|
+
//
|
|
40
|
+
// Usage:
|
|
41
|
+
// node gen-ref-toc.mjs write/refresh every eligible file
|
|
42
|
+
// node gen-ref-toc.mjs --check report drift, write nothing, exit 1 if any
|
|
43
|
+
// node gen-ref-toc.mjs --stats byte cost, write nothing
|
|
44
|
+
// node gen-ref-toc.mjs --verify every generated anchor resolves to a heading
|
|
45
|
+
|
|
46
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
47
|
+
import { dirname, join, relative } from "node:path";
|
|
48
|
+
import { fileURLToPath } from "node:url";
|
|
49
|
+
|
|
50
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
51
|
+
const REFS = join(ROOT, "pipeline", "multi-agent-refs");
|
|
52
|
+
const MIN_LINES = 100;
|
|
53
|
+
const MARK_OPEN = "<!-- toc -->";
|
|
54
|
+
const MARK_CLOSE = "<!-- /toc -->";
|
|
55
|
+
|
|
56
|
+
// Every file that some other gate already holds to a byte or token ceiling.
|
|
57
|
+
function budgeted() {
|
|
58
|
+
const out = new Set();
|
|
59
|
+
const budget = JSON.parse(readFileSync(join(ROOT, "pipeline/schemas/token-budget.json"), "utf8"));
|
|
60
|
+
for (const phase of Object.keys(budget.phases)) {
|
|
61
|
+
out.add(join(REFS, "phases", `${phase}.md`));
|
|
62
|
+
}
|
|
63
|
+
// The always-loaded set, read out of the gate that owns it rather than copied.
|
|
64
|
+
const ctx = readFileSync(join(ROOT, "pipeline/scripts/smoke-context-budget.sh"), "utf8");
|
|
65
|
+
const block = ctx.match(/TRACKED="\n([\s\S]*?)"/);
|
|
66
|
+
if (block) {
|
|
67
|
+
for (const line of block[1].split("\n")) {
|
|
68
|
+
const t = line.trim();
|
|
69
|
+
if (t) out.add(join(ROOT, t));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The analysis set, mounted whole per analysis run under its own ceiling. Same
|
|
74
|
+
// two globs that gate uses - `analysis/<name>.md` named by the analysis
|
|
75
|
+
// command, plus every analysis-template*.md.
|
|
76
|
+
const cmd = readFileSync(join(ROOT, "pipeline/commands/multi-agent/analysis/SKILL.md"), "utf8");
|
|
77
|
+
for (const m of cmd.matchAll(/analysis\/[a-z-]+\.md/g)) out.add(join(REFS, m[0]));
|
|
78
|
+
for (const e of readdirSync(REFS, { withFileTypes: true })) {
|
|
79
|
+
if (e.isFile() && /^analysis-template.*\.md$/.test(e.name)) out.add(join(REFS, e.name));
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function walk(dir, out = []) {
|
|
85
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
86
|
+
const p = join(dir, e.name);
|
|
87
|
+
if (e.isDirectory()) walk(p, out);
|
|
88
|
+
else if (e.name.endsWith(".md")) out.push(p);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// GitHub's anchor rule, which is what a `](#...)` link resolves against.
|
|
94
|
+
function slug(text) {
|
|
95
|
+
return text
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/`/g, "")
|
|
98
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
99
|
+
.replace(/[^\w\s-]/g, "")
|
|
100
|
+
.trim()
|
|
101
|
+
.replace(/\s+/g, "-");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sections(lines) {
|
|
105
|
+
const heads = [];
|
|
106
|
+
let fenced = false;
|
|
107
|
+
lines.forEach((l, i) => {
|
|
108
|
+
if (/^\s*```/.test(l)) fenced = !fenced;
|
|
109
|
+
if (fenced) return;
|
|
110
|
+
const m = l.match(/^(#{1,6})\s+(.*\S)\s*$/);
|
|
111
|
+
if (m) heads.push({ depth: m[1].length, text: m[2], line: i });
|
|
112
|
+
});
|
|
113
|
+
if (!heads.length) return { level: 0, heads: [] };
|
|
114
|
+
const counts = {};
|
|
115
|
+
for (const h of heads) counts[h.depth] = (counts[h.depth] || 0) + 1;
|
|
116
|
+
const depths = Object.keys(counts)
|
|
117
|
+
.map(Number)
|
|
118
|
+
.sort((a, b) => a - b);
|
|
119
|
+
// The shallowest depth used more than once. A depth used exactly once is the
|
|
120
|
+
// document's title and listing it as the only entry is a ToC of one.
|
|
121
|
+
const level = depths.find((d) => counts[d] > 1) ?? depths[depths.length - 1];
|
|
122
|
+
return { level, heads: heads.filter((h) => h.depth === level) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function render(heads) {
|
|
126
|
+
const seen = new Map();
|
|
127
|
+
const rows = heads.map((h) => {
|
|
128
|
+
let a = slug(h.text);
|
|
129
|
+
const n = (seen.get(a) || 0) + 1;
|
|
130
|
+
seen.set(a, n);
|
|
131
|
+
if (n > 1) a = `${a}-${n - 1}`;
|
|
132
|
+
return `- [${h.text.replace(/\|/g, "\\|")}](#${a})`;
|
|
133
|
+
});
|
|
134
|
+
return [MARK_OPEN, ...rows, MARK_CLOSE].join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// After the frontmatter and after the first heading, so the reader sees what the
|
|
138
|
+
// file IS before a list of what is in it.
|
|
139
|
+
function insertionPoint(lines) {
|
|
140
|
+
let i = 0;
|
|
141
|
+
if (lines[0] === "---") {
|
|
142
|
+
i = 1;
|
|
143
|
+
while (i < lines.length && lines[i] !== "---") i += 1;
|
|
144
|
+
i += 1;
|
|
145
|
+
}
|
|
146
|
+
for (let j = i; j < lines.length; j += 1) {
|
|
147
|
+
if (/^#{1,6}\s/.test(lines[j])) return j + 1;
|
|
148
|
+
}
|
|
149
|
+
return i;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const BUDGETED = budgeted();
|
|
153
|
+
const excluded = [];
|
|
154
|
+
const files = walk(REFS)
|
|
155
|
+
.filter((f) => readFileSync(f, "utf8").split("\n").length - 1 > MIN_LINES)
|
|
156
|
+
.filter((f) => {
|
|
157
|
+
if (!BUDGETED.has(f)) return true;
|
|
158
|
+
excluded.push(relative(ROOT, f));
|
|
159
|
+
return false;
|
|
160
|
+
})
|
|
161
|
+
.sort();
|
|
162
|
+
|
|
163
|
+
// Does every `](#anchor)` in a generated block name a heading that exists in the
|
|
164
|
+
// same file? --check cannot answer that: it compares the file against THIS
|
|
165
|
+
// script's output, so a wrong slug rule agrees with itself. This validates the
|
|
166
|
+
// rule against the real headings instead, and it lives here so there is one
|
|
167
|
+
// definition of `slug`, not a second copy inside a gate.
|
|
168
|
+
if (process.argv.includes("--verify")) {
|
|
169
|
+
let total = 0;
|
|
170
|
+
const bad = [];
|
|
171
|
+
for (const f of walk(REFS)) {
|
|
172
|
+
const raw = readFileSync(f, "utf8");
|
|
173
|
+
if (!raw.includes(MARK_OPEN)) continue;
|
|
174
|
+
const lines = raw.split("\n");
|
|
175
|
+
const seen = new Map();
|
|
176
|
+
const anchors = new Set();
|
|
177
|
+
let fenced = false;
|
|
178
|
+
for (const l of lines) {
|
|
179
|
+
if (/^\s*```/.test(l)) fenced = !fenced;
|
|
180
|
+
if (fenced) continue;
|
|
181
|
+
const m = l.match(/^#{1,6}\s+(.*\S)\s*$/);
|
|
182
|
+
if (!m) continue;
|
|
183
|
+
let a = slug(m[1]);
|
|
184
|
+
const n = (seen.get(a) || 0) + 1;
|
|
185
|
+
seen.set(a, n);
|
|
186
|
+
if (n > 1) a = `${a}-${n - 1}`;
|
|
187
|
+
anchors.add(a);
|
|
188
|
+
}
|
|
189
|
+
const s0 = lines.indexOf(MARK_OPEN);
|
|
190
|
+
const e0 = lines.indexOf(MARK_CLOSE, s0);
|
|
191
|
+
for (const l of lines.slice(s0 + 1, e0)) {
|
|
192
|
+
const m = l.match(/\]\(#(.+)\)\s*$/);
|
|
193
|
+
if (!m) continue;
|
|
194
|
+
total += 1;
|
|
195
|
+
if (!anchors.has(m[1])) bad.push(`${relative(ROOT, f)} -> #${m[1]}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (total === 0) {
|
|
199
|
+
console.log("gen-ref-toc: no anchors found at all - the generator produced nothing");
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
if (bad.length) {
|
|
203
|
+
console.log(`gen-ref-toc: ${bad.length} of ${total} anchor(s) point at no heading:`);
|
|
204
|
+
for (const b of bad.slice(0, 10)) console.log(` ${b}`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
console.log(`gen-ref-toc: ${total} anchors, all resolve to a heading in their own file`);
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const mode = process.argv.includes("--check")
|
|
212
|
+
? "check"
|
|
213
|
+
: process.argv.includes("--stats")
|
|
214
|
+
? "stats"
|
|
215
|
+
: "write";
|
|
216
|
+
|
|
217
|
+
const drifted = [];
|
|
218
|
+
const skipped = [];
|
|
219
|
+
let added = 0;
|
|
220
|
+
|
|
221
|
+
for (const f of files) {
|
|
222
|
+
const raw = readFileSync(f, "utf8");
|
|
223
|
+
const rel = relative(ROOT, f);
|
|
224
|
+
const lines = raw.split("\n");
|
|
225
|
+
const { heads } = sections(lines);
|
|
226
|
+
// Fewer than three sections is a file that does not need a map of itself.
|
|
227
|
+
if (heads.length < 3) {
|
|
228
|
+
skipped.push(`${rel} (${heads.length} section(s))`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const block = render(heads);
|
|
232
|
+
|
|
233
|
+
const start = lines.indexOf(MARK_OPEN);
|
|
234
|
+
let next;
|
|
235
|
+
if (start !== -1) {
|
|
236
|
+
const end = lines.indexOf(MARK_CLOSE, start);
|
|
237
|
+
if (end === -1) {
|
|
238
|
+
drifted.push(`${rel}: ${MARK_OPEN} with no ${MARK_CLOSE}`);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const current = lines.slice(start, end + 1).join("\n");
|
|
242
|
+
if (current === block) continue;
|
|
243
|
+
next = [...lines.slice(0, start), ...block.split("\n"), ...lines.slice(end + 1)];
|
|
244
|
+
drifted.push(`${rel}: contents no longer match the headings`);
|
|
245
|
+
} else {
|
|
246
|
+
const at = insertionPoint(lines);
|
|
247
|
+
next = [...lines.slice(0, at), "", ...block.split("\n"), ...lines.slice(at)];
|
|
248
|
+
drifted.push(`${rel}: no table of contents`);
|
|
249
|
+
}
|
|
250
|
+
const out = next.join("\n");
|
|
251
|
+
added += out.length - raw.length;
|
|
252
|
+
if (mode === "write") writeFileSync(f, out);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (mode === "stats") {
|
|
256
|
+
console.log(`eligible: ${files.length} ref files over ${MIN_LINES} lines`);
|
|
257
|
+
console.log(`excluded as loaded-whole-by-contract: ${excluded.length}`);
|
|
258
|
+
console.log(`skipped (under 3 sections): ${skipped.length}`);
|
|
259
|
+
console.log(
|
|
260
|
+
`would add: ${added} bytes total, ~${Math.round(added / 4)} tokens if every one were read`,
|
|
261
|
+
);
|
|
262
|
+
process.exit(0);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (mode === "check") {
|
|
266
|
+
if (drifted.length === 0) {
|
|
267
|
+
console.log(`gen-ref-toc: ${files.length} long ref(s) carry a current table of contents`);
|
|
268
|
+
process.exit(0);
|
|
269
|
+
}
|
|
270
|
+
console.log(
|
|
271
|
+
`gen-ref-toc: ${drifted.length} file(s) need \`node pipeline/scripts/gen-ref-toc.mjs\`:`,
|
|
272
|
+
);
|
|
273
|
+
for (const d of drifted) console.log(` ${d}`);
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log(
|
|
278
|
+
`gen-ref-toc: ${drifted.length} file(s) updated, ${skipped.length} skipped, ${added} bytes added`,
|
|
279
|
+
);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# jira-search.sh - run one JQL query and print Jira's own JSON.
|
|
4
|
+
#
|
|
5
|
+
# A thin front door onto `jira_search` in lib/issue-fetcher.sh, which owns the
|
|
6
|
+
# credential resolution, the host lookup and the rule that a token never reaches
|
|
7
|
+
# argv. This file adds exactly two things that function does not do for itself:
|
|
8
|
+
# it resolves the account from preferences, and it URL-encodes the JQL.
|
|
9
|
+
#
|
|
10
|
+
# The encoding is not a detail. A JQL carrying a quoted label, a space or an
|
|
11
|
+
# `=` reaches the server mangled otherwise, and the failure is a 400 that reads
|
|
12
|
+
# like a permissions problem. python3 does it correctly; the shell does not.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# jira-search.sh --jql '<JQL>' [--fields key,summary,priority,created] [--max 50]
|
|
16
|
+
#
|
|
17
|
+
# Exit: 0 with JSON on stdout, 1 when the query failed (a warning on stderr,
|
|
18
|
+
# never a token).
|
|
19
|
+
|
|
20
|
+
set -uo pipefail
|
|
21
|
+
|
|
22
|
+
JQL=""
|
|
23
|
+
FIELDS="key,summary,status,priority,created"
|
|
24
|
+
MAXR=50
|
|
25
|
+
while [ $# -gt 0 ]; do
|
|
26
|
+
case "$1" in
|
|
27
|
+
--jql) JQL="${2:-}"; shift 2 ;;
|
|
28
|
+
--fields) FIELDS="${2:-}"; shift 2 ;;
|
|
29
|
+
--max) MAXR="${2:-}"; shift 2 ;;
|
|
30
|
+
-h | --help) grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'; exit 0 ;;
|
|
31
|
+
*) echo "jira-search: unknown option $1" >&2; exit 2 ;;
|
|
32
|
+
esac
|
|
33
|
+
done
|
|
34
|
+
[ -n "$JQL" ] || { echo "jira-search: --jql is required" >&2; exit 2; }
|
|
35
|
+
|
|
36
|
+
PREFS="${MA_PREFS_FILE:-$HOME/.claude/multi-agent-preferences.json}"
|
|
37
|
+
[ -f "$PREFS" ] || { echo "jira-search: no preferences file" >&2; exit 1; }
|
|
38
|
+
|
|
39
|
+
# Host and the LOGICAL key name, never the literal Keychain service name: the
|
|
40
|
+
# mapping is per-user and resolving it here is what keeps the service name out
|
|
41
|
+
# of every synced file.
|
|
42
|
+
eval "$(python3 - "$PREFS" <<'PY'
|
|
43
|
+
import json, sys, shlex
|
|
44
|
+
try:
|
|
45
|
+
g = json.load(open(sys.argv[1]))["global"]
|
|
46
|
+
except Exception:
|
|
47
|
+
g = {}
|
|
48
|
+
host = (g.get("hosts") or {}).get("jira", "")
|
|
49
|
+
key = (g.get("keychainMapping") or {}).get("jira", "")
|
|
50
|
+
print(f"ACCOUNT_JIRA_HOST={shlex.quote(host)}")
|
|
51
|
+
print(f"ACCOUNT_JIRA_TOKEN_KEY={shlex.quote(key)}")
|
|
52
|
+
PY
|
|
53
|
+
)"
|
|
54
|
+
export ACCOUNT_JIRA_HOST ACCOUNT_JIRA_TOKEN_KEY
|
|
55
|
+
[ -n "$ACCOUNT_JIRA_HOST" ] && [ -n "$ACCOUNT_JIRA_TOKEN_KEY" ] || {
|
|
56
|
+
echo "jira-search: jira is not onboarded (host or keychainMapping.jira missing)" >&2
|
|
57
|
+
exit 1
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
61
|
+
for cand in "$HERE/../lib/issue-fetcher.sh" "$HOME/.claude/lib/issue-fetcher.sh"; do
|
|
62
|
+
[ -f "$cand" ] && { . "$cand"; break; }
|
|
63
|
+
done
|
|
64
|
+
command -v jira_search >/dev/null 2>&1 || {
|
|
65
|
+
echo "jira-search: issue-fetcher.sh not found" >&2
|
|
66
|
+
exit 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
ENCODED=$(printf '%s' "$JQL" | python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read()))')
|
|
70
|
+
jira_search "$ENCODED" "$FIELDS" "$MAXR"
|