@mmerterden/multi-agent-pipeline 11.4.0 → 12.0.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 (117) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +1 -0
  3. package/docs/adr/0007-multi-tool-adapter-framework.md +2 -2
  4. package/index.js +9 -2
  5. package/install/_common.mjs +107 -5
  6. package/install/_telemetry.mjs +5 -23
  7. package/install/claude.mjs +95 -11
  8. package/install/copilot.mjs +75 -6
  9. package/install/index.mjs +34 -1
  10. package/package.json +4 -10
  11. package/pipeline/commands/multi-agent/SKILL.md +1 -0
  12. package/pipeline/commands/multi-agent/analysis/SKILL.md +1 -0
  13. package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +1 -0
  14. package/pipeline/commands/multi-agent/autopilot/SKILL.md +1 -0
  15. package/pipeline/commands/multi-agent/build-optimize/SKILL.md +1 -0
  16. package/pipeline/commands/multi-agent/channels/SKILL.md +1 -0
  17. package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -0
  18. package/pipeline/commands/multi-agent/dev/SKILL.md +1 -0
  19. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +1 -0
  20. package/pipeline/commands/multi-agent/dev-local/SKILL.md +2 -1
  21. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +1 -0
  22. package/pipeline/commands/multi-agent/diff-explain/SKILL.md +1 -0
  23. package/pipeline/commands/multi-agent/finish/SKILL.md +1 -0
  24. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +29 -3
  25. package/pipeline/commands/multi-agent/help/SKILL.md +3 -2
  26. package/pipeline/commands/multi-agent/issue/SKILL.md +1 -0
  27. package/pipeline/commands/multi-agent/jira/SKILL.md +1 -0
  28. package/pipeline/commands/multi-agent/kill/SKILL.md +1 -0
  29. package/pipeline/commands/multi-agent/language/SKILL.md +20 -1
  30. package/pipeline/commands/multi-agent/local/SKILL.md +1 -0
  31. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +1 -0
  32. package/pipeline/commands/multi-agent/log/SKILL.md +1 -0
  33. package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -0
  34. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +1 -0
  35. package/pipeline/commands/multi-agent/purge/SKILL.md +27 -24
  36. package/pipeline/commands/multi-agent/refactor/SKILL.md +1 -0
  37. package/pipeline/commands/multi-agent/resume/SKILL.md +1 -0
  38. package/pipeline/commands/multi-agent/review/SKILL.md +1 -0
  39. package/pipeline/commands/multi-agent/review-issue/SKILL.md +1 -0
  40. package/pipeline/commands/multi-agent/review-jira/SKILL.md +1 -0
  41. package/pipeline/commands/multi-agent/scan/SKILL.md +1 -0
  42. package/pipeline/commands/multi-agent/search/SKILL.md +1 -0
  43. package/pipeline/commands/multi-agent/setup/SKILL.md +1 -0
  44. package/pipeline/commands/multi-agent/stack/SKILL.md +1 -0
  45. package/pipeline/commands/multi-agent/status/SKILL.md +1 -0
  46. package/pipeline/commands/multi-agent/sync/SKILL.md +20 -2
  47. package/pipeline/commands/multi-agent/test/SKILL.md +1 -0
  48. package/pipeline/commands/multi-agent/uninstall/SKILL.md +87 -0
  49. package/pipeline/commands/multi-agent/update/SKILL.md +1 -0
  50. package/pipeline/lib/account-resolver.sh +61 -23
  51. package/pipeline/lib/channels-multi-repo.sh +7 -2
  52. package/pipeline/lib/count-lib.sh +27 -0
  53. package/pipeline/lib/credential-store.sh +23 -6
  54. package/pipeline/lib/extract-conventions.sh +27 -21
  55. package/pipeline/lib/fetch-confluence.sh +25 -13
  56. package/pipeline/lib/fetch-crashlytics.sh +30 -8
  57. package/pipeline/lib/fetch-fortify.sh +11 -2
  58. package/pipeline/lib/fetch-swagger.sh +18 -7
  59. package/pipeline/lib/figma-mcp-refresh.sh +26 -11
  60. package/pipeline/lib/figma-screenshot.sh +11 -2
  61. package/pipeline/lib/issue-fetcher.sh +31 -6
  62. package/pipeline/lib/multi-repo-pipeline.sh +84 -20
  63. package/pipeline/lib/plan-todos.sh +12 -3
  64. package/pipeline/lib/post-pr-review.sh +5 -3
  65. package/pipeline/lib/repo-cache.sh +53 -9
  66. package/pipeline/lib/review-watch.sh +26 -6
  67. package/pipeline/lib/shadow-git.sh +45 -4
  68. package/pipeline/lib/vercel-deploy.sh +10 -1
  69. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -5
  70. package/pipeline/multi-agent-refs/phases/phase-0-init.md +12 -1
  71. package/pipeline/scripts/audit-log-rotate.sh +38 -10
  72. package/pipeline/scripts/check-md-links.mjs +34 -9
  73. package/pipeline/scripts/diff-risk-score.mjs +4 -1
  74. package/pipeline/scripts/evidence-gate.mjs +10 -1
  75. package/pipeline/scripts/fixtures/install-layout.tsv +6 -4
  76. package/pipeline/scripts/fixtures/pack-expected-count.txt +1 -0
  77. package/pipeline/scripts/gc-tmp.sh +67 -8
  78. package/pipeline/scripts/gc-worktrees.sh +140 -0
  79. package/pipeline/scripts/keychain-save.sh +13 -9
  80. package/pipeline/scripts/lint-skills.mjs +40 -2
  81. package/pipeline/scripts/localize-commands.mjs +130 -0
  82. package/pipeline/scripts/migrate-prefs.mjs +26 -7
  83. package/pipeline/scripts/phase-tracker.sh +71 -0
  84. package/pipeline/scripts/pre-commit-check.sh +39 -0
  85. package/pipeline/scripts/prune-logs.sh +100 -15
  86. package/pipeline/scripts/purge.sh +235 -0
  87. package/pipeline/scripts/scan-skills.sh +217 -127
  88. package/pipeline/scripts/smoke-bitbucket-contract.sh +21 -5
  89. package/pipeline/scripts/smoke-description-tr.sh +82 -0
  90. package/pipeline/scripts/smoke-fetchers-offline.sh +382 -0
  91. package/pipeline/scripts/smoke-gc-tmp.sh +50 -4
  92. package/pipeline/scripts/smoke-gc-worktrees.sh +125 -0
  93. package/pipeline/scripts/smoke-install-layout.sh +11 -3
  94. package/pipeline/scripts/smoke-lib-scripts.sh +54 -1
  95. package/pipeline/scripts/smoke-pack-contents.sh +140 -0
  96. package/pipeline/scripts/smoke-plugin-validate.sh +64 -0
  97. package/pipeline/scripts/smoke-prune-logs.sh +57 -7
  98. package/pipeline/scripts/smoke-purge.sh +138 -0
  99. package/pipeline/scripts/smoke-shadow-git.sh +48 -1
  100. package/pipeline/scripts/smoke-skill-authoring.sh +1 -1
  101. package/pipeline/scripts/smoke-update-check.sh +13 -0
  102. package/pipeline/scripts/smoke-workflow-audit.sh +69 -0
  103. package/pipeline/scripts/test-gap-scan.mjs +12 -1
  104. package/pipeline/scripts/triage-memory.mjs +10 -1
  105. package/pipeline/scripts/uninstall.mjs +146 -42
  106. package/pipeline/scripts/update-check.sh +9 -0
  107. package/pipeline/scripts/update-issue-progress.sh +60 -41
  108. package/pipeline/scripts/write-state.mjs +14 -4
  109. package/pipeline/skills/.skill-manifest.json +7 -7
  110. package/pipeline/skills/.skills-index.json +9 -9
  111. package/pipeline/skills/shared/README.md +19 -10
  112. package/pipeline/skills/shared/core/multi-agent/SKILL.md +1 -0
  113. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
  114. package/pipeline/skills/shared/core/multi-agent-uninstall/SKILL.md +74 -0
  115. package/pipeline/skills/skills-index.md +1 -1
  116. package/pipeline/commands/multi-agent/delete/SKILL.md +0 -67
  117. package/pipeline/skills/shared/core/multi-agent-delete/SKILL.md +0 -66
