@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
@@ -180,6 +180,10 @@ review_one_repo() {
180
180
  title=$(jq -r '.title' <<<"$pr")
181
181
  url=$(jq -r '.url' <<<"$pr")
182
182
 
183
+ # Advance the cursor candidate for every PR seen (including skips), so a
184
+ # PR that keeps its reviewed head SHA is not re-fetched on every poll.
185
+ if [ "$updated_at" \> "$newest_iso" ]; then newest_iso="$updated_at"; fi
186
+
183
187
  # Did we already review THIS head SHA?
184
188
  local prev_sha
185
189
  prev_sha=$(jq -r --arg n "$pr_num" '.reviewed_prs[$n] // empty' <<<"$state")
@@ -196,12 +200,18 @@ review_one_repo() {
196
200
  # (Claude Code session or future webhook handler). review-watch only
197
201
  # produces the dispatch list - it does not run the LLM itself, because
198
202
  # token cost decisions stay with the user.
199
- state=$(jq --arg n "$pr_num" --arg s "$head_sha" --arg t "$updated_at" \
200
- '.reviewed_prs[$n] = $s | .last_seen_iso = $t' <<<"$state")
201
-
202
- if [ "$updated_at" \> "$newest_iso" ]; then newest_iso="$updated_at"; fi
203
+ state=$(jq --arg n "$pr_num" --arg s "$head_sha" \
204
+ '.reviewed_prs[$n] = $s' <<<"$state")
203
205
  done < <(jq -c '.[]' <<<"$prs_json")
204
206
 
207
+ # Persist the NEWEST updatedAt seen this scan - the previous per-PR
208
+ # assignment stamped whichever PR the search returned last, which moves
209
+ # the cursor backwards whenever results are not oldest-first and causes
210
+ # permanent re-scans. newest_iso only ever grows (seeded from last_seen).
211
+ if [ -n "$newest_iso" ] && [ "$newest_iso" != "$last_seen" ]; then
212
+ state=$(jq --arg t "$newest_iso" '.last_seen_iso = $t' <<<"$state")
213
+ fi
214
+
205
215
  save_state "$repo" "$state"
206
216
  }
207
217
 
@@ -218,7 +228,13 @@ case "$CMD" in
218
228
  while IFS= read -r r; do [ -n "$r" ] && REPOS+=("$r"); done < <(read_repos_from_prefs)
219
229
  fi
220
230
  [ "${#REPOS[@]}" -eq 0 ] && { err "no repos configured (prefs.reviewWatch.repos)"; exit 2; }
221
- for r in "${REPOS[@]}"; do review_one_repo "$r"; done
231
+ # Per-repo failures (transient gh/network errors) must not kill the run
232
+ # under set -e; log, continue, and reflect them in the exit code.
233
+ RC=0
234
+ for r in "${REPOS[@]}"; do
235
+ review_one_repo "$r" || { err "scan failed for $r (continuing)"; RC=3; }
236
+ done
237
+ exit "$RC"
222
238
  ;;
223
239
  --watch)
224
240
  require_jq
@@ -227,7 +243,11 @@ case "$CMD" in
227
243
  [ "$interval" -lt 30 ] && interval=30 # GitHub rate limit guard
228
244
  printf 'review-watch: starting loop (interval=%ds)\n' "$interval"
229
245
  while :; do
230
- while IFS= read -r r; do [ -n "$r" ] && review_one_repo "$r"; done < <(read_repos_from_prefs)
246
+ # A transient failure on one repo must not kill the watch loop.
247
+ while IFS= read -r r; do
248
+ [ -n "$r" ] || continue
249
+ review_one_repo "$r" || err "scan failed for $r (continuing)"
250
+ done < <(read_repos_from_prefs)
231
251
  sleep "$interval"
232
252
  done
233
253
  ;;
