@mmerterden/multi-agent-pipeline 16.30.0 → 16.31.1

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.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,54 @@ Internal file-layout changes that don't affect the slash-command surface are sti
14
14
 
15
15
  ---
16
16
 
17
+ ## [16.31.1] - 2026-09-13
18
+
19
+ ### Fixed
20
+
21
+ - **A literal keychain item name shipped in `refs/rules.md` for two months.** The Vercel section's example read `credential-store.sh get <maintainer's own item>`, which both named whose machine the file came from and taught the wrong mechanism: `keychainMapping` exists precisely so shipped files name the LOGICAL key and the mapping resolves the per-machine item. The example is now the one-line wrapper call, which resolves the token itself. `sync/SKILL.md`'s publish-candidate table and `keychain.py`'s comment carried the same shape and are genericized.
22
+
23
+ - **`smoke-personal-data.sh` had no pattern for that shape**, which is why it survived every scan. Added, case-sensitively: the owner segment must start lowercase, which is exactly what separates a resolved item name from the `${USER}_...` and `{USER}_...` template spellings the convention calls for - under the main list's `-i` that distinction disappears and the templates match too. It found three live instances on its first run.
24
+
25
+ - `account-resolver.sh`'s prefix example and `smoke-pref-migration.sh`'s fixtures now use placeholder spellings rather than ones that read like a real account.
26
+
27
+ ## [16.31.0] - 2026-09-13
28
+
29
+ A pipeline that leaves worktrees, artefacts and exclude lines behind is not generic tooling, it is a guest that rearranges the furniture. This release makes a run leave the repo exactly as it found it, and fixes two credentials-and-integrity gaps found while proving it.
30
+
31
+ ### Added
32
+
33
+ - **`pipeline/lib/repo-hygiene.sh`** - one owner for everything the pipeline writes into somebody else's repo. `ensure_exclusions` writes a marked block into `.git/info/exclude` covering `.worktrees/`, `.pipeline/`, `.multi-agent/`, `triage-output.json`, `.review-diff.txt`, `.build.log` and `.test.log`; `release_exclusions` removes exactly that block and nothing around it; `prune_empty` removes directories it can `rmdir` and never touches `memory/`. The single-line residue guard that lived inline in Phase 0 moved here, so the write and the removal are now written next to each other rather than one existing and the other not.
34
+
35
+ The exclude path resolves through `--git-common-dir`, not `--git-dir`: inside a worktree the latter points at `.git/worktrees/<name>/`, where an exclude file is read by that worktree alone and disappears with it.
36
+
37
+ **`safe_root` refuses `/`, `$HOME`, an empty argument and anything that does not resolve.** Every sibling GC script already had that guard; this one wrote into `$HOME/.multi-agent/` without it, which is a real path on a real machine.
38
+
39
+ - **`doctor`: a `worktree-residue` check** (the 15th). Reports per-repo `.worktrees` count and total size and points at `/multi-agent:garbage-collect` above the threshold. Written because the leak was invisible: one reference repo had accumulated 15 worktrees and 11.4 GB, and nothing reported it.
40
+
41
+ - **`smoke-repo-residue.sh`** (26 assertions, 10 sections) - simulates a full run's artefacts in a temporary repo and proves `git status --porcelain` is empty afterwards. There was no gate of this shape before, which is why the leak survived.
42
+
43
+ ### Fixed
44
+
45
+ - **`vercel-deploy.sh` never read the credential store it advertised.** The header has promised "resolved from prefs keychainMapping if unset" since the wrapper was written; the block under that promise sourced the resolver only to name it in an error message. A machine with the token onboarded was still told to set an environment variable. Resolution now lives in one function that both `doctor` and `deploy` call, so the diagnostic can no longer report a token missing that a deploy would have found.
46
+
47
+ The key was also **impossible to map**: `keychainMapping` is `additionalProperties: false` and carried no `vercel` slot, so the remediation the error printed could not be followed. The slot now exists in the schema, the preferences template, `credential-inventory.sh`'s capability table and the `keychain.md` key table - the four places `smoke-credential-key-alignment.sh` requires a runtime key to appear, and that gate caught three of them being missing.
48
+
49
+ - **`.skill-manifest.json` had been stale since 09-09.** Four `SKILL.md` files changed under it and two more were never in it at all, so every install printed drift warnings nobody could act on, and a signed manifest that is allowed to rot is decorative. `smoke-gate-wiring.sh` exempted `sign-skills.sh` as "release-time signing" for a release step that did not exist; the release flow now names it, and the manifest smoke verifies the **shipped tree** rather than only its fixtures.
50
+
51
+ - **An exclude file with no trailing newline was corrupted.** The block marker was appended directly onto the user's last line, which both mangled that line and made the block unremovable, since the removal matches on the marker being at the start of a line.
52
+
53
+ - **`gc-tmp.sh` swept four temp prefixes it never knew about** (`complaint-analysis-*`, `generate-issue-*`, `cred-inventory-*`, `pr-body-*`) and `*-wiki` clones. The `.git` requirement that guards wiki clones applies to those alone - applied to the prefix list it would have narrowed an unconditional sweep.
54
+
55
+ - **`gc-worktrees.sh` failed silently when the hygiene lib was absent**, doing nothing where it was meant to write the guard. It now says so loudly.
56
+
57
+ - **Two legacy data roots** (`~/.claude/multi-agent-tasks/`, `~/.claude/.multi-agent-state/`, both with zero code references) are recognised by `pruneAbandonedTrees()` through an entry marker, so one content-bearing entry without the marker keeps the whole tree rather than the installer deleting somebody's data.
58
+
59
+ ### Changed
60
+
61
+ - `worktree-finalize.sh` prunes through the resolved real path rather than `$PROJECT_ROOT`, which differs whenever the repo is reached through a symlink.
62
+ - `purge` is the only path that releases the managed exclude block - a garbage collection run is not a reason to stop ignoring the pipeline's own artefacts.
63
+ - `smoke-offload-ref.sh` and `smoke-gc-worktrees.sh` now assert through `git check-ignore` on the real leak paths instead of matching our own printed strings.
64
+
17
65
  ## [16.30.0] - 2026-09-12
18
66
 
19
67
  The flow video existed as a contract with no recorder, no UI test ever ran, and the PR carried filenames where a reviewer needed a picture. This release makes the evidence real and asks the user what it is worth paying for.
@@ -268,13 +268,30 @@ export function pruneLegacyMultiAgentSkills(skillsDir) {
268
268
  *
269
269
  * `root: "home"` entries sit beside `~/.claude`, not inside it.
270
270
  *
271
- * @type {ReadonlyArray<{dir: string, root?: "claude"|"home", reason: string}>}
271
+ * @type {ReadonlyArray<{dir: string, root?: "claude"|"home", reason: string, knownEntries?: string[], entryMarker?: string}>}
272
272
  */
273
273
  const ABANDONED_TREES = Object.freeze([
274
274
  {
275
275
  dir: "eval",
276
276
  reason: "eval corpora; the harnesses that read them are maintainer-only and no longer ship",
277
277
  },
278
+ {
279
+ // Two roots the pipeline wrote task state into before the log root settled
280
+ // on ~/.claude/logs/multi-agent/. Nothing in the tree references either any
281
+ // more - no script, no doc, no schema - so they are orphaned copies of
282
+ // state nothing can read. `entryMarker` is the guard: every entry must be a
283
+ // directory holding an agent-state.json, which is the shape only this
284
+ // pipeline wrote. A folder someone else put there fails that test and the
285
+ // tree is kept.
286
+ dir: "multi-agent-tasks",
287
+ entryMarker: "agent-state.json",
288
+ reason: "pre-log-root task state; the current root is ~/.claude/logs/multi-agent/",
289
+ },
290
+ {
291
+ dir: ".multi-agent-state",
292
+ entryMarker: "agent-state.json",
293
+ reason: "pre-log-root task state; the current root is ~/.claude/logs/multi-agent/",
294
+ },
278
295
  {
279
296
  dir: ".multi-agent",
280
297
  root: "home",
@@ -290,9 +307,33 @@ const ABANDONED_TREES = Object.freeze([
290
307
  /**
291
308
  * @param {string} target
292
309
  * @param {ReadonlyArray<string>|undefined} knownEntries
310
+ * @param {string|undefined} entryMarker file every entry must contain, for trees keyed by task id
293
311
  * @returns {boolean} true when every top-level entry is one the abandoned tree is known to contain
294
312
  */
295
- function isKnownLegacyTree(target, knownEntries) {
313
+ function isKnownLegacyTree(target, knownEntries, entryMarker) {
314
+ if (entryMarker) {
315
+ // Shape check rather than a name list, for trees whose entries are task ids
316
+ // and therefore unknowable in advance. An entry qualifies when it carries
317
+ // the marker, or when it is an empty directory - an abandoned run that was
318
+ // torn down leaves one of those, and nothing that is empty can belong to
319
+ // anyone else. A directory with content but no marker is somebody's, and
320
+ // one such entry keeps the whole tree.
321
+ try {
322
+ return readdirSync(target)
323
+ .filter((name) => name !== ".DS_Store")
324
+ .every((name) => {
325
+ const entry = join(target, name);
326
+ if (existsSync(join(entry, entryMarker))) return true;
327
+ try {
328
+ return readdirSync(entry).filter((n) => n !== ".DS_Store").length === 0;
329
+ } catch {
330
+ return false;
331
+ }
332
+ });
333
+ } catch {
334
+ return false;
335
+ }
336
+ }
296
337
  if (!knownEntries) return true;
297
338
  try {
298
339
  return readdirSync(target).every((name) => knownEntries.includes(name));
@@ -329,11 +370,11 @@ export const COMMAND_RENAMES = Object.freeze({
329
370
  */
330
371
  export function pruneAbandonedTrees(claudeDir, home = dirname(claudeDir)) {
331
372
  let removed = 0;
332
- for (const { dir, root, reason, knownEntries } of ABANDONED_TREES) {
373
+ for (const { dir, root, reason, knownEntries, entryMarker } of ABANDONED_TREES) {
333
374
  const base = root === "home" ? home : claudeDir;
334
375
  const target = join(base, dir);
335
376
  if (!existsSync(target)) continue;
336
- if (!isKnownLegacyTree(target, knownEntries)) {
377
+ if (!isKnownLegacyTree(target, knownEntries, entryMarker)) {
337
378
  console.log(
338
379
  ` kept ${target}: holds entries no abandoned install wrote, so it is not ours to remove`,
339
380
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "16.30.0",
3
+ "version": "16.31.1",
4
4
  "description": "8-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -120,9 +120,8 @@ Step 0 gate rules and why: `features/doctor.md`.
120
120
  - `~/.claude/CLAUDE.md`, `~/.claude/rules/`, `~/.claude/knowledge/`
121
121
  - `~/.claude/scripts/` - EXCEPT `pre-commit-check.sh`, `agent-guard.sh`, `agent-guard.py`, and `build-stack-plugins.mjs` (generic, synced)
122
122
  - `~/.claude/settings.json`
123
- - **Any `~/.claude/commands/multi-agent/*/SKILL.md` whose frontmatter has `local-only: true`** - these are user/repo-specific alias wrappers that delegate to a private marketplace plugin's skills exposed as `multi-agent:<name>`. Syncing them would leak the private plugin/skill names into the public pipeline. Filter before copy: skip every source file containing `local-only: true`, and after copy assert none reached `pipeline/commands/`.
123
+ - **Any `~/.claude/commands/multi-agent/*/SKILL.md` whose frontmatter has `local-only: true`** - these are user/repo-specific alias wrappers that delegate to a private marketplace plugin's skills exposed as `multi-agent:<name>`. Syncing them would leak the private plugin/skill names into the public pipeline. Skip them at copy time; the backstop below proves none reached `pipeline/commands/`.
124
124
  ```bash