@@ -136,6 +136,18 @@ else
136
136
  else
137
137
  fail "jira-id path output unexpected"
138
138
  fi
139
+
140
+ # Bare "#316" and "316" must land in the gh-num branch (needsRepoPicker
141
+ # descriptor when no default owner/repo), NOT gh-short with an empty repo
142
+ # prefix that builds https://github.com/<owner>//issues/316.
143
+ for bare in "#316" "316"; do
144
+ out=$(ACCOUNT_DEFAULT_OWNER="" ACCOUNT_DEFAULT_REPO="" "$FETCHER" "$bare" 2>/dev/null || echo "")
145
+ if printf '%s' "$out" | python3 -c 'import json,sys; d=json.loads(sys.stdin.read()); assert d["kind"]=="github", d["kind"]; assert d["key"]=="316", d["key"]; assert d.get("needsRepoPicker") is True, d' 2>/dev/null; then
146
+ ok "bare '$bare' classified as gh-num (key=316, needsRepoPicker)"
147
+ else
148
+ fail "bare '$bare' misclassified: $(printf '%s' "$out" | head -c 200)"
149
+ fi
150
+ done
139
151
  fi
140
152
 
141
153
  # --- multi-repo-pipeline.sh: bridge --------------------------------------
@@ -294,6 +306,20 @@ assert d["projects"][1]["project"] == "repo-b"
294
306
  fail "bridge output missing projects[] entries"
295
307
  fi
296
308
 
309
+ # teardown guard: a worktreeRoot of "." (the current project root) must be
310
+ # refused loudly, never rm -rf'd.
311
+ UNSAFE_STATE="$FIXTURE_ROOT/unsafe-state.json"
312
+ sed 's|"worktreeRoot":"[^"]*"|"worktreeRoot":"."|' "$FIXTURE_ROOT/state.json" > "$UNSAFE_STATE"
313
+ GUARD_DIR=$(mktemp -d)
314
+ ( cd "$GUARD_DIR" && "$MRP" teardown "$UNSAFE_STATE" ) >/dev/null 2>&1
315
+ guard_rc=$?
316
+ if [ "$guard_rc" -ne 0 ] && [ -d "$GUARD_DIR" ]; then
317
+ ok "teardown refuses worktreeRoot='.' (exit=$guard_rc, dir intact)"
318
+ else
319
+ fail "teardown did not refuse unsafe worktreeRoot='.' (exit=$guard_rc)"
320
+ fi
321
+ rm -rf "$GUARD_DIR"
322
+
297
323
  # teardown
298
324
  "$MRP" teardown "$FIXTURE_ROOT/state.json" >/dev/null 2>&1
299
325
  if [ ! -d "$WORKTREE_ROOT" ]; then
@@ -357,13 +383,40 @@ EOF
357
383
 
358
384
  # render-jira: bulleted PR list at top
359
385
  rendered=$("$CMR" render-jira "$TMP/multi.json" "$TMP/body.md" 2>/dev/null)
360
- bullet_count=$(printf '%s' "$rendered" | grep -c '^\* PR:' || echo 0)
386
+ bullet_count=$(printf '%s' "$rendered" | grep -c '^\* PR:' || true)
361
387
  if [ "$bullet_count" = "2" ]; then
362
388
  ok "render-jira prepends bulleted PR list (2 entries)"
363
389
  else
364
390
  fail "render-jira bullet count $bullet_count != 2"
365
391
  fi
366
392
 
