@mmerterden/multi-agent-pipeline 20.3.1 → 20.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 (61) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/docs/ecosystem.md +2 -2
  3. package/docs/facts.json +1 -1
  4. package/install/claude.mjs +35 -0
  5. package/manifest.json +64 -62
  6. package/package.json +1 -1
  7. package/pipeline/agents/android-architect.md +1 -0
  8. package/pipeline/agents/backend-architect.md +1 -0
  9. package/pipeline/agents/bulk-reader.md +1 -0
  10. package/pipeline/agents/code-reviewer.md +2 -1
  11. package/pipeline/agents/dev-critic.md +1 -0
  12. package/pipeline/agents/explorer.md +1 -0
  13. package/pipeline/agents/ios-architect.md +1 -0
  14. package/pipeline/agents/plan-critic.md +1 -0
  15. package/pipeline/agents/security-auditor.md +1 -0
  16. package/pipeline/agents/task-clarifier.md +1 -0
  17. package/pipeline/commands/figma-to-swiftui.md +6 -4
  18. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +1 -1
  19. package/pipeline/commands/multi-agent/jira/SKILL.md +2 -2
  20. package/pipeline/commands/multi-agent/refactor/SKILL.md +1 -1
  21. package/pipeline/commands/multi-agent/review/SKILL.md +1 -1
  22. package/pipeline/commands/multi-agent/setup/SKILL.md +2 -2
  23. package/pipeline/commands/multi-agent/stack/SKILL.md +2 -4
  24. package/pipeline/commands/multi-agent/sync/SKILL.md +2 -2
  25. package/pipeline/commands/multi-agent/update/SKILL.md +6 -11
  26. package/pipeline/lib/redact.mjs +50 -0
  27. package/pipeline/multi-agent-refs/channels/pr.md +2 -2
  28. package/pipeline/multi-agent-refs/features/doctor.md +2 -1
  29. package/pipeline/multi-agent-refs/features/prior-fix-detection.md +1 -1
  30. package/pipeline/multi-agent-refs/features/unattended-security.md +5 -0
  31. package/pipeline/multi-agent-refs/generate-issue.md +9 -9
  32. package/pipeline/multi-agent-refs/outside-the-pipeline.md +2 -2
  33. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  34. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -1
  35. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +4 -4
  36. package/pipeline/multi-agent-refs/setup/repo-discovery.md +2 -2
  37. package/pipeline/schemas/autopilot-config.schema.json +5 -0
  38. package/pipeline/schemas/prefs.schema.json +1 -1
  39. package/pipeline/scripts/_autopilot-outcomes.mjs +2 -0
  40. package/pipeline/scripts/audit-log-rotate.sh +3 -0
  41. package/pipeline/scripts/audit-log.sh +10 -2
  42. package/pipeline/scripts/autopilot-intake.mjs +17 -15
  43. package/pipeline/scripts/autopilot-publish.mjs +16 -1
  44. package/pipeline/scripts/autopilot-runner.mjs +20 -0
  45. package/pipeline/scripts/capture-resume.sh +51 -37
  46. package/pipeline/scripts/check-derived-drift.mjs +1 -1
  47. package/pipeline/scripts/doctor.mjs +21 -0
  48. package/pipeline/scripts/log-metric.sh +5 -0
  49. package/pipeline/scripts/plugin-refresh.sh +92 -0
  50. package/pipeline/scripts/pr-request.mjs +9 -3
  51. package/pipeline/scripts/update-check.sh +73 -21
  52. package/pipeline/scripts/usage-report.mjs +4 -4
  53. package/pipeline/skills/.skill-manifest.json +8 -8
  54. package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -4
  55. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +1 -1
  56. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +2 -2
  57. package/pipeline/skills/shared/core/multi-agent-jira/SKILL.md +2 -2
  58. package/pipeline/skills/shared/core/multi-agent-refactor/SKILL.md +1 -1
  59. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +2 -4
  60. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +1 -1
  61. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +1 -1
@@ -112,7 +112,7 @@ import { OUTCOME } from "./_autopilot-outcomes.mjs";
112
112
  import { commandFor, getAdapter, loadAdapters, resolveStack } from "./_stack-adapter.mjs";
113
113
  import { checkCommit, park, updateState } from "./gate-ledger.mjs";
114
114
  import { LOCKFILES } from "./package-manager.mjs";
115
- import { PROTECTED_BRANCHES, validatePrRequest } from "./pr-request.mjs";
115
+ import { PROTECTED_BRANCHES, hasAutoClose, validatePrRequest } from "./pr-request.mjs";
116
116
  import { summarize } from "./test-summary.mjs";
117
117
 
118
118
  const HERE = import.meta.dirname;
@@ -576,6 +576,20 @@ function checkRemoteBranch(stage, url, branch, headSha, prior) {
576
576
  }
577
577
 
578
578
  /** Gate ledger: the commit hook's requirement, re-checked for every commit in the range. */