125
- # backstop: no local-only wrapper may exist in the synced target
126
125
  grep -rl "^local-only: true" ~/multi-agent-pipeline/pipeline/commands/ 2>/dev/null \
127
126
  && { echo "ABORT: local-only wrapper leaked into pipeline/commands"; exit 1; } || true
128
127
  # backstop: corporate refs only (generic ai-ios-toolkit is fine)
@@ -140,7 +139,6 @@ Step 0 gate rules and why: `features/doctor.md`.
140
139
  # normalize: restore English descriptions, strip description-en sidecars
141
140
  node ~/multi-agent-pipeline/pipeline/scripts/localize-commands.mjs restore \
142
141
  --dir ~/multi-agent-pipeline/pipeline/commands/multi-agent
143
- # backstop: no sidecar may survive into the repo
144
142
  grep -rl "^description-en:" ~/multi-agent-pipeline/pipeline/commands/ 2>/dev/null \
145
143
  && { echo "ABORT: description-en sidecar leaked into pipeline/commands"; exit 1; } || true
146
144
  # backstop: repo description lines must be English (Turkish-diacritics proxy)
@@ -386,8 +384,8 @@ Measured on this machine, which is why the order is what it is:
386
384
  | Candidate | login | scopes |
387
385
  |---|---|---|
388
386
  | `keychainMapping.github` | corporate EMU account | cannot publish to a personal scope under any grant |
389
- | `mmerterden_Github_Auth_Token` | personal | `admin:public_key, gist, read:org, repo` - no `write:packages` |
390
- | `gh auth token -u mmerterden` | personal | `gist, read:org, repo, workflow, write:packages` ✓ |
387
+ | the personal PAT in the store | personal | `admin:public_key, gist, read:org, repo` - no `write:packages` |
388
+ | `gh auth token -u {owner}` | personal | `gist, read:org, repo, workflow, write:packages` ✓ |
391
389
 
392
390
  **Two 403s mean two different things, and neither says "wrong token" plainly:**
393
391
 
