@mmerterden/multi-agent-pipeline 11.4.0 → 11.5.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 (54) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +1 -0
  3. package/index.js +9 -2
  4. package/install/_common.mjs +107 -5
  5. package/install/_telemetry.mjs +5 -23
  6. package/install/claude.mjs +70 -10
  7. package/install/copilot.mjs +75 -6
  8. package/package.json +4 -10
  9. package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -1
  10. package/pipeline/lib/account-resolver.sh +61 -23
  11. package/pipeline/lib/channels-multi-repo.sh +7 -2
  12. package/pipeline/lib/count-lib.sh +27 -0
  13. package/pipeline/lib/credential-store.sh +23 -6
  14. package/pipeline/lib/extract-conventions.sh +27 -21
  15. package/pipeline/lib/fetch-confluence.sh +25 -13
  16. package/pipeline/lib/fetch-crashlytics.sh +30 -8
  17. package/pipeline/lib/fetch-fortify.sh +11 -2
  18. package/pipeline/lib/fetch-swagger.sh +18 -7
  19. package/pipeline/lib/figma-mcp-refresh.sh +26 -11
  20. package/pipeline/lib/figma-screenshot.sh +11 -2
  21. package/pipeline/lib/issue-fetcher.sh +31 -6
  22. package/pipeline/lib/multi-repo-pipeline.sh +84 -20
  23. package/pipeline/lib/plan-todos.sh +12 -3
  24. package/pipeline/lib/post-pr-review.sh +5 -3
  25. package/pipeline/lib/repo-cache.sh +53 -9
  26. package/pipeline/lib/review-watch.sh +26 -6
  27. package/pipeline/lib/shadow-git.sh +45 -4
  28. package/pipeline/lib/vercel-deploy.sh +10 -1
  29. package/pipeline/scripts/audit-log-rotate.sh +38 -10
  30. package/pipeline/scripts/check-md-links.mjs +34 -9
  31. package/pipeline/scripts/diff-risk-score.mjs +4 -1
  32. package/pipeline/scripts/evidence-gate.mjs +10 -1
  33. package/pipeline/scripts/fixtures/install-layout.tsv +4 -4
  34. package/pipeline/scripts/fixtures/pack-expected-count.txt +1 -0
  35. package/pipeline/scripts/keychain-save.sh +13 -9
  36. package/pipeline/scripts/lint-skills.mjs +40 -2
  37. package/pipeline/scripts/migrate-prefs.mjs +26 -7
  38. package/pipeline/scripts/phase-tracker.sh +71 -0
  39. package/pipeline/scripts/pre-commit-check.sh +39 -0
  40. package/pipeline/scripts/scan-skills.sh +217 -127
  41. package/pipeline/scripts/smoke-bitbucket-contract.sh +21 -5
  42. package/pipeline/scripts/smoke-fetchers-offline.sh +382 -0
  43. package/pipeline/scripts/smoke-install-layout.sh +11 -3
  44. package/pipeline/scripts/smoke-lib-scripts.sh +54 -1
  45. package/pipeline/scripts/smoke-pack-contents.sh +140 -0
  46. package/pipeline/scripts/smoke-plugin-validate.sh +64 -0
  47. package/pipeline/scripts/smoke-shadow-git.sh +48 -1
  48. package/pipeline/scripts/smoke-skill-authoring.sh +1 -1
  49. package/pipeline/scripts/smoke-workflow-audit.sh +69 -0
  50. package/pipeline/scripts/test-gap-scan.mjs +12 -1
  51. package/pipeline/scripts/triage-memory.mjs +10 -1
  52. package/pipeline/scripts/uninstall.mjs +105 -36
  53. package/pipeline/scripts/update-issue-progress.sh +60 -41
  54. package/pipeline/scripts/write-state.mjs +14 -4
@@ -22,7 +22,9 @@ import {
22
22
  copyFile,
23
23
  countFiles,
24
24
  ensureDir,
25
+ ensureRealDir,
25
26
  isDryRun,
27
+ removePipelineAgentFiles,
26
28
  wipeDir,
27
29
  writeFile,
28
30
  } from "./_common.mjs";
@@ -61,17 +63,55 @@ export function installCopilot(ctx) {
61
63
  installSkills({ pipelineSrc, dest: COPILOT_SKILLS, indexOnly, useSymlinks, platformFlag });
62
64
  }
63
65
 