579
+ // A closing keyword in a commit message closes the issue when the PR merges,
580
+ // whatever the PR text says, and issues are closed by people, not by a run.
581
+ function checkCommitMessages(stage, commits) {
582
+ for (const sha of commits) {
583
+ const message = stage.out(["log", "-1", "--format=%B", sha]) || "";
584
+ if (hasAutoClose(message)) {
585
+ throw new Refusal(
586
+ "auto-close",
587
+ `commit ${sha.slice(0, 12)} carries an auto-close keyword; use Ref: #N`,
588
+ );
589
+ }
590
+ }
591
+ }
592
+
579
593
  function checkLedger(state, commits, wt, env) {
580
594
  for (const c of commits) {
581
595
  const parent = gitOut(wt, ["rev-parse", "--verify", `${c}^1`], env);
@@ -1319,6 +1333,7 @@ export async function publish({ sessionId, statePath, item, config, env = proces
1319
1333
  const mergeBase = stage.out(["merge-base", baseSha, headSha]);
1320
1334
  if (!mergeBase) throw new Refusal("branch", `${req.branch} shares no history with ${req.base}`);
1321
1335
 
1336
+ checkCommitMessages(stage, commits);
1322
1337
  checkLedger(state, commits, wt, localEnv);
1323
1338
  checkTestLog(state, wt, statePath);
1324
1339
  checkSecrets(wt, `${mergeBase}..${headSha}`, env);
@@ -2086,6 +2086,26 @@ async function main() {
2086
2086
  return 0;
2087
2087
  }
2088
2088
 
2089
+ // ---- 3d. VERSION --------------------------------------------------------
2090
+ // Phase 0 of the child halts below the published floor, and the update it
2091
+ // would need is refused unattended. Checked here so a floor costs no session.
2092
+ const floorGate = join(SCRIPTS, "require-supported-version.sh");
2093
+ if (existsSync(floorGate)) {
2094
+ const floor = spawnSync("bash", [floorGate], { encoding: "utf-8", timeout: 30_000 });
2095
+ if (floor.status === 3) {
2096
+ const [, local, , required] = (floor.stdout || "").trim().split("|");
2097
+ const reason = `installed v${local || "?"} is below the required v${required || "?"}; run the update`;
2098
+ log(`not launching: ${reason}`);
2099
+ if (!DRY) {
2100
+ record({ source: next.source, id: next.id, outcome: OUTCOME.BLOCKED_VERSION_FLOOR });
2101
+ writeState("queue.json", { ...queue, blockedReason: reason });
2102
+ refreshStatus();
2103
+ }
2104
+ tick({ action: "version-floor" });
2105
+ return 0;
2106
+ }
2107
+ }
2108
+
2089
2109
  // ---- 4. TAKE -----------------------------------------------------------
2090
2110
  const sessionId = randomUUID();
2091
2111
  const taskId = `ap-${Date.now()}`;
@@ -33,16 +33,9 @@ LOGS="${LOGS_ROOT:-$HOME/.claude/logs/multi-agent}"
33
33
  PIPELINE_MEM="$HOME/.claude/memory/multi-agent/_pipeline"
34
34
  REVIEW_DAYS=7
35
35
 
36
- # Three buckets, not one line. Measured on the development machine: 22 runs read
37
- # `in_progress` and every one was over a day old, but they are not one failure.
38
- # 13 stopped at Phase 0, which is almost entirely questions - those runs never
39
- # started. 3 had their PR already open and were waiting at Phase 4/7, where the
40
- # pipeline pauses ON PURPOSE for channel selection. 4 died mid-development.
41
- #
42
- # Reporting the newest one of those as "stopped at Phase N" told the truth about
43
- # one run and hid twenty-one, and it read the same whether the pipeline was
44
- # waiting for the user or had crashed. A finished job and a dead one should not
45
- # share a sentence.
36
+ # Unfinished runs are sorted into three buckets because each needs a different
37
+ # action: waiting on the user (an open PR, or a pause for the channel answer),
38
+ # left at a Phase 0 question (nothing built), and stopped mid-development.
46
39
  AWAITING_N=0; AWAITING_TASK=""; AWAITING_PHASE=""
47
40
  DIED_N=0; DIED_TASK=""; DIED_PHASE=""
48
41
  UNSTARTED_N=0; UNSTARTED_TASK=""
@@ -50,34 +43,35 @@ STALE_TASK=""
50
43
  STALE_PHASE=""
51
44
 
52
45
  if [ -d "$LOGS" ] && command -v jq >/dev/null 2>&1; then
53
- # Newest first, so the first hit in each bucket is the one worth naming.
54
- # Both layouts are walked: state is written flat as <task-id>/ by
46
+ # Oldest first, so the first hit in each bucket is the one the banner calls
47
+ # the oldest. Both layouts are walked: state is written flat as <task-id>/ by
55
48
  # phase-tracker.sh and nested as <project>/<task-id>/ by Phase 0, and a
56
49
  # reader that knows only one of them under-reports by the size of the other.
50
+ # artifacts/ holds the copy gc-abandoned takes before it marks a run failed,
51
+ # so it keeps the status the run had while it was alive.
57
52
  while IFS= read -r f; do
58
53
  [ -f "$f" ] || continue
59
54
  STATUS=$(jq -r '.status // ""' "$f" 2>/dev/null)
60
- # Only runs that CLAIM to be running are bucketed. A file with no status is
61
- # unknown - eight of them here, six with no currentPhase either - and
62
- # reporting unknown as "stopped mid-development" is the same false claim
63
- # this change exists to remove, just pointed the other way. If that hides a
64
- # real run, the fix is for its writer to record a status.
55
+ # Only runs that claim to be running are bucketed. A file with no status is
56
+ # unknown, and reporting unknown as "stopped mid-development" would be a
57
+ # false claim. The fix for a hidden run is for its writer to record a status.
65
58
  # `complete` is the schema spelling and `completed` is in the wild too, so
66
59
  # both are excluded; a reader that knows one calls finished runs stale.
67
60
  case "$STATUS" in in_progress | paused | awaiting_input) ;; *) continue ;; esac
68
- PHASE=$(jq -r '.currentPhase // "?"' "$f" 2>/dev/null)
61
+ # `phase` is the field name a state written before currentPhase carries.
62
+ PHASE=$(jq -r '.currentPhase // .phase // "?" | tostring' "$f" 2>/dev/null)
69
63
  TASK=$(jq -r '.taskId // .jiraId // ""' "$f" 2>/dev/null)
70
64
  [ -n "$TASK" ] || continue
71
- # `.pr` is an object in the schema and a bare URL string in three state files
72
- # on disk. `jq '.pr.url'` on a string errors, and with stderr suppressed that
73
- # reads as "no PR" - so a run whose PR is already open would be classed as dead
74
- # and reaped. Read the shape, do not assume it.
65
+ # `.pr` is an object in the schema and a bare URL string in older state
66
+ # files. `jq '.pr.url'` on a string errors, and with stderr suppressed that
67
+ # reads as "no PR", so the shape is read rather than assumed.
75
68
  PR=$(jq -r 'if (.pr|type)=="string" then .pr elif (.pr|type)=="object" then (.pr.url // .pr.number // "") else "" end | tostring' "$f" 2>/dev/null)
76
69
 
77
70
  # Waiting for you, not broken: an open PR means the work landed, and
78
- # Phase 5 pauses for channel selection by design (modes.md).
71
+ # Phase 5 pauses for channel selection by design (modes.md). 6 and 7 are
72
+ # the same pause in a state written before the six-phase numbering.
79
73
  if [ "$STATUS" = "awaiting_input" ] || [ -n "$PR" ] ||
80
- [ "$PHASE" = "6" ] || [ "$PHASE" = "7" ]; then
74
+ [ "$PHASE" = "5" ] || [ "$PHASE" = "6" ] || [ "$PHASE" = "7" ]; then
81
75
  AWAITING_N=$((AWAITING_N + 1))
82
76
  [ -z "$AWAITING_TASK" ] && { AWAITING_TASK="$TASK"; AWAITING_PHASE="$PHASE"; }
83
77
  elif [ "$PHASE" = "0" ]; then
@@ -90,7 +84,7 @@ if [ -d "$LOGS" ] && command -v jq >/dev/null 2>&1; then
90
84
  [ -z "$DIED_TASK" ] && { DIED_TASK="$TASK"; DIED_PHASE="$PHASE"; }
91
85
  fi
92
86
  done <<EOF
93
- $(find "$LOGS" -name agent-state.json -type f -maxdepth 4 -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null)
87
+ $(find "$LOGS" -maxdepth 4 -name agent-state.json -type f -not -path '*/artifacts/*' -print0 2>/dev/null | xargs -0 ls -tr 2>/dev/null)
94
88
  EOF
95
89
  # Kept for the JSON consumers that read staleTask/stalePhase.
96
90
  if [ -n "$AWAITING_TASK" ]; then
@@ -130,24 +124,44 @@ if [ "$JSON" -eq 1 ]; then
130
124
  exit 0
131
125
  fi
132
126
 
133
- # One line per bucket, each naming its own next action, because they are
134
- # different actions. Counts are printed even when they are 1: "1 run" and
135
- # "13 runs" call for different reactions and the old line could not tell them
136
- # apart.
127
+ # One line per bucket, each naming its own next action. Counts are printed
128
+ # even when they are 1, because "1 run" and "13 runs" call for different
129
+ # reactions.
130
+ LANG_CODE=$(ma_output_lang 2>/dev/null || echo en)
137
131
  if [ "$AWAITING_N" -gt 0 ]; then
138
- printf 'multi-agent: %s run(s) waiting on you, oldest %s at Phase %s - `%s` to finish.\n' \
139
- "$AWAITING_N" "$AWAITING_TASK" "$AWAITING_PHASE" "$(ma_command resume)"
132
+ if [ "$LANG_CODE" = "tr" ]; then
133
+ printf 'multi-agent: %s çalışma seni bekliyor, en eskisi %s (Phase %s). Bitirmek için `%s`.\n' \
134
+ "$AWAITING_N" "$AWAITING_TASK" "$AWAITING_PHASE" "$(ma_command resume)"
135
+ else
136
+ printf 'multi-agent: %s run(s) waiting on you, oldest %s at Phase %s - `%s` to finish.\n' \
137
+ "$AWAITING_N" "$AWAITING_TASK" "$AWAITING_PHASE" "$(ma_command resume)"
138
+ fi
140
139
  fi
141
140
  if [ "$DIED_N" -gt 0 ]; then
142
- printf 'multi-agent: %s run(s) stopped mid-development, oldest %s at Phase %s - `%s` or `%s`.\n' \
143
- "$DIED_N" "$DIED_TASK" "$DIED_PHASE" "$(ma_command resume)" "$(ma_command kill)"
141
+ if [ "$LANG_CODE" = "tr" ]; then
142
+ printf 'multi-agent: %s çalışma geliştirme sırasında durdu, en eskisi %s (Phase %s). Devam için `%s`, silmek için `%s`.\n' \
143
+ "$DIED_N" "$DIED_TASK" "$DIED_PHASE" "$(ma_command resume)" "$(ma_command kill)"
144
+ else
145
+ printf 'multi-agent: %s run(s) stopped mid-development, oldest %s at Phase %s - `%s` or `%s`.\n' \
146
+ "$DIED_N" "$DIED_TASK" "$DIED_PHASE" "$(ma_command resume)" "$(ma_command kill)"
147
+ fi
144
148
  fi
145
149
  if [ "$UNSTARTED_N" -gt 0 ]; then
146
- printf 'multi-agent: %s run(s) left at a Phase 0 question, nothing built - `%s --abandoned` to clear.\n' \
147
- "$UNSTARTED_N" "$(ma_command garbage-collect)"
150
+ if [ "$LANG_CODE" = "tr" ]; then
151
+ printf 'multi-agent: %s çalışma Phase 0 sorusunda kaldı, hiçbir şey üretilmedi. Temizlemek için `%s --abandoned`.\n' \
152
+ "$UNSTARTED_N" "$(ma_command garbage-collect)"
153
+ else
154
+ printf 'multi-agent: %s run(s) left at a Phase 0 question, nothing built - `%s --abandoned` to clear.\n' \
155
+ "$UNSTARTED_N" "$(ma_command garbage-collect)"
156
+ fi
148
157
  fi
149
158
  if [ "$REVIEW_DUE" -eq 1 ]; then
150
- printf 'multi-agent: %s open pipeline observation(s), last review %s+ days ago - `%s backlog` when convenient.\n' \
151
- "$OPEN_OBS" "$REVIEW_DAYS" "$(ma_command refactor)"
159
+ if [ "$LANG_CODE" = "tr" ]; then
160
+ printf 'multi-agent: %s açık pipeline gözlemi var, son inceleme %s+ gün önce. Uygun olduğunda `%s backlog`.\n' \
161
+ "$OPEN_OBS" "$REVIEW_DAYS" "$(ma_command refactor)"
162
+ else
163
+ printf 'multi-agent: %s open pipeline observation(s), last review %s+ days ago - `%s backlog` when convenient.\n' \
164
+ "$OPEN_OBS" "$REVIEW_DAYS" "$(ma_command refactor)"
165
+ fi
152
166
  fi
153
167
  exit 0
@@ -5,7 +5,7 @@
5
5
  // This exists because the resolution order is the whole correctness question,
6
6
  // and prose in a skill file cannot be tested. The step used to read the
7
7
  // installed plugin cache and call the answer authoritative. A cache is only as
8
- // fresh as the last `claude marketplace update`, so it reported "up to date"
8
+ // fresh as the last `claude plugin marketplace update`, so it reported "up to date"
9
9
  // for a derivation that was four releases behind, and the version it had been
10
10
  // recorded from was the wrong manifest besides.
11
11
  //
@@ -476,6 +476,27 @@ function checkHookCoverage() {
476
476
  );
477
477
  return;
478
478
  }
479
+ const seen = new Set();
480
+ const twice = new Set();
481
+ for (const [event, groups] of Object.entries(have?.hooks || {})) {
482
+ for (const group of Array.isArray(groups) ? groups : []) {
483
+ for (const h of group?.hooks || []) {
484
+ if (typeof h?.command !== "string") continue;
485
+ const key = `${event} ${group.matcher || ""} ${h.command}`;
486
+ if (seen.has(key)) twice.add(`${h.command.split("/").pop()} on ${group.matcher || event}`);
487
+ seen.add(key);
488
+ }
489
+ }
490
+ }
491
+ if (twice.size) {
492
+ report(
493
+ "hook-coverage",
494
+ "WARN",
495
+ `registered twice, so it runs twice per call: ${[...twice].join(", ")}`,
496
+ `run ${hostCommand("update", HERE)}`,
497
+ );
498
+ return;
499
+ }
479
500
  ok("hook-coverage");
480
501
  }
