@mmerterden/multi-agent-pipeline 20.3.0 → 20.4.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 (39) hide show
  1. package/CHANGELOG.md +20 -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 +42 -40
  6. package/package.json +1 -1
  7. package/pipeline/commands/figma-to-swiftui.md +6 -4
  8. package/pipeline/commands/multi-agent/jira/SKILL.md +2 -2
  9. package/pipeline/commands/multi-agent/refactor/SKILL.md +1 -1
  10. package/pipeline/commands/multi-agent/review/SKILL.md +1 -1
  11. package/pipeline/commands/multi-agent/setup/SKILL.md +2 -2
  12. package/pipeline/commands/multi-agent/stack/SKILL.md +2 -4
  13. package/pipeline/commands/multi-agent/sync/SKILL.md +2 -2
  14. package/pipeline/commands/multi-agent/update/SKILL.md +3 -10
  15. package/pipeline/lib/usage-endpoint.mjs +33 -0
  16. package/pipeline/multi-agent-refs/channels/pr.md +2 -2
  17. package/pipeline/multi-agent-refs/features/doctor.md +2 -1
  18. package/pipeline/multi-agent-refs/features/prior-fix-detection.md +1 -1
  19. package/pipeline/multi-agent-refs/generate-issue.md +9 -9
  20. package/pipeline/multi-agent-refs/outside-the-pipeline.md +2 -2
  21. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  22. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -1
  23. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +4 -4
  24. package/pipeline/multi-agent-refs/setup/repo-discovery.md +2 -2
  25. package/pipeline/schemas/prefs.schema.json +1 -1
  26. package/pipeline/scripts/capture-resume.sh +51 -37
  27. package/pipeline/scripts/check-derived-drift.mjs +1 -1
  28. package/pipeline/scripts/doctor.mjs +21 -0
  29. package/pipeline/scripts/plugin-refresh.sh +92 -0
  30. package/pipeline/scripts/usage-register.mjs +6 -6
  31. package/pipeline/scripts/usage-report.mjs +6 -5
  32. package/pipeline/skills/.skill-manifest.json +7 -7
  33. package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -4
  34. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +2 -2
  35. package/pipeline/skills/shared/core/multi-agent-jira/SKILL.md +2 -2
  36. package/pipeline/skills/shared/core/multi-agent-refactor/SKILL.md +1 -1
  37. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +2 -4
  38. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +1 -1
  39. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +1 -1
@@ -251,7 +251,7 @@ The two adapters run in the same phase over the same source markdown, so the fai
251
251
  ## Reviewer-preserving Bitbucket payload (required)
252
252
 
253
253
  ```bash
254
- PR_JSON=$(curl -s -u "$BB_USER:$BB_TOKEN" "$PR_URL")
254
+ PR_JSON=$(curl -s --config <(printf 'user = "%s:%s"\n' "$BB_USER" "$BB_TOKEN") "$PR_URL")
255
255
  REVIEWERS=$(jq '[.reviewers[] | {user: {name: .user.name}, approved, status}]' <<< "$PR_JSON")
256
256
  VERSION=$(jq -r '.version' <<< "$PR_JSON")
257
257
  TITLE=$(jq -r '.title' <<< "$PR_JSON")
@@ -268,7 +268,7 @@ jq -n --rawfile body /tmp/channels-$TASK_ID-pr.md \
268
268
  reviewers: $reviewers, fromRef: $fromRef, toRef: $toRef, draft: $draft}' \
269
269
  > /tmp/channels-$TASK_ID-pr-payload.json
270
270
 
271
- curl -s -X PUT -u "$BB_USER:$BB_TOKEN" -H "Content-Type: application/json" \
271
+ curl -s -X PUT --config <(printf 'user = "%s:%s"\n' "$BB_USER" "$BB_TOKEN") -H "Content-Type: application/json" \
272
272
  --data-binary @/tmp/channels-$TASK_ID-pr-payload.json "$PR_URL"
273
273
  ```
274
274
 
@@ -167,7 +167,8 @@ reaches Phase 4 and stops there.
167
167
  ### hook-coverage
168
168
 