@@ -467,7 +465,8 @@ When invoked with the `release` argument:
467
465
  ```
468
466
  1. REPO Claude Code -> pipeline repo (genericized)
469
467
  2. VERIFY Personal data scan -> 0 results; config-hygiene scan (scan-agent-config.sh) -> 0 HIGH
470
- 3. LINT + TEST ESLint + smoke tests
468
+ 3. LINT + TEST ESLint + smoke tests (stale .skill-manifest.json fails here; re-sign
469
+ it with sign-skills.sh whenever a SKILL.md moved)
471
470
  4. VERSION package.json version bump (patch/minor/major)
472
471
  5. Commit + TAG git commit + git tag v{VERSION}
473
472
  6. PUSH git push --tags; publish is local (release.yml off)
@@ -8,7 +8,7 @@
8
8
  # that precedes the provider segment. Examples:
9
9
  # ${USER}_Github_Access_Token -> prefix=${USER}
10
10
  # ${USER}_Tktech_Github_Access_Token -> prefix=${USER}_Tktech
11
- # personal_Github_Auth_Token -> prefix=personal
11
+ # {prefix}_Github_Auth_Token -> prefix={prefix}
12
12
  #
13
13
  # Usage:
14
14
  # ./account-resolver.sh # JSON array
@@ -358,6 +358,7 @@ capability_of() {
358
358
  figma|figma_mcp) echo "fetch design context, screenshots and Code Connect mappings" ;;
359
359
  jenkins) echo "read build results" ;;
360
360
  npm) echo "publish to the npm registry" ;;
361
+ vercel) echo "deploy the website to Vercel and read its deployment status" ;;
361
362
  appstore_connect_key_id|appstore_connect_issuer_id)
362
363
  echo "validate an archive against App Store rules before submission (the .p8 itself is a file at ~/.appstoreconnect/private_keys/, never a mapping)" ;;
363
364
  appstore_connect_apple_id|appstore_connect_password_item)
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # repo-hygiene.sh
4
+ # Keeps pipeline artefacts out of the user's repository. Sourceable library.
5
+ #
6
+ # The pipeline writes into a checkout in three ways: the worktree parent
7
+ # (.worktrees/), the per-run artefact dir (.pipeline/), and the offload tree
8
+ # (.multi-agent/). Only the first was ever excluded, and only by a one-line
9
+ # guard duplicated across two phase docs and gc-worktrees.sh. The other two
10
+ # were never excluded anywhere, which is invisible in worktree mode (the
11
+ # worktree is deleted) and permanent in --local mode (nothing deletes it).
12
+ #
13
+ # Functions:
14
+ # ma_hygiene_ensure_exclusions <repo-root> Write the managed block into
15
+ # .git/info/exclude. Idempotent;
16
+ # rewrites the block on upgrade so
17
+ # new entries reach old checkouts.
18
+ # ma_hygiene_release_exclusions <repo-root> Remove the managed block, and
19
+ # nothing else. Lines a human put
20
+ # there are never touched.
21
+ # ma_hygiene_local_gitignore <repo-root> Write .multi-agent/.gitignore.
22
+ # ma_hygiene_prune_empty <repo-root> rmdir the artefact parents when
23
+ # their last child is gone.
24
+ #
25
+ # Every function is best-effort: a read-only or non-git directory is not an
26
+ # error, because refusing to run a pipeline over a repo we cannot tidy is worse
27
+ # than leaving it untidy. Callers do not check the return value.
28
+ #
29
+ # bash 3.2 compatible (macOS ships 3.2 and has no flock, no mapfile).
30
+
31
+ MA_HYGIENE_BEGIN='# >>> multi-agent pipeline (managed) >>>'
32
+ MA_HYGIENE_END='# <<< multi-agent pipeline (managed) <<<'
33
+
34
+ # Paths the pipeline writes inside a checkout. `.worktrees/` predates this file
35
+ # and may already be present as a bare line; git treats a duplicated pattern as
36
+ # one, so the managed block restates it rather than trying to adopt it.
37
+ ma_hygiene_patterns() {
38
+ cat <<'PATTERNS'
39
+ .worktrees/
40
+ .pipeline/
41
+ .multi-agent/
42
+ triage-output.json
43
+ .review-diff.txt
44
+ .build.log
45
+ .test.log
46
+ PATTERNS
47
+ }
48
+
49
+ # Same contract every gc-*.sh in this tree carries: refuse to operate on `/`,
50
+ # on $HOME, or on a path that does not resolve. $HOME matters specifically -
51
+ # `$HOME/.multi-agent/` is a real directory the installer knows about, so a
52
+ # caller that passed the wrong root would have had `prune_empty` delete a file
53
+ # out of it. Callers never check the return value; refusing is silent and safe.
54
+ #
55
+ # One consequence worth knowing: offload-ref.sh and bulk-read.sh fall back to
56
+ # $PWD when they are not inside a repo, so offloading from $HOME itself now
57
+ # skips writing the ignore file. The payload is still written; only the ignore
58
+ # is skipped, and $HOME/.multi-agent/ is a tree the installer prunes anyway.
59
+ ma_hygiene_safe_root() {
60
+ local root="${1:-}" resolved
61
+ [ -n "$root" ] || return 1
62
+ resolved=$(cd "$root" 2>/dev/null && pwd -P) || return 1
63
+ [ -n "$resolved" ] || return 1
64
+ [ "$resolved" = "/" ] && return 1
65
+ [ "$resolved" = "$(cd "$HOME" 2>/dev/null && pwd -P)" ] && return 1
66
+ printf '%s\n' "$resolved"
67
+ }
68
+
69
+ # Resolve the exclude file through --git-common-dir, not --git-dir: inside a
70
+ # worktree the latter points at .git/worktrees/<name>, whose info/exclude is
71
+ # per-worktree and dies with it.
72
+ ma_hygiene_exclude_path() {
73
+ local root="$1" common
74
+ common=$(git -C "$root" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 1
75
+ [ -n "$common" ] || return 1
76
+ printf '%s/info/exclude\n' "$common"
77
+ }
78
+
79
+ ma_hygiene_ensure_exclusions() {
80
+ local root ex tmp
81
+ root=$(ma_hygiene_safe_root "${1:-}") || return 0
82
+ ex=$(ma_hygiene_exclude_path "$root") || return 0
83
+ mkdir -p "$(dirname "$ex")" 2>/dev/null || return 0
84
+ [ -f "$ex" ] || : > "$ex" 2>/dev/null || return 0
85
+
86
+ tmp="${ex}.ma-tmp.$$"
87
+ # Drop any previous managed block, keep everything else verbatim, then append
88
+ # the current one. sed is the portable way to delete an inclusive range.
89
+ if grep -qF "$MA_HYGIENE_BEGIN" "$ex" 2>/dev/null; then
90
+ sed "/^$(printf '%s' "$MA_HYGIENE_BEGIN" | sed 's/[][\.*^$\/]/\\&/g')$/,/^$(printf '%s' "$MA_HYGIENE_END" | sed 's/[][\.*^$\/]/\\&/g')$/d" "$ex" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; }
91
+ else
92
+ cat "$ex" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; }
93
+ fi
94
+
95
+ # A file whose last line has no newline would take the BEGIN marker onto the
96
+ # end of it. That corrupts the user's line AND leaves the marker unanchored,
97
+ # so `release_exclusions` can never match it again and the block is stuck in
98
+ # their file for good. Close the line first.
99
+ if [ -s "$tmp" ] && [ "$(tail -c 1 "$tmp" | wc -l | tr -d ' ')" = "0" ]; then
100
+ printf '\n' >> "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; }
101
+ fi
102
+
103
+ {
104
+ printf '%s\n' "$MA_HYGIENE_BEGIN"
105
+ ma_hygiene_patterns
106
+ printf '%s\n' "$MA_HYGIENE_END"
107
+ } >> "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; }
108
+
109
+ mv "$tmp" "$ex" 2>/dev/null || rm -f "$tmp"
110
+ return 0
111
+ }
112
+
113
+ ma_hygiene_release_exclusions() {
114
+ local root ex tmp
115
+ root=$(ma_hygiene_safe_root "${1:-}") || return 0
116
+ ex=$(ma_hygiene_exclude_path "$root") || return 0
117
+ [ -f "$ex" ] || return 0
118
+ grep -qF "$MA_HYGIENE_BEGIN" "$ex" 2>/dev/null || return 0
119
+
120
+ tmp="${ex}.ma-tmp.$$"
121
+ sed "/^$(printf '%s' "$MA_HYGIENE_BEGIN" | sed 's/[][\.*^$\/]/\\&/g')$/,/^$(printf '%s' "$MA_HYGIENE_END" | sed 's/[][\.*^$\/]/\\&/g')$/d" "$ex" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; }
122
+ mv "$tmp" "$ex" 2>/dev/null || rm -f "$tmp"
123
+ return 0
124
+ }
125
+
126
+ # `*` and not a list of children: an ignore file that names its siblings does
127
+ # not name itself, so .multi-agent/.gitignore stayed permanently untracked -
128
+ # the one piece of residue the guard existed to prevent. Git reads an ignore
129
+ # file whether or not it is itself ignored, so self-exclusion is safe.
130
+ ma_hygiene_local_gitignore() {
131
+ local root gi
132
+ root=$(ma_hygiene_safe_root "${1:-}") || return 0
133
+ gi="$root/.multi-agent/.gitignore"
134
+ mkdir -p "$root/.multi-agent" 2>/dev/null || return 0
135
+ if [ ! -f "$gi" ] || ! grep -q '^\*$' "$gi" 2>/dev/null; then
136
+ printf '# Local run artefacts - never commit. Ignores this file too.\n*\n' > "$gi" 2>/dev/null || return 0
137
+ fi
138
+ return 0
139
+ }
140
+
141
+ # rmdir, never rm -rf: it fails harmlessly when anything is left, which is the
142
+ # behaviour we want. An empty .worktrees/ and .pipeline/evidence/ survive a
143
+ # clean run today and read as leftovers to anyone looking at the checkout.
144
+ ma_hygiene_prune_empty() {
145
+ local root d
146
+ root=$(ma_hygiene_safe_root "${1:-}") || return 0
147
+ for d in "$root/.pipeline/evidence" "$root/.pipeline" "$root/.worktrees/.archive" "$root/.worktrees" "$root/.multi-agent/refs"; do
148
+ [ -d "$d" ] && rmdir "$d" 2>/dev/null
149
+ done
150
+
151
+ # .multi-agent survives the loop above because it still holds the .gitignore
152
+ # this library wrote. That file is ours, so a directory holding nothing else
153
+ # is residue, not user data - but `memory/` there IS user data and is never
154
+ # touched (see commands/multi-agent/uninstall/SKILL.md).
155
+ if [ -d "$root/.multi-agent" ]; then
156
+ local leftover
157
+ leftover=$(ls -A "$root/.multi-agent" 2>/dev/null)
158
+ if [ "$leftover" = ".gitignore" ]; then
159
+ rm -f "$root/.multi-agent/.gitignore" 2>/dev/null
160
+ rmdir "$root/.multi-agent" 2>/dev/null
161
+ fi
162
+ fi
163
+ return 0
164
+ }
@@ -23,7 +23,8 @@
23
23
  # pipeline/lib/vercel-deploy.sh doctor # env + CLI check
24
24
  #
25
25
  # Environment:
26
- # VERCEL_TOKEN - required for deploy (resolved from prefs keychainMapping if unset)
26
+ # VERCEL_TOKEN - required for deploy; when unset it is resolved from
27
+ # prefs keychainMapping.vercel via credential-store.sh
27
28
  # VERCEL_ORG_ID - optional, scopes deploys to a team
28
29
  # VERCEL_PROJECT_ID - optional, links to a specific project
29
30
  # VERCEL_DEPLOY_QUIET - when set, suppresses progress lines (raw CLI output stays redacted)
@@ -63,8 +64,44 @@ cmd_redact() {
63
64
  redact_filter
64
65
  }
65
66
 
67
+ # Resolve VERCEL_TOKEN from the credential store when the environment has none.
68
+ # The header has promised "resolved from prefs keychainMapping if unset" since
69
+ # this wrapper was written, but nothing ever read the store: the block below
70
+ # sourced the resolver only to name it in an error message. Both `doctor` and
71
+ # `deploy` call this now, so the diagnostic can no longer report "unset" for a
72
+ # token a deploy would have found.
73
+ #
74
+ # credential-store.sh takes the LOGICAL key and maps it through
75
+ # prefs.global.keychainMapping itself, so `get vercel` is the whole mechanism.
76
+ # Command substitution, never argv: the CLI echoes its own arguments on retry.
77
+ ma_vercel_resolve_token() {
78
+ [ -z "${VERCEL_TOKEN:-}" ] || return 0
79
+ local _r
80
+ for _r in \
81
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
82
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
83
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
84
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
85
+ [ -f "$_r" ] || continue
86
+ # shellcheck source=/dev/null
87
+ . "$_r" 2>/dev/null || true
88
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
89
+ done
90
+ [ -n "${CRED_STORE:-}" ] || return 0
91
+ local _tok
92
+ _tok=$("$CRED_STORE" get vercel 2>/dev/null || true)
93
+ # Only export a real value. Exporting an empty VERCEL_TOKEN would hand every
94
+ # child process a defined-but-blank variable where it previously saw none,
95
+ # and "set to nothing" reads differently from "unset" to the Vercel CLI.
96
+ [ -n "$_tok" ] || return 0
97
+ VERCEL_TOKEN="$_tok"
98
+ export VERCEL_TOKEN
99
+ return 0
100
+ }
101
+
66
102
  cmd_doctor() {
67
103
  local rc=0
104
+ ma_vercel_resolve_token
68
105
  if command -v vercel >/dev/null 2>&1; then
69
106
  echo "vercel: $(vercel --version 2>&1 | redact_filter | head -1)"
70
107
  else
@@ -109,28 +146,10 @@ cmd_deploy() {
109
146
  exit 1
110
147
  fi
111
148
 
149
+ ma_vercel_resolve_token
112
150
  if [ -z "${VERCEL_TOKEN:-}" ]; then
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
- # Existence check before sourcing: `. <missing>` aborts the shell under `set -e`,
119
- # `||` included, so a `.`-chain reaches neither its later candidates nor its error
120
- # branch. The loop also covers all three hosts - the chain it replaced knew only
121
- # .claude and .copilot, so a Codex-only install could not resolve at all.
122
- for _cred_resolver in \
123
- "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
124
- "$HOME/.claude/lib/credential-store-resolver.sh" \
125
- "$HOME/.copilot/lib/credential-store-resolver.sh" \
126
- "$HOME/.codex/lib/credential-store-resolver.sh"; do
127
- [ -f "$_cred_resolver" ] || continue
128
- # shellcheck source=/dev/null
129
- . "$_cred_resolver" 2>/dev/null || true
130
- if [ -n "${CRED_STORE:-}" ]; then break; fi
131
- done
132
- unset _cred_resolver
133
- echo "ERROR: VERCEL_TOKEN not set. Resolve via env or keychain ('${CRED_STORE:-credential-store.sh} get <vercel-key>')." >&2
151
+ echo "ERROR: VERCEL_TOKEN not set and keychainMapping.vercel resolved nothing." >&2
152
+ echo "Map it once: point prefs.global.keychainMapping.vercel at the keychain item holding the token." >&2
134
153
  echo "Hint: vercel CLI accepts the token via env var; do NOT pass --token= on argv (leaks on retry)." >&2
135
154
  exit 1
136
155
  fi
@@ -195,3 +195,15 @@ read the registration and stopped would have called that healthy.
195
195
  Free space on the volume holding `$HOME`. WARN under 2 GB: a worktree plus a
196
196
  build is the largest thing a run writes, and ENOSPC mid-run corrupts the state
197
197
  file it was writing at the time.
198
+
199
+ ### worktree-residue
200
+
201
+ Worktrees left under `<repo>/.worktrees/` in the repository the caller is
202
+ standing in. WARN at five or more, or at 2 GB. A finished task removes its own
203
+ worktree at PR time, but a run that stops before Phase 6 never reaches that
204
+ step and nothing else collects it: the finalizer only runs on success, and
205
+ `gc-worktrees` only sweeps entries git has already forgotten. Each survivor is
206
+ a full second checkout, so the total is measured in gigabytes rather than
207
+ megabytes. SKIP outside a git repository - a project name in prefs is a name,
208
+ not a path, and guessing checkout locations to produce a number is how a
209
+ diagnostic starts lying.
@@ -121,10 +121,10 @@ evidence, and there is now one fewer of them. Triage also runs on `opus`, which
121
121
  makes it the same model as Reviewer 1; the Step 3 anonymisation requirement
122
122
  already covers that case and is not optional here.
123
123
 
124
- **Cost accounting.** `prefs.global.costBudget.priceAt` defaults to `fable` to
124
+ **Cost accounting.** `prefs.global.costBudget.pricingModel` defaults to `fable` to
125
125
  keep the estimate an upper bound. With the rung off, that default prices every
126
126
  call above what it can cost and trips the budget ceiling early, which then
127
- triggers a downgrade nobody needed. Set `priceAt` to `opus` alongside
127
+ triggers a downgrade nobody needed. Set `pricingModel` to `opus` alongside
128
128
  `fableEnabled: false`.
129
129
 
130
130
  No log line is emitted per dispatch for this: it is a configured absence, not a
@@ -158,6 +158,7 @@ The shell driver auto-delegates to `~/.claude/scripts/keychain.py` on macOS / Li
158
158
  | `graylog_test` | Graylog (test) | `${USER}_Graylog_Test_Access_Token` | API Token, optional - unset falls back to `graylog` | Same page on the TEST instance; optional |
159
159
  | `firebase` | Firebase | `${USER}_Firebase_Access_Json` | Service-account JSON, as issued. One key per Firebase project; extras are named `..._Json_<projectId>` and listed in `global.firebase.accounts[]` | Firebase Console -> Project settings -> Service accounts -> Generate new private key |
160
160
  | `jenkins` | Jenkins CI | `${USER}_Jenkins_Access_Token` | API Token | Jenkins -> User -> Configure -> API Token |
161
+ | `vercel` | Vercel deploys | `${USER}_Vercel_Access_Token` | Access Token | Vercel -> Account Settings -> Tokens. Read by `vercel-deploy.sh`; never passed on argv |
161
162
  | `appstore_connect_key_id` | App Store Connect | `${USER}_AppStoreConnect_Key_Id` | Identifier, not a secret | App Store Connect -> Users and Access -> Integrations -> App Store Connect API |
162
163
  | `appstore_connect_issuer_id` | App Store Connect | `${USER}_AppStoreConnect_Issuer_Id` | Identifier, not a secret | Same page as the key id; one issuer id per team |
163
164
  | `appstore_connect_apple_id` | App Store Connect | `${USER}_AppStoreConnect_Apple_Id` | Email address | The Apple ID itself; no generation step |
@@ -63,7 +63,7 @@ Knowledge files grow over time. Maintenance rules:
63
63
  and a rebuild is seconds, so age is the wrong question to ask of it.
64
64
  - Stale entries are added to the prompt with a "STALE - verify before relying" tag
65
65
  - `prune-logs` does not touch knowledge - only deletes logs and state
66
- - `purge` does not touch knowledge either - separate command: `/multi-agent clear-knowledge {project}`
66
+ - `purge` does not touch knowledge either. Today the only removal path is `/multi-agent:uninstall --all-data`, which drops every project's knowledge at once; there is no per-project clear yet
67
67
 
68
68
  ---
69
69
 
@@ -447,14 +447,15 @@ fi
447
447
 
448
448
  If the path is still registered and healthy after the heal, enter + pull instead of re-adding (existing behavior). Only when `worktree add` still fails after the heal do the rollback / collision flow in the multi-repo block below.
449
449
 
450
- **Worktree residue guard (required once per repo, idempotent):** every worktree dir holds a `.git` file, so a blanket `git add -A` in the parent tree records `.worktrees/{id}` as a gitlink that pollutes the branch. Keep `.worktrees/` out of the index via the clone-local exclude file (never committed, so no PR noise):
450
+ **Repo residue guard (required once per repo, idempotent):** keep every path the pipeline writes - `.worktrees/`, `.pipeline/`, `.multi-agent/` and the loose logs - out of the index via the clone-local exclude file (never committed, so no PR noise). One call; the rationale is in the lib header:
451
451
 
452
452
  ```bash