393
+ # render-jira/-conf with ZERO PR urls: must pass the body through unchanged
394
+ # (the old `grep -c || echo 0` idiom emitted "0\n0" here and prepended
395
+ # bogus "* PR:" bullets to the posted body).
396
+ cat > "$TMP/noprs.json" <<'EOF'
397
+ {
398
+ "schemaVersion":"2.1.0","taskId":"Z","shortId":1,"project":"my-app",
399
+ "branch":"feature/Z","baseBranch":"develop","currentPhase":6,"status":"in_progress",
400
+ "startedAt":"2026-04-27T12:00:00Z","phases":{},"identity":{"name":"T","email":"t@e.com"},
401
+ "projects":[
402
+ {"project":"my-app","projectRoot":"/tmp/a","branch":"feature/Z","baseBranch":"develop","identity":{"name":"T","email":"t@e.com"},"pr":null},
403
+ {"project":"common","projectRoot":"/tmp/b","branch":"feature/Z","baseBranch":"develop","identity":{"name":"T","email":"t@e.com"},"pr":null}
404
+ ]
405
+ }
406
+ EOF
407
+ rendered=$("$CMR" render-jira "$TMP/noprs.json" "$TMP/body.md" 2>/dev/null)
408
+ if [ "$rendered" = "body content" ]; then
409
+ ok "render-jira with zero PR urls passes body through unchanged"
410
+ else
411
+ fail "render-jira with zero PR urls mutated body: $rendered"
412
+ fi
413
+ rendered=$("$CMR" render-conf "$TMP/noprs.json" "$TMP/body.md" 2>/dev/null)
414
+ if [ "$rendered" = "body content" ]; then
415
+ ok "render-conf with zero PR urls passes body through unchanged"
416
+ else
417
+ fail "render-conf with zero PR urls mutated body: $rendered"
418
+ fi
419
+
367
420
  # render-conf: ## Related PRs heading
368
421
  rendered=$("$CMR" render-conf "$TMP/multi.json" "$TMP/body.md" 2>/dev/null)