169
169
  The blocking `PreToolUse` gates from `templates/claude-hooks.json` are present in
170
- `settings.json`. WARN. SKIP when the template is not installed - which was the
170
+ `settings.json`, and none of them is registered twice on the same matcher
171
+ (Claude Code runs a duplicate on every matching call). WARN. SKIP when the template is not installed - which was the
171
172
  permanent state until the installer began copying `templates/`, and is exactly
172
173
  the case this severity exists to make visible.
173
174
 
@@ -19,7 +19,7 @@ Before any analysis, check if this issue was already fixed by someone else.
19
19
  3. **Jira comments/status** - fetch latest comments to see if someone reported a fix:
20
20
 
21
21
  ```bash
22
- curl -s -H "Authorization: Bearer $JIRA_TOKEN" \
22
+ printf 'Authorization: Bearer %s\n' "$JIRA_TOKEN" | curl -s -H @- \
23
23
  "$JIRA_BASE/issue/{jiraId}?fields=status,comment&expand=changelog" \
24
24
  | jq '.fields.status.name, .fields.comment.comments[-3:][]?.body'
25
25
  ```
@@ -79,7 +79,7 @@ Fallback chain (same as `$HOME/.claude/multi-agent-refs/issue-jira-triad.md` Cre
79
79
  2. `prefs.global.defaultJiraKey`
80
80
  3. Fetch and ask:
81
81
  ```bash
82
- curl -s -H "Authorization: Bearer $TOKEN" \
82
+ printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -s -H @- \
83
83
  "https://$ACCOUNT_JIRA_HOST/rest/api/2/project" \
84
84
  | python3 -c 'import json,sys; [print(p["key"]+"\t"+p["name"]) for p in json.load(sys.stdin)]'
85
85
  ```
@@ -108,7 +108,7 @@ Three GET groups. Run them before drafting anything.
108
108
  **5a. Sample recent same-type issues:**
109
109
  ```bash
110
110
  JQL="project = ${PROJECT_KEY} AND issuetype = ${ISSUE_TYPE} ORDER BY created DESC"
111
- curl -s -H "Authorization: Bearer $TOKEN" \
111
+ printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -s -H @- \
112
112
  "https://$ACCOUNT_JIRA_HOST/rest/api/2/search?jql=$(printf '%s' "$JQL" | python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read()))')&fields=summary,description,labels,components,priority,fixVersions,issuelinks&maxResults=30"
113
113
  ```
114
114
  Extract from the sample (thresholds are guidance, not hard gates):
@@ -123,7 +123,7 @@ Extract from the sample (thresholds are guidance, not hard gates):
123
123
 
124
124
  **5b. Field discovery (createmeta):**
125
125
  ```bash
126
- curl -s -H "Authorization: Bearer $TOKEN" \
126
+ printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -s -H @- \
127
127
  "https://$ACCOUNT_JIRA_HOST/rest/api/2/issue/createmeta?projectKeys=${PROJECT_KEY}&issuetypeNames=${ISSUE_TYPE}&expand=projects.issuetypes.fields"
128
128
  ```
129
129
  - 404 or empty `projects[]` (newer Jira DC deprecates the classic form) → fall back to the paged endpoint: `GET /rest/api/2/issue/createmeta/{PROJECT_KEY}/issuetypes` then `GET /rest/api/2/issue/createmeta/{PROJECT_KEY}/issuetypes/{issuetypeId}`.
@@ -136,9 +136,9 @@ curl -s -H "Authorization: Bearer $TOKEN" \
136
136
 
137
137
  **5c. Board + active sprint (Agile REST):**
138
138
  ```bash
139
- curl -s -H "Authorization: Bearer $TOKEN" \
139
+ printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -s -H @- \
140
140
  "https://$ACCOUNT_JIRA_HOST/rest/agile/1.0/board?projectKeyOrId=${PROJECT_KEY}"
141
- curl -s -H "Authorization: Bearer $TOKEN" \
141
+ printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -s -H @- \
142
142
  "https://$ACCOUNT_JIRA_HOST/rest/agile/1.0/board/${BOARD_ID}/sprint?state=active"
143
143
  ```
144
144
  - Multiple boards → AskUserQuestion (board name + type). One board → silent. Zero boards or Agile API 404 → the sprint option is silently omitted; the preview notes "backlog only (no board found)".
@@ -217,7 +217,7 @@ jq -n --rawfile desc /tmp/generate-issue-$$-escaped.txt \
217
217
  '{fields: {project: {key: $key}, issuetype: {name: $type}, summary: $summary, description: $desc}}' \
218
218
  > /tmp/generate-issue-$$-payload.json
219
219
  # merge approved optional fields (priority / labels / components / epic / assignee / required customs) into .fields with jq before POST
220
- curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
220
+ printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -sS -X POST -H @- -H "Content-Type: application/json" \
221
221
  --data-binary @/tmp/generate-issue-$$-payload.json \
222
222
  "https://$ACCOUNT_JIRA_HOST/rest/api/2/issue"
223
223
  ```
@@ -231,20 +231,20 @@ Each step is failure-isolated: on error, warn + continue - the issue already e
231
231
  1. **Sprint** (when chosen in step 8/9):
232
232
  ```bash
233
233
  jq -n --arg key "$NEW_KEY" '{issues: [$key]}' > /tmp/generate-issue-$$-sprint.json
234
- curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
234
+ printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -sS -X POST -H @- -H "Content-Type: application/json" \
235
235
  --data-binary @/tmp/generate-issue-$$-sprint.json \
236
236
  "https://$ACCOUNT_JIRA_HOST/rest/agile/1.0/sprint/${SPRINT_ID}/issue"
237
237
  ```
238
238
  Post-create move instead of a Sprint custom field in the create payload: field-id-agnostic, works on every Jira Software instance.
239
239
  2. **Figma remote link** (when `FIGMA_URL` set):
240
240
  ```bash
241
- curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
241
+ printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -sS -X POST -H @- -H "Content-Type: application/json" \
242
242
  -d '{"object":{"url":"'"$FIGMA_URL"'","title":"Figma design"}}' \
243
243
  "https://$ACCOUNT_JIRA_HOST/rest/api/2/issue/${NEW_KEY}/remotelink"
244
244
  ```
245
245
  3. **Swagger remote link** (when `SWAGGER_URL` set):
246
246
  ```bash
247
- curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
247
+ printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -sS -X POST -H @- -H "Content-Type: application/json" \
248
248
  -d '{"object":{"url":"'"$SWAGGER_URL"'","title":"API contract"}}' \
249
249
  "https://$ACCOUNT_JIRA_HOST/rest/api/2/issue/${NEW_KEY}/remotelink"
250
250
  ```
@@ -24,10 +24,10 @@ mapping would have given you.
24
24
 
25
25
  ## Keeping the value out of everything
26
26
 
27
- `curl` config on stdin, so the token never reaches argv (where `ps` can read it):
27
+ The header goes to `curl` on stdin, so the token never reaches argv (where `ps` can read it):
28
28
 
29
29
  ```bash
30
- curl --config <(printf 'header = "Authorization: Bearer %s"\n' "$TOKEN") \
30
+ printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -H @- \
31
31
  "https://$(jq -r '.global.hosts.jira' "$PREFS")/rest/api/3/issue/PROJ-1"
32
32
  ```
33
33
 
@@ -234,7 +234,7 @@ Classify and fetch external data:
234
234
  [ -z "$JIRA_KEY" ] && JIRA_KEY="${USER}_Jira_Access_Token"
235
235
  # unattended: skipped - a run reads no credential itself (features/unattended-security.md)
236
236
  JIRA_TOKEN=$(~/.claude/lib/credential-store.sh get "$JIRA_KEY" 2>/dev/null)
237
- curl -s -H "Authorization: Bearer $JIRA_TOKEN" \
237
+ printf 'Authorization: Bearer %s\n' "$JIRA_TOKEN" | curl -s -H @- \
238
238
  "https://{JIRA_HOST}/rest/api/2/issue/{jiraId}?fields=summary,issuetype,status"
239
239
  ```
240
240
  2. Extract: summary, issueType (Bug/Story/Task/Feature)
@@ -177,8 +177,9 @@ For each task (respecting dependency order):
177
177
  6. **Intermediate commit** (after each completed task in the plan):
178
178
  ```bash
179
179
  git -C "{worktreePath}" add -A
180
- git -C "{worktreePath}" commit -m "wip({scope}): {task summary} [{jiraId}]"
180
+ git -C "{worktreePath}" commit -F "/tmp/wip-message-$TASK_ID.txt"
181
181
  ```
182
+ Write `wip({scope}): {task summary} [{jiraId}]` to `/tmp/wip-message-$TASK_ID.txt` with the file tool first. The summary comes from the issue, and inside a double-quoted `-m` the shell would run any `$(...)` in it.
182
183
  WIP commits are squashed in Phase 4 before PR. This prevents work loss if a later task fails or the session crashes.
183
184
  7. Update task status: `completed` (apply the same verify rule - re-read the Status field and retry once on mismatch)
184
185
  8. Log: "Phase 2: {task} - Test + Build passed"
@@ -127,7 +127,7 @@ Branch **deterministically**, no implicit fallback. Read `agent-state.json` and
127
127
 
128
128
  The script salvages before removing, keeps the branch, and does **not** check it out - the user's HEAD and uncommitted work stay put. On success it stamps `worktreeRemovedAt`, `artifactsPath` and `worktreePath: null` into the **salvaged** `agent-state.json` itself. Do NOT write those via `write-state.mjs "$STATE_FILE"`: in single-repo mode that path went away with the worktree, so the write fails, the fields land nowhere, and Phase 5 reads a dead path and silently skips the triage ingest. After a removal re-point `STATE_FILE` at `$(jq -r .artifactsPath <<< "$FIN")/agent-state.json`. Exit 3 is a safe skip (uncommitted changes, unpushed or detached HEAD, `--local`, cwd inside the tree, preference off): report and continue. Never `--force`. Contract: [`features/worktree-finalize.md`]($HOME/.claude/multi-agent-refs/features/worktree-finalize.md).
129
129
 
130
- 10. **Issue body update** (GitHub Issue only): if the issue body has a `### Pull Requests` section and/or a `### Progress` table, fill in the PR URL(s) (one row per submodule - e.g. `- **common:** {url}`, `- **uicomponents:** {url}`) and flip the Implementation / Testing / Code Connect flags from Pending to Done using whatever marker the template uses (match in place, do NOT introduce new markers). Apply with `gh issue edit {issueNo} --body "{updated body}"` and preserve every other section unchanged.
130
+ 10. **Issue body update** (GitHub Issue only): if the issue body has a `### Pull Requests` section and/or a `### Progress` table, fill in the PR URL(s) (one row per submodule - e.g. `- **common:** {url}`, `- **uicomponents:** {url}`) and flip the Implementation / Testing / Code Connect flags from Pending to Done using whatever marker the template uses (match in place, do NOT introduce new markers). Apply it with `gh issue edit {issueNo} --body-file <file>`, preserving every other section unchanged.
131
131
  11. **NEVER close or resolve the issue** - neither GitHub Issue nor Jira. Issues require team review (4 approvals) before closing. Only post a comment with commit/PR URLs.
132
132
  12. Log: "Phase 4: Commit {sha} - PR #{number}, worktree {removed|kept: <reason>}"
133
133
 
@@ -174,13 +174,13 @@ Inputs are read from state and the worktree before writing, not recalled from th
174
174
 
175
175
  **Default Reviewers**: use `prefs.projects[{project}].defaultReviewers` when set; otherwise ask once ("Default reviewers for this project? comma-separated, or skip") and persist the answer there. Per-remote resolution rules are in "Default reviewers (required on every PR)" below - this is the prefs half only.
176
176
 
177
- **Standard PR (bug fix, feature, refactor)** - invocation shape; body comes from a file so newlines survive:
177
+ **Standard PR (bug fix, feature, refactor)** - invocation shape. Title (`{type}({scope}): {summary} [{jiraId}]`) and body go to files first, so issue text never sits in a quoted argument:
178
178
 
179
179
  ```bash
180
- REVIEWERS=$(jq -r '.projects["'$PROJECT'"].defaultReviewers // [] | map("--reviewer " + .) | join(" ")' "$PREFS_FILE")
180
+ REVIEWERS=$(jq -r --arg p "$PROJECT" '.projects[$p].defaultReviewers // [] | map("--reviewer " + .) | join(" ")' "$PREFS_FILE")
181
181
  # unattended: skipped - pr-request.mjs replaces the PR (features/unattended-security.md)
182
182
  gh pr create --base {baseBranch} --head {branch} $REVIEWERS \
183
- --title "{type}({scope}): {summary} [{jiraId}]" \
183
+ --title "$(cat /tmp/pr-title-$TASK_ID.txt)" \
184
184
  --body-file /tmp/pr-body-$TASK_ID.md
185
185
  ```
186
186
 
@@ -39,7 +39,7 @@ BB_HOST="$(jq -r '.global.bitbucketHost // empty' "$PREFS_FILE")"
39
39
  BB_USER="$(~/.claude/lib/credential-store.sh get "$(jq -r '.global.keychainMapping.bitbucket_user' "$PREFS_FILE")" 2>/dev/null)"
40
40
  BB_TOK="$(~/.claude/lib/credential-store.sh get "$(jq -r '.global.keychainMapping.bitbucket_token' "$PREFS_FILE")" 2>/dev/null)"
41
41
  CUTOFF_MS=$(( ( $(date +%s) - 7776000 ) * 1000 )) # 90 days
42
- curl -sfS -u "$BB_USER:$BB_TOK" \
42
+ curl -sfS --config <(printf 'user = "%s:%s"\n' "$BB_USER" "$BB_TOK") \
43
43
  "https://${BB_HOST}/rest/api/1.0/repos?permission=REPO_WRITE&limit=200" \
44
44
  | jq -r --argjson cutoff "$CUTOFF_MS" '
45
45
  .values[] | select((.updatedDate // 0) >= $cutoff) |
@@ -59,7 +59,7 @@ CUTOFF_ISO="$(date -u -v-90d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '90 d
59
59
  gh api --paginate 'user/repos?affiliation=owner,collaborator&sort=pushed&per_page=100' \
60
60
  -q ".[] | select(.pushed_at > \"$CUTOFF_ISO\") | {full: .full_name, clone: .clone_url}"
61
61
  # Fallback without gh:
62
- curl -sfS -H "Authorization: Bearer $GH_TOK" \
62
+ printf 'Authorization: Bearer %s\n' "$GH_TOK" | curl -sfS -H @- \
63
63
  'https://api.github.com/user/repos?affiliation=owner,collaborator&sort=pushed&per_page=100'
64
64
  ```
65
65
 
@@ -1820,7 +1820,7 @@
1820
1820
  },
1821
1821
  "upstreamLocalClone": {
1822
1822
  "type": "string",
1823
- "description": "Working copy of the upstream repository, checked before the plugin cache. The cache under ~/.claude/plugins/cache is only as fresh as the last `claude marketplace update`, so it is a mirror and never the authority. A private upstream may also be unreachable over the API from the active account, in which case this clone is the only source."
1823
+ "description": "Working copy of the upstream repository, checked before the plugin cache. The cache under ~/.claude/plugins/cache is only as fresh as the last `claude plugin marketplace update`, so it is a mirror and never the authority. A private upstream may also be unreachable over the API from the active account, in which case this clone is the only source."
1824
1824
  },
1825
1825
  "upstreamRepoUrl": {
1826
1826
  "type": "string",
@@ -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
 
@@ -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
@@ -49,10 +49,12 @@ import { join } from "node:path";
49
49
  import { homedir, hostname } from "node:os";
50
50
  import { pathToFileURL } from "node:url";
51
51
  import { hostCommand } from "../lib/user-facing.mjs";
52
+ import { canonicalEndpoint } from "../lib/usage-endpoint.mjs";
52
53
  import { ghTokenFor, resolveReportingLogin } from "./usage-identity.mjs";
53
54
  import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
54
55
 
55
56
  const DEFAULT_ENDPOINT = "https://mmerterden.com/api/usage/ingest";
57
+ const BUILT_IN_HOSTS = ["mmerterden.com"];
56
58
  const TIMEOUT_MS = 10000;
57
59
 
58
60
  function readJson(file) {
@@ -118,7 +120,7 @@ export function decideRegistration(
118
120
 
119
121
  /** The /register URL derived from whatever endpoint the prefs name. */
120
122
  export function registerUrl(endpoint) {
121
- const ep = (endpoint || DEFAULT_ENDPOINT).trim().replace(/\/+$/, "");
123
+ const ep = canonicalEndpoint((endpoint || DEFAULT_ENDPOINT).trim()).replace(/\/+$/, "");
122
124
  return ep.endsWith("/ingest") ? `${ep.slice(0, -"/ingest".length)}/register` : `${ep}/register`;
123
125
  }
124
126
 
@@ -132,7 +134,7 @@ function storedToken(prefs, credStore) {
132
134
 
133
135
  /**
134
136
  * May the gh token be sent to `url`? The token is a full GitHub credential, so
135
- * it only goes over https to the default reporting host or a host the prefs
137
+ * it only goes over https to the reporting site's own hosts or a host the prefs
136
138
  * name in `usageLog.trustedHosts`; http only to loopback (local development).
137
139
  */
138
140
  export function registerUrlAllowed(url, trustedHosts = []) {
@@ -144,9 +146,7 @@ export function registerUrlAllowed(url, trustedHosts = []) {
144
146
  }
145
147
  if (u.protocol === "http:") return ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname);
146
148
  if (u.protocol !== "https:") return false;
147
- const trusted = new Set(
148
- [new URL(DEFAULT_ENDPOINT).hostname, ...trustedHosts].map((h) => String(h).toLowerCase()),
149
- );
149
+ const trusted = new Set([...BUILT_IN_HOSTS, ...trustedHosts].map((h) => String(h).toLowerCase()));
150
150
  return trusted.has(u.hostname.toLowerCase());
151
151
  }
152
152
 
@@ -355,7 +355,7 @@ async function main(argv) {
355
355
 
356
356
  const optedOut = prefs.global?.usageLog?.optOut === true;
357
357
  const entry = `${process.env.USER || user}_Usage_Ingest_Token`;
358
- const set = spawnSync("bash", [credStore, "set", entry], {
358
+ const set = spawnSync("bash", [credStore, "set", entry, "-"], {
359
359
  input: token,
360
360
  encoding: "utf8",
361
361
  timeout: 10000,
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { readFileSync, existsSync, readdirSync, appendFileSync } from "fs";
31
+ import { canonicalEndpoint } from "../lib/usage-endpoint.mjs";
31
32
  import { homedir, platform } from "os";
32
33
  import { dirname, join } from "path";
33
34
  import { fileURLToPath, pathToFileURL } from "url";
@@ -339,10 +340,10 @@ function commandOf(state, mode) {
339
340
  return mode;
340
341
  }
341
342
 
342
- // A repository name is the user's, not ours. It put a corporate project into
343
- // the maintainer's database, which is the disclosure this telemetry must not
344
- // make - and the panel never needed the name, only the ability to tell one
345
- // 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.
346
347
  function projectDigest(name) {
347
348
  if (typeof name !== "string" || !name.trim()) return null;
348
349
  return `p${createHash("sha256").update(name.trim()).digest("hex").slice(0, 8)}`;
@@ -721,7 +722,7 @@ async function main() {
721
722
  const args = parseArgs(process.argv.slice(2));
722
723
  if (process.env.MULTI_AGENT_SMOKE && !args.dryRun) return;
723
724
  const prefs = resolvePrefs();
724
- const endpoint = prefs.endpoint || ENDPOINT_DEFAULT;
725
+ const endpoint = canonicalEndpoint(prefs.endpoint || ENDPOINT_DEFAULT);
725
726
 
726
727
  // Gate BEFORE touching the keychain: optOut is a hard block, and reading the
727
728
  // secret for a disabled feature is both wasteful and can raise a macOS
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-09-25T08:57:50Z",
3
+ "generatedAt": "2026-09-25T15:14:42Z",
4
4
  "skillCount": 218,
5
5
  "entries": [
6
6
  {
@@ -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",