@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/README.md +56 -4
  3. package/README.tr.md +57 -4
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/token-budget-history.md +22 -0
  7. package/install/_dev-only-files.mjs +1 -0
  8. package/install/codex.mjs +18 -1
  9. package/install/copilot.mjs +17 -1
  10. package/install/templates/multi-agent-autopilot.plist.template +79 -0
  11. package/package.json +1 -1
  12. package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +64 -0
  13. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +181 -0
  14. package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +74 -0
  15. package/pipeline/commands/multi-agent/channels/SKILL.md +41 -12
  16. package/pipeline/commands/multi-agent/help/SKILL.md +41 -35
  17. package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
  18. package/pipeline/commands/multi-agent/sync/SKILL.md +10 -9
  19. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  20. package/pipeline/lib/autopilot-activation.sh +117 -0
  21. package/pipeline/lib/autopilot-state.sh +184 -0
  22. package/pipeline/lib/issue-fetcher.sh +18 -1
  23. package/pipeline/lib/plan-todos.sh +18 -0
  24. package/pipeline/multi-agent-refs/_dev-context.md +10 -0
  25. package/pipeline/multi-agent-refs/analysis/redesign.md +8 -0
  26. package/pipeline/multi-agent-refs/analysis/review.md +9 -0
  27. package/pipeline/multi-agent-refs/android-guide.md +14 -0
  28. package/pipeline/multi-agent-refs/audit-guide.md +12 -0
  29. package/pipeline/multi-agent-refs/backend-guide.md +10 -0
  30. package/pipeline/multi-agent-refs/channels/confluence.md +11 -0
  31. package/pipeline/multi-agent-refs/channels/issue-comment.md +12 -0
  32. package/pipeline/multi-agent-refs/channels/jira.md +90 -20
  33. package/pipeline/multi-agent-refs/channels/pr-review-actions.md +13 -0
  34. package/pipeline/multi-agent-refs/channels/pr.md +76 -19
  35. package/pipeline/multi-agent-refs/component-dispatch.md +11 -0
  36. package/pipeline/multi-agent-refs/component-generation.md +11 -0
  37. package/pipeline/multi-agent-refs/conventions-defaults.md +15 -0
  38. package/pipeline/multi-agent-refs/cross-cli-contract.md +49 -5
  39. package/pipeline/multi-agent-refs/features/analysis-jira.md +11 -0
  40. package/pipeline/multi-agent-refs/features/design-conformance.md +10 -0
  41. package/pipeline/multi-agent-refs/features/doctor.md +10 -0
  42. package/pipeline/multi-agent-refs/features/external-context-injection.md +7 -0
  43. package/pipeline/multi-agent-refs/features/jira-context.md +9 -0
  44. package/pipeline/multi-agent-refs/features/model-fallback.md +10 -0
  45. package/pipeline/multi-agent-refs/features/skill-conformance.md +13 -0
  46. package/pipeline/multi-agent-refs/features/url-enrichment.md +9 -0
  47. package/pipeline/multi-agent-refs/features/visual-evidence.md +61 -7
  48. package/pipeline/multi-agent-refs/generate-issue.md +7 -0
  49. package/pipeline/multi-agent-refs/issue-jira-triad.md +9 -0
  50. package/pipeline/multi-agent-refs/knowledge.md +6 -0
  51. package/pipeline/multi-agent-refs/multi-repo-integration-build.md +13 -0
  52. package/pipeline/multi-agent-refs/phases/modes.md +7 -0
  53. package/pipeline/multi-agent-refs/phases/operations.md +9 -0
  54. package/pipeline/multi-agent-refs/phases/phase-0-init.md +2 -2
  55. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +17 -15
  56. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +1 -1
  57. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +1 -1
  58. package/pipeline/multi-agent-refs/phases.md +11 -0
  59. package/pipeline/multi-agent-refs/picker-contract.md +12 -0
  60. package/pipeline/multi-agent-refs/platform-parity.md +10 -0
  61. package/pipeline/multi-agent-refs/progress-contract.md +10 -0
  62. package/pipeline/multi-agent-refs/readiness-review.md +7 -1
  63. package/pipeline/multi-agent-refs/rules.md +3 -11
  64. package/pipeline/multi-agent-refs/setup/firebase.md +9 -0
  65. package/pipeline/multi-agent-refs/swiftui-guide.md +17 -0
  66. package/pipeline/multi-agent-refs/tracker-contract.md +44 -0
  67. package/pipeline/multi-agent-refs/web-guide.md +10 -0
  68. package/pipeline/multi-agent-refs/wiki-capture.md +11 -0
  69. package/pipeline/schemas/autopilot-config.schema.json +149 -0
  70. package/pipeline/schemas/prefs.schema.json +4 -0
  71. package/pipeline/schemas/token-budget.json +10 -19
  72. package/pipeline/scripts/autopilot-arming.mjs +147 -0
  73. package/pipeline/scripts/autopilot-intake.mjs +387 -0
  74. package/pipeline/scripts/autopilot-menubar.swift +361 -0
  75. package/pipeline/scripts/autopilot-runner.mjs +354 -0
  76. package/pipeline/scripts/autopilot-status.sh +213 -0
  77. package/pipeline/scripts/capture-evidence.sh +79 -11
  78. package/pipeline/scripts/gen-ref-toc.mjs +279 -0
  79. package/pipeline/scripts/jira-search.sh +70 -0
  80. package/pipeline/scripts/phase-tracker.sh +134 -12
  81. package/pipeline/scripts/probe-evidence-capability.sh +27 -3
  82. package/pipeline/scripts/run-ui-tests.sh +113 -4
  83. package/pipeline/skills/.skill-manifest.json +16 -4
  84. package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +67 -0
  85. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +146 -0
  86. package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +64 -0
  87. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +62 -11
  88. 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, visualEvidence.maxAttachmentMb and
42
- # visualEvidence.maxVideoSeconds. One node call: three separate ones cost three
43
- # process starts on every phase boundary.
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
- ' "$PREFS" 2>/dev/null || printf 'VISUAL_EVIDENCE_ENABLED=true\nMAX_ATTACHMENT_MB=10\nMAX_VIDEO_SECONDS=60\n'
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"