369
422
  if printf '%s' "$rendered" | grep -q "## Related PRs"; then
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # smoke-pack-contents.sh - guard what `npm publish` would actually ship.
4
+ #
5
+ # 1. Deny patterns: package.json files[] carries negations (`!...`) for
6
+ # dev-only / personal-data files. The published tarball must contain
7
+ # no file matching any of them. Patterns are read from package.json at
8
+ # runtime (via node), so the smoke never goes stale when the deny list
9
+ # changes.
10
+ # 2. File-count band: the total packed file count is compared against the
11
+ # committed fixture pipeline/scripts/fixtures/pack-expected-count.txt.
12
+ # Drift beyond +-15 percent fails - that magnitude means the files[]
13
+ # globs picked up (or dropped) a whole subtree, which deserves a human
14
+ # look. Refresh the fixture after reviewing:
15
+ # npm pack --dry-run --json 2>/dev/null \
16
+ # | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s)[0].files.length))' \
17
+ # > pipeline/scripts/fixtures/pack-expected-count.txt
18
+ # 3. publint (optional): linted via `npx --no-install` so it only runs
19
+ # when the package is already installed - never a network fetch.
20
+ #
21
+ # Exit codes: 0=pass, 1=violation, 2=environment broken
22
+
23
+ set -uo pipefail
24
+
25
+ REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
26
+ FIXTURE="$REPO_ROOT/pipeline/scripts/fixtures/pack-expected-count.txt"
27
+
28
+ command -v npm >/dev/null 2>&1 || { echo "FAIL: npm not on PATH"; exit 2; }
29
+ command -v node >/dev/null 2>&1 || { echo "FAIL: node not on PATH"; exit 2; }
30
+
31
+ PASS=0
32
+ FAIL=0
33
+ pass() { PASS=$((PASS + 1)); echo " ✓ $1"; }
34
+ fail() { FAIL=$((FAIL + 1)); echo " ✗ $1"; }
35
+
36
+ cleanup() { [ -n "${TMP:-}" ] && rm -rf "$TMP"; }
37
+ trap cleanup EXIT
38
+ TMP=$(mktemp -d)
39
+
40
+ # ──────────────────────────────────────────────────────────────────────────
41
+ echo "→ 1. npm pack --dry-run --json"
42
+ if ! (cd "$REPO_ROOT" && npm pack --dry-run --json 2>/dev/null > "$TMP/pack.json"); then
43
+ echo "FAIL: npm pack --dry-run failed"
44
+ exit 2
45
+ fi
46
+
47
+ # Deny check runs in node so the patterns come straight from package.json
48
+ # files[] negations - never hardcoded here.
49
+ cat > "$TMP/check.mjs" <<'MJS'
50
+ import { readFileSync } from "node:fs";
51
+
52
+ const [packJsonPath, pkgJsonPath] = process.argv.slice(2);
53
+ const packed = JSON.parse(readFileSync(packJsonPath, "utf-8"))[0].files.map((f) => f.path);
54
+ const files = JSON.parse(readFileSync(pkgJsonPath, "utf-8")).files || [];
55
+ const denies = files.filter((p) => p.startsWith("!")).map((p) => p.slice(1));
56
+
57
+ // Minimal glob -> regex: '**' spans path separators, '*' does not.
58
+ const toRegex = (glob) => {
59
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
60
+ const body = escaped
61
+ .split("**")
62
+ .map((part) => part.replace(/\*/g, "[^/]*"))
63
+ .join(".*");
64
+ return new RegExp("^" + body + "$");
65
+ };
66
+
67
+ const regexes = denies.map((d) => [d, toRegex(d)]);
68
+ let bad = 0;
69
+ for (const path of packed) {
70
+ for (const [glob, re] of regexes) {
71
+ if (re.test(path)) {
72
+ console.log(`DENY ${path} (matches !${glob})`);
73
+ bad++;
74
+ }
75
+ }
76
+ }
77
+ console.log(`DENYCOUNT ${bad}`);
78
+ console.log(`PATTERNS ${denies.length}`);
79
+ console.log(`COUNT ${packed.length}`);
80
+ MJS
81
+
82
+ CHECK=$(node "$TMP/check.mjs" "$TMP/pack.json" "$REPO_ROOT/package.json")
83
+ COUNT=$(echo "$CHECK" | awk '/^COUNT /{print $2}')
84
+ DENYCOUNT=$(echo "$CHECK" | awk '/^DENYCOUNT /{print $2}')
85
+ PATTERNS=$(echo "$CHECK" | awk '/^PATTERNS /{print $2}')
86
+
87
+ if [ -z "$COUNT" ] || [ -z "$DENYCOUNT" ]; then
88
+ echo "FAIL: deny-pattern checker produced no result (node error?)"
89
+ exit 2
90
+ fi
91
+
92
+ pass "pack dry-run produced $COUNT files ($PATTERNS deny patterns loaded from package.json)"
93
+
94
+ # ──────────────────────────────────────────────────────────────────────────
95
+ echo "→ 2. deny patterns (files[] negations must not ship)"
96
+ if [ "$DENYCOUNT" -eq 0 ]; then
97
+ pass "no packed file matches a files[] negation"
98
+ else
99
+ fail "$DENYCOUNT packed file(s) match dev-only/personal-data exclusions:"
100
+ echo "$CHECK" | grep '^DENY ' | sed 's/^/ /'
101
+ fi
102
+
103
+ # ──────────────────────────────────────────────────────────────────────────
104
+ echo "→ 3. file-count band vs fixture (+-15 percent)"
105
+ if [ ! -f "$FIXTURE" ]; then
106
+ fail "fixture missing: $FIXTURE - create it from the refresh command in this script's header"
107
+ else
108
+ EXPECTED=$(tr -cd '0-9' < "$FIXTURE")
109
+ if [ -z "$EXPECTED" ] || [ "$EXPECTED" -eq 0 ]; then
110
+ fail "fixture unreadable or zero: $FIXTURE"
111
+ else
112
+ DIFF=$((COUNT - EXPECTED))
113
+ [ "$DIFF" -lt 0 ] && DIFF=$((-DIFF))
114
+ LIMIT=$((EXPECTED * 15 / 100))
115
+ if [ "$DIFF" -le "$LIMIT" ]; then
116
+ pass "count $COUNT within +-15 percent of expected $EXPECTED (drift $DIFF, limit $LIMIT)"
117
+ else
118
+ fail "count $COUNT drifted beyond +-15 percent of expected $EXPECTED (drift $DIFF, limit $LIMIT)"
119
+ echo " Review what the files[] globs now include, then refresh the fixture"
120
+ echo " (refresh command is in this script's header comment)."
121
+ fi
122
+ fi
123
+ fi
124
+
125
+ # ──────────────────────────────────────────────────────────────────────────
126
+ echo "→ 4. publint (optional, npx --no-install only)"
127
+ if (cd "$REPO_ROOT" && npx --no-install publint --help >/dev/null 2>&1); then
128
+ if OUT=$(cd "$REPO_ROOT" && npx --no-install publint 2>&1); then
129
+ pass "publint: no errors"
130
+ else
131
+ fail "publint reported errors"
132
+ echo "$OUT" | sed 's/^/ /'
133
+ fi
134
+ else
135
+ echo " ↷ SKIP: publint not installed (npx --no-install found no local package)"
136
+ fi
137
+
138
+ echo ""
139
+ echo "══ smoke-pack-contents: $PASS passed, $FAIL failed ══"
140
+ [ "$FAIL" -gt 0 ] && exit 1 || exit 0
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # smoke-plugin-validate.sh - run `claude plugin validate --strict` against
4
+ # every plugin / marketplace surface committed to this repo.
5
+ #
6
+ # Discovery: any directory containing `.claude-plugin/plugin.json` or
7
+ # `.claude-plugin/marketplace.json` (node_modules excluded). The `claude`
8
+ # CLI validates the containing directory; --strict promotes warnings
9
+ # (unrecognized fields, missing metadata) to failures so CI catches drift
10
+ # the runtime would silently tolerate.
11
+ #
12
+ # Skip semantics (exit 0 with a SKIP line):
13
+ # - `claude` binary not on PATH (optional tooling; billing-friendly CI
14
+ # runners don't install it)
15
+ # - no validatable surface in the repo (the marketplace plugins live in
16
+ # the separate multi-agent-plugins repo; this repo may carry none)
17
+ #
18
+ # Exit codes: 0=pass or skip, 1=validation failure
19
+
20
+ set -uo pipefail
21
+
22
+ REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
23
+
24
+ if ! command -v claude >/dev/null 2>&1; then
25
+ echo "SKIP smoke-plugin-validate: claude binary not on PATH"
26
+ exit 0
27
+ fi
28
+
29
+ # Each hit is a manifest; the validatable surface is its .claude-plugin parent's parent.
30
+ MANIFESTS=$(find "$REPO_ROOT" \
31
+ -path "$REPO_ROOT/node_modules" -prune -o \
32
+ -path "$REPO_ROOT/coverage" -prune -o \
33
+ -type f -path '*/.claude-plugin/*' \( -name plugin.json -o -name marketplace.json \) -print \
34
+ 2>/dev/null | sort)
35
+
36
+ if [ -z "$MANIFESTS" ]; then
37
+ echo "SKIP smoke-plugin-validate: no .claude-plugin plugin/marketplace manifest in this repo"
38
+ exit 0
39
+ fi
40
+
41
+ PASS=0
42
+ FAIL=0
43
+ pass() { PASS=$((PASS + 1)); echo " ✓ $1"; }
44
+ fail() { FAIL=$((FAIL + 1)); echo " ✗ $1"; }
45
+
46
+ # Validate each surface directory once, even if it carries both manifests.
47
+ SURFACES=$(echo "$MANIFESTS" | sed 's|/\.claude-plugin/[^/]*$||' | sort -u)
48
+
49
+ for surface in $SURFACES; do
50
+ rel="${surface#"$REPO_ROOT"/}"
51
+ [ "$surface" = "$REPO_ROOT" ] && rel="."
52
+ echo ""
53
+ echo "→ claude plugin validate --strict $rel"
54
+ if OUT=$(claude plugin validate --strict "$surface" 2>&1); then
55
+ pass "$rel validates clean (strict)"
56
+ else
57
+ fail "$rel failed strict validation"
58
+ echo "$OUT" | sed 's/^/ /'
59
+ fi
60
+ done
61
+
62
+ echo ""
63
+ echo "══ smoke-plugin-validate: $PASS passed, $FAIL failed ══"
64
+ [ "$FAIL" -gt 0 ] && exit 1 || exit 0
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  # smoke-prune-logs.sh - prune-logs.sh removes per-task log dirs, honors the
3
- # project/task/age filters, is dry-run by default, and NEVER deletes the audit
4
- # trail or metrics corpus.
3
+ # project/task/age filters, is dry-run by default, NEVER deletes the audit
4
+ # trail or metrics corpus (even under adversarial dir names), refuses unsafe
5
+ # log roots, and spares active task dirs.
5
6
  #
6
7
  # Exit 0 = all pass, 1 = any failure.
7
8
 