453
- ex="$(git -C "$PROJECT_ROOT" rev-parse --path-format=absolute --git-common-dir)/info/exclude"
454
- mkdir -p "$(dirname "$ex")"
455
- grep -qxF '.worktrees/' "$ex" 2>/dev/null || printf '.worktrees/\n' >> "$ex"
453
+ . "$HOME/.claude/lib/repo-hygiene.sh"
454
+ ma_hygiene_ensure_exclusions "$PROJECT_ROOT"
456
455
  ```
457
456
 
457
+ Rewritten on each call, so an older checkout picks up new entries; lines a human added are never touched.
458
+
458
459
  **Traversal-prune contract:** the exclude guard covers only the git INDEX, not
459
460
  filesystem scans. Since each worktree is a full checkout, an unpruned tree walk
460
461
  double-processes every file and can re-stage gitlinks. So `.worktrees` joins the
@@ -468,14 +469,13 @@ scan, `shadow-git.sh` excludes, and the shared walkers (`extract-conventions.sh`
468
469
  When `state.projects[].length > 1`, repeat steps 2-4 **serially per repo** (worktrees are cheap; serial keeps git index sane and surfaces collisions one at a time):
469
470
 
470
471
  ```bash
472
+ . "$HOME/.claude/lib/repo-hygiene.sh"
471
473
  for proj in "${PROJECTS[@]}"; do
