@mmerterden/multi-agent-pipeline 11.3.2 → 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 (73) hide show
  1. package/CHANGELOG.md +141 -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 +113 -11
  7. package/install/copilot.mjs +75 -6
  8. package/package.json +4 -10
  9. package/pipeline/commands/multi-agent/SKILL.md +5 -1
  10. package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -1
  11. package/pipeline/commands/multi-agent/help/SKILL.md +6 -2
  12. package/pipeline/commands/multi-agent/review/SKILL.md +36 -4
  13. package/pipeline/commands/multi-agent/review-issue/SKILL.md +22 -0
  14. package/pipeline/commands/multi-agent/review-jira/SKILL.md +22 -0
  15. package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
  16. package/pipeline/lib/account-resolver.sh +61 -23
  17. package/pipeline/lib/channels-multi-repo.sh +7 -2
  18. package/pipeline/lib/count-lib.sh +27 -0
  19. package/pipeline/lib/credential-store.sh +23 -6
  20. package/pipeline/lib/extract-conventions.sh +27 -21
  21. package/pipeline/lib/fetch-confluence.sh +25 -13
  22. package/pipeline/lib/fetch-crashlytics.sh +30 -8
  23. package/pipeline/lib/fetch-fortify.sh +11 -2
  24. package/pipeline/lib/fetch-swagger.sh +18 -7
  25. package/pipeline/lib/figma-mcp-refresh.sh +26 -11
  26. package/pipeline/lib/figma-screenshot.sh +11 -2
  27. package/pipeline/lib/issue-fetcher.sh +31 -6
  28. package/pipeline/lib/multi-repo-pipeline.sh +84 -20
  29. package/pipeline/lib/plan-todos.sh +12 -3
  30. package/pipeline/lib/post-pr-review.sh +5 -3
  31. package/pipeline/lib/repo-cache.sh +53 -9
  32. package/pipeline/lib/review-watch.sh +26 -6
  33. package/pipeline/lib/shadow-git.sh +45 -4
  34. package/pipeline/lib/vercel-deploy.sh +10 -1
  35. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
  36. package/pipeline/multi-agent-refs/readiness-review.md +65 -0
  37. package/pipeline/scripts/audit-log-rotate.sh +38 -10
  38. package/pipeline/scripts/check-md-links.mjs +34 -9
  39. package/pipeline/scripts/diff-risk-score.mjs +4 -1
  40. package/pipeline/scripts/evidence-gate.mjs +10 -1
  41. package/pipeline/scripts/fixtures/install-layout.tsv +7 -7
  42. package/pipeline/scripts/fixtures/pack-expected-count.txt +1 -0
  43. package/pipeline/scripts/keychain-save.sh +13 -9
  44. package/pipeline/scripts/lint-skills.mjs +40 -2
  45. package/pipeline/scripts/migrate-prefs.mjs +26 -7
  46. package/pipeline/scripts/phase-tracker.sh +71 -0
  47. package/pipeline/scripts/pre-commit-check.sh +39 -0
  48. package/pipeline/scripts/scan-skills.sh +217 -127
  49. package/pipeline/scripts/smoke-bitbucket-contract.sh +21 -5
  50. package/pipeline/scripts/smoke-fetchers-offline.sh +382 -0
  51. package/pipeline/scripts/smoke-generate-issue.sh +3 -3
  52. package/pipeline/scripts/smoke-install-layout.sh +11 -3
  53. package/pipeline/scripts/smoke-lib-scripts.sh +54 -1
  54. package/pipeline/scripts/smoke-pack-contents.sh +140 -0
  55. package/pipeline/scripts/smoke-plugin-validate.sh +64 -0
  56. package/pipeline/scripts/smoke-review-readiness.sh +91 -0
  57. package/pipeline/scripts/smoke-shadow-git.sh +48 -1
  58. package/pipeline/scripts/smoke-skill-authoring.sh +1 -1
  59. package/pipeline/scripts/smoke-workflow-audit.sh +69 -0
  60. package/pipeline/scripts/smoke-wrapper-preservation.sh +68 -0
  61. package/pipeline/scripts/test-gap-scan.mjs +12 -1
  62. package/pipeline/scripts/triage-memory.mjs +10 -1
  63. package/pipeline/scripts/uninstall.mjs +105 -36
  64. package/pipeline/scripts/update-issue-progress.sh +60 -41
  65. package/pipeline/scripts/write-state.mjs +14 -4
  66. package/pipeline/skills/.skill-manifest.json +13 -5
  67. package/pipeline/skills/.skills-index.json +20 -2
  68. package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +6 -2
  69. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +19 -10
  70. package/pipeline/skills/shared/core/multi-agent-review-issue/SKILL.md +25 -0
  71. package/pipeline/skills/shared/core/multi-agent-review-jira/SKILL.md +25 -0
  72. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  73. package/pipeline/skills/skills-index.md +4 -2