@@ -61,6 +61,23 @@ usage() {
61
61
 
62
62
  shadow_dir() { printf '%s/%s' "$SHADOW_ROOT" "$1"; }
63
63
 
64
+ # Task ids become path components under $SHADOW_ROOT, so an unvalidated id
65
+ # like ".." would make prune's `rm -rf` walk out of the shadow tree and
66
+ # delete ~/.claude/state. Allow only [A-Za-z0-9._-]+ and explicitly reject
67
+ # ".", "..", empty, and anything containing a slash.
68
+ require_task_id() {
69
+ case "${1:-}" in
70
+ "" | . | ..)
71
+ err "invalid task id: '${1:-}' (empty, '.' and '..' are rejected)"
72
+ exit 2
73
+ ;;
74
+ *[!A-Za-z0-9._-]*)
75
+ err "invalid task id: '$1' (allowed characters: A-Z a-z 0-9 . _ -)"
76
+ exit 2
77
+ ;;
78
+ esac
79
+ }
80
+
64
81
  # Wrap git so .git lives in $SHADOW_DIR but worktree is the project root.
65
82
  sg() {
66
83
  local sd="$1" wt="$2"; shift 2
@@ -74,6 +91,7 @@ iso_now() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
74
91
  do_init() {
75
92
  local task_id="${1:-}" project_root="${2:-}"
76
93
  [ -z "$task_id" ] || [ -z "$project_root" ] && { err "usage: init <task-id> <project-root>"; exit 1; }
94
+ require_task_id "$task_id"
77
95
  [ ! -d "$project_root" ] && { err "project root not a directory: $project_root"; exit 1; }
78
96
  local sd; sd=$(shadow_dir "$task_id")
79
97
  if [ -d "$sd/.git" ]; then
@@ -83,12 +101,16 @@ do_init() {
83
101
  mkdir -p "$sd"
84
102
  # Bare-ish: .git inside $sd, worktree is the project root.
85
103
  git init --quiet "$sd" >/dev/null
86
- # Mirror the project's .gitignore so we don't snapshot node_modules / Pods.
104
+ # Ignore rules must live in $GIT_DIR/info/exclude: the work tree is the
105
+ # project root, so a .gitignore placed next to the shadow .git is never
106
+ # read by git. Seed the exclude file with the project's own .gitignore
107
+ # (protects snapshots even if the project file is deleted mid-run) plus
108
+ # forced excludes for build artifacts the project may not ignore.
109
+ mkdir -p "$sd/.git/info"
87
110
  if [ -f "$project_root/.gitignore" ]; then
88
- cp "$project_root/.gitignore" "$sd/.gitignore"
111
+ cat "$project_root/.gitignore" >> "$sd/.git/info/exclude"
89
112
  fi
90
- # Also exclude these from snapshots even if .gitignore omits them.
91
- cat >> "$sd/.gitignore" <<EOF
113
+ cat >> "$sd/.git/info/exclude" <<EOF
92
114
  node_modules/
93
115
  Pods/
94
116
  .build/
@@ -111,6 +133,7 @@ do_snapshot() {
111
133
  local task_id="${1:-}" project_root="${2:-}" label="${3:-}"
112
134
  [ -z "$task_id" ] || [ -z "$project_root" ] || [ -z "$label" ] && {
113
135
  err "usage: snapshot <task-id> <project-root> <label>"; exit 1; }
136
+ require_task_id "$task_id"
114
137
  local sd; sd=$(shadow_dir "$task_id")
115
138
  [ ! -d "$sd/.git" ] && { err "shadow not initialized for $task_id - run 'init' first"; exit 1; }
116
139
  sg "$sd" "$project_root" add -A >/dev/null 2>&1 || true
@@ -129,6 +152,7 @@ do_snapshot() {
129
152
  do_list() {
130
153
  local task_id="${1:-}"
131
154
  [ -z "$task_id" ] && { err "usage: list <task-id>"; exit 1; }
155
+ require_task_id "$task_id"
132
156
  local sd; sd=$(shadow_dir "$task_id")
133
157
  [ ! -d "$sd/.git" ] && { err "shadow not initialized for $task_id"; exit 1; }
134
158
  # Use the embedded `iso:` line from the commit message body so the listing
@@ -141,12 +165,28 @@ do_restore() {
141
165
  local task_id="${1:-}" project_root="${2:-}" sha="${3:-}" mode="${4:---files}"
142
166
  [ -z "$task_id" ] || [ -z "$project_root" ] || [ -z "$sha" ] && {
143
167
  err "usage: restore <task-id> <project-root> <sha> [--files|--state|--both]"; exit 1; }
168
+ require_task_id "$task_id"
144
169
  local sd; sd=$(shadow_dir "$task_id")
145
170
  [ ! -d "$sd/.git" ] && { err "shadow not initialized for $task_id"; exit 1; }
146
171
  case "$mode" in
147
172
  --files|--both)
148
173
  sg "$sd" "$project_root" checkout --quiet "$sha" -- . \
149
174
  || { err "git checkout failed"; exit 3; }
175
+ # `checkout <sha> -- .` restores tracked content but leaves files that
176
+ # did not exist in the snapshot. Remove both classes of leftovers so a
177
+ # restore is a full rollback, staying strictly inside the work tree:
178
+ # a) files committed in snapshots AFTER $sha (added between sha..HEAD)
179
+ # b) files created since the last snapshot (still untracked; `clean`
180
+ # respects the info/exclude rules, so ignored trees are untouched)
181
+ local extra
182
+ extra=$(sg "$sd" "$project_root" diff --name-only --diff-filter=A "$sha" HEAD 2>/dev/null || true)
183
+ if [ -n "$extra" ]; then
184
+ printf '%s\n' "$extra" | while IFS= read -r f; do
185
+ [ -n "$f" ] && rm -f "$project_root/$f"
186
+ done
187
+ fi
188
+ ( cd "$project_root" && \
189
+ git --git-dir="$sd/.git" --work-tree="$project_root" clean -fdq ) || true
150
190
  printf 'shadow-git: restored files to %s\n' "$sha" >&2
151
191
  ;;
152
192
  --state)
@@ -161,6 +201,7 @@ do_restore() {
161
201
  do_prune() {
162
202
  local task_id="${1:-}"; shift || true
163
203
  [ -z "$task_id" ] && { err "usage: prune <task-id> [--older-than-days N]"; exit 1; }
204
+ require_task_id "$task_id"
164
205
  local older=""
165
206
  while [ "$#" -gt 0 ]; do
166
207
  case "$1" in
@@ -110,7 +110,16 @@ cmd_deploy() {
110
110
  fi
111
111
 
112
112
  if [ -z "${VERCEL_TOKEN:-}" ]; then
113
- echo "ERROR: VERCEL_TOKEN not set. Resolve via env or keychain ('~/.claude/lib/credential-store.sh get <vercel-key>')." >&2
113
+ # Route the credential-helper hint through the resolver instead of a
114
+ # hardcoded ~/.claude path so Copilot-only installs get a usable command.
115
+ # The resolver ships alongside this wrapper in lib/; fall back to the
116
+ # per-CLI install locations when running from a different cwd layout.
117
+ # shellcheck source=/dev/null
118
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/credential-store-resolver.sh" 2>/dev/null \
119
+ || . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
120
+ || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
121
+ || true
122
+ echo "ERROR: VERCEL_TOKEN not set. Resolve via env or keychain ('${CRED_STORE:-credential-store.sh} get <vercel-key>')." >&2
114
123
  echo "Hint: vercel CLI accepts the token via env var; do NOT pass --token= on argv (leaks on retry)." >&2
115
124
  exit 1
116
125
  fi
@@ -34,24 +34,52 @@ mkdir -p "$AUDIT_DIR"
34
34
 
35
35
  # Rotate today's file (only if it has content)
36
36
  if [ -s "$AUDIT_FILE" ]; then
37
- STAMP="$(date -u -r "$AUDIT_FILE" +"%Y-%m-%d" 2>/dev/null || date -u +"%Y-%m-%d")"
37
+ # Portable file-mtime read. BSD `date -r` takes epoch SECONDS (not a file
38
+ # path), so the previous `date -r "$AUDIT_FILE"` always errored on macOS and
39
+ # mislabeled every rotated file with today's date.
40
+ case "$(uname -s)" in
41
+ Darwin|*BSD*) MTIME="$(stat -f %m "$AUDIT_FILE" 2>/dev/null || true)" ;;
42
+ *) MTIME="$(stat -c %Y "$AUDIT_FILE" 2>/dev/null || true)" ;;
43
+ esac
44
+ if [ -n "$MTIME" ]; then
45
+ # BSD: date -r <epoch>; GNU: date -d @<epoch>
46
+ STAMP="$(date -u -r "$MTIME" +"%Y-%m-%d" 2>/dev/null \
47
+ || date -u -d "@$MTIME" +"%Y-%m-%d" 2>/dev/null \
48
+ || date -u +"%Y-%m-%d")"
49
+ else
50
+ STAMP="$(date -u +"%Y-%m-%d")"
51
+ fi
38
52
  ROTATED="$AUDIT_DIR/audit.${STAMP}.jsonl"
39
53
 
40
- # Don't clobber a rotated file from earlier today (multiple invocations)
41
- if [ -f "$ROTATED" ]; then
42
- cat "$AUDIT_FILE" >> "$ROTATED"
54
+ # Atomic against concurrent appenders: mv the live inode FIRST, then create
55
+ # a fresh empty live file. Writers appending via >> to the moved inode land
56
+ # in the spool and are preserved; the old cat-then-truncate flow lost any
57
+ # event appended between the copy and the truncate.
58
+ SPOOL="$AUDIT_DIR/.audit.rotate.$$"
59
+ mv "$AUDIT_FILE" "$SPOOL"
60
+ : > "$AUDIT_FILE"
61
+ chmod 600 "$AUDIT_FILE" 2>/dev/null || true
62
+
63
+ # Don't clobber a rotated file from earlier today (multiple invocations).
64
+ # Earlier rotations may already be gzipped; appending a second gzip member
65
+ # is valid (gunzip concatenates members).
66
+ if [ -f "$ROTATED.gz" ] && command -v gzip >/dev/null 2>&1; then
67
+ if gzip -c "$SPOOL" >> "$ROTATED.gz" 2>/dev/null; then
68
+ rm -f "$SPOOL"
69
+ else
70
+ mv "$SPOOL" "$ROTATED"
71
+ fi
72
+ elif [ -f "$ROTATED" ]; then
73
+ cat "$SPOOL" >> "$ROTATED"
74
+ rm -f "$SPOOL"
43
75
  else
44
- mv "$AUDIT_FILE" "$ROTATED"
76
+ mv "$SPOOL" "$ROTATED"
45
77
  fi
46
78
 
47
79
  # gzip immediately to save space
48
- if command -v gzip >/dev/null 2>&1; then
80
+ if [ -f "$ROTATED" ] && command -v gzip >/dev/null 2>&1; then
49
81
  gzip -f "$ROTATED" 2>/dev/null || true
50
82
  fi
51
-
52
- # Reset live file (mode 0600 - sensitive)
53
- : > "$AUDIT_FILE"
54
- chmod 600 "$AUDIT_FILE" 2>/dev/null || true
55
83
  fi
56
84
 
57
85
  # Drop archives older than KEEP_DAYS
@@ -1,11 +1,20 @@
1
1
  #!/usr/bin/env node
2
- // Internal markdown link checker for the user-facing docs surface.
2
+ // Internal markdown link checker for the user-facing docs surface and the
3
+ // pipeline instruction tree.
3
4
  //
4
- // Scope: root *.md + docs/**/*.md + examples/**/*.md. Verifies that every
5
- // relative link target ([text](./path) or [text](path#anchor)) resolves to
6
- // an existing file or directory. External (http/https/mailto) links and
7
- // pure #anchors are skipped - this is a drift gate for file moves/renames,
8
- // not a network crawler.
5
+ // Scope: root *.md + docs/**/*.md + examples/**/*.md + pipeline/**/*.md.
6
+ // Verifies that every relative link target ([text](./path) or
7
+ // [text](path#anchor)) resolves to an existing file or directory. Skipped:
8
+ // external (http/https/mailto) links, pure #anchors, template links whose
9
+ // path contains a {placeholder} variable, and links through shell
10
+ // variables ($HOME, ...) - except $HOME/.claude/multi-agent-refs/, which
11
+ // is checked against its repo source (pipeline/multi-agent-refs/) so a
12
+ // ref rename cannot leave stale runtime links behind. This is a drift
13
+ // gate for file moves/renames, not a network crawler.
14
+ //
15
+ // Excluded trees: pipeline/skills/shared/external/ is vendored community
16
+ // skill content - several packs (the Apple HIG set) index upstream
17
+ // references/ companion files that are deliberately not vendored here.
9
18
 
10
19
  import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
11
20
  import { dirname, join, resolve } from "node:path";
@@ -25,10 +34,13 @@ function mdFiles(dir, acc = []) {
25
34
 
26
35
  const targets = [
27
36
  ...readdirSync(repoRoot).filter((f) => f.endsWith(".md")).map((f) => join(repoRoot, f)),
28
- ...["docs", "examples"].filter((d) => existsSync(join(repoRoot, d))).flatMap((d) => mdFiles(join(repoRoot, d))),
37
+ ...["docs", "examples", "pipeline"].filter((d) => existsSync(join(repoRoot, d))).flatMap((d) => mdFiles(join(repoRoot, d))),
29
38
  // CHANGELOG-archive.md is frozen history - it legitimately references docs
30
39
  // that were deleted in later releases.
31
- ].filter((f) => !f.endsWith("CHANGELOG-archive.md"));
40
+ ]
41
+ .filter((f) => !f.endsWith("CHANGELOG-archive.md"))
42
+ // Vendored community skill packs (see header note).
43
+ .filter((f) => !f.includes(join("pipeline", "skills", "shared", "external") + "/"));
32
44
 
33
45
  const LINK_RE = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
34
46
  const errors = [];
@@ -42,9 +54,22 @@ for (const file of targets) {
42
54
  for (const match of text.matchAll(LINK_RE)) {
43
55
  const raw = match[1];
44
56
  if (/^(https?:|mailto:|#)/.test(raw)) continue;
57
+ // Template placeholders ({fileKey}, {slug}, ...) resolve at runtime,
58
+ // never on disk - skip them.
59
+ if (raw.includes("{") || raw.includes("}")) continue;
45
60
  const target = raw.split("#")[0];
46
61
  if (target === "") continue;
47
- const resolved = resolve(dirname(file), decodeURIComponent(target));
62
+ let resolved;
63
+ const refsPrefix = "$HOME/.claude/multi-agent-refs/";
64
+ if (target.startsWith(refsPrefix)) {
65
+ // Runtime install path with a repo source - check the source tree.
66
+ resolved = join(repoRoot, "pipeline", "multi-agent-refs", target.slice(refsPrefix.length));
67
+ } else if (target.includes("$") || target.startsWith("~")) {
68
+ // Other shell-variable / home paths resolve at runtime only.
69
+ continue;
70
+ } else {
71
+ resolved = resolve(dirname(file), decodeURIComponent(target));
72
+ }
48
73
  if (!existsSync(resolved)) {
49
74
  errors.push(`${file.slice(repoRoot.length + 1)}: broken link -> ${raw}`);
50
75
  }
@@ -85,7 +85,6 @@ function loadDiff() {
85
85
  if (!existsSync(DIFF_FILE)) throw new Error(`diff file not found: ${DIFF_FILE}`);
86
86
  return readFileSync(DIFF_FILE, "utf8");
87
87
  }
88
- BASE = resolveBase();
89
88
  return git(["diff", "--unified=0", `${BASE}...${HEAD}`]);
90
89
  }
91
90
 
@@ -295,6 +294,10 @@ function buildRow(stat, addedLines, allChangedPaths) {
295
294
  }
296
295
 
297
296
  function main() {
297
+ // Resolve the base ref ONCE, before any git diff runs. loadNumstat() and
298
+ // loadDiff() both interpolate BASE; resolving it lazily inside loadDiff()
299
+ // used to leave BASE=null for the numstat call (git diff null...HEAD).
300
+ if (!DIFF_FILE) BASE = resolveBase();
298
301
  const numstat = loadNumstat();
299
302
  if (numstat.length === 0) {
300
303
  process.stderr.write("diff-risk-score: empty diff\n");
@@ -138,7 +138,16 @@ if (body.trim().length === 0) {
138
138
  const successRe = userRegExp(args["success-pattern"], DEFAULT_MARKERS[claim].success, "success");
139
139
  const failureRe = userRegExp(args["failure-pattern"], DEFAULT_MARKERS[claim].failure, "failure");
140
140
 
141
- const showsFailure = failureRe.test(body);
141
+ // Zero-count summaries ("0 failed", "0 tests failed out of 10", "0 failures")
142
+ // are success statements, not failure markers. The default \bFAILED\b marker
143
+ // would otherwise flip a passing log to fail - and failure is decisive. Only
144
+ // applied when the DEFAULT failure marker is in play; a caller-supplied
145
+ // --failure-pattern is matched against the raw body untouched.
146
+ const ZERO_FAIL_SUMMARY = /\b0\s+(?:\w+\s+){0,2}(?:failed|failures?|failing)\b/gi;
147
+ const usingDefaultFailure = !args["failure-pattern"] || args["failure-pattern"] === true;
148
+ const failureBody = usingDefaultFailure ? body.replace(ZERO_FAIL_SUMMARY, "") : body;
149
+
150
+ const showsFailure = failureRe.test(failureBody);
142
151
  const showsSuccess = successRe.test(body);
143
152
 
144
153
  // Failure is DECISIVE. A log that shows a definitive failure marker cannot
@@ -1,17 +1,17 @@
1
1
  .claude/CLAUDE.md 1
2
2
  .claude/agents 8
3
3
  .claude/commands 44
4
- .claude/lib 24
4
+ .claude/lib 25
5
5
  .claude/multi-agent-preferences.json 1
6
6
  .claude/multi-agent-refs 51
7
7
  .claude/rules 12
8
8
  .claude/schemas 23
9
- .claude/scripts 180
9
+ .claude/scripts 184
10
10
  .claude/settings.json 1
11
11
  .claude/skills 428
12
12
  .copilot/agents 8
13
13
  .copilot/copilot-instructions.md 1
14
- .copilot/lib 24
14
+ .copilot/lib 25
15
15
  .copilot/schemas 23
16
- .copilot/scripts 180
16
+ .copilot/scripts 184
17
17
  .copilot/skills 467
@@ -17,11 +17,11 @@ fi
17
17
  echo "Ne tür bir secret kaydedeceksin?"
18
18
  echo " 1) Personal Access Token / API Key"
19
19
  echo " 2) JSON dosyası (service account vb.)"
20
- read -p "Seçim (1/2): " CHOICE
20
+ read -rp "Seçim (1/2): " CHOICE
21
21
 
22
22
  case "$CHOICE" in
23
23
  1)
24
- read -sp "Token'ı yapıştır (gizli yazılır): " SECRET
24
+ read -rsp "Token'ı yapıştır (gizli yazılır): " SECRET
25
25
  echo ""
26
26
  if [ -z "$SECRET" ]; then
27
27
  echo "Hata: Token boş olamaz."
@@ -29,7 +29,7 @@ case "$CHOICE" in
29
29
  fi
30
30
  ;;
31
31
  2)
32
- read -p "JSON dosya yolunu gir: " JSON_PATH
32
+ read -rp "JSON dosya yolunu gir: " JSON_PATH
33
33
  # Kullanıcının girdiği tırnak işaretlerini temizle
34
34
  JSON_PATH="${JSON_PATH//\'/}"
35
35
  JSON_PATH="${JSON_PATH//\"/}"
@@ -53,20 +53,24 @@ case "$CHOICE" in
53
53
  ;;
54
54
  esac
55
55
 
56
- CRED="$HOME/.claude/lib/credential-store.sh"
56
+ # Locate the credential helper via the resolver so Copilot-only installs work.
57
+ # shellcheck disable=SC1090,SC1091
58
+ . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
59
+ || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
60
+ || { echo "Hata: credential-store bulunamadi. Kurulum: npx @mmerterden/multi-agent-pipeline install"; exit 1; }
61
+ CRED="$CRED_STORE"
57
62
 
58
63
  "$CRED" delete "$SERVICE_NAME" >/dev/null 2>&1 || true
59
- "$CRED" set "$SERVICE_NAME" "$SECRET"
60
-
61
- if [ $? -eq 0 ]; then
64
+ # Secret goes through stdin ("set <key> -") so it never appears on argv.
65
+ if printf '%s' "$SECRET" | "$CRED" set "$SERVICE_NAME" -; then
62
66
  echo "Kaydedildi: $SERVICE_NAME (platform: $("$CRED" platform))"
63
67
  echo ""
64
68
  echo "Okumak icin:"
65
- echo " ~/.claude/lib/credential-store.sh get \"$SERVICE_NAME\""
69
+ echo " $CRED get \"$SERVICE_NAME\""
66
70
  if [ "$CHOICE" = "2" ]; then
67
71
  echo ""
68
72
  echo "JSON'a geri cevirmek icin:"
69
- echo " ~/.claude/lib/credential-store.sh get \"$SERVICE_NAME\" | base64 -d"
73
+ echo " $CRED get \"$SERVICE_NAME\" | base64 -d"
70
74
  fi
71
75
  else
72
76
  echo "Hata: Kaydedilemedi."
@@ -1,19 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  // Frontmatter linter for every SKILL.md shipped by the pipeline.
3
3
  //
4
- // Until v10.0.0 the 217 SKILL.md files were counted by smokes but never
4
+ // Until v10.0.0 the shipped SKILL.md files were counted by smokes but never
5
5
  // content-checked, so a skill could ship without the frontmatter that
6
6
  // Claude Code / Copilot CLI need to register it. This gate enforces the
7
7
  // minimal contract; it is zero-dependency (line-based YAML subset) so it
8
8
  // runs inside `npm test` and ci-lite without installs.
9
9
  //
10
- // Contract per SKILL.md:
10
+ // Contract per SKILL.md (errors - fail the gate):
11
11
  // - frontmatter block present (--- ... ---) starting at line 1
12
12
  // - name: required, lowercase kebab-case (underscores tolerated for
13
13
  // legacy skills already published under a snake_case name)
14
14
  // - description: required, non-empty, single line, <= 1024 chars
15
15
  // - user-invocable: when present, must be true or false
16
16
  // - platform: when present, must be ios | android | common | all
17
+ //
18
+ // Spec-conformance signals (warnings - never fail the gate):
19
+ // - name should match the agent-skills spec pattern
20
+ // (lowercase letters/digits/hyphens, <= 64 chars)
21
+ // - description should carry a routing/trigger clause ("use when ...")
22
+ // so the model can decide when to load the skill
23
+ // - a body over ~500 lines with no companion-file reference should be
24
+ // split into references/ files loaded on demand
17
25
 
18
26
  import { readFileSync } from "node:fs";
19
27
  import { execSync } from "node:child_process";
@@ -61,6 +69,16 @@ for (const file of files) {
61
69
  fail(file, "missing required field: name");
62
70
  } else if (!/^[a-z0-9]+([-_][a-z0-9]+)*$/.test(fields.name)) {
63
71
  fail(file, `name is not lowercase kebab/snake-case: "${fields.name}"`);
72
+ } else {
73
+ // Spec conformance (warn-only): the agent-skills spec allows lowercase
74
+ // letters, digits, and hyphens, max 64 chars. Snake_case passes the
75
+ // legacy error check above but is out of spec.
76
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(fields.name)) {
77
+ warn(file, `name is outside the agent-skills spec pattern (hyphens only): "${fields.name}"`);
78
+ }
79
+ if (fields.name.length > 64) {
80
+ warn(file, `name exceeds the spec's 64-char limit (${fields.name.length})`);
81
+ }
64
82
  }
65
83
 
66
84
  if (!("description" in fields)) {
@@ -81,6 +99,26 @@ for (const file of files) {
81
99
  if (!isBlockScalar && words < 6) {
82
100
  warn(file, `description is terse (${words} words) - say what it does AND when to use it`);
83
101
  }
102
+ // Routing quality (warn-only): without a trigger clause the model has no
103
+ // signal for WHEN to load the skill. Heuristic phrase match; block
104
+ // scalars are skipped for the same parser reason as above.
105
+ const ROUTING_RE = /\b(use (this|it|when|for|whenever)|when (the|a|an|you|asked|working|building|implementing|writing|reviewing)|triggers?|invoked?( when| for)?|applies when|activated?)\b/i;
106
+ if (!isBlockScalar && !ROUTING_RE.test(desc)) {
107
+ warn(file, "description has no routing/trigger clause (e.g. \"use when ...\")");
108
+ }
109
+ }
110
+
111
+ // Body size (warn-only): the agent-skills guidance is to keep SKILL.md
112
+ // lean and push detail into companion files loaded on demand. A ~500+
113
+ // line body that references no companion file loads whole every time.
114
+ const body = raw.slice(end + 4);
115
+ const bodyLines = body.split("\n").length;
116
+ if (bodyLines > 500) {
117
+ const referencesCompanion =
118
+ /\]\((?!https?:|mailto:|#)[^)]+\)/.test(body) || /`[^`\n]*\/[^`\n]*\.(md|sh|mjs|json|py|txt)`/.test(body);
119
+ if (!referencesCompanion) {
120
+ warn(file, `body is ${bodyLines} lines with no companion-file reference - consider splitting into references/`);
121
+ }
84
122
  }
85
123
 
86
124
  if ("user-invocable" in fields && !["true", "false"].includes(fields["user-invocable"])) {
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // migrate-prefs.mjs - migrates multi-agent-preferences.json v2.0.0 v2.1.0
2
+ // migrate-prefs.mjs - migrates multi-agent-preferences.json to the current
3
+ // target schema (see TARGET_VERSION below).
3
4
  //
4
5
  // Usage:
5
6
  // node migrate-prefs.mjs [--dry-run] [--file <path>]
6
7
  //
7
- // - Idempotent: safe to run multiple times. v2.1.0 files pass through unchanged.
8
+ // - Idempotent: safe to run multiple times. Target-version files pass through unchanged.
8
9
  // - Atomic: writes to a .tmp file and rename()s. No partial writes.
9
10
  // - Non-destructive: renames gitIdentities → identities, preserves everything else.
10
11
  // - Fills defaults for new v2.1.0 fields.
@@ -15,15 +16,33 @@ import os from "node:os";
15
16
 
16
17
  const DEFAULT_FILE = path.join(os.homedir(), ".claude", "multi-agent-preferences.json");
17
18
 
19
+ // Single source of truth for the migration target version. Referenced by the
20
+ // migrate() bump chain AND every user-facing message, so they cannot drift.
21
+ const TARGET_VERSION = "2.3.0";
22
+
23
+ const USAGE = "Usage: migrate-prefs.mjs [--dry-run] [--file <path>]";
24
+
18
25
  function parseArgs(argv) {
19
26
  const args = { file: DEFAULT_FILE, dryRun: false };
20
27
  for (let i = 2; i < argv.length; i++) {
21
28
  const a = argv[i];
22
29
  if (a === "--dry-run") args.dryRun = true;
23
- else if (a === "--file") args.file = argv[++i];
24
- else if (a === "--help" || a === "-h") {
25
- console.log("Usage: migrate-prefs.mjs [--dry-run] [--file <path>]");
30
+ else if (a === "--file") {
31
+ const v = argv[++i];
32
+ if (v === undefined) {
33
+ console.error("ERROR: --file requires a path argument");
34
+ console.error(USAGE);
35
+ process.exit(64);
36
+ }
37
+ args.file = v;
38
+ } else if (a === "--help" || a === "-h") {
39
+ console.log(USAGE);
26
40
  process.exit(0);
41
+ } else {
42
+ // A typo like --dry-rnu must never fall through to a real write.
43
+ console.error(`ERROR: unknown argument: ${a}`);
44
+ console.error(USAGE);
45
+ process.exit(64);
27
46
  }
28
47
  }
29
48
  return args;
@@ -142,7 +161,7 @@ function migrate(prefs) {
142
161
  // v2.3.0 is the v7.4.0 target - adds optional `accounts[]` (per-account host
143
162
  // overrides) and `recentAccounts[]` (LRU for autopilot account picker).
144
163
  // Both default to empty arrays; old prefs continue to validate without them.
145
- const TARGET = "2.3.0";
164
+ const TARGET = TARGET_VERSION;
146
165
  if (out.schemaVersion === TARGET) {
147
166
  // already on target - skip version bump, but still run sub-migrations below
148
167
  } else if (out.schemaVersion === "2.2.0") {
@@ -371,7 +390,7 @@ function main() {
371
390
  const { out, changes, alreadyMigrated } = migrate(prefs);
372
391
 
373
392
  if (alreadyMigrated) {
374
- console.log(`✓ already v2.1.0 - no changes: ${args.file}`);
393
+ console.log(`✓ already v${TARGET_VERSION} - no changes: ${args.file}`);
375
394
  process.exit(0);
376
395
  }
377
396