66
+ /** Start of the pipeline-managed span in copilot-instructions.md. */
67
+ export const INSTRUCTIONS_START_MARKER = "# Multi-Agent Development Pipeline";
68
+
69
+ /**
70
+ * Explicit end marker written after the pipeline section (v11.4.1+). Updates
71
+ * replace only the start..end span, so anything the user appends AFTER the
72
+ * pipeline section survives. Pre-marker files fall back to
73
+ * `legacyTrailingContent` bounding.
74
+ */
75
+ export const INSTRUCTIONS_END_MARKER =
76
+ "<!-- multi-agent-pipeline:copilot-instructions:end -->";
77
+
78
+ /**
79
+ * Legacy files (written before the end marker existed) have no explicit
80
+ * terminator. Bound the pipeline span at the next top-level "# " heading
81
+ * after the start marker when one exists OUTSIDE fenced code blocks (the
82
+ * pipeline body carries bash comments like "# Bootstrap once ..." inside
83
+ * fences that must not be mistaken for headings); otherwise the span runs
84
+ * to EOF, which matches the pre-marker behavior.
85
+ *
86
+ * @param {string} section - file content from the start marker onward
87
+ * @returns {string} user content trailing the pipeline span ("" if none)
88
+ */
89
+ export function legacyTrailingContent(section) {
90
+ const lines = section.split("\n");
91
+ let inFence = false;
92
+ for (let i = 1; i < lines.length; i++) {
93
+ if (/^\s*(```|~~~)/.test(lines[i])) {
94
+ inFence = !inFence;
95
+ continue;
96
+ }
97
+ if (!inFence && /^# /.test(lines[i])) return lines.slice(i).join("\n");
98
+ }
99
+ return "";
100
+ }
101
+
64
102
  function writeInstructionsFile(path) {
65
103
  const pipelineSection = generateCopilotInstructions();
104
+ const managedBlock =
105
+ pipelineSection.trimEnd() + "\n\n" + INSTRUCTIONS_END_MARKER + "\n";
66
106
 
67
107
  if (!existsSync(path)) {
68
- writeFile(path, pipelineSection + "\n");
108
+ writeFile(path, managedBlock);
69
109
  console.log(" -> Created copilot-instructions.md with pipeline");
70
110
  return;
71
111
  }
72
112
 
73
113
  let existing = readFileSync(path, "utf-8");
74
- const marker = "# Multi-Agent Development Pipeline";
114
+ const marker = INSTRUCTIONS_START_MARKER;
75
115
 
76
116
  // Drift cleanup (v5.6.2): detect pre-v5.0 pipeline sections that predate
77
117
  // the stable marker and strip them. These blocks ("## Multi-Agent Task
@@ -103,12 +143,26 @@ function writeInstructionsFile(path) {
103
143
  }
104
144
 
105
145
  if (existing.includes(marker)) {
106
- const before = existing.split(marker)[0].trimEnd();
107
- writeFile(path, before + "\n\n" + pipelineSection + "\n");
146
+ const startIdx = existing.indexOf(marker);
147
+ const before = existing.slice(0, startIdx).trimEnd();
148
+ const fromStart = existing.slice(startIdx);
149
+ // Replace only the start..end span. User content appended AFTER the
150
+ // pipeline section (below the end marker, or below the next top-level
151
+ // heading in legacy files) is preserved.
152
+ const endIdx = fromStart.indexOf(INSTRUCTIONS_END_MARKER);
153
+ const trailing =
154
+ endIdx >= 0
155
+ ? fromStart.slice(endIdx + INSTRUCTIONS_END_MARKER.length)
156
+ : legacyTrailingContent(fromStart);
157
+ let out = before.length > 0 ? before + "\n\n" : "";
158
+ out += managedBlock;
159
+ const trailingClean = trailing.replace(/^[\r\n]+/, "").trimEnd();
160
+ if (trailingClean.length > 0) out += "\n" + trailingClean + "\n";
161
+ writeFile(path, out);
108
162
  const suffix = cleaned ? " (also scrubbed pre-v5.0 drift section)" : "";
109
163
  console.log(` -> Updated existing pipeline section in copilot-instructions.md${suffix}`);
110
164
  } else {
111
- writeFile(path, existing.trimEnd() + "\n\n" + pipelineSection + "\n");
165
+ writeFile(path, existing.trimEnd() + "\n\n" + managedBlock);
112
166
  console.log(" -> Appended pipeline section to copilot-instructions.md");
113
167
  }
114
168
  }
@@ -117,6 +171,9 @@ function installScripts(pipelineSrc, dest, useSymlinks) {
117
171
  console.log(" [Copilot CLI] Installing scripts...");
118
172
  const scriptsSrc = join(pipelineSrc, "scripts");
119
173
  if (!existsSync(scriptsSrc)) return;
174
+ // Replace a --link-era symlink with a real dir before wiping/copying so the
175
+ // install can never land inside the developer's repo checkout.
176
+ if (!useSymlinks) ensureRealDir(dest);
120
177
  wipeDir(dest);
121
178
  copyDir(scriptsSrc, dest, { exclude: DEV_ONLY_SCRIPTS, useSymlinks });
122
179
  // Count files actually excluded, not the array length: DEV_ONLY_SCRIPTS mixes
@@ -133,7 +190,13 @@ function installAgents(pipelineSrc, dest, useSymlinks) {
133
190
  console.log(" [Copilot CLI] Installing agent definitions...");
134
191
  const agentsSrc = join(pipelineSrc, "agents");
135
192
  if (!existsSync(agentsSrc)) return;
136
- wipeDir(dest);
193
+ // ~/.copilot/agents also holds user-authored agent files. Never wipe the
194
+ // whole dir; remove only the pipeline-shipped agent files (name set derived
195
+ // from the source tree so it cannot go stale), then copy the fresh set in.
196
+ if (!useSymlinks) {
197
+ ensureRealDir(dest);
198
+ removePipelineAgentFiles(dest, agentsSrc);
199
+ }
137
200
  copyDir(agentsSrc, dest, { useSymlinks });
138
201
  console.log(` -> ${countFiles(agentsSrc)} files copied to ${dest}`);
139
202
  }
@@ -142,6 +205,7 @@ function installSchemas(pipelineSrc, dest, useSymlinks) {
142
205
  console.log(" [Copilot CLI] Installing JSON schemas...");
143
206
  const schemasSrc = join(pipelineSrc, "schemas");
144
207
  if (!existsSync(schemasSrc)) return;
208
+ if (!useSymlinks) ensureRealDir(dest);
145
209
  wipeDir(dest);
146
210
  copyDir(schemasSrc, dest, { useSymlinks });
147
211
  console.log(` -> ${countFiles(schemasSrc)} files copied to ${dest}`);
@@ -151,6 +215,7 @@ function installLib(pipelineSrc, dest, useSymlinks) {
151
215
  console.log(" [Copilot CLI] Installing shell libraries...");
152
216
  const libSrc = join(pipelineSrc, "lib");
153
217
  if (!existsSync(libSrc)) return;
218
+ if (!useSymlinks) ensureRealDir(dest);
154
219
  wipeDir(dest);
155
220
  copyDir(libSrc, dest, { useSymlinks });
156
221
  console.log(` -> ${countFiles(libSrc)} files copied to ${dest}`);
@@ -160,6 +225,10 @@ function installSkills(opts) {
160
225
  const { pipelineSrc, dest, indexOnly, useSymlinks, platformFlag } = opts;
161
226
  console.log(" [Copilot CLI] Installing skills...");
162
227
 
228
+ // Same symlink guard as the other owned trees: never prune or copy through
229
+ // a --link-era symlink into the repo checkout.
230
+ if (!useSymlinks) ensureRealDir(dest);
231
+
163
232
  if (indexOnly) {
164
233
  try {
165
234
  mkdirSync(dest, { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "11.4.0",
3
+ "version": "11.5.0",
4
4
  "description": "8-phase AI development pipeline with full orchestration on Claude Code and Copilot CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -18,7 +18,8 @@
18
18
  "test:unit": "node --test test/*.test.mjs",
19
19
  "test:smoke": "node pipeline/scripts/run-smokes.mjs",
20
20
  "lint:skills": "node pipeline/scripts/lint-skills.mjs",
21
- "test:coverage": "c8 --reporter=text --reporter=lcov node --test test/*.test.mjs && c8 report",
21
+ "test:quick": "node --test test/*.test.mjs && node pipeline/scripts/lint-skills.mjs",
22
+ "test:coverage": "c8 --check-coverage --reporter=text --reporter=lcov node --test test/*.test.mjs && c8 report",
22
23
  "lint": "eslint .",
23
24
  "lint:fix": "eslint . --fix",
24
25
  "format": "prettier --write \"**/*.{js,mjs,json,md,yml}\" --ignore-path .gitignore",
@@ -69,17 +70,10 @@
69
70
  "README.md",
70
71
  "CHANGELOG.md",
71
72
  "LICENSE",
72
- "!pipeline/scripts/figma-placeholder-map.json",
73
- "!pipeline/scripts/sync-figma-source.sh",
74
- "!pipeline/scripts/import-figma-skills.sh",
75
- "!pipeline/scripts/smoke-figma-skill-import.sh",
76
73
  "!pipeline/scripts/smoke-figma-config-schema.sh",
77
74
  "!pipeline/scripts/smoke-personal-data.sh",
78
75
  "!pipeline/scripts/smoke-install-leak-gate.sh",
79
- "!pipeline/scripts/README-figma-smokes.md",
80
- "!pipeline/scripts/.last-figma-sync-plan.json",
81
- "!docs/GENERICITY-REVIEW.md",
82
- "!docs/STABILITY-FIX-PLAN.md"
76
+ "!docs/internal/**"
83
77
  ],
84
78
  "devDependencies": {
85
79
  "@eslint/js": "^10.0.1",
@@ -20,7 +20,7 @@ Phase 7: Report → Jira / Wiki + log + knowledge/memory
20
20
 
21
21
  `--dev local` skips Phase 1 (Analysis), Phase 2 (Planning + Approval Gate), Phase 4 (Review), and Phase 5 (User Test - local/autopilot variants skip the interactive test gate). It differs from `--dev` on two axes: no git worktree is created (development happens directly on the current branch in `$PROJECT_ROOT`), and the interactive User Test phase is skipped.
22
22
 
23
- > **Want the quality tail afterwards?** Since this mode skips Review + Test, run [`/multi-agent:finish`](./finish.md) on the same branch when you're done to add parallel review, a build+test success gate, PR, and a Jira technical-analysis + test-scenario comment - without re-developing.
23
+ > **Want the quality tail afterwards?** Since this mode skips Review + Test, run [`/multi-agent:finish`](../finish/SKILL.md) on the same branch when you're done to add parallel review, a build+test success gate, PR, and a Jira technical-analysis + test-scenario comment - without re-developing.
24
24
 
25
25
  ## When to use it
26
26
 
@@ -63,10 +63,23 @@ except Exception:
63
63
 
64
64
  # 1. Pull every credential entry that looks like one of our token services.
65
65
  # Includes token entries AND username entries (BB needs the user keychain key).
66
- # Uses cross-platform credential-store wrapper (macOS/Windows/Linux backends).
67
- services=$("$HOME/.claude/lib/credential-store.sh" list 2>/dev/null \
68
- | grep -E '_(Github|Jira|Bitbucket|Confluence)_[A-Za-z]+_(Token|Json|Username)$|_Github_Auth_Token$|_Github_Access_Token$|_Jira_Access_Token$|_Bitbucket_Access_Token$|_Bitbucket_Username$|_Confluence_Access_Token$' \
69
- || true)
66
+ # Uses cross-platform credential-store wrapper (macOS/Windows/Linux backends),
67
+ # located via the shared resolver (repo checkout or either install tree) -
68
+ # never a hardcoded path. A missing helper degrades to an empty inventory.
69
+ # A pre-set CRED_STORE (tests, custom installs) is honored as-is.
70
+ if [ -z "${CRED_STORE:-}" ]; then
71
+ # shellcheck disable=SC1090,SC1091
72
+ . "$(cd "$(dirname "$0")" && pwd)/credential-store-resolver.sh" 2>/dev/null \
73
+ || . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
74
+ || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
75
+ || true
76
+ fi
77
+ services=""
78
+ if [ -n "${CRED_STORE:-}" ]; then
79
+ services=$("$CRED_STORE" list 2>/dev/null \
80
+ | grep -E '_(Github|Jira|Bitbucket|Confluence)_[A-Za-z]+_(Token|Json|Username)$|_Github_Auth_Token$|_Github_Access_Token$|_Jira_Access_Token$|_Bitbucket_Access_Token$|_Bitbucket_Username$|_Confluence_Access_Token$' \
81
+ || true)
82
+ fi
70
83
 
71
84
  # 2. Compute unique prefixes (everything before the provider segment).
72
85
  prefixes=$(printf '%s\n' "$services" \
@@ -114,14 +127,6 @@ emit_accounts() {
114
127
  [ $match -eq 0 ] && continue
115
128
  fi
116
129
 
117
- # Provider list
118
- local prov_json=""
119
- [ -n "$gh_token" ] && prov_json="${prov_json}\"github\","
120
- [ -n "$jira_token" ] && prov_json="${prov_json}\"jira\","
121
- [ -n "$bb_token" ] && prov_json="${prov_json}\"bitbucket\","
122
- [ -n "$conf_token" ] && prov_json="${prov_json}\"confluence\","
123
- prov_json="${prov_json%,}"
124
-
125
130
  # Work-account heuristic: bitbucket+jira together indicates corporate.
126
131
  local is_work="false"
127
132
  if [ -n "$bb_token" ] && [ -n "$jira_token" ]; then
@@ -134,18 +139,18 @@ emit_accounts() {
134
139
 
135
140
  # Hosts: per-account override (prefs.global.accounts[].jiraHost) wins; falls
136
141
  # back to global.hosts.{jira,bitbucket}; otherwise null. No hardcoded values.
137
- local jira_host="null" bb_host="null"
142
+ local jira_host="" bb_host=""
138
143
  local per_jira per_bb
139
144
  per_jira=$(account_host_override "$short_id" "jiraHost")
140
145
  per_bb=$(account_host_override "$short_id" "bitbucketHost")
141
146
  if [ -n "$jira_token" ]; then
142
- if [ -n "$per_jira" ]; then jira_host="\"$per_jira\""
143
- elif [ -n "$prefs_jira_host" ]; then jira_host="\"$prefs_jira_host\""
147
+ if [ -n "$per_jira" ]; then jira_host="$per_jira"
148
+ elif [ -n "$prefs_jira_host" ]; then jira_host="$prefs_jira_host"
144
149
  fi
145
150
  fi
146
151
  if [ -n "$bb_token" ]; then
147
- if [ -n "$per_bb" ]; then bb_host="\"$per_bb\""
148
- elif [ -n "$prefs_bb_host" ]; then bb_host="\"$prefs_bb_host\""
152
+ if [ -n "$per_bb" ]; then bb_host="$per_bb"
153
+ elif [ -n "$prefs_bb_host" ]; then bb_host="$prefs_bb_host"
149
154
  fi
150
155
  fi
151
156
 
@@ -185,12 +190,45 @@ emit_accounts() {
185
190
  [ $first -eq 0 ] && printf ','
186
191
  first=0
187
192
 
188
- printf '{"id":"%s","label":"%s","prefix":"%s","providers":[%s],"tokens":{"github":"%s","jira":"%s","bitbucket":"%s","confluence":"%s"},"userKeys":{"bitbucket":"%s"},"users":{"github":"%s"},"jiraHost":%s,"bitbucketHost":%s,"isWork":%s}' \
189
- "$short_id" "$label" "$prefix" "$prov_json" \
190
- "$gh_token" "$jira_token" "$bb_token" "$conf_token" \
191
- "$bb_user_key" \
192
- "$gh_user" \
193
- "$jira_host" "$bb_host" "$is_work"
193
+ # Build the entry with python3 from env vars - printf-interpolated JSON
194
+ # has zero escaping, so a quote or backslash in a keychain service name
195
+ # would corrupt the whole array. Values cross into python via the
196
+ # environment, never via string interpolation.
197
+ ACC_ID="$short_id" ACC_LABEL="$label" ACC_PREFIX="$prefix" \
198
+ ACC_GH_TOKEN="$gh_token" ACC_JIRA_TOKEN="$jira_token" \
199
+ ACC_BB_TOKEN="$bb_token" ACC_CONF_TOKEN="$conf_token" \
200
+ ACC_BB_USER_KEY="$bb_user_key" ACC_GH_USER="$gh_user" \
201
+ ACC_JIRA_HOST="$jira_host" ACC_BB_HOST="$bb_host" ACC_IS_WORK="$is_work" \
202
+ python3 - <<'PY'
203
+ import json, os
204
+
205
+ def env(name):
206
+ return os.environ.get(name, "")
207
+
208
+ providers = []
209
+ if env("ACC_GH_TOKEN"): providers.append("github")
210
+ if env("ACC_JIRA_TOKEN"): providers.append("jira")
211
+ if env("ACC_BB_TOKEN"): providers.append("bitbucket")
212
+ if env("ACC_CONF_TOKEN"): providers.append("confluence")
213
+
214
+ print(json.dumps({
215
+ "id": env("ACC_ID"),
216
+ "label": env("ACC_LABEL"),
217
+ "prefix": env("ACC_PREFIX"),
218
+ "providers": providers,
219
+ "tokens": {
220
+ "github": env("ACC_GH_TOKEN"),
221
+ "jira": env("ACC_JIRA_TOKEN"),
222
+ "bitbucket": env("ACC_BB_TOKEN"),
223
+ "confluence": env("ACC_CONF_TOKEN"),
224
+ },
225
+ "userKeys": {"bitbucket": env("ACC_BB_USER_KEY")},
226
+ "users": {"github": env("ACC_GH_USER")},
227
+ "jiraHost": env("ACC_JIRA_HOST") or None,
228
+ "bitbucketHost": env("ACC_BB_HOST") or None,
229
+ "isWork": env("ACC_IS_WORK") == "true",
230
+ }, ensure_ascii=False), end="")
231
+ PY
194
232
  done <<< "$prefixes"
195
233
  printf ']'
196
234
  }
@@ -26,6 +26,11 @@ STATE="${2:-}"
26
26
  }
27
27
  [ ! -f "$STATE" ] && { echo "ERR: state file not found: $STATE" >&2; exit 2; }
28
28
 
29
+ # count_matches: single-line numeric grep -c (the bare `grep -c || echo 0`
30
+ # idiom emits "0" twice on zero matches and breaks the -le comparisons below).
31
+ # shellcheck source=count-lib.sh disable=SC1091
32
+ . "$(cd "$(dirname "$0")" && pwd)/count-lib.sh"
33
+
29
34
  # --- Helpers ----------------------------------------------------------------
30
35
  # Extract the targets array from agent-state.json. Output: TSV with columns
31
36
  # repoName, prUrl, isPrimary (1/0). One target per row.
@@ -156,7 +161,7 @@ do_render_jira() {
156
161
  local urls
157
162
  urls=$(all_pr_urls | tr ' ' '\n' | grep -v '^$' || true)
158
163
  local count
159
- count=$(printf '%s\n' "$urls" | grep -c . || echo 0)
164
+ count=$(printf '%s\n' "$urls" | count_matches .)
160
165
 
161
166
  if [ "$count" -le 1 ]; then
162
167
  cat "$body_file"
@@ -179,7 +184,7 @@ do_render_conf() {
179
184
  local urls
180
185
  urls=$(all_pr_urls | tr ' ' '\n' | grep -v '^$' || true)
181
186
  local count
182
- count=$(printf '%s\n' "$urls" | grep -c . || echo 0)
187
+ count=$(printf '%s\n' "$urls" | count_matches .)
183
188
 
184
189
  if [ "$count" -le 1 ]; then
185
190
  cat "$body_file"
@@ -0,0 +1,27 @@
1
+ #!/bin/bash
2
+ # count-lib.sh - shared counting helper for pipeline shell scripts.
3
+ #
4
+ # Why: `grep -c` prints "0" AND exits 1 when nothing matches, so the common
5
+ # idiom `$(grep -c pattern file || echo 0)` emits TWO lines ("0" plus the
6
+ # fallback "0"). That breaks arithmetic (`$((x + 0\n0))` is a syntax error)
7
+ # and numeric comparisons (`[ "0\n0" -le 1 ]` silently evaluates false).
8
+ #
9
+ # count_matches guarantees single-line numeric output in every case:
10
+ # - match count when grep succeeds
11
+ # - "0" when nothing matches or grep itself errors (missing file, ...)
12
+ #
13
+ # Usage (same arguments as grep -c, file or stdin):
14
+ # n=$(count_matches -E 'pattern' "$file")
15
+ # n=$(printf '%s\n' "$blob" | count_matches .)
16
+ #
17
+ # Source from a sibling script:
18
+ # . "$(cd "$(dirname "$0")" && pwd)/count-lib.sh"
19
+
20
+ count_matches() {
21
+ local n
22
+ n=$(grep -c "$@" 2>/dev/null) || true
23
+ case "$n" in
24
+ '' | *[!0-9]*) n=0 ;;
25
+ esac
26
+ printf '%s\n' "$n"
27
+ }
@@ -8,6 +8,9 @@
8
8
  # Subcommands:
9
9
  # get <key> Read secret value to stdout (empty + exit 1 if missing)
10
10
  # set <key> <value> Store secret (overwrites if exists)
11
+ # set <key> - Same, but read the secret from stdin. Preferred:
12
+ # keeps the secret off this script's argv (visible to
13
+ # ps). A literal "-" value cannot be stored this way.
11
14
  # delete <key> Remove secret
12
15
  # list List all keys (one per line)
13
16
  # platform Print detected platform: macos | windows | linux | unknown
@@ -124,7 +127,11 @@ if (\$c) { \$c.GetNetworkCredential().Password }
124
127
 
125
128
  do_set() {
126
129
  local key="${1:-}" val="${2:-}"
127
- [ -z "$key" ] && { echo "usage: $0 set <key> <value>" >&2; exit 3; }
130
+ [ -z "$key" ] && { echo "usage: $0 set <key> <value|->" >&2; exit 3; }
131
+ # "-" means: read the secret from stdin (keeps it off argv).
132
+ if [ "$val" = "-" ]; then
133
+ val=$(cat)
134
+ fi
128
135
  if delegate_to_python; then
129
136
  # Pass the value via stdin to keep it out of process listings / shell history.
130
137
  printf '%s' "$val" | python3 "$KEYCHAIN_PY" set "$key" -
@@ -132,7 +139,15 @@ do_set() {
132
139
  fi
133
140
  case "$PLATFORM" in
134
141
  macos)
135
- security add-generic-password -s "$key" -a "${USER:-claude}" -w "$val" -U >/dev/null 2>&1
142
+ # `security -i` reads the add command from stdin, so the secret never
143
+ # lands on the `security` argv (visible to ps). printf is a bash
144
+ # builtin, so no argv exposure there either. Double quotes/backslashes
145
+ # are escaped for security's interactive tokenizer.
146
+ local key_sec val_sec
147
+ key_sec=${key//\\/\\\\}; key_sec=${key_sec//\"/\\\"}
148
+ val_sec=${val//\\/\\\\}; val_sec=${val_sec//\"/\\\"}
149
+ printf 'add-generic-password -U -s "%s" -a "%s" -w "%s"\n' \
150
+ "$key_sec" "${USER:-claude}" "$val_sec" | security -i >/dev/null 2>&1
136
151
  ;;
137
152
  linux)
138
153
  require_cmd secret-tool "Install libsecret-tools (see doctor)."
@@ -140,11 +155,13 @@ do_set() {
140
155
  ;;
141
156
  windows)
142
157
  # Escape single quotes for PowerShell single-quoted strings ('' = literal ').
143
- local key_esc="${key//\'/\'\'}" val_esc="${val//\'/\'\'}"
144
- # CredentialManager module: New-StoredCredential
145
- ps_run "
158
+ local key_esc="${key//\'/\'\'}"
159
+ # The secret is piped via stdin ([Console]::In) so it never appears on
160
+ # the PowerShell argv (visible to process listings).
161
+ printf '%s' "$val" | ps_run "
146
162
  \$ErrorActionPreference='Stop'
147
- \$pwd = ConvertTo-SecureString '$val_esc' -AsPlainText -Force
163
+ \$raw = [Console]::In.ReadToEnd()
164
+ \$pwd = ConvertTo-SecureString \$raw -AsPlainText -Force
148
165
  New-StoredCredential -Target '$key_esc' -UserName '${USER:-${USERNAME:-claude}}' -SecurePassword \$pwd -Persist LocalMachine | Out-Null
149
166
  " >/dev/null
150
167
  ;;
@@ -40,6 +40,12 @@ if ! command -v jq >/dev/null 2>&1; then
40
40
  exit 1
41
41
  fi
42
42
 
43
+ # count_matches: single-line numeric grep -c. The bare `grep -c || echo 0`
44
+ # idiom emits "0" twice on zero matches, which turns the $((...)) counter
45
+ # arithmetic below into a silent syntax error that zeroes the bucket.
46
+ # shellcheck source=count-lib.sh disable=SC1091
47
+ . "$(cd "$(dirname "$0")" && pwd)/count-lib.sh"
48
+
43
49
  # ---- global timing budget -----------------------------------------------------
44
50
  SCRIPT_START_EPOCH=$(date +%s)
45
51
  GLOBAL_BUDGET_SEC=180
@@ -672,16 +678,16 @@ bucket_test_method_naming() {
672
678
  [ -z "$f" ] && continue
673
679
  case "$PLATFORM" in
674
680
  ios|android)
675
- underscore_count=$((underscore_count + $(grep -cE 'func +test[A-Za-z0-9]+_[A-Za-z0-9_]+' "$f" 2>/dev/null || echo 0)))
676
- underscore_count=$((underscore_count + $(grep -cE 'fun +[A-Za-z0-9]+_[A-Za-z0-9_]+_[A-Za-z0-9_]+ *\(' "$f" 2>/dev/null || echo 0)))
677
- camel_count=$((camel_count + $(grep -cE 'func +test[A-Z][A-Za-z0-9]+ *\(' "$f" 2>/dev/null || echo 0)))
678
- backtick_count=$((backtick_count + $(grep -cE '`[^`]*`\s*\(' "$f" 2>/dev/null || echo 0)))
681
+ underscore_count=$((underscore_count + $(count_matches -E 'func +test[A-Za-z0-9]+_[A-Za-z0-9_]+' "$f")))
682
+ underscore_count=$((underscore_count + $(count_matches -E 'fun +[A-Za-z0-9]+_[A-Za-z0-9_]+_[A-Za-z0-9_]+ *\(' "$f")))
683
+ camel_count=$((camel_count + $(count_matches -E 'func +test[A-Z][A-Za-z0-9]+ *\(' "$f")))
684
+ backtick_count=$((backtick_count + $(count_matches -E '`[^`]*`\s*\(' "$f")))
679
685
  ;;
680
686
  backend)
681
- underscore_count=$((underscore_count + $(grep -cE '^def +test_[a-z0-9_]+' "$f" 2>/dev/null || echo 0)))
687
+ underscore_count=$((underscore_count + $(count_matches -E '^def +test_[a-z0-9_]+' "$f")))
682
688
  ;;
683
689
  frontend)
684
- it_count=$((it_count + $(grep -cE "(it|test)\\(['\"\`]" "$f" 2>/dev/null || echo 0)))
690
+ it_count=$((it_count + $(count_matches -E "(it|test)\\(['\"\`]" "$f")))
685
691
  ;;
686
692
  esac
687
693
  done <<< "$test_files"
@@ -745,9 +751,9 @@ bucket_accessibility_identifier() {
745
751
  if [ -z "$sample_ids" ]; then empty_bucket; return; fi
746
752
 
747
753
  local dot_count snake_count camel_count
748
- dot_count=$(printf '%s\n' "$sample_ids" | grep -cE '"[a-z]+(\.[a-zA-Z]+)+' 2>/dev/null || echo 0)
749
- snake_count=$(printf '%s\n' "$sample_ids" | grep -cE '"[a-z]+_[a-z_]+' 2>/dev/null || echo 0)
750
- camel_count=$(printf '%s\n' "$sample_ids" | grep -cE '"[a-z]+[A-Z][a-zA-Z]+' 2>/dev/null || echo 0)
754
+ dot_count=$(printf '%s\n' "$sample_ids" | count_matches -E '"[a-z]+(\.[a-zA-Z]+)+')
755
+ snake_count=$(printf '%s\n' "$sample_ids" | count_matches -E '"[a-z]+_[a-z_]+')
756
+ camel_count=$(printf '%s\n' "$sample_ids" | count_matches -E '"[a-z]+[A-Z][a-zA-Z]+')
751
757
  dot_count=${dot_count:-0}; snake_count=${snake_count:-0}; camel_count=${camel_count:-0}
752
758
 
753
759
  local evidence_json
@@ -772,9 +778,9 @@ bucket_accessibility_identifier() {
772
778
  done | head -30 || true)
773
779
  if [ -z "$ids" ]; then empty_bucket; return; fi
774
780
  local snake_count kebab_count camel_count
775
- snake_count=$(printf '%s\n' "$ids" | grep -cE '"[a-z]+_[a-z_]+' || echo 0)
776
- kebab_count=$(printf '%s\n' "$ids" | grep -cE '"[a-z]+-[a-z\-]+' || echo 0)
777
- camel_count=$(printf '%s\n' "$ids" | grep -cE '"[a-z]+[A-Z][a-zA-Z]+' || echo 0)
781
+ snake_count=$(printf '%s\n' "$ids" | count_matches -E '"[a-z]+_[a-z_]+')
782
+ kebab_count=$(printf '%s\n' "$ids" | count_matches -E '"[a-z]+-[a-z\-]+')
783
+ camel_count=$(printf '%s\n' "$ids" | count_matches -E '"[a-z]+[A-Z][a-zA-Z]+')
778
784
  snake_count=${snake_count:-0}; kebab_count=${kebab_count:-0}; camel_count=${camel_count:-0}
779
785
  if [ "$snake_count" -ge "$kebab_count" ] && [ "$snake_count" -ge "$camel_count" ] && [ "$snake_count" -gt 0 ]; then
780
786
  emit_bucket "snake_case testTag" "checkin_continue_button" "$(confidence_for "$snake_count")" "[]" '["kebab","camelCase"]'
@@ -797,8 +803,8 @@ bucket_accessibility_identifier() {
797
803
  done | head -50 || true)
798
804
  if [ -z "$op_ids" ]; then empty_bucket; return; fi
799
805
  local camel_count snake_count
800
- camel_count=$(printf '%s\n' "$op_ids" | grep -cE '[a-z]+[A-Z][A-Za-z]+' || echo 0)
801
- snake_count=$(printf '%s\n' "$op_ids" | grep -cE '[a-z]+_[a-z_]+' || echo 0)
806
+ camel_count=$(printf '%s\n' "$op_ids" | count_matches -E '[a-z]+[A-Z][A-Za-z]+')
807
+ snake_count=$(printf '%s\n' "$op_ids" | count_matches -E '[a-z]+_[a-z_]+')
802
808
  camel_count=${camel_count:-0}; snake_count=${snake_count:-0}
803
809
  if [ "$camel_count" -ge "$snake_count" ] && [ "$camel_count" -gt 0 ]; then
804
810
  emit_bucket "camelCase operationId" "createFoo" "$(confidence_for "$camel_count")" "[]" '["snake_case"]'
@@ -817,9 +823,9 @@ bucket_accessibility_identifier() {
817
823
  done | head -30 || true)
818
824
  if [ -z "$ids" ]; then empty_bucket; return; fi
819
825
  local kebab_count camel_count snake_count
820
- kebab_count=$(printf '%s\n' "$ids" | grep -cE '"[a-z]+-[a-z\-]+' || echo 0)
821
- camel_count=$(printf '%s\n' "$ids" | grep -cE '"[a-z]+[A-Z][a-zA-Z]+' || echo 0)
822
- snake_count=$(printf '%s\n' "$ids" | grep -cE '"[a-z]+_[a-z_]+' || echo 0)
826
+ kebab_count=$(printf '%s\n' "$ids" | count_matches -E '"[a-z]+-[a-z\-]+')
827
+ camel_count=$(printf '%s\n' "$ids" | count_matches -E '"[a-z]+[A-Z][a-zA-Z]+')
828
+ snake_count=$(printf '%s\n' "$ids" | count_matches -E '"[a-z]+_[a-z_]+')
823
829
  kebab_count=${kebab_count:-0}; camel_count=${camel_count:-0}; snake_count=${snake_count:-0}
824
830
  if [ "$kebab_count" -ge "$camel_count" ] && [ "$kebab_count" -ge "$snake_count" ] && [ "$kebab_count" -gt 0 ]; then
825
831
  emit_bucket "kebab-case data-testid" 'data-testid="checkin-continue-button"' "$(confidence_for "$kebab_count")" "[]" '["camelCase","snake"]'
@@ -879,9 +885,9 @@ bucket_localization_key() {
879
885
  if [ -z "$keys" ]; then empty_bucket; return; fi
880
886
 
881
887
  local dot_count snake_count camel_count
882
- dot_count=$(printf '%s\n' "$keys" | grep -cE '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+' || echo 0)
883
- snake_count=$(printf '%s\n' "$keys" | grep -cE '^[a-z][a-z0-9]*(_[a-z0-9]+)+' || echo 0)
884
- camel_count=$(printf '%s\n' "$keys" | grep -cE '^[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*' || echo 0)
888
+ dot_count=$(printf '%s\n' "$keys" | count_matches -E '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+')
889
+ snake_count=$(printf '%s\n' "$keys" | count_matches -E '^[a-z][a-z0-9]*(_[a-z0-9]+)+')
890
+ camel_count=$(printf '%s\n' "$keys" | count_matches -E '^[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*')
885
891
  dot_count=${dot_count:-0}; snake_count=${snake_count:-0}; camel_count=${camel_count:-0}
886
892
 
887
893
  if [ "$dot_count" -ge "$snake_count" ] && [ "$dot_count" -ge "$camel_count" ] && [ "$dot_count" -gt 0 ]; then
@@ -907,7 +913,7 @@ bucket_di_registration() {
907
913
  local resolver_count factory_count manual_count swinject_count
908
914
  manual_count=0; resolver_count=0; factory_count=0; swinject_count=0
909
915
  if [ -n "$configurators" ]; then
910
- manual_count=$(printf '%s\n' "$configurators" | grep -c 'DependencyConfigurator' || echo 0)
916
+ manual_count=$(printf '%s\n' "$configurators" | count_matches 'DependencyConfigurator')
911
917
  fi
912
918
  files=$(run_find -type f -name '*.swift' 2>/dev/null || true)
913
919
  if [ -n "$files" ]; then
@@ -67,15 +67,17 @@ if [ -z "$URL" ] && [ -z "$PAGE_ID" ]; then
67
67
  exit 4
68
68
  fi
69
69
 
70
- # Resolve host + pageId from URL when not given explicitly.
71
- URL_PARSE_INPUT="$URL" URL_HOST_OVERRIDE="$HOST_OVERRIDE" URL_PAGE_ID="$PAGE_ID" \
72
- PARSED=$(python3 - <<'PY'
73
- import os, sys, re
70
+ # Resolve host + pageId from URL when not given explicitly. Inputs are passed
71
+ # as argv: an env prefix on a plain assignment (VAR=x PARSED=$(...)) never
72
+ # reaches the child process, so os.environ would always come back empty.
73
+ PARSED=$(python3 - "$URL" "$HOST_OVERRIDE" "$PAGE_ID" <<'PY'
74
+ import sys, re
74
75
  import urllib.parse as up
75
76
 
76
- url = os.environ.get("URL_PARSE_INPUT", "")
77
- host_override = os.environ.get("URL_HOST_OVERRIDE", "")
78
- explicit_id = os.environ.get("URL_PAGE_ID", "")
77
+ argv = sys.argv[1:] + ["", "", ""]
78
+ url = argv[0]
79
+ host_override = argv[1]
80
+ explicit_id = argv[2]
79
81
 
80
82
  host = host_override
81
83
  page_id = explicit_id
@@ -124,12 +126,22 @@ except Exception:
124
126
  fi
125
127
  [ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Confluence_Access_Token"
126
128
 
127
- TOKEN=$("$HOME/.claude/lib/credential-store.sh" get "$TOKEN_KEY" 2>/dev/null || true)
129
+ # Locate the credential helper via the resolver so Copilot-only installs work.
130
+ # shellcheck disable=SC1090,SC1091
131
+ . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
132
+ || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
133
+ || { printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"confluence","expected_key":"'"$TOKEN_KEY"'"}' >&2; exit 2; }
134
+
135
+ TOKEN=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
128
136
  if [ -z "$TOKEN" ]; then
129
137
  printf '%s\n' '{"status":"blocked","reason":"missing-token","service":"confluence","expected_key":"'"$TOKEN_KEY"'"}' >&2
130
138
  exit 2
131
139
  fi
132
140
 
141
+ # Bearer auth via a curl config fed through process substitution so the token
142
+ # never appears in argv (argv is visible to ps).
143
+ confluence_auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$TOKEN"; }
144
+
133
145
  # Endpoint resolution: Confluence Cloud (<tenant>.atlassian.net/wiki/rest/api)
134
146
  # vs Server/Data Center (<host>/rest/api). The same paths exist on both.
135
147
  case "$HOST" in
@@ -141,14 +153,14 @@ esac
141
153
  if [ -z "$PAGE_ID_RESOLVED" ] && [ -n "$SPACE_KEY" ] && [ -n "$URL" ]; then
142
154
  TITLE_RAW=$(python3 -c "
143
155
  import urllib.parse as up
144
- import os, re
145
- p = up.urlparse(os.environ['URL_PARSE_INPUT'])
156
+ import sys, re
157
+ p = up.urlparse(sys.argv[1])
146
158
  m = re.match(r'/display/[^/]+/(.+)', p.path)
147
159
  print(up.unquote(m.group(1)) if m else '')
148
- " URL_PARSE_INPUT="$URL")
160
+ " "$URL")
149
161
  if [ -n "$TITLE_RAW" ]; then
150
162
  LOOKUP=$(curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
151
- -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \
163
+ -K <(confluence_auth_cfg) -H "Accept: application/json" \
152
164
  --data-urlencode "spaceKey=$SPACE_KEY" \
153
165
  --data-urlencode "title=$TITLE_RAW" \
154
166
  --data-urlencode "expand=" \
@@ -172,7 +184,7 @@ fi
172
184
 
173
185
  # Fetch the page body in storage format.
174
186
  PAGE_JSON=$(curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
175
- -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \
187
+ -K <(confluence_auth_cfg) -H "Accept: application/json" \
176
188
  "$API_BASE/content/$PAGE_ID_RESOLVED?expand=body.storage,space" 2>/dev/null || true)
177
189
 
178
190
  if [ -z "$PAGE_JSON" ]; then