472
474
  WT_PATH="$proj/.worktrees/$BRANCH_DIR/"
473
475
  git -C "$proj" worktree prune 2>/dev/null || true # heal stale admin state
474
476
  git -C "$proj" worktree list --porcelain | grep -qF "$WT_PATH" \
475
477
  && git -C "$proj" worktree unlock "$WT_PATH" 2>/dev/null || true
476
- ex="$(git -C "$proj" rev-parse --path-format=absolute --git-common-dir)/info/exclude"
477
- mkdir -p "$(dirname "$ex")"
478
- grep -qxF '.worktrees/' "$ex" 2>/dev/null || printf '.worktrees/\n' >> "$ex" # residue guard
478
+ ma_hygiene_ensure_exclusions "$proj" # residue guard
479
479
  git -C "$proj" worktree add "$WT_PATH" -b "$BRANCH" "origin/$BASE_BRANCH"
480
480
  git -C "$WT_PATH" config user.name "${proj_identity_name}"
481
481
  git -C "$WT_PATH" config user.email "${proj_identity_email}"
@@ -120,7 +120,7 @@ Based on Phase 1 `detectedStack`, assign relevant skills:
120
120
 
121
121
  #### Output contract
122
122
 
123
- Phase 2 produces an object conforming to `$HOME/.claude/schemas/planning-output.schema.json` - `tasks[]` with `id`, `subject`, `targetFiles`, `complexity`, `blockedBy`, plus optional `architectureNotes` and `mode`. Phase 3 reads `tasks[]` in dependency order; the schema's `blockedBy` field drives the ready-task picker.
123
+ Phase 2 produces an object conforming to `$HOME/.claude/schemas/planning-output.schema.json` - `tasks[]` with `id`, `title`, `type`, `files`, plus optional `dependsOn` and `acceptanceCriteria`. Phase 3 reads `tasks[]` in dependency order; the schema's `dependsOn` field drives the ready-task picker.
124
124
 
125
125
  **Required: validator gate (deterministic) - run on the persisted file before the approval gate renders the plan; the validator's exit code decides, not the LLM turn:**
126
126
 
@@ -49,7 +49,7 @@ The analysis document is the SOLE design source in Phase 3. Variant choices, pad
49
49
 
50
50
  #### Input contract
51
51
 
52
- Phase 3 consumes the Phase 2 output object conforming to `$HOME/.claude/schemas/planning-output.schema.json` - the task graph (`tasks[]` with `id`, `subject`, `targetFiles`, `complexity`, `blockedBy`) plus the architecture review notes. Tasks execute in dependency order; the schema's `blockedBy` field drives the ready-task picker. In a Short run (no Phase 2), Opus generates the equivalent task list inline before entering the loop below.
52
+ Phase 3 consumes the Phase 2 output object conforming to `$HOME/.claude/schemas/planning-output.schema.json` - the task graph (`tasks[]` with `id`, `title`, `type`, `files`, and optional `dependsOn` / `acceptanceCriteria`) plus the architecture review notes. Tasks execute in dependency order; the schema's `dependsOn` field drives the ready-task picker. In a Short run (no Phase 2), Opus generates the equivalent task list inline before entering the loop below.
53
53
 
54
54
  **Plan Todo iteration (opt-in)**: gated by `prefs.global.planTodos.enabled` (default: `false`). When enabled and Phase 2 Step 4.5 emitted a `plan.todos[]`, Phase 3 iterates via `$HOME/.claude/lib/plan-todos.sh next/start/complete/fail` instead of walking `tasks[]` directly. When disabled, the loop walks `tasks[]` from `planning-output` - TDD contract is unchanged. Full helper loop + state semantics: `$HOME/.claude/multi-agent-refs/features/plan-todos.md`. A todo with `sourceTag: Reuse` binds the file analysis already found; `Modify` edits in place. Writing a new file over a `Reuse` step is a Locked 11 violation and Phase 4 flags it.
55
55
 
@@ -126,7 +126,7 @@ Tier 1 / Tier 2 records print `screenshotUrl` from the captured evidence (Tier 2
126
126
  git -C "$PROJECT_ROOT" worktree unlock "{worktree-path}" 2>/dev/null || true
127
127
  fi
128
128
  ```
129
- Phase 0's `.worktrees/` residue guard is already in `.git/info/exclude` - no re-add.
129
+ Phase 0's repo residue guard is already in `.git/info/exclude` - no re-add.
130
130
  - Recreate worktree from branch: `git -C $PROJECT_ROOT worktree add {worktree-path} {branch}`
131
131
  - Re-set git identity: `git -C {worktree-path} config user.name/email` (from state)
132
132
  - Go back to Phase 3
@@ -161,11 +161,10 @@ This is the single source of truth. When a contributor or model is unsure where
161
161
  Provider tools that print failed argv on retry leak credentials into the conversation transcript. Every Vercel call from the pipeline (Phase 6 deploy hooks, Phase 7 site updates, manual `vercel deploy` shells) MUST go through the wrapper:
162
162
 
163
163
  ```bash
164
- # CORRECT - token via env var, output redacted automatically. Token resolution
165
- # goes through the cross-platform credential helper so the same snippet works
166
- # on macOS / Linux / Windows installs:
167
- VERCEL_TOKEN="$(~/.claude/lib/credential-store.sh get mmerterden_Vercel_Access_Token)" \
168
- bash $HOME/.claude/lib/vercel-deploy.sh deploy --prod
164
+ # CORRECT - the wrapper resolves the token itself, via the logical key `vercel`
165
+ # that keychainMapping maps to this machine's item. Never name the item here.
166
+ # An explicit VERCEL_TOKEN in the environment still wins.
167
+ bash $HOME/.claude/lib/vercel-deploy.sh deploy --prod
169
168
 
170
169
  # Health check before deploy:
171
170
  bash $HOME/.claude/lib/vercel-deploy.sh doctor
@@ -15,7 +15,8 @@
15
15
  "graylog_test": null,
16
16
  "usage_ingest": null,
17
17
  "firebase": null,
18
- "jenkins": null
18
+ "jenkins": null,
19
+ "vercel": null
19
20
  },
20
21
  "tokenScripts": {},
21
22
  "platformIdentityRouting": {},
@@ -198,6 +198,10 @@
198
198
  "type": ["string", "null"],
199
199
  "description": "NPM registry token (npm.pkg.github.com or npmjs.com). Used by package publish flow."
200
200
  },