481
502
 
@@ -81,6 +81,11 @@ PHASE_SCHEMA="$(node -e 'const c=require("'"$(cd "$(dirname "$0")" && pwd)"'/../
81
81
  TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
82
82
  LINE="{\"ts\":\"${TS}\",\"task_id\":\"${TASK_ID}\",\"phase\":\"${PHASE}\",\"event\":\"${EVENT}\",\"details\":${DETAILS},\"phaseSchema\":${PHASE_SCHEMA}}"
83
83
 
84
+ # Caller-supplied values can carry a credential copied out of an error.
85
+ # redact.mjs replaces it with a marker; without node the line is written as is.
86
+ REDACT="$(cd "$(dirname "$0")" && pwd)/../lib/redact.mjs"
87
+ [ -f "$REDACT" ] && LINE=$(printf '%s' "$LINE" | node "$REDACT" 2>/dev/null || printf '%s' "$LINE")
88
+
84
89
  # Atomic append (one line, well under PIPE_BUF).
85
90
  echo "$LINE" >> "$METRICS_FILE" 2>/dev/null || {
86
91
  echo "warn: log-metric: cannot write to $METRICS_FILE - telemetry skipped" >&2
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # plugin-refresh.sh - bring the stack plugins to the latest published version.
4
+ #
5
+ # Two steps, because they are two different things: `claude plugin marketplace
6
+ # update` refreshes the catalog, and each installed plugin then needs its own
7
+ # `claude plugin update` in the scope it was installed in. A project or local
8
+ # install is updated from inside its own project, because that is how the CLI
9
+ # tells two projects' installs apart. The catalog refresh alone leaves every
10
+ # installed plugin where it was.
11
+ #
12
+ # Usage: plugin-refresh.sh <owner> [--add-only]
13
+ # <owner> GitHub owner of the multi-agent-plugins repository
14
+ # --add-only make sure the marketplace is configured, update nothing
15
+ #
16
+ # The CLI's own error output is passed through, never discarded.
17
+ # Exit codes: 0 = done (or no claude CLI on this machine), 1 = a step failed
18
+
19
+ set -uo pipefail
20
+
21
+ OWNER="${1:-}"
22
+ MODE="${2:-}"
23
+ MP="multi-agent-plugins"
24
+ CLAUDE="${CLAUDE_BIN:-claude}"
25
+
26
+ if [ -z "$OWNER" ]; then
27
+ echo "usage: plugin-refresh.sh <owner> [--add-only]" >&2
28
+ exit 1
29
+ fi
30
+
31
+ if ! command -v "$CLAUDE" >/dev/null 2>&1; then
32
+ echo " -> claude CLI not found; stack plugins not refreshed"
33
+ exit 0
34
+ fi
35
+
36
+ FAILED=0
37
+
38
+ if ! "$CLAUDE" plugin marketplace list 2>/dev/null | grep -q "$MP"; then
39
+ if "$CLAUDE" plugin marketplace add "$OWNER/$MP"; then
40
+ echo " -> marketplace $MP added"
41
+ else
42
+ echo " -> could not add marketplace $OWNER/$MP" >&2
43
+ exit 1
44
+ fi
45
+ fi
46
+
47
+ [ "$MODE" = "--add-only" ] && exit 0
48
+
49
+ if "$CLAUDE" plugin marketplace update "$MP"; then
50
+ echo " -> marketplace $MP refreshed"
51
+ else
52
+ echo " -> marketplace $MP refresh failed" >&2
53
+ FAILED=1
54
+ fi
55
+
56
+ INSTALLED=$("$CLAUDE" plugin list --json 2>/dev/null | node -e '
57
+ let s = "";
58
+ process.stdin.on("data", (d) => (s += d)).on("end", () => {
59
+ let rows = [];
60
+ try { rows = JSON.parse(s); } catch { process.exit(0); }
61
+ const seen = new Set();
62
+ for (const r of Array.isArray(rows) ? rows : []) {
63
+ if (typeof r.id !== "string" || !r.id.endsWith("@" + process.argv[1])) continue;
64
+ const scope = r.scope || "user";
65
+ const dir = scope === "project" || scope === "local" ? r.projectPath || "" : "";
66
+ if ((scope === "project" || scope === "local") && !dir) continue;
67
+ const key = [r.id, scope, dir].join("\t");
68
+ if (seen.has(key)) continue;
69
+ seen.add(key);
70
+ console.log(key);
71
+ }
72
+ });' "$MP")
73
+
74
+ while IFS=$'\t' read -r ID SCOPE DIR; do
75
+ [ -n "$ID" ] || continue
76
+ WHERE="$SCOPE${DIR:+ $DIR}"
77
+ if [ -n "$DIR" ] && [ ! -d "$DIR" ]; then
78
+ echo " -> $ID ($WHERE) skipped, project directory is gone"
79
+ continue
80
+ fi
81
+ if (cd "${DIR:-$PWD}" && "$CLAUDE" plugin update "$ID" --scope "$SCOPE"); then
82
+ echo " -> $ID ($WHERE) updated"
83
+ else
84
+ echo " -> $ID ($WHERE) update failed" >&2
85
+ FAILED=1
86
+ fi
87
+ done <<EOF
88
+ $INSTALLED
89
+ EOF
90
+
91
+ [ "$FAILED" -eq 0 ] || exit 1
92
+ exit 0
@@ -46,7 +46,14 @@ export const FIELDS = ["branch", "base", "title", "body", "draft", "repo"];
46
46
  export const PROTECTED_BRANCHES = ["main", "master", "develop"];
47
47
  const REF = /^[A-Za-z0-9._/-]+$/;
48
48
  const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
49
- const AUTO_CLOSE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+(?:[\w.-]+\/[\w.-]+)?#\d+/i;
49
+ // GitHub closes an issue from `#N`, `owner/repo#N` and the issue's full URL.
50
+ const AUTO_CLOSE =
51
+ /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+(?:(?:[\w.-]+\/[\w.-]+)?#\d+|https?:\/\/[^\s/]+\/[\w.-]+\/[\w.-]+\/issues\/\d+)/i;
52
+
53
+ /** True when the text would close an issue once the PR or commit merges. */
54
+ export function hasAutoClose(text) {
55
+ return typeof text === "string" && AUTO_CLOSE.test(text);
56
+ }
50
57
 
51
58
  /**
52
59
  * The request object `write` produces. draft is always true.
@@ -92,8 +99,7 @@ export function validatePrRequest(req) {
92
99
  if (typeof req.branch === "string" && req.branch === req.base)
93
100
  errors.push("branch and base are the same");
94
101
  for (const k of ["title", "body"]) {
95
- if (typeof req[k] === "string" && AUTO_CLOSE.test(req[k]))
96
- errors.push(`${k} carries an auto-close keyword; use Ref: #N`);
102
+ if (hasAutoClose(req[k])) errors.push(`${k} carries an auto-close keyword; use Ref: #N`);
97
103
  }
98
104
  const secrets = [];
99
105
  for (const k of ["title", "body"]) {
@@ -26,7 +26,8 @@
26
26
  # Usage:
27
27
  # bash pipeline/scripts/update-check.sh # auto-detect local version
28
28
  # bash pipeline/scripts/update-check.sh --local 10.8.0 # explicit local version
29
- # bash pipeline/scripts/update-check.sh --ttl-hours 24 # cache window (default 24)
29
+ # bash pipeline/scripts/update-check.sh --ttl-hours 24 # cache window (default: prefs
30
+ # # updateCheck.ttlHours, else 24)
30
31
  # bash pipeline/scripts/update-check.sh --force # ignore cache
31
32
  # bash pipeline/scripts/update-check.sh --print-required # emit the floor only
32
33
  #
@@ -41,7 +42,8 @@ set -euo pipefail
41
42
  PKG="@mmerterden/multi-agent-pipeline"
42
43
  REGISTRY_URL="https://registry.npmjs.org/${PKG/\//%2F}"
43
44
  CACHE_FILE="${UPDATE_CHECK_CACHE:-$HOME/.claude/logs/multi-agent/.update-check}"
44
- TTL_HOURS=24
45
+ TTL_HOURS=""
46
+ FAIL_TTL_SECONDS=3600
45
47
  LOCAL_VERSION=""
46
48
  FORCE=0
47
49
  PRINT_REQUIRED=0
@@ -49,37 +51,65 @@ PRINT_REQUIRED=0
49
51
  while [ $# -gt 0 ]; do
50
52
  case "$1" in
51
53
  --local) LOCAL_VERSION="${2:-}"; shift; [ $# -gt 0 ] && shift ;;
52
- --ttl-hours) TTL_HOURS="${2:-24}"; shift; [ $# -gt 0 ] && shift ;;
54
+ --ttl-hours) TTL_HOURS="${2:-}"; shift; [ $# -gt 0 ] && shift ;;
53
55
  --force) FORCE=1; shift ;;
54
56
  --print-required) PRINT_REQUIRED=1; shift ;;
55
57
  *) shift ;;
56
58
  esac
57
59
  done
58
60
 
59
- # Local version: explicit arg, else read from the pipeline repo clone.
61
+ # Local version: explicit arg, else the marker the installer stamps. The
62
+ # marker is what is installed and what /multi-agent:update rewrites, so it is
63
+ # read before any repo clone; a clone is only a fallback for a machine that
64
+ # runs the pipeline from source without installing it.
60
65
  if [ -z "$LOCAL_VERSION" ]; then
61
- for candidate in "$HOME/multi-agent-pipeline" "$HOME/dev/multi-agent-pipeline" "$HOME/projects/multi-agent-pipeline"; do
62
- if [ -f "$candidate/package.json" ]; then
63
- LOCAL_VERSION=$(node -p "require('$candidate/package.json').version" 2>/dev/null || true)
66
+ for marker in "$HOME/.claude/.pipeline-version" "$HOME/.copilot/.pipeline-version" \
67
+ "$HOME/.codex/.pipeline-version"; do
68
+ if [ -f "$marker" ]; then
69
+ LOCAL_VERSION=$(head -1 "$marker" 2>/dev/null | tr -d '[:space:]')
64
70
  [ -n "$LOCAL_VERSION" ] && break
65
71
  fi
66
72
  done
67
73
  fi
68
- # npx-only installs have no repo clone; the installer stamps the version here.
69
74
  if [ -z "$LOCAL_VERSION" ]; then
70
- for marker in "$HOME/.claude/.pipeline-version" "$HOME/.copilot/.pipeline-version" \
71
- "$HOME/.codex/.pipeline-version"; do
72
- if [ -f "$marker" ]; then
73
- LOCAL_VERSION=$(head -1 "$marker" 2>/dev/null | tr -d '[:space:]')
75
+ for candidate in "$HOME/multi-agent-pipeline" "$HOME/dev/multi-agent-pipeline" "$HOME/projects/multi-agent-pipeline"; do
76
+ if [ -f "$candidate/package.json" ]; then
77
+ LOCAL_VERSION=$(node -p "require('$candidate/package.json').version" 2>/dev/null || true)
74
78
  [ -n "$LOCAL_VERSION" ] && break
75
79
  fi
76
80
  done
77
81
  fi
78
82
  [ -z "$LOCAL_VERSION" ] && exit 0 # cannot determine local version -> silent no-op
79
83
 
80
- # Highest of two dotted versions; ties resolve to the second argument.
81
- highest_of() {
82
- printf '%s\n%s\n' "$1" "$2" | sort -t. -k1,1n -k2,2n -k3,3n | tail -1
84
+ # Cache lifetime: --ttl-hours, else prefs global.updateCheck.ttlHours, else 24.
85
+ # Anything that is not a whole number falls back to 24.
86
+ if [ -z "$TTL_HOURS" ]; then
87
+ PREFS_FILE="${MULTI_AGENT_PREFS:-$HOME/.claude/multi-agent-preferences.json}"
88
+ if [ -f "$PREFS_FILE" ] && command -v jq >/dev/null 2>&1; then
89
+ TTL_HOURS=$(jq -r '.global.updateCheck.ttlHours // empty' "$PREFS_FILE" 2>/dev/null || true)
90
+ fi
91
+ fi
92
+ case "$TTL_HOURS" in (""|*[!0-9]*) TTL_HOURS=24 ;; esac
93
+
94
+ # 0 when $1 sorts strictly below $2. Semver order: numeric major.minor.patch,
95
+ # and a prerelease (1.2.3-beta) below its release (1.2.3). A leading v is ignored.
96
+ version_lt() {
97
+ local a="${1#v}" b="${2#v}" ac ap bc bp i x y
98
+ ac="${a%%-*}"; bc="${b%%-*}"
99
+ ap=""; bp=""
100
+ [ "$ac" != "$a" ] && ap="${a#*-}"
101
+ [ "$bc" != "$b" ] && bp="${b#*-}"
102
+ for i in 1 2 3; do
103
+ x=$(printf '%s' "$ac" | cut -d. -f"$i"); y=$(printf '%s' "$bc" | cut -d. -f"$i")
104
+ case "$x" in (""|*[!0-9]*) x=0 ;; esac
105
+ case "$y" in (""|*[!0-9]*) y=0 ;; esac
106
+ [ "$x" -lt "$y" ] && return 0
107
+ [ "$x" -gt "$y" ] && return 1
108
+ done
109
+ [ -n "$ap" ] && [ -z "$bp" ] && return 0
110
+ [ -z "$ap" ] && [ -n "$bp" ] && return 1
111
+ [ -n "$ap" ] && [ "$(printf '%s\n%s\n' "$ap" "$bp" | sort | head -1)" = "$ap" ] && [ "$ap" != "$bp" ] && return 0
112
+ return 1
83
113
  }
84
114
 
85
115
  now=$(date +%s)
@@ -92,6 +122,8 @@ if [ "$FORCE" -eq 0 ] && [ -f "$CACHE_FILE" ]; then
92
122
  cached_latest=$(cut -d'|' -f2 "$CACHE_FILE" 2>/dev/null || echo "")
93
123
  cached_required=$(cut -d'|' -f3 "$CACHE_FILE" 2>/dev/null || echo "")
94
124
  case "$cached_epoch" in (*[!0-9]*|"") cached_epoch=0 ;; esac
125
+ # A stamp in the future is a clock or copy mistake, not a fresh answer.
126
+ [ "$cached_epoch" -gt "$now" ] && cached_epoch=0
95
127
  if [ $((now - cached_epoch)) -lt $((TTL_HOURS * 3600)) ] && [ -n "$cached_latest" ]; then
96
128
  latest="$cached_latest"
97
129
  required="$cached_required"
@@ -100,6 +132,16 @@ fi
100
132
 
101
133
  # Stale or missing cache -> one bounded registry call (silent on any failure).
102
134
  # The abbreviated packument carries dist-tags at a quarter of the full document.
135
+ # A failed read is remembered for FAIL_TTL_SECONDS in "<cache>.fail", so an
136
+ # offline machine does not wait out the timeout on every call.
137
+ if [ -z "$latest" ] && [ "$FORCE" -eq 0 ] && [ -f "$CACHE_FILE.fail" ]; then
138
+ failed_at=$(head -1 "$CACHE_FILE.fail" 2>/dev/null | tr -d '[:space:]')
139
+ case "$failed_at" in (*[!0-9]*|"") failed_at=0 ;; esac
140
+ if [ "$failed_at" -le "$now" ] && [ $((now - failed_at)) -lt "$FAIL_TTL_SECONDS" ]; then
141
+ exit 0
142
+ fi
143
+ fi
144
+
103
145
  if [ -z "$latest" ]; then
104
146
  tags=$(curl -sm 3 -H 'Accept: application/vnd.npm.install-v1+json' "$REGISTRY_URL" 2>/dev/null \
105
147
  | { if command -v jq >/dev/null 2>&1; then
@@ -110,14 +152,25 @@ if [ -z "$latest" ]; then
110
152
  latest="${tags%%|*}"
111
153
  required="${tags#*|}"
112
154
  [ "$required" = "$tags" ] && required=""
113
- [ -z "$latest" ] && exit 0
114
155
  mkdir -p "$(dirname "$CACHE_FILE")" 2>/dev/null || exit 0
115
- printf '%s|%s|%s\n' "$now" "$latest" "$required" > "$CACHE_FILE" 2>/dev/null || true
156
+ if [ -z "$latest" ]; then
157
+ printf '%s\n' "$now" > "$CACHE_FILE.fail" 2>/dev/null || true
158
+ exit 0
159
+ fi
160
+ rm -f "$CACHE_FILE.fail" 2>/dev/null || true
161
+ # Written through a temp file and a rename, so a concurrent reader never
162
+ # sees a half-written line.
163
+ tmp_cache=$(mktemp "$CACHE_FILE.XXXXXX" 2>/dev/null) || exit 0
164
+ if printf '%s|%s|%s\n' "$now" "$latest" "$required" > "$tmp_cache" 2>/dev/null; then
165
+ mv -f "$tmp_cache" "$CACHE_FILE" 2>/dev/null || rm -f "$tmp_cache"
166
+ else
167
+ rm -f "$tmp_cache"
168
+ fi
116
169
  fi
117
170
 
118
171
  # A floor above latest is a publisher mistake (the `required` tag was moved to a
119
172
  # version that `latest` no longer covers). Clamp rather than brick every user.
120
- if [ -n "$required" ] && [ "$(highest_of "$required" "$latest")" != "$latest" ]; then
173
+ if [ -n "$required" ] && version_lt "$latest" "$required"; then
121
174
  required="$latest"
122
175
  fi
123
176
 
@@ -127,8 +180,7 @@ if [ "$PRINT_REQUIRED" -eq 1 ]; then
127
180
  fi
128
181
 
129
182
  # Forced update: local sorts strictly BELOW the required floor.
130
- if [ -n "$required" ] && [ "$LOCAL_VERSION" != "$required" ] \
131
- && [ "$(highest_of "$LOCAL_VERSION" "$required")" = "$required" ]; then
183
+ if [ -n "$required" ] && version_lt "$LOCAL_VERSION" "$required"; then
132
184
  printf '%s|%s|force\n' "$LOCAL_VERSION" "$latest"
133
185
  exit 0
134
186
  fi
@@ -137,7 +189,7 @@ fi
137
189
 
138
190
  # Update available only when latest sorts strictly ABOVE local (a dev machine
139
191
  # running ahead of the registry must not see an "update" prompt).
140
- if [ "$(highest_of "$LOCAL_VERSION" "$latest")" = "$latest" ]; then
192
+ if version_lt "$LOCAL_VERSION" "$latest"; then
141
193
  printf '%s|%s\n' "$LOCAL_VERSION" "$latest"
142
194
  fi
143
195
  exit 0
@@ -340,10 +340,10 @@ function commandOf(state, mode) {
340
340
  return mode;
341
341
  }
342
342
 
343
- // A repository name is the user's, not ours. It put a corporate project into
344
- // the maintainer's database, which is the disclosure this telemetry must not
345
- // make - and the panel never needed the name, only the ability to tell one
346
- // project's runs from another's. A digest does that and cannot be read back.
343
+ // A repository name is the user's, not ours, and the panel never needs it:
344
+ // only the ability to tell one project's runs from another's. A digest keeps
345
+ // the name out of the record. It is not secret: anyone who can guess a name
346
+ // can hash it and compare.
347
347
  function projectDigest(name) {
348
348
  if (typeof name !== "string" || !name.trim()) return null;
349
349
  return `p${createHash("sha256").update(name.trim()).digest("hex").slice(0, 8)}`;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-09-25T08:57:50Z",
3
+ "generatedAt": "2026-09-25T16:25:54Z",
4
4
  "skillCount": 218,
5
5
  "entries": [
6
6
  {
@@ -29,7 +29,7 @@
29
29
  },
30
30
  {
31
31
  "path": "shared/core/multi-agent-autopilot-on/SKILL.md",
32
- "sha256": "ca671ffe46809e3b0c825a6b5c19e8ea00e529874531eb480a0987428366d2f4"
32
+ "sha256": "d4f23f6f7bb493a4efa2cfa252fab50b22b6506aeaf31985db4498b5f010c8c8"
33
33
  },
34
34
  {
35
35
  "path": "shared/core/multi-agent-autopilot-status/SKILL.md",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  {
47
47
  "path": "shared/core/multi-agent-channels/SKILL.md",
48
- "sha256": "e52daada5aba4f772018d4cab508acc87b9b1c2686e1f5a770c4843f76c74208"
48
+ "sha256": "03f7c648e1938f3d6d7c48d4c1c01f2bd734cdce873ec5203120b5be3ae761db"
49
49
  },
50
50
  {
51
51
  "path": "shared/core/multi-agent-complaint-analysis/SKILL.md",
@@ -97,7 +97,7 @@
97
97
  },
98
98
  {
99
99
  "path": "shared/core/multi-agent-jira/SKILL.md",
100
- "sha256": "c3e75df9f353bfeb0eb33632fab409721f03aab9c901102502d328eea93a6092"
100
+ "sha256": "d7e23fd1565b6640d33c23cce4d025660212d9d3f3ab80a43369ad80ae8be080"
101
101
  },
102
102
  {
103
103
  "path": "shared/core/multi-agent-kill/SKILL.md",
@@ -133,7 +133,7 @@
133
133
  },
134
134
  {
135
135
  "path": "shared/core/multi-agent-refactor/SKILL.md",
136
- "sha256": "dedcdb18c961b0ddf87d1f3308e9aba94cc31534cc4263503098fbdd5dd292e1"
136
+ "sha256": "889c17f800b83d5c5d13d097eff63f28dc8d7869679bfcdb72efecd8b1d66391"
137
137
  },
138
138
  {
139
139
  "path": "shared/core/multi-agent-research/SKILL.md",
@@ -205,7 +205,7 @@
205
205
  },
206
206
  {
207
207
  "path": "shared/core/multi-agent-stack/SKILL.md",
208
- "sha256": "827d1582ee536ed65660574f24687b17b8b0a2a76f9b69ab03c9bb184a7712bb"
208
+ "sha256": "ac964cd00dd6ed06512530489d867efbc51d7d7614265f9aa022679325d5defe"
209
209
  },
210
210
  {
211
211
  "path": "shared/core/multi-agent-status/SKILL.md",
@@ -253,11 +253,11 @@
253
253
  },
254
254
  {
255
255
  "path": "shared/core/multi-agent-update/SKILL.md",
256
- "sha256": "b58574eb82a28c1657d30524b528a55a8d1f3e2eadd7b23f85525b2aca243c4b"
256
+ "sha256": "d96e18b70bab0a601057f75f212dd4fb563db0cb250607c770ce1c7a1df4b1d0"
257
257
  },
258
258
  {
259
259
  "path": "shared/core/multi-agent/SKILL.md",
260
- "sha256": "b738b5055f4eb9a37934730ceddec8f02058c9a5e5c3d99158de1b4634e0d2a6"
260
+ "sha256": "3bfaa4f0743aa1ab8d7f2bec4edb6b3c759f6c7a890535b2f0e1606f2ce7547d"
261
261
  },
262
262
  {
263
263
  "path": "shared/external/accessibility-compliance-accessibility-audit/SKILL.md",