@@ -61,7 +61,10 @@ resolve_state() {
61
61
 
62
62
  write_state() {
63
63
  local path="$1" content="$2"
64
- local tmp; tmp=$(mktemp)
64
+ # mktemp NEXT TO the target: a $TMPDIR temp file can live on a different
65
+ # filesystem than $HOME, where mv degrades to copy+unlink and loses the
66
+ # atomic-rename guarantee.
67
+ local tmp; tmp=$(mktemp "${path}.XXXXXX")
65
68
  printf '%s\n' "$content" > "$tmp"
66
69
  mv "$tmp" "$path"
67
70
  }
@@ -93,6 +96,12 @@ do_set() {
93
96
  echo "plan-todos: input must be an object with .title (string) and .todos (array)" >&2
94
97
  exit 2
95
98
  fi
99
+ # Per-item validation: every todo needs a string .id and .task, otherwise
100
+ # downstream jq like `.id + "\t" + .task` crashes on (null + string).
101
+ if ! jq -e '.todos | all(type == "object" and (.id | type == "string") and (.task | type == "string"))' <<<"$plan_blob" >/dev/null 2>&1; then
102
+ echo "plan-todos: every todo must be an object with string .id and .task fields" >&2
103
+ exit 2
104
+ fi
96
105
  local state_path; state_path=$(resolve_state "$task_id")
97
106
  local now; now=$(iso_now)
98
107
  local new
@@ -209,7 +218,7 @@ do_next() {
209
218
  )
210
219
  )
211
220
  | (.[0] // empty)
212
- | if . == null or . == "" then "" else .id + "\t" + .task end
221
+ | if . == null or . == "" then "" else (.id // "") + "\t" + (.task // "") end
213
222
  ' "$state_path"
214
223
  }
215
224
 
@@ -229,7 +238,7 @@ do_list() {
229
238
  skipped: "- [/]",
230
239
  failed: "- [!]"
231
240
  } as $marks
232
- | ($marks[.status] // "- [ ]") + " **" + .id + "** " + .task
241
+ | ($marks[.status] // "- [ ]") + " **" + (.id // "") + "** " + (.task // "")
233
242
  + (if (.notes // "") != "" then " _(" + .notes + ")_" else "" end)
234
243
  + (if (.failureReason // "") != "" then " _**failed:** " + .failureReason + "_" else "" end)
235
244
  + (if (.skipReason // "") != "" then " _**skipped:** " + .skipReason + "_" else "" end))
@@ -75,9 +75,11 @@ ITERATION=$(jq -r '.review.iterationNumber // 1' "$AGENT_STATE")
75
75
  # --- Decision ---------------------------------------------------------------
76
76
 
77
77
  # Single jq pass - group accepted findings by severity into tab-separated counts.
78
+ # `.review.findings // []` so a null/absent findings array (clean review)
79
+ # cannot raise "Cannot iterate over null" under set -e.
78
80
  read -r ACCEPTED_BLOCKING ACCEPTED_IMPORTANT < <(
79
81
  jq -r '
80
- [.review.findings[] | select(.status == "accepted")] as $a
82
+ [(.review.findings // [])[] | select(.status == "accepted")] as $a
81
83
  | [($a | map(select(.severity == "blocking")) | length),
82
84
  ($a | map(select(.severity == "important")) | length)]
83
85
  | @tsv
@@ -242,7 +244,7 @@ post_github() {
242
244
  fi
243
245
 
244
246
  if [ "$DECISION" = "needs_work" ]; then
245
- jq -c '.review.findings[] | select(.status == "accepted" and (.severity == "blocking" or .severity == "important"))' "$AGENT_STATE" \
247
+ jq -c '(.review.findings // [])[] | select(.status == "accepted" and (.severity == "blocking" or .severity == "important"))' "$AGENT_STATE" \
246
248
  | while read -r finding; do
247
249
  local sev path line issue_b64 fix_b64 rule_id_b64 issue fix rule_id body fingerprint
248
250
  IFS=$'\t' read -r sev path line issue_b64 fix_b64 rule_id_b64 < <(
@@ -333,7 +335,7 @@ post_bitbucket_server() {
333
335
  fi
334
336
 
335
337
  if [ "$DECISION" = "needs_work" ]; then
336
- jq -c '.review.findings[] | select(.status == "accepted" and (.severity == "blocking" or .severity == "important"))' "$AGENT_STATE" \
338
+ jq -c '(.review.findings // [])[] | select(.status == "accepted" and (.severity == "blocking" or .severity == "important"))' "$AGENT_STATE" \
337
339
  | while read -r finding; do
338
340
  local sev path line issue_b64 fix_b64 rule_id_b64 issue fix rule_id body payload http fingerprint
339
341
  IFS=$'\t' read -r sev path line issue_b64 fix_b64 rule_id_b64 < <(
@@ -59,6 +59,36 @@ if [ $REFRESH -eq 0 ] && cache_fresh; then
59
59
  exit 0
60
60
  fi
61
61
 
62
+ # Refresh staging: providers write to $CACHE_TMP, then commit_cache validates
63
+ # and mv's it into place. Writing straight to $CACHE_FILE truncated the cache
64
+ # BEFORE the fetch ran, so any failure left an empty file that cache_fresh
65
+ # then served for the whole TTL.
66
+ CACHE_TMP="${CACHE_FILE}.tmp.$$"
67
+ cleanup_tmp() { rm -f "$CACHE_TMP" "$CACHE_TMP.list" 2>/dev/null || true; }
68
+ trap cleanup_tmp EXIT
69
+
70
+ # Validate the staged file (non-empty valid JSON) and move it into place.
71
+ # On failure keep the previous cache and serve it with a warning; hard-fail
72
+ # only when there is no previous cache to fall back to.
73
+ commit_cache() {
74
+ if [ -s "$CACHE_TMP" ] \
75
+ && python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$CACHE_TMP" 2>/dev/null; then
76
+ mv "$CACHE_TMP" "$CACHE_FILE"
77
+ return 0
78
+ fi
79
+ rm -f "$CACHE_TMP"
80
+ if [ -f "$CACHE_FILE" ]; then
81
+ echo "WARN: repo-cache refresh failed for $PROVIDER/$SCOPE; serving previous cache" >&2
82
+ return 0
83
+ fi
84
+ echo "ERR: repo-cache refresh failed for $PROVIDER/$SCOPE and no previous cache exists" >&2
85
+ exit 3
86
+ }
87
+
88
+ # Basic-auth via a curl config fed through process substitution so the
89
+ # credential never appears in argv (argv is visible to `ps`).
90
+ bb_auth_cfg() { printf 'user = "%s:%s"\n' "$1" "$2"; }
91
+
62
92
  case "$PROVIDER" in
63
93
  github)
64
94
  if ! command -v gh >/dev/null 2>&1; then
@@ -100,20 +130,32 @@ for r in data:
100
130
  "provider": "github"
101
131
  })
102
132
  print(json.dumps(out))
103
- ' > "$CACHE_FILE"
133
+ ' > "$CACHE_TMP" || true
134
+ commit_cache
104
135
  ;;
105
136
 
106
137
  bitbucket)
107
138
  : "${BB_TOKEN_KEY:?BB_TOKEN_KEY required for bitbucket}"
108
139
  : "${BB_HOST:?BB_HOST required (e.g. bitbucket.example.com)}"
109
- BB_USER="${BB_USER:-${BB_USER_KEY:+$("$HOME/.claude/lib/credential-store.sh" get "$BB_USER_KEY" 2>/dev/null || true)}}"
110
- BB_TOKEN=$("$HOME/.claude/lib/credential-store.sh" get "$BB_TOKEN_KEY" 2>/dev/null || true)
140
+ # Resolve the credential helper via the shared resolver (works from the
141
+ # repo checkout and from both install trees) - never hardcode the path.
142
+ # A pre-set CRED_STORE (tests, custom installs) is honored as-is.
143
+ if [ -z "${CRED_STORE:-}" ]; then
144
+ # shellcheck disable=SC1090,SC1091
145
+ . "$(cd "$(dirname "$0")" && pwd)/credential-store-resolver.sh" 2>/dev/null \
146
+ || . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
147
+ || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
148
+ || { echo "ERR: credential helper not found" >&2; exit 3; }
149
+ fi
150
+ BB_USER="${BB_USER:-${BB_USER_KEY:+$("$CRED_STORE" get "$BB_USER_KEY" 2>/dev/null || true)}}"
151
+ BB_TOKEN=$("$CRED_STORE" get "$BB_TOKEN_KEY" 2>/dev/null || true)
111
152
  if [ -z "$BB_TOKEN" ]; then
112
153
  echo "ERR: bitbucket token not found in credential store ($BB_TOKEN_KEY)" >&2
113
154
  exit 3
114
155
  fi
115
156
  # Bitbucket Server REST: /rest/api/1.0/projects/{key}/repos?limit=200
116
- curl -sf -u "${BB_USER:-bot}:${BB_TOKEN}" \
157
+ # Auth via curl config file (-K) so the token never appears on argv.
158
+ curl -sf -K <(bb_auth_cfg "${BB_USER:-bot}" "$BB_TOKEN") \
117
159
  "https://${BB_HOST}/rest/api/1.0/projects/${SCOPE}/repos?limit=200" \
118
160
  | python3 -c '
119
161
  import json,sys
@@ -147,7 +189,8 @@ for r in vals:
147
189
  "provider": "bitbucket"
148
190
  })
149
191
  print(json.dumps(out))
150
- ' > "$CACHE_FILE"
192
+ ' > "$CACHE_TMP" || true
193
+ commit_cache
151
194
  ;;
152
195
 
153
196
  local)
@@ -161,7 +204,7 @@ print(json.dumps(out))
161
204
  if [ -d "$SCOPE" ]; then
162
205
  ROOTS="$SCOPE"
163
206
  fi
164
- : > "$CACHE_FILE.tmp"
207
+ : > "$CACHE_TMP.list"
165
208
  for root in $ROOTS; do
166
209
  [ -d "$root" ] || continue
167
210
  # -prune avoids descending into already-found .git trees (nested submodules
@@ -178,7 +221,7 @@ print(json.dumps(out))
178
221
  # truly offline-only or a clone with an upstream we just chose
179
222
  # to treat as local.
180
223
  remote=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
181
- printf '%s\t%s\t%s\n' "$name" "$repo_dir" "$remote" >> "$CACHE_FILE.tmp"
224
+ printf '%s\t%s\t%s\n' "$name" "$repo_dir" "$remote" >> "$CACHE_TMP.list"
182
225
  done
183
226
  done
184
227
  python3 -c '
@@ -218,8 +261,9 @@ for raw in open(sys.argv[1]):
218
261
  "provider": "local"
219
262
  })
220
263
  print(json.dumps(out))
221
- ' "$CACHE_FILE.tmp" > "$CACHE_FILE"
222
- rm -f "$CACHE_FILE.tmp"
264
+ ' "$CACHE_TMP.list" > "$CACHE_TMP" || true
265
+ rm -f "$CACHE_TMP.list"
266
+ commit_cache
223
267
  ;;
224
268
 
225
269
  *)
@@ -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
@@ -6,13 +6,13 @@
6
6
 
7
7
  ---
8
8
 
9
- ## 1. Command Inventory (36 commands)
9
+ ## 1. Command Inventory (38 commands)
10
10
 
11
11
  ```
12
12
  analysis, analysis-resolve, autopilot, build-optimize, channels, create-jira, delete, dev,
13
13
  dev-autopilot, dev-local, dev-local-autopilot, diff-explain, finish, garbage-collect,
14
14
  help, issue, jira, kill, language, local,
15
- local-autopilot, log, manual-test, prune-logs, purge, refactor, resume, review,
15
+ local-autopilot, log, manual-test, prune-logs, purge, refactor, resume, review, review-issue, review-jira,
16
16
  scan, search, setup, stack, status, sync, test, update
17
17
  ```
18
18
 
@@ -23,7 +23,7 @@ Categories:
23
23
  - **Full 8-phase modes**: `autopilot`, `local`, `local-autopilot`
24
24
  - **Fast modes** (Init -> Dev(Opus) -> Commit -> Report): `dev`, `dev-autopilot`, `dev-local`, `dev-local-autopilot`
25
25
  - **Tail modes** (run the pipeline tail over already-done local work): `finish`
26
- - **Ops commands** (one-shot, no worktree): `status`, `log`, `kill`, `purge`, `delete`, `resume`, `review`, `analysis`, `analysis-resolve`, `build-optimize`, `channels`, `scan`, `search`, `diff-explain`, `garbage-collect`, `prune-logs`
26
+ - **Ops commands** (one-shot, no worktree): `status`, `log`, `kill`, `purge`, `delete`, `resume`, `review`, `review-jira`, `review-issue`, `analysis`, `analysis-resolve`, `build-optimize`, `channels`, `scan`, `search`, `diff-explain`, `garbage-collect`, `prune-logs`
27
27
  - **Meta-ops**: `setup`, `sync`, `update`, `help`, `refactor`, `test`, `stack`, `manual-test`, `language`
28
28
 
29
29
  > **Inventory drift is a contract violation.** Adding a slash command under `pipeline/commands/multi-agent/` without updating this list + its counterpart Copilot dir (`pipeline/skills/shared/core/multi-agent-<cmd>/`) is a merge blocker. `smoke-commands-skills-parity.sh` enforces command ↔ skill directory parity; `smoke-cross-cli-behavior.sh` enforces behavior parity. This doc is the authoritative command list - bump the count + table together.
@@ -245,7 +245,7 @@ For clipboard ops, callers still gate with `if command -v pbpaste >/dev/null; th
245
245
 
246
246
  This contract is validated by:
247
247
 
248
- - `smoke-cross-cli-behavior.sh` - asserts all 36 commands behave identically, pulls from Section 2 (placeholder vocab), Section 5 (argument parsing), Section 6 (output formats); also regression-locks the 6-persona agent deployment
248
+ - `smoke-cross-cli-behavior.sh` - asserts all 38 commands behave identically, pulls from Section 2 (placeholder vocab), Section 5 (argument parsing), Section 6 (output formats); also regression-locks the 6-persona agent deployment
249
249
  - `smoke-commands-skills-parity.sh` (50 assertions) - enforces colon-form command ↔ dash-form skill directory parity
250
250
  - `smoke-compliance-skills.sh` (45 assertions) - enforces store-compliance skill catalog + 4 consumer wiring
251
251
  - `smoke-personal-data.sh` - extended in 0.5.5 to treat deprecated placeholders (`{github-username}`, `{your-website}`, `{website-repo}`) as leaks; adds `mmerterden` to public-handle blocklist for generic docs
@@ -0,0 +1,65 @@
1
+ # Readiness Review (shared flow for review-jira + review-issue)
2
+
3
+ Assess whether a tracker item (Jira issue or GitHub issue) is READY to hand to the multi-agent pipeline, list the concrete gaps, and - after confirmation - post them back as a comment on the item so the reporter can fix it. Read-only on code: no worktree, no branch, no commits, no dev chaining. This is the inverse of `/multi-agent:create-jira` (which authors a well-formed item); here we grade an existing one.
4
+
5
+ Both `/multi-agent:review-jira` and `/multi-agent:review-issue` execute this flow; only the provider (fetch + comment endpoint + picker) differs.
6
+
7
+ ## Language
8
+ Instruction prose here is English. The verdict shown in chat and the comment body follow `prefs.global.outputLanguage` (the comment is authored for the user's team, same exception as create-jira). AskUserQuestion `label`/`header` stay English; `question`/`description` follow `outputLanguage`.
9
+
10
+ ## Step 1 - resolve the item
11
+ - With an argument: `review-jira KEY-123` / `https://<jira>/browse/KEY-123`; `review-issue #N` / `repo#N` / GitHub issue URL.
12
+ - With no argument: offer the picker - review-jira reuses the `jira` command's issue list (`jira/SKILL.md` JQL, assignee=currentUser, open); review-issue reuses the `issue` command's list (`gh issue list`). Single-select (one item reviewed per run; re-run for more).
13
+
14
+ ## Step 2 - fetch + base maturity
15
+ Run `$HOME/.claude/lib/issue-fetcher.sh` (the same fetcher Phase 0 uses). It returns a descriptor with `title`, `type`, `status`, `description`, and `maturity = { score (0..100), blockers[], warnings[], summary }`. Reuse that maturity verbatim as the baseline; never re-fetch through MCP.
16
+
17
+ ## Step 3 - readiness rubric (extends maturity)
18
+ Maturity is generic; add these pipeline-readiness dimensions by reading the fetched `title` + `description` (no invention - only judge what is written). Each is `pass` / `gap`:
19
+
20
+ | Dimension | Ready when | Gap signal |
21
+ |---|---|---|
22
+ | Scope is testable | the change is bounded + verifiable (what "done" means is stated) | vague ("improve X"), no measurable outcome |
23
+ | Acceptance criteria / DoD | AC or DoD present (Task/Story/Feature) | none (aligns with maturity `no_acceptance_criteria`) |
24
+ | Reproduction | Bugs have steps + expected/actual | none (aligns with maturity `no_repro_steps`) |
25
+ | Design reference | UI work links a Figma frame or attaches a screenshot | UI implied but no design source |
26
+ | API contract | backend/integration work names endpoints or a Swagger/OpenAPI ref | data/contract implied, none given |
27
+ | Platform / stack signal | the target stack is inferable (iOS / Android / frontend / backend) | ambiguous - Phase 1 could not route |
28
+ | Dependencies | blocking deps/links are called out when they exist | hidden dependency likely |
29
+
30
+ Only emit a dimension as a gap when its trigger applies (do not demand an API contract on a copy-change task). Merge: `gaps = maturity.blockers + maturity.warnings + rubric gaps`.
31
+
32
+ ## Step 4 - verdict (chat, always)
33
+ Print a compact verdict; reuse the maturity blocker-vs-warning severity contract (same as the Phase 0 maturity check):
34
+
35
+ ```
36
+ Readiness: KEY-123 - score 60/100 - NEEDS WORK
37
+ BLOCK Description is empty
38
+ WARN No acceptance criteria detected
39
+ GAP UI work but no design reference
40
+ GAP Target stack ambiguous
41
+ ```
42
+ Verdict rule: any blocker -> NOT READY (blocking); else any warning/gap -> NEEDS WORK; else READY. Mirrors `jira/SKILL.md` maturity handling: blockers would halt a real run, warnings would prompt.
43
+
44
+ ## Step 5 - post the comment (confirmed)
45
+ Posting to a tracker is outward-facing - confirm first (autopilot auto-posts):
46
+
47
+ ```
48
+ question (outputLanguage): "Eksiklikleri {KEY|#N} üzerine yorum olarak ekleyeyim mi?"
49
+ header (English): "Post readiness comment"
50
+ options:
51
+ - label "Post comment", description (outputLanguage): "Eksiklik listesini item'a yorum olarak yaz"
52
+ - label "Chat only", description (outputLanguage): "Sadece sohbet özeti, item'a dokunma"
53
+ default: option 1
54
+ ```
55
+
56
+ Comment body: a short "Multi-agent readiness review" heading, the score + verdict, then the gap list grouped Blockers / Warnings / Gaps, each with a one-line fix suggestion. Tone rules (same as `channels/issue-comment.md`): no AI/Claude/Copilot attribution, no "generated by", no em-dash/section-sign, plain ASCII; if referencing another item use `Ref: #N` never `Closes/Fixes`. READY items get a short "ready to pick up" confirmation instead of a gap list.
57
+
58
+ Provider dispatch:
59
+ - **Jira** (`review-jira`): post via `$HOME/.claude/multi-agent-refs/channels/jira.md` comment path - `POST /rest/api/2/issue/{id}/comment`, Bearer token from `keychainMapping.jira` via `credential-store.sh`, UTF-8 verbatim (`jq --rawfile` + `curl --data-binary @payload.json`), markdown->wiki conversion.
60
+ - **GitHub** (`review-issue`): `gh issue comment "$N" --repo "$org/$repo" --body-file <file>` per `channels/issue-comment.md` (auth via the Phase 0 `gh` account).
61
+
62
+ On post failure, surface the failing endpoint on stderr; the chat verdict remains the source of truth.
63
+
64
+ ## Not in scope
65
+ No status transitions, no assignment changes, no issue creation, no code review (that is `/multi-agent:review`). This command only reads the item and comments its readiness.
@@ -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