@@ -40,7 +41,7 @@ seed() {
40
41
 
41
42
  echo "→ 1. dry-run lists task dirs and deletes nothing"
42
43
  seed
43
- out=$(PRUNE_LOG_ROOT="$LR" bash "$PL" 2>&1)
44
+ out=$(PRUNE_LOG_ROOT="$LR" PRUNE_GRACE_MIN=0 bash "$PL" 2>&1)
44
45
  echo "$out" | grep -q "DRY-RUN" && pass "dry-run banner shown" || fail "dry-run banner missing"
45
46
  [ -f "$LR/my-ios-app/PROJ-1/agent-state.json" ] && pass "dry-run kept task dirs" || fail "dry-run deleted a task dir"
46
47
  echo "$out" | grep -qi "preserved" && pass "dry-run states audit/metrics are preserved" || fail "no preservation note"
@@ -48,7 +49,7 @@ echo "$out" | grep -qi "preserved" && pass "dry-run states audit/metrics are pre
48
49
  echo ""
49
50
  echo "→ 2. --yes deletes task dirs but preserves audit + metrics + .counter"
50
51
  seed
51
- PRUNE_LOG_ROOT="$LR" bash "$PL" --yes >/dev/null 2>&1
52
+ PRUNE_LOG_ROOT="$LR" PRUNE_GRACE_MIN=0 bash "$PL" --yes >/dev/null 2>&1
52
53
  [ -f "$LR/audit.jsonl" ] && pass "audit.jsonl preserved" || fail "audit.jsonl was deleted"
53
54
  [ -f "$LR/metrics.jsonl" ] && pass "metrics.jsonl preserved" || fail "metrics.jsonl was deleted"
54
55
  [ -f "$LR/my-ios-app/.counter" ] && pass ".counter preserved" || fail ".counter was deleted"
@@ -58,21 +59,21 @@ PRUNE_LOG_ROOT="$LR" bash "$PL" --yes >/dev/null 2>&1
58
59
  echo ""
59
60
  echo "→ 3. --project scopes deletion to one project"
60
61
  seed
61
- PRUNE_LOG_ROOT="$LR" bash "$PL" --project=my-ios-app --yes >/dev/null 2>&1
62
+ PRUNE_LOG_ROOT="$LR" PRUNE_GRACE_MIN=0 bash "$PL" --project=my-ios-app --yes >/dev/null 2>&1
62
63
  [ ! -e "$LR/my-ios-app/PROJ-1" ] && pass "my-ios-app task removed" || fail "my-ios-app task survived"
63
64
  [ -f "$LR/other/PROJ-9/agent-log.md" ] && pass "other project untouched" || fail "other project was deleted"
64
65
 
65
66
  echo ""
66
67
  echo "→ 4. --task scopes deletion to one task dir"
67
68
  seed
68
- PRUNE_LOG_ROOT="$LR" bash "$PL" --task=PROJ-1 --yes >/dev/null 2>&1
69
+ PRUNE_LOG_ROOT="$LR" PRUNE_GRACE_MIN=0 bash "$PL" --task=PROJ-1 --yes >/dev/null 2>&1
69
70
  [ ! -e "$LR/my-ios-app/PROJ-1" ] && pass "PROJ-1 removed" || fail "PROJ-1 survived"
70
71
  [ -f "$LR/my-ios-app/PROJ-2/tracker-state.json" ] && pass "PROJ-2 untouched" || fail "PROJ-2 was deleted"
71
72
 
72
73
  echo ""
73
74
  echo "→ 5. --older-than excludes fresh task dirs"
74
75
  seed
75
- out=$(PRUNE_LOG_ROOT="$LR" bash "$PL" --older-than=1 2>&1)
76
+ out=$(PRUNE_LOG_ROOT="$LR" PRUNE_GRACE_MIN=0 bash "$PL" --older-than=1 2>&1)
76
77
  echo "$out" | grep -q "nothing" && pass "freshly-created dirs excluded by --older-than=1" || fail "--older-than did not exclude fresh dirs"
77
78
 
78
79
  echo ""
@@ -82,6 +83,55 @@ PRUNE_LOG_ROOT="$LR" bash "$PL" --bogus >/dev/null 2>&1
82
83
  PRUNE_LOG_ROOT="/nonexistent-$$-dir" bash "$PL" >/dev/null 2>&1
83
84
  [ "$?" = "0" ] && pass "missing log root exits 0 (no-op)" || fail "missing log root did not exit 0"
84
85
 
86
+ echo ""
87
+ echo "→ 7. adversarial dirs named like preserved files survive --yes"
88
+ rm -rf "${LR:?}"/*
89
+ mkdir -p "$LR/audit.jsonl" "$LR/proj/metrics.jsonl" "$LR/proj/metrics-summary.md" "$LR/proj/.counter" "$LR/proj/PROJ-5"
90
+ echo '{}' >"$LR/audit.jsonl/agent-state.json"
91
+ echo '{}' >"$LR/proj/metrics.jsonl/agent-log.md"
92
+ echo '{}' >"$LR/proj/metrics-summary.md/tracker-state.json"
93
+ echo '{}' >"$LR/proj/.counter/agent-state.json"
94
+ echo '{}' >"$LR/proj/PROJ-5/agent-state.json"
95
+ PRUNE_LOG_ROOT="$LR" PRUNE_GRACE_MIN=0 bash "$PL" --yes >/dev/null 2>&1
96
+ [ -d "$LR/audit.jsonl" ] && pass "dir named audit.jsonl spared" || fail "dir named audit.jsonl deleted"
97
+ [ -d "$LR/proj/metrics.jsonl" ] && pass "dir named metrics.jsonl spared" || fail "dir named metrics.jsonl deleted"
98
+ [ -d "$LR/proj/metrics-summary.md" ] && pass "dir named metrics-summary.md spared" || fail "dir named metrics-summary.md deleted"
99
+ [ -d "$LR/proj/.counter" ] && pass "dir named .counter spared" || fail "dir named .counter deleted"
100
+ [ ! -e "$LR/proj/PROJ-5" ] && pass "normal task dir beside them still removed" || fail "normal task dir survived"
101
+
102
+ echo ""
103
+ echo "→ 8. unsafe log roots are refused (exit 2, dry-run only)"
104
+ PRUNE_LOG_ROOT="$HOME" bash "$PL" >/dev/null 2>&1
105
+ [ "$?" = "2" ] && pass "\$HOME as log root refused" || fail "\$HOME as log root not refused"
106
+ GITROOT="$(mktemp -d)"
107
+ mkdir -p "$GITROOT/.git"
108
+ PRUNE_LOG_ROOT="$GITROOT" bash "$PL" --yes >/dev/null 2>&1
109
+ rc=$?
110
+ rm -rf "$GITROOT"
111
+ [ "$rc" = "2" ] && pass "git work tree as log root refused" || fail "git work tree as log root not refused"
112
+
113
+ echo ""
114
+ echo "→ 9. default grace spares freshly-touched (active) task dirs"
115
+ seed
116
+ out=$(PRUNE_LOG_ROOT="$LR" bash "$PL" --yes 2>&1)
117
+ rc=$?
118
+ [ "$rc" = "0" ] && pass "grace run exits 0" || fail "grace run exited $rc"
119
+ [ -f "$LR/my-ios-app/PROJ-1/agent-state.json" ] && pass "active task dir spared by default grace" || fail "default grace deleted an active task dir"
120
+ echo "$out" | grep -qi "spared" && pass "output mentions spared active dirs" || fail "no spared-active note in output"
121
+ PRUNE_LOG_ROOT="$LR" bash "$PL" --task=PROJ-1 --yes >/dev/null 2>&1
122
+ [ ! -e "$LR/my-ios-app/PROJ-1" ] && pass "explicit --task bypasses the grace" || fail "--task did not bypass the grace"
123
+
124
+ echo ""
125
+ echo "→ 10. traversal-shaped --task/--project values are rejected"
126
+ PRUNE_LOG_ROOT="$LR" bash "$PL" "--task=../evil" >/dev/null 2>&1
127
+ [ "$?" = "2" ] && pass "--task=../evil exits 2" || fail "--task=../evil accepted"
128
+ PRUNE_LOG_ROOT="$LR" bash "$PL" "--task=.counter" >/dev/null 2>&1
129
+ [ "$?" = "2" ] && pass "--task=.counter (leading dot) exits 2" || fail "--task=.counter accepted"
130
+ PRUNE_LOG_ROOT="$LR" bash "$PL" "--project=a/b" >/dev/null 2>&1
131
+ [ "$?" = "2" ] && pass "--project=a/b exits 2" || fail "--project=a/b accepted"
132
+ PRUNE_LOG_ROOT="$LR" bash "$PL" "--task=" >/dev/null 2>&1
133
+ [ "$?" = "2" ] && pass "empty --task exits 2" || fail "empty --task accepted"
134
+
85
135
  echo ""
86
136
  echo "══ prune-logs smoke: $PASS passed, $FAIL failed ══"
87
137
  [ "$FAIL" -eq 0 ]
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env bash
2
+ # smoke-purge.sh - purge.sh wipes .worktrees/ worktrees (registered and
3
+ # orphaned), their local branches, and the project task counter; is dry-run
4
+ # by default; preserves the main worktree, protected branches, and the
5
+ # audit/metrics corpus; and never follows symlinks out of .worktrees/.
6
+ #
7
+ # Exit 0 = all pass, 1 = any failure.
8
+
9
+ set -uo pipefail
10
+
11
+ ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
12
+ PG="$ROOT/pipeline/scripts/purge.sh"
13
+ PASS=0
14
+ FAIL=0
15
+ pass() {
16
+ PASS=$((PASS + 1))
17
+ echo " ✓ $1"
18
+ }
19
+ fail() {
20
+ FAIL=$((FAIL + 1))
21
+ echo " ✗ $1"
22
+ }
23
+ [ -f "$PG" ] || {
24
+ echo "FAIL: purge.sh missing" >&2
25
+ exit 1
26
+ }
27
+
28
+ SB="$(cd "$(mktemp -d)" && pwd -P)"
29
+ trap 'rm -rf "$SB"' EXIT
30
+
31
+ REPO="$SB/repo"
32
+ LR="$SB/logs"
33
+ VICTIM="$SB/victim"
34
+
35
+ seed() {
36
+ rm -rf "$REPO" "$LR" "$VICTIM"
37
+ mkdir -p "$REPO" "$LR/repo" "$VICTIM"
38
+ echo keep >"$VICTIM/keep.txt"
39
+ git -C "$REPO" init -q -b main
40
+ git -C "$REPO" -c user.email=t@t.t -c user.name=t commit -q --allow-empty -m init
41
+ git -C "$REPO" worktree add -q -b feature/PROJ-1-x "$REPO/.worktrees/PROJ-1" >/dev/null 2>&1
42
+ git -C "$REPO" worktree add -q -b bugfix/PROJ-2-y "$REPO/.worktrees/PROJ-2" >/dev/null 2>&1
43
+ mkdir -p "$REPO/.worktrees/PROJ-9" # orphan: dir git no longer registers
44
+ echo '{}' >"$REPO/.worktrees/PROJ-9/agent-state.json"
45
+ ln -s "$VICTIM" "$REPO/.worktrees/evil-link" # symlink escape attempt
46
+ echo '7' >"$LR/repo/.counter"
47
+ echo audit >"$LR/audit.jsonl"
48
+ echo metric >"$LR/metrics.jsonl"
49
+ }
50
+
51
+ has_branch() {
52
+ git -C "$REPO" show-ref --verify -q "refs/heads/$1"
53
+ }
54
+
55
+ echo "→ 1. dry-run lists everything and deletes nothing"
56
+ seed
57
+ out=$(PURGE_LOG_ROOT="$LR" bash "$PG" --repo "$REPO" 2>&1)
58
+ rc=$?
59
+ [ "$rc" = "0" ] && pass "dry-run exits 0" || fail "dry-run exited $rc"
60
+ echo "$out" | grep -q "DRY-RUN" && pass "dry-run banner shown" || fail "dry-run banner missing"
61
+ echo "$out" | grep -q "PROJ-1" && pass "worktree PROJ-1 listed" || fail "worktree PROJ-1 not listed"
62
+ echo "$out" | grep -q "feature/PROJ-1-x" && pass "branch listed" || fail "branch not listed"
63
+ echo "$out" | grep -q ".counter" && pass "counter listed" || fail "counter not listed"
64
+ [ -d "$REPO/.worktrees/PROJ-1" ] && pass "dry-run kept worktrees" || fail "dry-run deleted a worktree"
65
+ [ -f "$LR/repo/.counter" ] && pass "dry-run kept the counter" || fail "dry-run deleted the counter"
66
+ has_branch feature/PROJ-1-x && pass "dry-run kept branches" || fail "dry-run deleted a branch"
67
+
68
+ echo ""
69
+ echo "→ 2. --yes wipes worktrees + branches + counter, preserves the rest"
70
+ seed
71
+ PURGE_LOG_ROOT="$LR" bash "$PG" --repo "$REPO" --yes >/dev/null 2>&1
72
+ rc=$?
73
+ [ "$rc" = "0" ] && pass "--yes exits 0" || fail "--yes exited $rc"
74
+ [ ! -e "$REPO/.worktrees/PROJ-1" ] && pass "registered worktree PROJ-1 removed" || fail "PROJ-1 survived"
75
+ [ ! -e "$REPO/.worktrees/PROJ-2" ] && pass "registered worktree PROJ-2 removed" || fail "PROJ-2 survived"
76
+ [ ! -e "$REPO/.worktrees/PROJ-9" ] && pass "orphan worktree dir removed" || fail "orphan PROJ-9 survived"
77
+ ! has_branch feature/PROJ-1-x && pass "branch feature/PROJ-1-x deleted" || fail "feature/PROJ-1-x survived"
78
+ ! has_branch bugfix/PROJ-2-y && pass "branch bugfix/PROJ-2-y deleted" || fail "bugfix/PROJ-2-y survived"
79
+ has_branch main && pass "main branch preserved" || fail "main branch was deleted"
80
+ git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1 && pass "main worktree still a git repo" || fail "main worktree destroyed"
81
+ wt_count=$(git -C "$REPO" worktree list 2>/dev/null | wc -l | tr -d ' ')
82
+ [ "$wt_count" = "1" ] && pass "only the main worktree remains registered" || fail "expected 1 registered worktree, got $wt_count"
83
+ [ ! -e "$LR/repo/.counter" ] && pass "task counter removed" || fail "task counter survived"
84
+ [ -f "$LR/audit.jsonl" ] && pass "audit.jsonl preserved" || fail "audit.jsonl was deleted"
85
+ [ -f "$LR/metrics.jsonl" ] && pass "metrics.jsonl preserved" || fail "metrics.jsonl was deleted"
86
+ [ -f "$VICTIM/keep.txt" ] && pass "symlink target outside repo untouched" || fail "symlink target was deleted"
87
+
88
+ echo ""
89
+ echo "→ 3. protected branch carried by a worktree survives, worktree still removed"
90
+ seed
91
+ git -C "$REPO" worktree add -q -b develop "$REPO/.worktrees/DEV-1" >/dev/null 2>&1
92
+ PURGE_LOG_ROOT="$LR" bash "$PG" --repo "$REPO" --yes >/dev/null 2>&1
93
+ [ ! -e "$REPO/.worktrees/DEV-1" ] && pass "develop worktree removed" || fail "develop worktree survived"
94
+ has_branch develop && pass "develop branch preserved" || fail "develop branch was deleted"
95
+
96
+ echo ""
97
+ echo "→ 4. clean repo and non-repo are no-ops (exit 0)"
98
+ CLEAN="$SB/clean"
99
+ mkdir -p "$CLEAN"
100
+ git -C "$CLEAN" init -q -b main
101
+ out=$(PURGE_LOG_ROOT="$LR" bash "$PG" --repo "$CLEAN" 2>&1)
102
+ rc=$?
103
+ { [ "$rc" = "0" ] && echo "$out" | grep -q "nothing to do"; } && pass "clean repo is a no-op" || fail "clean repo not a clean no-op (rc=$rc)"
104
+ PLAIN="$SB/plain"
105
+ mkdir -p "$PLAIN"
106
+ out=$(bash "$PG" --repo "$PLAIN" 2>&1)
107
+ rc=$?
108
+ { [ "$rc" = "0" ] && echo "$out" | grep -q "not a git work tree"; } && pass "non-repo dir is a no-op" || fail "non-repo dir not a clean no-op (rc=$rc)"
109
+
110
+ echo ""
111
+ echo "→ 5. usage errors exit 2"
112
+ bash "$PG" --bogus >/dev/null 2>&1
113
+ [ "$?" = "2" ] && pass "unknown argument exits 2" || fail "unknown argument did not exit 2"
114
+ bash "$PG" --repo >/dev/null 2>&1
115
+ [ "$?" = "2" ] && pass "--repo without a path exits 2" || fail "--repo without a path did not exit 2"
116
+
117
+ echo ""
118
+ echo "→ 6. --delete-remote without a remote warns but does not fail"
119
+ seed
120
+ out=$(PURGE_LOG_ROOT="$LR" bash "$PG" --repo "$REPO" --delete-remote 2>&1)
121
+ echo "$out" | grep -q "remote branch origin/feature/PROJ-1-x" && pass "dry-run lists remote branch deletion" || fail "dry-run missing remote branch line"
122
+ out=$(PURGE_LOG_ROOT="$LR" bash "$PG" --repo "$REPO" --delete-remote --yes 2>&1)
123
+ rc=$?
124
+ [ "$rc" = "0" ] && pass "--delete-remote --yes exits 0 without a remote" || fail "--delete-remote --yes exited $rc"
125
+ echo "$out" | grep -qi "warning" && pass "missing remote surfaces a warning" || fail "no warning for missing remote"
126
+
127
+ echo ""
128
+ echo "→ 7. purge from inside a linked worktree still anchors on the main repo"
129
+ seed
130
+ out=$(PURGE_LOG_ROOT="$LR" bash "$PG" --repo "$REPO/.worktrees/PROJ-1" --yes 2>&1)
131
+ rc=$?
132
+ [ "$rc" = "0" ] && pass "run from linked worktree exits 0" || fail "run from linked worktree exited $rc"
133
+ [ ! -e "$REPO/.worktrees/PROJ-1" ] && pass "worktrees removed when invoked from inside one" || fail "worktrees survived"
134
+ git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1 && pass "main repo intact" || fail "main repo destroyed"
135
+
136
+ echo ""
137
+ echo "══ purge smoke: $PASS passed, $FAIL failed ══"
138
+ [ "$FAIL" -eq 0 ]
@@ -98,8 +98,22 @@ else
98
98
  record_fail "node_modules leaked into shadow tracking"
99
99
  fi
100
100
 
101
- # Make a real edit + snapshot
101
+ # 7b. Forced excludes apply even when NOT in the project .gitignore
102
+ # (DerivedData is not in this fixture's .gitignore; it must be excluded via
103
+ # the shadow repo's .git/info/exclude).
104
+ mkdir -p DerivedData && echo "buildjunk" > DerivedData/junk.txt
105
+ bash "$SG" snapshot "$TASK_ID" "$TMP" "derived-data-probe" >/dev/null 2>&1
106
+ if ! git --git-dir="$HOME/.claude/state/shadow-git/$TASK_ID/.git" --work-tree="$TMP" \
107
+ ls-files 2>/dev/null | grep -q "^DerivedData/"; then
108
+ record_pass "DerivedData NOT tracked (forced exclude in .git/info/exclude)"
109
+ else
110
+ record_fail "DerivedData leaked into shadow tracking (forced excludes dead)"
111
+ fi
112
+ rm -rf DerivedData
113
+
114
+ # Make a real edit + a brand-new file + snapshot
102
115
  echo "hello-changed" > a.txt
116
+ echo "post-baseline" > b.txt
103
117
  sha_edit=$(bash "$SG" snapshot "$TASK_ID" "$TMP" "edited a.txt" 2>/dev/null | head -1)
104
118
  if [ -n "$sha_edit" ] && [ "${#sha_edit}" -ge 7 ]; then
105
119
  record_pass "snapshot after edit produced a sha: $sha_edit"
@@ -115,6 +129,7 @@ else
115
129
  fi
116
130
 
117
131
  # 8. Restore the baseline
132
+ echo "never-snapshotted" > c.txt # created after the last snapshot
118
133
  baseline_sha=$(bash "$SG" list "$TASK_ID" 2>/dev/null | tail -1 | awk '{print $1}')
119
134
  bash "$SG" restore "$TASK_ID" "$TMP" "$baseline_sha" --files >/dev/null 2>&1
120
135
  if [ "$(cat "$TMP/a.txt")" = "hello" ]; then
@@ -123,6 +138,23 @@ else
123
138
  record_fail "restore did not revert a.txt (got '$(cat "$TMP/a.txt")')"
124
139
  fi
125
140
 
141
+ # 8b. Restore removes files that did not exist in the snapshot
142
+ if [ ! -f "$TMP/b.txt" ]; then
143
+ record_pass "restore removed b.txt (committed after baseline)"
144
+ else
145
+ record_fail "restore left b.txt in place (created after snapshot must be removed)"
146
+ fi
147
+ if [ ! -f "$TMP/c.txt" ]; then
148
+ record_pass "restore removed c.txt (never snapshotted)"
149
+ else
150
+ record_fail "restore left c.txt in place (untracked new file must be removed)"
151
+ fi
152
+ if [ -f "$TMP/node_modules/x.txt" ]; then
153
+ record_pass "restore left ignored node_modules untouched"
154
+ else
155
+ record_fail "restore deleted ignored files (node_modules must survive clean)"
156
+ fi
157
+
126
158
  # 9. Prune
127
159
  bash "$SG" prune "$TASK_ID" >/dev/null 2>&1
128
160
  if [ ! -d "$HOME/.claude/state/shadow-git/$TASK_ID" ]; then
@@ -131,6 +163,21 @@ else
131
163
  record_fail "prune did not remove the shadow dir"
132
164
  fi
133
165
 
166
+ # 9b. Task-id validation: traversal and separator ids are rejected before any
167
+ # rm -rf (a "prune .." must never delete ~/.claude/state).
168
+ for bad_id in ".." "." "a/b" "-x!"; do
169
+ if bash "$SG" prune "$bad_id" >/dev/null 2>&1; then
170
+ record_fail "prune accepted unsafe task id '$bad_id'"
171
+ else
172
+ record_pass "prune rejects unsafe task id '$bad_id'"
173
+ fi
174
+ done
175
+ if [ -d "$HOME/.claude/state" ]; then
176
+ record_pass "~/.claude/state survived unsafe prune attempts"
177
+ else
178
+ record_fail "~/.claude/state is GONE after unsafe prune attempts"
179
+ fi
180
+
134
181
  cd - >/dev/null
135
182
  rm -rf "$TMP"
136
183
 
@@ -51,7 +51,7 @@ TARGETS=(
51
51
 
52
52
  # Collect the list once.
53
53
  SKILLS=$(find "${TARGETS[@]}" -name 'SKILL.md' -type f 2>/dev/null | sort)
54
- SKILL_COUNT=$(echo "$SKILLS" | grep -c . || echo 0)
54
+ SKILL_COUNT=$(echo "$SKILLS" | grep -c . || true)
55
55
 
56
56
  echo "→ Scope: $SKILL_COUNT authored SKILL.md files under shared/core/"
57
57
  echo " (external/ intentionally excluded - upstream conventions)"
@@ -67,6 +67,19 @@ else
67
67
  record_fail "local-ahead should be silent (got '$out', rc=$rc)"
68
68
  fi
69
69
 
70
+ # 3b2. No --local, no repo clone -> falls back to the install-time version
71
+ # marker (npx-only installs have no clone to read a version from)
72
+ marker_home="$tmpdir/marker-home"
73
+ mkdir -p "$marker_home/.claude"
74
+ printf '10.0.0\n' > "$marker_home/.claude/.pipeline-version"
75
+ printf '%s|99.0.0\n' "$now" > "$CACHE"
76
+ out=$(HOME="$marker_home" UPDATE_CHECK_CACHE="$CACHE" bash "$SCRIPT" 2>/dev/null); rc=$?
77
+ if [ "$rc" -eq 0 ] && [ "$out" = "10.0.0|99.0.0" ]; then
78
+ record_pass "no clone -> .pipeline-version marker fallback works"
79
+ else
80
+ record_fail "marker fallback should print '10.0.0|99.0.0' (got '$out', rc=$rc)"
81
+ fi
82
+
70
83
  # 3c. Offline + stale cache -> silent exit 0 (advisory: never blocks)
71
84
  printf '0|10.0.0\n' > "$CACHE"
72
85
  out=$(UPDATE_CHECK_CACHE="$CACHE" http_proxy="http://127.0.0.1:1" https_proxy="http://127.0.0.1:1" \