201
+ "vercel": {
202
+ "type": ["string", "null"],
203
+ "description": "Keychain item holding the Vercel access token. vercel-deploy.sh resolves it through credential-store.sh so the value never reaches argv, where the CLI would leak it on a retry. Absent = deploys need VERCEL_TOKEN in the environment."
204
+ },
201
205
  "appstore_connect_key_id": {
202
206
  "type": ["string", "null"],
203
207
  "description": "App Store Connect API key ID. Tier 1 of the App Store Connect access chain, used by /multi-agent:store-ready Gate 2 (and its iOS alias /multi-agent:testflight-validation). An identifier rather than a secret; mapped anyway so every credential is read through the same layer. Creating an API key needs an Admin or App Manager role, which is why Tier 2 exists."
@@ -124,11 +124,19 @@ ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
124
124
  REFS_DIR="$ROOT/.multi-agent/refs"
125
125
  NODE_ID=""
126
126
  if mkdir -p "$REFS_DIR" 2>/dev/null; then
127
- GITIGNORE="$ROOT/.multi-agent/.gitignore"
128
- if [ ! -f "$GITIGNORE" ]; then
129
- printf '# Local run artefacts - never commit.\nmemory/\nrefs/\n' > "$GITIGNORE"
130
- elif ! grep -q '^refs/$' "$GITIGNORE" 2>/dev/null; then
131
- printf 'refs/\n' >> "$GITIGNORE"
127
+ # Shared with offload-ref.sh via pipeline/lib/repo-hygiene.sh. `../lib` resolves in
128
+ # both layouts: pipeline/scripts -> pipeline/lib in the repo, ~/.claude/scripts ->
129
+ # ~/.claude/lib installed. Fall back to writing it inline if the lib is absent,
130
+ # so this script stays usable standalone.
131
+ _MA_HYG="$(dirname "${BASH_SOURCE[0]}")/../lib/repo-hygiene.sh"
132
+ if [ -f "$_MA_HYG" ]; then
133
+ . "$_MA_HYG"
134
+ ma_hygiene_local_gitignore "$ROOT"
135
+ else
136
+ GITIGNORE="$ROOT/.multi-agent/.gitignore"
137
+ if [ ! -f "$GITIGNORE" ] || ! grep -q '^\*$' "$GITIGNORE" 2>/dev/null; then
138
+ printf '# Local run artefacts - never commit. Ignores this file too.\n*\n' > "$GITIGNORE"
139
+ fi
132
140
  fi
133
141
  if command -v shasum >/dev/null 2>&1; then
134
142
  DIGEST=$(shasum -a 256 "$FILE" | awk '{print substr($1,1,8)}')
@@ -84,6 +84,7 @@ const CHECK_IDS = [
84
84
  "task-tools",
85
85
  "mcp-registration",
86
86
  "disk-space",
87
+ "worktree-residue",
87
88
  ];
88
89
 
89
90
  // Only these five may return BLOCK. Enforced below, not merely documented: a
@@ -641,6 +642,59 @@ function checkDiskSpace() {
641
642
  ok("disk-space");
642
643
  }
643
644
 
645
+ // A finished task removes its own worktree at PR time, but a run that dies
646
+ // before Phase 6 never reaches that step and nothing else collects it: the
647
+ // finalizer only runs on success and gc-worktrees only sweeps entries git no
648
+ // longer knows about. So worktrees accumulate silently, and each one is a full
649
+ // second checkout. Measured on one real iOS repo: 15 left behind, 11 GB.
650
+ //
651
+ // Only the repo the caller is standing in is examined. A project name in prefs
652
+ // is a name, not a path, and guessing checkout locations to report a number is
653
+ // how a diagnostic starts lying.
654
+ function checkWorktreeResidue() {
655
+ let repo;
656
+ try {
657
+ repo = execFileSync("git", ["rev-parse", "--show-toplevel"], {
658
+ encoding: "utf8",
659
+ stdio: ["ignore", "pipe", "ignore"],
660
+ }).trim();
661
+ } catch {
662
+ skip("worktree-residue", "not inside a git repository, so there is no checkout to measure");
663
+ return;
664
+ }
665
+ const wt = join(repo, ".worktrees");
666
+ let entries;
667
+ try {
668
+ entries = readdirSync(wt).filter((n) => n !== ".DS_Store" && n !== ".archive");
669
+ } catch {
670
+ ok("worktree-residue");
671
+ return;
672
+ }
673
+ if (entries.length === 0) {
674
+ ok("worktree-residue");
675
+ return;
676
+ }
677
+ let gb = null;
678
+ try {
679
+ const out = execFileSync("du", ["-sk", wt], { encoding: "utf8" });
680
+ const kb = Number(out.trim().split(/\s+/)[0]);
681
+ if (Number.isFinite(kb)) gb = kb / 1024 / 1024;
682
+ } catch {
683
+ /* du is not everywhere; the count alone is still worth reporting */
684
+ }
685
+ const size = gb === null ? "" : `, ${gb.toFixed(1)} GB`;
686
+ if (entries.length >= 5 || (gb !== null && gb >= 2)) {
687
+ report(
688
+ "worktree-residue",
689
+ "WARN",
690
+ `${entries.length} worktree(s) left under ${wt}${size}; runs that stopped before Phase 6 are never collected`,
691
+ "run /multi-agent:garbage-collect, or /multi-agent:kill for a task you know is dead",
692
+ );
693
+ return;
694
+ }
695
+ ok("worktree-residue");
696
+ }
697
+
644
698
  /* ------------------------------------------------------------------ main -- */
645
699
 
646
700
  function main() {
@@ -675,7 +729,7 @@ function main() {
675
729
  } else {
676
730
  process.stdout.write(`${line.severity} ${line.id} - ${line.problem} - ${line.step}\n`);
677
731
  process.stdout.write(
678
- "\n-> indeterminate: the layout did not resolve, so 13 checks did not run\n",
732
+ `\n-> indeterminate: the layout did not resolve, so ${CHECK_IDS.length - 1} checks did not run\n`,
679
733
  );
680
734
  }
681
735
  process.exitCode = 4;
@@ -694,6 +748,7 @@ function main() {
694
748
  checkTaskTools();
695
749
  checkMcpRegistration();
696
750
  checkDiskSpace();
751
+ checkWorktreeResidue();
697
752
 
698
753
  const blocked = results.filter((r) => r.severity === "BLOCK");
699
754
  const warned = results.filter((r) => r.severity === "WARN");
@@ -97,8 +97,24 @@ NAME_ARGS=(
97
97
  -o -name 'context-links-*'
98
98
  -o -name 'context-by-type-*'
99
99
  -o -name 'analysis-*'
100
+ -o -name 'complaint-analysis-*'
101
+ -o -name 'generate-issue-*'
102
+ -o -name 'cred-inventory-*'
103
+ -o -name 'pr-body-*'
104
+ -o \( -name '*-wiki' -type d \)
100
105
  )
101
106
 
107
+ # `complaint-analysis-*` needs its own entry: find matches the basename against
108
+ # the whole glob, so `analysis-*` never matched it. Same for the other three,
109
+ # which were writing into $ROOT with nothing sweeping them (phase-0-init.md,
110
+ # phase-6-commit.md, generate-issue.md).
111
+ #
112
+ # `*-wiki` is the one loose pattern here, and a suffix glob over $ROOT could
113
+ # name something a person created. It is narrowed twice: `-type d` above, and
114
+ # the `.git` check in the loop below - the pipeline's only `*-wiki` artefact is
115
+ # a shallow clone (analysis/evidence.md), so a directory without a `.git` is
116
+ # somebody else's and is left alone.
117
+
102
118
  MTIME_ARGS=()
103
119
  if [ "$OLDER_MIN" -gt 0 ]; then
104
120
  MTIME_ARGS=(-mmin "+$OLDER_MIN")
@@ -114,6 +130,20 @@ while IFS= read -r -d '' p; do
114
130
  skipped_active=$((skipped_active + 1))
115
131
  continue
116
132
  fi
133
+ # The `.git` requirement belongs to the `*-wiki` pattern alone. Checking the
134
+ # suffix first would re-narrow entries an unambiguous prefix already claimed:
135
+ # `multi-agent-foo-wiki` ends in `-wiki`, and demanding a `.git` there would
136
+ # spare scratch that `multi-agent-*` has always swept unconditionally.
137
+ case "${p##*/}" in
138
+ multi-agent-* | issue-progress-* | channels-* | context-links-* | context-by-type-* | \
139
+ analysis-* | complaint-analysis-* | generate-issue-* | cred-inventory-* | pr-body-*) ;;
140
+ *-wiki)
141
+ # See the NAME_ARGS note: only our shallow clones, never a user's folder.
142
+ if [ ! -e "$p/.git" ]; then
143
+ continue
144
+ fi
145
+ ;;
146
+ esac
117
147
  matches+=("$p")
118
148
  done < <(find "$ROOT" -mindepth 1 -maxdepth 1 \( "${NAME_ARGS[@]}" \) ${MTIME_ARGS[@]+"${MTIME_ARGS[@]}"} -print0 2>/dev/null)
119
149
 
@@ -12,8 +12,13 @@
12
12
  # "Subproject commit" entry by a blanket `git add -A` before the
13
13
  # residue guard existed. --yes runs `git rm --cached` (the commit
14
14
  # itself stays yours to make).
15
- # 4. missing residue guard -> ensures `.worktrees/` is in
16
- # .git/info/exclude so the gitlink class cannot re-occur.
15
+ # 4. missing residue guard -> writes the managed block in
16
+ # .git/info/exclude (`.worktrees/`, `.pipeline/`, `.multi-agent/` and
17
+ # the loose run logs) so none of them can reach the index. The block is
18
+ # rewritten on each call, so a repo guarded by the old one-line form is
19
+ # upgraded in place.
20
+ # 5. empty artefact parents -> rmdir only, so a `.worktrees/` or
21
+ # `.pipeline/` left behind by a finished run stops reading as residue.
17
22
  #
18
23
  # Registered, healthy worktrees are NEVER touched. Finishing a task removes its
19
24
  # own worktree in Phase 6 (worktree-finalize, v14.1.0+); killing one is
@@ -143,22 +148,39 @@ $gitlinks
143
148
  EOF
144
149
  fi
145
150
 
146
- # 4. Residue guard: keep .worktrees/ out of the index from now on.
151
+ # 4. Residue guard: keep every pipeline artefact out of the index from now on.
152
+ # `.worktrees/` was the only entry for a long time, which left `.pipeline/` and
153
+ # `.multi-agent/` writable into the branch; the managed block in repo-hygiene.sh
154
+ # covers all of them and is rewritten on each call, so an old checkout upgrades.
147
155
  ex="$(git -C "$REPO" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)/info/exclude"
148
- if [ -n "${ex%/info/exclude}" ] && ! grep -qxF '.worktrees/' "$ex" 2>/dev/null; then
149
- if [ "$DELETE" -eq 1 ]; then
150
- mkdir -p "$(dirname "$ex")" \
151
- && printf '.worktrees/\n' >> "$ex" \
152
- && { echo "→ added .worktrees/ to .git/info/exclude (residue guard)"; changed=$((changed + 1)); }
156
+ _MA_HYG="$(dirname "${BASH_SOURCE[0]}")/../lib/repo-hygiene.sh"
157
+ [ -f "$_MA_HYG" ] && . "$_MA_HYG"
158
+ if [ -n "${ex%/info/exclude}" ] && ! grep -qF "${MA_HYGIENE_BEGIN:-# >>> multi-agent pipeline (managed) >>>}" "$ex" 2>/dev/null; then
159
+ if ! command -v ma_hygiene_ensure_exclusions >/dev/null 2>&1; then
160
+ # The guard is the whole point of step 4. Going quiet when the library is
161
+ # missing would leave the repo unguarded and say nothing, which is the one
162
+ # outcome worse than failing.
163
+ echo "gc-worktrees: WARN - $_MA_HYG not found; the residue guard was NOT written" >&2
164
+ elif [ "$DELETE" -eq 1 ]; then
165
+ ma_hygiene_ensure_exclusions "$REPO"
166
+ echo "→ wrote the managed residue block to .git/info/exclude"
167
+ changed=$((changed + 1))
153
168
  else
154
- echo "would add .worktrees/ to .git/info/exclude (residue guard)"
169
+ echo "would write the managed residue block to .git/info/exclude"
155
170
  fi
156
171
  fi
157
172
 
173
+ # 5. Artefact parents left empty by a finished run read as leftovers to anyone
174
+ # looking at the checkout. rmdir, never rm -rf: it fails harmlessly if anything
175
+ # is still there.
176
+ if [ "$DELETE" -eq 1 ] && command -v ma_hygiene_prune_empty >/dev/null 2>&1; then
177
+ ma_hygiene_prune_empty "$REPO"
178
+ fi
179
+
158
180
  total=$(( ${#orphans[@]} + $(printf '%s' "$gitlinks" | grep -c . || true) ))
159
181
  if [ "$DELETE" -eq 1 ]; then
160
182
  echo "══ gc-worktrees: applied $changed change(s) in $REPO ══"
161
- elif [ "$total" -eq 0 ] && [ "$pruned" -eq 0 ] && { [ -z "${ex%/info/exclude}" ] || grep -qxF '.worktrees/' "$ex" 2>/dev/null; }; then
183
+ elif [ "$total" -eq 0 ] && [ "$pruned" -eq 0 ] && { [ -z "${ex%/info/exclude}" ] || grep -qF "${MA_HYGIENE_BEGIN:-# >>> multi-agent pipeline (managed) >>>}" "$ex" 2>/dev/null; }; then
162
184
  echo "gc-worktrees: no worktree residue in $REPO - nothing to do"
163
185
  else
164
186
  echo "══ gc-worktrees: dry-run - re-run with --yes to apply ══"
@@ -189,7 +189,7 @@ def _macos_find(args: list[str]) -> Optional[str]:
189
189
 
190
190
  def get(label: str, account: Optional[str] = None) -> Optional[str]:
191
191
  """Lookup by label first, then by service name. Two coexisting conventions:
192
- - User's manually-added tokens (e.g. mmerterden_Vercel_Access_Token) use -l (label).
192
+ - User's manually-added tokens (e.g. ${USER}_Vercel_Access_Token) use -l (label).
193
193
  - credential-store.sh-managed items use -s (service).
194
194
  The helper finds either."""
195
195
  pf = _ensure_backend()
@@ -158,11 +158,19 @@ mkdir -p "$REFS_DIR" 2>/dev/null || { cat "$BUF"; exit 0; }
158
158
 
159
159
  # Never commit an offloaded payload: it is a build log, and the worktree it
160
160
  # belongs to is deleted at the end of the task.
161
- GITIGNORE="$ROOT/.multi-agent/.gitignore"
162
- if [ ! -f "$GITIGNORE" ]; then
163
- printf '# Local run artefacts - never commit.\nmemory/\nrefs/\n' > "$GITIGNORE"
164
- elif ! grep -q '^refs/$' "$GITIGNORE"; then
165
- printf 'refs/\n' >> "$GITIGNORE"
161
+ # Shared with bulk-read.sh via pipeline/lib/repo-hygiene.sh. `../lib` resolves in
162
+ # both layouts: pipeline/scripts -> pipeline/lib in the repo, ~/.claude/scripts ->
163
+ # ~/.claude/lib installed. Fall back to writing it inline if the lib is absent,
164
+ # so this script stays usable standalone.
165
+ _MA_HYG="$(dirname "${BASH_SOURCE[0]}")/../lib/repo-hygiene.sh"
166
+ if [ -f "$_MA_HYG" ]; then
167
+ . "$_MA_HYG"
168
+ ma_hygiene_local_gitignore "$ROOT"
169
+ else
170
+ GITIGNORE="$ROOT/.multi-agent/.gitignore"
171
+ if [ ! -f "$GITIGNORE" ] || ! grep -q '^\*$' "$GITIGNORE" 2>/dev/null; then
172
+ printf '# Local run artefacts - never commit. Ignores this file too.\n*\n' > "$GITIGNORE"
173
+ fi
166
174
  fi
167
175
 
168
176
  SLUG=$(printf '%s' "$LABEL" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/-\{1,\}/-/g; s/^-//; s/-$//')
@@ -231,5 +231,16 @@ fi
231
231
  # Drop the now-empty .worktrees/ shell (only when truly empty).
232
232
  [ -d "$WT_ROOT" ] && rmdir "$WT_ROOT" 2>/dev/null
233
233
 
234
+ # purge is the whole-repo teardown, so it is the one place the managed exclude
235
+ # block should come back out - a per-task finalize must not, because other tasks
236
+ # in the same repo still need the guard. Empty artefact parents go with it.
237
+ # Only the marker-delimited block is removed; lines a human added stay.
238
+ _MA_HYG="$(dirname "${BASH_SOURCE[0]}")/../lib/repo-hygiene.sh"
239
+ if [ -f "$_MA_HYG" ]; then
240
+ . "$_MA_HYG"
241
+ ma_hygiene_prune_empty "$MAIN_WT"
242
+ ma_hygiene_release_exclusions "$MAIN_WT"
243
+ fi
244
+
234
245
  echo "══ purge: removed $removed_wt worktree(s) and $removed_br local branch(es) in $MAIN_WT ══"
235
246
  exit 0
@@ -318,6 +318,15 @@ if git -C "$rp_pr" worktree remove "$rp_wt" 2>/dev/null; then
318
318
  ' 2>/dev/null || true
319
319
  fi
320
320
 
321
+ # The worktree is gone, but its parent `.worktrees/` stays behind as an empty
322
+ # directory and reads as leftover to anyone looking at the checkout. rmdir,
323
+ # never rm -rf: it fails harmlessly while any other task still has one.
324
+ _MA_HYG="$(dirname "${BASH_SOURCE[0]}")/../lib/repo-hygiene.sh"
325
+ if [ -f "$_MA_HYG" ]; then
326
+ . "$_MA_HYG"
327
+ ma_hygiene_prune_empty "$rp_pr"
328
+ fi
329
+
321
330
  emit
322
331
  exit 0
323
332
  fi
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-09-09T18:15:55Z",
4
- "skillCount": 207,
3
+ "generatedAt": "2026-09-13T12:09:01Z",
4
+ "skillCount": 209,
5
5
  "entries": [
6
6
  {
7
7
  "path": "shared/core/apple-archive-compliance/SKILL.md",
@@ -11,6 +11,10 @@
11
11
  "path": "shared/core/google-play-compliance/SKILL.md",
12
12
  "sha256": "356ff2b30f414e582bb215c4728696af8e249dfe924e16a550294e48fd9bdcf9"
13
13
  },
14
+ {
15
+ "path": "shared/core/multi-agent-analysis-jira/SKILL.md",
16
+ "sha256": "405e3afb02ad00cbcd1eace0ab3659d8d405dc78ec68c46f0836c23a83a43950"
17
+ },
14
18
  {
15
19
  "path": "shared/core/multi-agent-analysis-resolve/SKILL.md",
16
20
  "sha256": "ad59c484a3a251799101aaebfe1cf50ceb10a2e2a389d89c6d56e67ba331ef79"
@@ -47,6 +51,10 @@
47
51
  "path": "shared/core/multi-agent-diff-explain/SKILL.md",
48
52
  "sha256": "7cb37a224e61d66e350202ecfc5fbc6c33da3fe0718504a093602f5cde5b648f"
49
53
  },
54
+ {
55
+ "path": "shared/core/multi-agent-doctor/SKILL.md",
56
+ "sha256": "a9e6b8a764df223b9d6867cc9f2a89d93f989605891c829a81fc3e5ca139a1b2"
57
+ },
50
58
  {
51
59
  "path": "shared/core/multi-agent-feedback/SKILL.md",
52
60
  "sha256": "9ec0c33fdaef52691fac48ac582e665bb8eb87d59009feabf156f71728d977a4"
@@ -73,7 +81,7 @@
73
81
  },
74
82
  {
75
83
  "path": "shared/core/multi-agent-issue/SKILL.md",
76
- "sha256": "a491d26b94a7c909f98be96f881010666fafedf6e323a6b75515cc26912dfc02"
84
+ "sha256": "aec8737af368b01c7e3133521c711861456c18b07e3829425e5c0485a0181051"
77
85
  },
78
86
  {
79
87
  "path": "shared/core/multi-agent-jira/SKILL.md",
@@ -101,7 +109,7 @@
101
109
  },
102
110
  {
103
111
  "path": "shared/core/multi-agent-manual-test/SKILL.md",
104
- "sha256": "8d4501e9e1321146f7a1759fd5c8b4397cbd77d7a31fb265fabf7816386fba4b"
112
+ "sha256": "e7f7f8004d286c5b5b596f8659bcf57b2350eb9c67a55470bb2e9b714513a3a4"
105
113
  },
106
114
  {
107
115
  "path": "shared/core/multi-agent-prune-logs/SKILL.md",
@@ -117,7 +125,7 @@
117
125
  },
118
126
  {
119
127
  "path": "shared/core/multi-agent-refactor/SKILL.md",
120
- "sha256": "83cc0567ede79eabc42045b4e39d6d3ec820e73a3e14e5f01ebff898cfdec117"
128
+ "sha256": "56b49a8216086961bc407a60b559942252a1aae3005b73811b1d40668758cc84"
121
129
  },
122
130
  {
123
131
  "path": "shared/core/multi-agent-resume-local/SKILL.md",
@@ -133,7 +141,7 @@
133
141
  },
134
142
  {
135
143
  "path": "shared/core/multi-agent-review-issue/SKILL.md",
136
- "sha256": "f5579a33ea0e0ec6e601d539e22acd6c0bf65b3c95464c974f3fbddf286749e7"
144
+ "sha256": "d9c0a256d37febab7be7d00252710ca065bd4e8a186ef47e00c5898e48f53cf5"
137
145
  },
138
146
  {
139
147
  "path": "shared/core/multi-agent-review-jira/SKILL.md",
@@ -157,11 +165,11 @@
157
165
  },
158
166
  {
159
167
  "path": "shared/core/multi-agent-search/SKILL.md",
160
- "sha256": "2a3a8c4a53b65b828c7093076a0ec05dc6b146825f7407f91b3852347717d94e"
168
+ "sha256": "32541ceca20c489814f9d948763d5237d4a71473a05683d9da262b8f6dea815b"
161
169
  },
162
170
  {
163
171
  "path": "shared/core/multi-agent-setup/SKILL.md",
164
- "sha256": "dc5840da1022b6930fa28dab01ac15ad66fd720743fb7ecfcdf6c6d85b4410e6"
172
+ "sha256": "40dc72793cd6bd1054d6203275fbc9f4ecdd6d8dc5de8f2437e7f670f4c5c646"
165
173
  },
166
174
  {
167
175
  "path": "shared/core/multi-agent-stack/SKILL.md",
@@ -169,7 +177,7 @@
169
177
  },
170
178
  {
171
179
  "path": "shared/core/multi-agent-status/SKILL.md",
172
- "sha256": "1137106f9af881834a970ca3dc748001e937666601d3fcb18b6913bb2cd1ac11"
180
+ "sha256": "436d1716ce5fc4977dadfce8a85b09877f93c626953739a7ad70eefbbcd25869"
173
181
  },
174
182
  {
175
183
  "path": "shared/core/multi-agent-steer/SKILL.md",
@@ -181,7 +189,7 @@
181
189
  },
182
190
  {
183
191
  "path": "shared/core/multi-agent-sync/SKILL.md",
184
- "sha256": "c0e2c22d0f6da9a60f5552d6f47db0490a355ea4ce08722716470e03535e5418"
192
+ "sha256": "8f033b904e90926b15f6e5a8f8703b47a17373e5450921514633ea0a9edaf327"
185
193
  },
186
194
  {
187
195
  "path": "shared/core/multi-agent-test-accessibility/SKILL.md",
@@ -213,7 +221,7 @@
213
221
  },
214
222
  {
215
223
  "path": "shared/core/multi-agent-update/SKILL.md",
216
- "sha256": "66d960a9c360ba0353b6496fde641c1a99834274bc0ac68932ed107b15411ccb"
224
+ "sha256": "e93e913473cb5d3a5810b30f26011a0d18754bed983890f4261b87972526d94a"
217
225
  },
218
226
  {
219
227
  "path": "shared/core/multi-agent/SKILL.md",
@@ -207,7 +207,8 @@ When invoked with the `release` argument:
207
207
  ```
208
208
  1. REPO Claude Code -> pipeline repo (genericized)
209
209
  2. VERIFY Personal data scan -> 0 results
210
- 3. LINT + TEST ESLint + smoke tests
210
+ 3. LINT + TEST ESLint + smoke tests (stale .skill-manifest.json fails here; re-sign
211
+ it with sign-skills.sh whenever a SKILL.md moved)
211
212
  4. VERSION package.json version bump (patch/minor/major)
212
213
  5. Commit + TAG git commit + git tag v{VERSION}
213
214
  6. PUSH git push --tags; publish is local (release.yml off)