@mmerterden/multi-agent-pipeline 16.31.0 → 17.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 (36) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +101 -101
  3. package/README.tr.md +101 -101
  4. package/docs/adr/0011-dormant-ci.md +10 -1
  5. package/docs/adr/0012-macos-only.md +98 -0
  6. package/docs/adr/README.md +1 -0
  7. package/docs/engineering.md +1 -1
  8. package/index.js +26 -0
  9. package/install/_dev-only-files.mjs +0 -1
  10. package/install/index.mjs +10 -0
  11. package/package.json +5 -3
  12. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +40 -3
  13. package/pipeline/commands/multi-agent/help/SKILL.md +2 -2
  14. package/pipeline/commands/multi-agent/setup/SKILL.md +15 -7
  15. package/pipeline/commands/multi-agent/stack/SKILL.md +31 -32
  16. package/pipeline/commands/multi-agent/status/SKILL.md +17 -1
  17. package/pipeline/commands/multi-agent/sync/SKILL.md +26 -21
  18. package/pipeline/lib/account-resolver.sh +1 -1
  19. package/pipeline/lib/stack-detect.sh +200 -0
  20. package/pipeline/multi-agent-refs/cross-cli-contract.md +29 -10
  21. package/pipeline/multi-agent-refs/features/doctor.md +15 -3
  22. package/pipeline/multi-agent-refs/features/visual-evidence.md +49 -1
  23. package/pipeline/multi-agent-refs/phases/phase-0-init.md +14 -5
  24. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +26 -12
  25. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +6 -4
  26. package/pipeline/multi-agent-refs/phases/phase-7-report.md +2 -3
  27. package/pipeline/multi-agent-refs/rules.md +4 -5
  28. package/pipeline/schemas/agent-state.schema.json +99 -25
  29. package/pipeline/schemas/token-budget.json +4 -4
  30. package/pipeline/scripts/_stack-routing.mjs +91 -0
  31. package/pipeline/scripts/capture-resume.sh +76 -14
  32. package/pipeline/scripts/doctor.mjs +26 -3
  33. package/pipeline/scripts/gc-abandoned.sh +352 -0
  34. package/pipeline/scripts/keychain.py +1 -1
  35. package/pipeline/scripts/usage-report.mjs +5 -5
  36. package/pipeline/scripts/gate-linux.sh +0 -62
@@ -29,20 +29,70 @@ LOGS="$HOME/.claude/logs/multi-agent"
29
29
  PIPELINE_MEM="$HOME/.claude/memory/multi-agent/_pipeline"
30
30
  REVIEW_DAYS=7
31
31
 
32
+ # Three buckets, not one line. Measured on the development machine: 22 runs read
33
+ # `in_progress` and every one was over a day old, but they are not one failure.
34
+ # 13 stopped at Phase 0, which is almost entirely questions - those runs never
35
+ # started. 3 had their PR already open and were waiting at Phase 6/7, where the
36
+ # pipeline pauses ON PURPOSE for channel selection. 4 died mid-development.
37
+ #
38
+ # Reporting the newest one of those as "stopped at Phase N" told the truth about
39
+ # one run and hid twenty-one, and it read the same whether the pipeline was
40
+ # waiting for the user or had crashed. A finished job and a dead one should not
41
+ # share a sentence.
42
+ AWAITING_N=0; AWAITING_TASK=""; AWAITING_PHASE=""
43
+ DIED_N=0; DIED_TASK=""; DIED_PHASE=""
44
+ UNSTARTED_N=0; UNSTARTED_TASK=""
32
45
  STALE_TASK=""
33
46
  STALE_PHASE=""
34
47
 
35
48
  if [ -d "$LOGS" ] && command -v jq >/dev/null 2>&1; then
36
- # Newest by mtime, not by name: task ids do not sort chronologically.
37
- NEWEST=$(find "$LOGS" -name agent-state.json -type f -maxdepth 4 -print0 2>/dev/null \
38
- | xargs -0 ls -t 2>/dev/null | head -1)
39
- if [ -n "$NEWEST" ] && [ -f "$NEWEST" ]; then
40
- P7=$(jq -r '[.phases[]? | select((.id // "") == "7") | .status] | first // ""' "$NEWEST" 2>/dev/null)
41
- STATUS=$(jq -r '.status // ""' "$NEWEST" 2>/dev/null)
42
- if [ "$P7" != "completed" ] && [ "$STATUS" != "completed" ]; then
43
- STALE_TASK=$(jq -r '.taskId // .jiraId // ""' "$NEWEST" 2>/dev/null)
44
- STALE_PHASE=$(jq -r '.currentPhase // "?"' "$NEWEST" 2>/dev/null)
49
+ # Newest first, so the first hit in each bucket is the one worth naming.
50
+ # Both layouts are walked: state is written flat as <task-id>/ by
51
+ # phase-tracker.sh and nested as <project>/<task-id>/ by Phase 0, and a
52
+ # reader that knows only one of them under-reports by the size of the other.
53
+ while IFS= read -r f; do
54
+ [ -f "$f" ] || continue
55
+ STATUS=$(jq -r '.status // ""' "$f" 2>/dev/null)
56
+ # Only runs that CLAIM to be running are bucketed. A file with no status is
57
+ # unknown - eight of them here, six with no currentPhase either - and
58
+ # reporting unknown as "stopped mid-development" is the same false claim
59
+ # this change exists to remove, just pointed the other way. If that hides a
60
+ # real run, the fix is for its writer to record a status.
61
+ # `complete` is the schema spelling and `completed` is in the wild too, so
62
+ # both are excluded; a reader that knows one calls finished runs stale.
63
+ case "$STATUS" in in_progress | paused | awaiting_input) ;; *) continue ;; esac
64
+ PHASE=$(jq -r '.currentPhase // "?"' "$f" 2>/dev/null)
65
+ TASK=$(jq -r '.taskId // .jiraId // ""' "$f" 2>/dev/null)
66
+ [ -n "$TASK" ] || continue
67
+ # `.pr` is an object in the schema and a bare URL string in three state files
68
+ # on disk. `jq '.pr.url'` on a string errors, and with stderr suppressed that
69
+ # reads as "no PR" - so a run whose PR is already open would be classed as dead
70
+ # and reaped. Read the shape, do not assume it.
71
+ PR=$(jq -r 'if (.pr|type)=="string" then .pr elif (.pr|type)=="object" then (.pr.url // .pr.number // "") else "" end | tostring' "$f" 2>/dev/null)
72
+
73
+ # Waiting for you, not broken: an open PR means the work landed, and
74
+ # Phase 7 pauses for channel selection by design (modes.md).
75
+ if [ "$STATUS" = "awaiting_input" ] || [ -n "$PR" ] ||
76
+ [ "$PHASE" = "6" ] || [ "$PHASE" = "7" ]; then
77
+ AWAITING_N=$((AWAITING_N + 1))
78
+ [ -z "$AWAITING_TASK" ] && { AWAITING_TASK="$TASK"; AWAITING_PHASE="$PHASE"; }
79
+ elif [ "$PHASE" = "0" ]; then
80
+ # Phase 0 is almost all questions. Nothing was built, so there is nothing
81
+ # to resume and nothing to lose.
82
+ UNSTARTED_N=$((UNSTARTED_N + 1))
83
+ [ -z "$UNSTARTED_TASK" ] && UNSTARTED_TASK="$TASK"
84
+ else
85
+ DIED_N=$((DIED_N + 1))
86
+ [ -z "$DIED_TASK" ] && { DIED_TASK="$TASK"; DIED_PHASE="$PHASE"; }
45
87
  fi
88
+ done <<EOF
89
+ $(find "$LOGS" -name agent-state.json -type f -maxdepth 4 -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null)
90
+ EOF
91
+ # Kept for the JSON consumers that read staleTask/stalePhase.
92
+ if [ -n "$AWAITING_TASK" ]; then
93
+ STALE_TASK="$AWAITING_TASK"; STALE_PHASE="$AWAITING_PHASE"
94
+ elif [ -n "$DIED_TASK" ]; then
95
+ STALE_TASK="$DIED_TASK"; STALE_PHASE="$DIED_PHASE"
46
96
  fi
47
97
  fi
48
98
 
@@ -71,14 +121,26 @@ if [ -d "$PIPELINE_MEM/observations" ]; then
71
121
  fi
72
122
 
73
123
  if [ "$JSON" -eq 1 ]; then
74
- printf '{"staleTask":"%s","stalePhase":"%s","openObservations":%s,"reviewDue":%s}\n' \
75
- "$STALE_TASK" "$STALE_PHASE" "${OPEN_OBS:-0}" "$REVIEW_DUE"
124
+ printf '{"staleTask":"%s","stalePhase":"%s","awaiting":%s,"died":%s,"unstarted":%s,"openObservations":%s,"reviewDue":%s}\n' \
125
+ "$STALE_TASK" "$STALE_PHASE" "$AWAITING_N" "$DIED_N" "$UNSTARTED_N" "${OPEN_OBS:-0}" "$REVIEW_DUE"
76
126
  exit 0
77
127
  fi
78
128
 
79
- if [ -n "$STALE_TASK" ]; then
80
- printf 'multi-agent: %s stopped at Phase %s - `/multi-agent:resume` to continue, `/multi-agent:kill` to drop it.\n' \
81
- "$STALE_TASK" "$STALE_PHASE"
129
+ # One line per bucket, each naming its own next action, because they are
130
+ # different actions. Counts are printed even when they are 1: "1 run" and
131
+ # "13 runs" call for different reactions and the old line could not tell them
132
+ # apart.
133
+ if [ "$AWAITING_N" -gt 0 ]; then
134
+ printf 'multi-agent: %s run(s) waiting on you, oldest %s at Phase %s - `/multi-agent:resume` to finish.\n' \
135
+ "$AWAITING_N" "$AWAITING_TASK" "$AWAITING_PHASE"
136
+ fi
137
+ if [ "$DIED_N" -gt 0 ]; then
138
+ printf 'multi-agent: %s run(s) stopped mid-development, oldest %s at Phase %s - `/multi-agent:resume` or `/multi-agent:kill`.\n' \
139
+ "$DIED_N" "$DIED_TASK" "$DIED_PHASE"
140
+ fi
141
+ if [ "$UNSTARTED_N" -gt 0 ]; then
142
+ printf 'multi-agent: %s run(s) left at a Phase 0 question, nothing built - `/multi-agent:garbage-collect --abandoned` to clear.\n' \
143
+ "$UNSTARTED_N"
82
144
  fi
83
145
  if [ "$REVIEW_DUE" -eq 1 ]; then
84
146
  printf 'multi-agent: %s open pipeline observation(s), last review %s+ days ago - `/multi-agent:refactor backlog` when convenient.\n' \
@@ -70,6 +70,7 @@ const TASK_TOOLS = taskFlag ? taskFlag.split("=")[1] : null;
70
70
  // the two are equal in BOTH directions: a check cannot ship without its entry,
71
71
  // and an entry cannot outlive its check.
72
72
  const CHECK_IDS = [
73
+ "host-platform",
73
74
  "install-present",
74
75
  "install-version",
75
76
  "script-surface",
@@ -87,10 +88,11 @@ const CHECK_IDS = [
87
88
  "worktree-residue",
88
89
  ];
89
90
 
90
- // Only these five may return BLOCK. Enforced below, not merely documented: a
91
+ // Only these six may return BLOCK. Enforced below, not merely documented: a
91
92
  // check that returns BLOCK without being listed here is downgraded and the
92
93
  // downgrade is reported, because an unenforced rule is a comment.
93
94
  const MAY_BLOCK = new Set([
95
+ "host-platform",
94
96
  "install-present",
95
97
  "script-surface",
96
98
  "state-writable",
@@ -153,6 +155,25 @@ function readJson(path) {
153
155
  // one step silently fails is worse than no step. Rule: smoke-npm-scope-pinning.sh.
154
156
  const SUBTREES = ["commands", "multi-agent-refs", "scripts", "lib", "schemas"];
155
157
 
158
+ // Runs before everything else so a non-darwin host gets one honest line instead
159
+ // of a cascade of failures whose common cause is never stated. This blocks
160
+ // rather than warns because a run there does not degrade, it fails: every
161
+ // credential read shells `security`, every iOS build `xcodebuild`, every piece
162
+ // of visual evidence `simctl`.
163
+ function checkHostPlatform() {
164
+ if (process.platform === "darwin") {
165
+ ok("host-platform");
166
+ return true;
167
+ }
168
+ report(
169
+ "host-platform",
170
+ "BLOCK",
171
+ `this host reports "${process.platform}"; the pipeline supports macOS only`,
172
+ "install on macOS, or set MULTI_AGENT_ALLOW_NON_DARWIN=1 to proceed untested",
173
+ );
174
+ return false;
175
+ }
176
+
156
177
  function checkInstallPresent() {
157
178
  if (!existsSync(CLAUDE)) {
158
179
  report(
@@ -718,18 +739,20 @@ function main() {
718
739
  return;
719
740
  }
720
741
 
742
+ checkHostPlatform();
743
+
721
744
  const resolved = checkInstallPresent();
722
745
  if (!resolved) {
723
746
  // Indeterminate, not healthy and not merely degraded: nothing after this
724
747
  // could be checked, and saying "blocked" would claim a verdict on checks
725
748
  // that never ran.
726
- const line = results[0];
749
+ const line = results[results.length - 1];
727
750
  if (JSON_OUT) {
728
751
  process.stdout.write(`${JSON.stringify({ exit: 4, checks: results }, null, 2)}\n`);
729
752
  } else {
730
753
  process.stdout.write(`${line.severity} ${line.id} - ${line.problem} - ${line.step}\n`);
731
754
  process.stdout.write(
732
- `\n-> indeterminate: the layout did not resolve, so ${CHECK_IDS.length - 1} checks did not run\n`,
755
+ `\n-> indeterminate: the layout did not resolve, so ${CHECK_IDS.length - results.length} checks did not run\n`,
733
756
  );
734
757
  }
735
758
  process.exitCode = 4;
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env bash
2
+ # gc-abandoned.sh - reap runs that stopped and were never picked back up.
3
+ #
4
+ # The gap this fills: gc-worktrees.sh deliberately skips REGISTERED worktrees,
5
+ # because a registered worktree belongs to a live run. That is right, and it
6
+ # means nothing at all collects a run that simply stopped - its worktree stays
7
+ # registered forever. Measured on one machine: 20 runs reading `in_progress`,
8
+ # every one more than a day old, one of them 157 days, holding 28 worktrees
9
+ # across 7 repos and 22.8 GB.
10
+ #
11
+ # Three things keep this from being a foot-gun, and each is a rule rather than a
12
+ # heuristic:
13
+ #
14
+ # 1. A run WAITING FOR YOU is never reaped. An open PR, Phase 6 or 7, or
15
+ # `status: awaiting_input` means the work landed and the pipeline is
16
+ # holding for an answer by design. That is finished work, not residue.
17
+ # 2. A path that is not strictly inside `<repo>/.worktrees/` is never removed.
18
+ # This is not theoretical: four state files on that machine record
19
+ # `worktreePath` as the REPO ROOT, so a sweep that trusted the field would
20
+ # have deleted the checkout.
21
+ # 3. Uncommitted work is never destroyed. The branch is stashed to
22
+ # `autopilot/abandoned/<task-id>` and the directory is left in place, which
23
+ # costs disk and keeps the work. Losing a day of edits is worse than 750 MB.
24
+ #
25
+ # TWO PASSES, because the state files are not where the disk is. Measured across
26
+ # 28 worktrees holding 23 GB: only 5 of them (1.5 GB) belong to a run that still
27
+ # reads `in_progress`. 19 of them (15.6 GB) have NO state file at all, and 3 more
28
+ # (5.1 GB) belong to runs that finished and whose worktree outlived them. A
29
+ # state-driven sweep alone reaps 4% of the problem, so pass 2 walks the worktrees
30
+ # themselves and asks each one what its run is doing.
31
+ #
32
+ # Pass 2 keeps every rule above and adds the one that matters most: **a worktree
33
+ # with no run state on record is REPORTED, never removed.** `.worktrees/` is not
34
+ # exclusively ours - the measured machine holds `174`, `pr-4051`, `task-1` and
35
+ # `create-screen` there, all hand-made by the user, one of them in active use.
36
+ # Nothing distinguishes those from a pipeline worktree whose log was pruned, so
37
+ # the honest move is to show them with their size and let the user decide. They
38
+ # are the 15.6 GB, and pointing at them is worth more than a rule that guesses.
39
+ #
40
+ # So --yes removes exactly two things: a worktree whose run is recorded as
41
+ # finished, and one whose run is recorded as stopped past its age limit.
42
+ #
43
+ # Two ages, because two different things are being reaped. A run left at a Phase
44
+ # 0 question never started - Phase 0 is almost entirely questions - so there is
45
+ # no code to lose and a short window is safe. A run that died mid-development
46
+ # may hold real edits, so it gets a long one and the stash rule above.
47
+ #
48
+ # State is moved to `<run-dir>/artifacts/` before the worktree goes, so the log,
49
+ # the findings and the phase history survive the sweep. The same recovery
50
+ # contract Phase 6 uses when it removes a finished worktree.
51
+ #
52
+ # SAFE BY DEFAULT: dry-run. Removes nothing until you pass --yes.
53
+ #
54
+ # Usage:
55
+ # gc-abandoned.sh # dry-run, default ages
56
+ # gc-abandoned.sh --yes # apply
57
+ # gc-abandoned.sh --days=14 # mid-development runs idle > 14 days
58
+ # gc-abandoned.sh --phase0-days=3 # Phase 0 leftovers idle > 3 days
59
+ # gc-abandoned.sh --logs <dir> # another state root (tests)
60
+ # gc-abandoned.sh --repos <dir> # another checkout root (tests)
61
+ # gc-abandoned.sh --state-only # pass 1 only
62
+ #
63
+ # Exit: 0 on success (including "nothing to do"), 2 on usage error.
64
+
65
+ set -uo pipefail
66
+
67
+ LOGS="$HOME/.claude/logs/multi-agent"
68
+ REPOS="$HOME"
69
+ DELETE=0
70
+ DAYS=7
71
+ PHASE0_DAYS=1
72
+ STATE_ONLY=0
73
+
74
+ usage() {
75
+ grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
76
+ }
77
+
78
+ while [ "$#" -gt 0 ]; do
79
+ case "$1" in
80
+ --yes | -y) DELETE=1; shift ;;
81
+ --days=*) DAYS="${1#*=}"; shift ;;
82
+ --phase0-days=*) PHASE0_DAYS="${1#*=}"; shift ;;
83
+ --logs) LOGS="${2:-}"; shift 2 ;;
84
+ --repos) REPOS="${2:-}"; shift 2 ;;
85
+ --state-only) STATE_ONLY=1; shift ;;
86
+ --abandoned) shift ;; # accepted so the command can forward its own flag
87
+ -h | --help) usage; exit 0 ;;
88
+ *) echo "gc-abandoned: unknown option $1" >&2; exit 2 ;;
89
+ esac
90
+ done
91
+
92
+ case "$DAYS$PHASE0_DAYS" in *[!0-9]*) echo "gc-abandoned: --days and --phase0-days take whole days" >&2; exit 2 ;; esac
93
+
94
+ command -v jq >/dev/null 2>&1 || { echo "gc-abandoned: jq is required"; exit 0; }
95
+ [ -d "$LOGS" ] || { echo "gc-abandoned: nothing to do (no state at $LOGS)"; exit 0; }
96
+
97
+ # GNU form FIRST. `stat -f` is a valid GNU flag (--file-system) that succeeds and
98
+ # prints a mount point, so BSD-first silently returns a number that is not a
99
+ # timestamp. ADR-0012 lists this among the constructs that look cross-platform
100
+ # and are load-bearing; it is the ordering that matters, not the platform.
101
+ _mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo "$NOW"; }
102
+
103
+ NOW=$(date +%s)
104
+ removed=0
105
+ freed_kb=0
106
+ kept_dirty=0
107
+ skipped_waiting=0
108
+ skipped_unsafe=0
109
+ listed=0
110
+ # Paths pass 1 has already decided about. Pass 2 must not revisit them: pass 1
111
+ # stashes a dirty worktree and KEEPS it, which leaves the tree clean, and pass 2
112
+ # would then see a clean worktree whose run pass 1 just marked `failed` and
113
+ # remove the very thing that was preserved. The two passes are complementary,
114
+ # not sequential filters over the same set.
115
+ HANDLED=""
116
+
117
+ # Both layouts: phase-tracker.sh writes <task-id>/ and Phase 0 writes
118
+ # <project>/<task-id>/. Six task ids on the measured machine exist in both, so a
119
+ # sweep that walks one layout leaves the other copy claiming the run is live.
120
+ while IFS= read -r state; do
121
+ [ -f "$state" ] || continue
122
+ status=$(jq -r '.status // ""' "$state" 2>/dev/null)
123
+ # Only runs that CLAIM to be running. A file with no status is unknown, and
124
+ # unknown is not a licence to delete.
125
+ case "$status" in in_progress | paused | awaiting_input) ;; *) continue ;; esac
126
+
127
+ task=$(jq -r '.taskId // .jiraId // ""' "$state" 2>/dev/null)
128
+ [ -n "$task" ] || continue
129
+ phase=$(jq -r '.currentPhase // "?"' "$state" 2>/dev/null)
130
+ # `.pr` is an object in the schema and a bare URL string in three state files
131
+ # on disk. `jq '.pr.url'` on a string errors, and with stderr suppressed that
132
+ # reads as "no PR" - so a run whose PR is already open would be classed as dead
133
+ # and reaped. Read the shape, do not assume it.
134
+ pr=$(jq -r 'if (.pr|type)=="string" then .pr elif (.pr|type)=="object" then (.pr.url // .pr.number // "") else "" end | tostring' "$state" 2>/dev/null)
135
+ wt=$(jq -r '.worktreePath // ""' "$state" 2>/dev/null)
136
+
137
+ # Rule 1: waiting for you is not residue.
138
+ if [ "$status" = "awaiting_input" ] || [ -n "$pr" ] || [ "$phase" = "6" ] || [ "$phase" = "7" ]; then
139
+ skipped_waiting=$((skipped_waiting + 1))
140
+ continue
141
+ fi
142
+
143
+ mtime=$(_mtime "$state")
144
+ age_days=$(((NOW - mtime) / 86400))
145
+ if [ "$phase" = "0" ]; then
146
+ limit="$PHASE0_DAYS"; kind="left at a Phase 0 question"
147
+ else
148
+ limit="$DAYS"; kind="stopped at Phase $phase"
149
+ fi
150
+ [ "$age_days" -gt "$limit" ] || continue
151
+
152
+ # Rule 2: the path must be strictly inside a .worktrees/ directory. A repo
153
+ # root recorded in worktreePath is the shape that would delete a checkout.
154
+ safe_wt=""
155
+ if [ -n "$wt" ] && [ -d "$wt" ] && [ ! -L "$wt" ]; then
156
+ real=$(cd "$wt" 2>/dev/null && pwd -P)
157
+ case "$real" in
158
+ */.worktrees/*) safe_wt="$real" ;;
159
+ *) skipped_unsafe=$((skipped_unsafe + 1)) ;;
160
+ esac
161
+ fi
162
+
163
+ size_kb=0
164
+ [ -n "$safe_wt" ] && size_kb=$(du -sk "$safe_wt" 2>/dev/null | cut -f1)
165
+
166
+ # Rule 3: uncommitted work is stashed, and the directory stays.
167
+ dirty=0
168
+ if [ -n "$safe_wt" ] && [ -n "$(git -C "$safe_wt" status --porcelain 2>/dev/null)" ]; then
169
+ dirty=1
170
+ fi
171
+
172
+ listed=$((listed + 1))
173
+ [ -n "$safe_wt" ] && HANDLED="$HANDLED
174
+ $safe_wt"
175
+ human=$(printf '%s' "${size_kb:-0}" | awk '{printf "%.1fG", $1/1048576}')
176
+ if [ "$DELETE" -eq 0 ]; then
177
+ if [ "$dirty" -eq 1 ]; then
178
+ printf ' would stash (uncommitted work): %s - %s, idle %sd, %s\n' "$task" "$kind" "$age_days" "$human"
179
+ elif [ -n "$safe_wt" ]; then
180
+ printf ' would remove: %s - %s, idle %sd, %s\n' "$task" "$kind" "$age_days" "$human"
181
+ else
182
+ printf ' would mark abandoned (no reapable worktree): %s - %s, idle %sd\n' "$task" "$kind" "$age_days"
183
+ fi
184
+ continue
185
+ fi
186
+
187
+ run_dir=$(dirname "$state")
188
+ mkdir -p "$run_dir/artifacts" 2>/dev/null
189
+ cp "$state" "$run_dir/artifacts/agent-state.json" 2>/dev/null
190
+ tmp="$state.tmp.$$"
191
+ if jq --arg r "abandoned by gc-abandoned after ${age_days}d idle" \
192
+ '.status = "failed" | .haltReason = $r' "$state" > "$tmp" 2>/dev/null; then
193
+ mv "$tmp" "$state"
194
+ else
195
+ rm -f "$tmp"
196
+ fi
197
+
198
+ if [ "$dirty" -eq 1 ]; then
199
+ br="autopilot/abandoned/$task"
200
+ git -C "$safe_wt" stash push -u -m "$br" >/dev/null 2>&1 &&
201
+ printf '→ stashed uncommitted work, worktree kept: %s (%s)\n' "$task" "$br" ||
202
+ printf '→ could NOT stash, worktree kept untouched: %s\n' "$task"
203
+ kept_dirty=$((kept_dirty + 1))
204
+ continue
205
+ fi
206
+
207
+ if [ -n "$safe_wt" ]; then
208
+ repo=${safe_wt%%/.worktrees/*}
209
+ git -C "$repo" worktree remove --force "$safe_wt" >/dev/null 2>&1 || rm -rf "$safe_wt"
210
+ git -C "$repo" worktree prune >/dev/null 2>&1
211
+ printf '→ removed: %s (%s, idle %sd)\n' "$task" "$human" "$age_days"
212
+ freed_kb=$((freed_kb + ${size_kb:-0}))
213
+ else
214
+ printf '→ marked abandoned, no reapable worktree: %s\n' "$task"
215
+ fi
216
+ removed=$((removed + 1))
217
+ done <<EOF
218
+ $(find "$LOGS" -name agent-state.json -type f -maxdepth 4 -not -path '*/artifacts/*' 2>/dev/null)
219
+ EOF
220
+
221
+ # ---- pass 2: the worktrees themselves ---------------------------------------
222
+ #
223
+ # Driven from disk rather than from state, because 19 of the 28 worktrees
224
+ # measured have no state file to drive from. For each one the question is what
225
+ # its run is doing, answered by looking the state up by path and by task id.
226
+ stale_wt=0
227
+ unattributed=0
228
+ unattributed_kb=0
229
+ if [ "$STATE_ONLY" -eq 0 ] && [ -d "$REPOS" ]; then
230
+ # Every state file once, so the per-worktree lookup below is a grep and not a
231
+ # re-walk of the log tree for each of 28 directories.
232
+ index=$(find "$LOGS" -name agent-state.json -type f -maxdepth 4 -not -path '*/artifacts/*' 2>/dev/null |
233
+ while IFS= read -r f; do
234
+ jq -r '[(.worktreePath // ""), (.taskId // .jiraId // ""), (.status // ""), (if (.pr|type)=="string" then .pr elif (.pr|type)=="object" then (.pr.url // .pr.number // "") else "" end | tostring), ((.currentPhase // "") | tostring)] | @tsv' "$f" 2>/dev/null
235
+ done)
236
+
237
+ for wtroot in "$REPOS"/*/.worktrees; do
238
+ [ -d "$wtroot" ] || continue
239
+ [ -L "$wtroot" ] && continue
240
+ repo=$(dirname "$wtroot")
241
+ for d in "$wtroot"/*; do
242
+ [ -d "$d" ] || continue
243
+ [ -L "$d" ] && continue
244
+ real=$(cd "$d" 2>/dev/null && pwd -P) || continue
245
+ # The namespace guard again, unconditionally: nothing outside a
246
+ # `.worktrees/` directory is a candidate, whatever any record claims.
247
+ case "$real" in */.worktrees/*) ;; *) continue ;; esac
248
+ printf '%s\n' "$HANDLED" | grep -qxF "$real" && continue
249
+ id=$(basename "$real")
250
+
251
+ row=$(printf '%s\n' "$index" | awk -F'\t' -v p="$real" -v i="$id" '$1==p || $2==i {print; exit}')
252
+ st=$(printf '%s' "$row" | cut -f3)
253
+ pr=$(printf '%s' "$row" | cut -f4)
254
+ ph=$(printf '%s' "$row" | cut -f5)
255
+
256
+ # Rule 1 again: landed work is not residue, whichever pass finds it. Order
257
+ # matters here - the phase-6/7 test is a proxy for "still waiting", and a
258
+ # run whose status already says it FINISHED is not waiting for anyone. A
259
+ # complete run sitting at phase 7 was read as waiting and never reaped,
260
+ # which is how three finished worktrees held 5.1 GB indefinitely.
261
+ case "$st" in
262
+ complete | completed | failed) ;;
263
+ awaiting_input)
264
+ skipped_waiting=$((skipped_waiting + 1)); continue ;;
265
+ in_progress | paused)
266
+ continue ;; # pass 1 owns these; do not report them twice
267
+ *)
268
+ if [ -n "$pr" ] || [ "$ph" = "6" ] || [ "$ph" = "7" ]; then
269
+ skipped_waiting=$((skipped_waiting + 1)); continue
270
+ fi ;;
271
+ esac
272
+
273
+ attributed=1
274
+ case "$st" in
275
+ complete | completed | failed)
276
+ why="run finished, worktree outlived it" ;;
277
+ *)
278
+ why="no run state on record - yours to judge, never removed here"
279
+ attributed=0 ;;
280
+ esac
281
+
282
+ # Age from the newest file in the tree, not from the directory stamp: a
283
+ # directory mtime only tracks its immediate entries, so an actively edited
284
+ # worktree can look untouched for weeks.
285
+ newest=$(find "$real" -type f -not -path '*/.git/*' -print0 2>/dev/null |
286
+ xargs -0 ls -t 2>/dev/null | head -1)
287
+ mt=$(_mtime "${newest:-$real}")
288
+ age_days=$(((NOW - mt) / 86400))
289
+ [ "$age_days" -gt "$DAYS" ] || continue
290
+
291
+ size_kb=$(du -sk "$real" 2>/dev/null | cut -f1)
292
+ human=$(printf '%s' "${size_kb:-0}" | awk '{printf "%.1fG", $1/1048576}')
293
+ dirty=0
294
+ [ -n "$(git -C "$real" status --porcelain 2>/dev/null)" ] && dirty=1
295
+
296
+ if [ "$attributed" -eq 0 ]; then
297
+ unattributed=$((unattributed + 1))
298
+ unattributed_kb=$((unattributed_kb + ${size_kb:-0}))
299
+ printf ' %s %s - %s, idle %sd, %s\n' \
300
+ "$([ "$DELETE" -eq 0 ] && echo "unattributed:" || echo "left alone:")" \
301
+ "$id" "$why" "$age_days" "$human"
302
+ continue
303
+ fi
304
+
305
+ stale_wt=$((stale_wt + 1))
306
+ if [ "$DELETE" -eq 0 ]; then
307
+ if [ "$dirty" -eq 1 ]; then
308
+ printf ' would stash (uncommitted work): %s - %s, idle %sd, %s\n' "$id" "$why" "$age_days" "$human"
309
+ else
310
+ printf ' would remove worktree: %s - %s, idle %sd, %s\n' "$id" "$why" "$age_days" "$human"
311
+ fi
312
+ continue
313
+ fi
314
+
315
+ if [ "$dirty" -eq 1 ]; then
316
+ git -C "$real" stash push -u -m "autopilot/abandoned/$id" >/dev/null 2>&1 &&
317
+ printf '→ stashed uncommitted work, worktree kept: %s\n' "$id" ||
318
+ printf '→ could NOT stash, worktree kept untouched: %s\n' "$id"
319
+ kept_dirty=$((kept_dirty + 1))
320
+ continue
321
+ fi
322
+ git -C "$repo" worktree remove --force "$real" >/dev/null 2>&1 || rm -rf "$real"
323
+ git -C "$repo" worktree prune >/dev/null 2>&1
324
+ printf '→ removed worktree: %s (%s, %s, idle %sd)\n' "$id" "$why" "$human" "$age_days"
325
+ freed_kb=$((freed_kb + ${size_kb:-0}))
326
+ removed=$((removed + 1))
327
+ done
328
+ done
329
+ fi
330
+ listed=$((listed + stale_wt))
331
+
332
+ note=""
333
+ [ "$skipped_waiting" -gt 0 ] && note="$note, $skipped_waiting waiting on you (kept)"
334
+ [ "$skipped_unsafe" -gt 0 ] && note="$note, $skipped_unsafe path(s) outside .worktrees/ (kept)"
335
+ [ "$kept_dirty" -gt 0 ] && note="$note, $kept_dirty stashed and kept"
336
+ if [ "$unattributed" -gt 0 ]; then
337
+ uh=$(printf '%s' "$unattributed_kb" | awk '{printf "%.1fG", $1/1048576}')
338
+ note="$note, $unattributed worktree(s) holding $uh with no run state (listed, untouched)"
339
+ fi
340
+
341
+ if [ "$DELETE" -eq 0 ]; then
342
+ if [ "$listed" -eq 0 ]; then
343
+ printf 'gc-abandoned: nothing to do%s\n' "${note:+ (}${note#, }${note:+)}"
344
+ else
345
+ printf '══ gc-abandoned (dry-run): %d run(s) would be reaped%s ══\n' "$listed" "$note"
346
+ fi
347
+ exit 0
348
+ fi
349
+
350
+ freed_h=$(printf '%s' "$freed_kb" | awk '{printf "%.1fG", $1/1048576}')
351
+ printf '══ gc-abandoned: reaped %d run(s), freed ~%s%s ══\n' "$removed" "$freed_h" "$note"
352
+ exit 0
@@ -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()
@@ -177,12 +177,12 @@ function invokedCalls(state) {
177
177
  return Array.from(names).slice(0, 30);
178
178
  }
179
179
 
180
+ // The package declares `os: ["darwin"]` and every entry point refuses a
181
+ // non-darwin host, so the branches this used to carry were unreachable. The
182
+ // function and the field stay: the panel groups by `os` and a row with the key
183
+ // missing reads as "unknown host" rather than "macOS".
180
184
  function detectOs() {
181
- const p = platform();
182
- if (p === "darwin") return "macos";
183
- if (p === "win32") return "windows";
184
- if (p === "linux") return "linux";
185
- return p;
185
+ return platform() === "darwin" ? "macos" : platform();
186
186
  }
187
187
 
188
188
  function detectCli() {
@@ -1,62 +0,0 @@
1
- #!/usr/bin/env bash
2
- # gate-linux.sh - run the ci-lite job on a real Ubuntu image, locally.
3
- #
4
- # The pre-push gate (pre-push-check.sh) is the primary one, but it runs on the
5
- # maintainer's macOS workstation against an installed node_modules that has
6
- # accumulated over months. Two things it cannot answer: does this tree work on
7
- # Linux, and does `npm ci` from the lockfile into an empty tree still resolve.
8
- # ci-lite.yml answers both, and has not executed since 2026-07-25.
9
- #
10
- # `act` runs that workflow against the same image GitHub would use, so the
11
- # answer comes from the workflow definition rather than from a second script
12
- # that would drift away from it.
13
- #
14
- # NOT part of `npm run gate` on purpose: act and Docker are not installed
15
- # everywhere, and a required gate that cannot run is the failure ADR-0011
16
- # exists to avoid. Run it before a release, or after touching install/,
17
- # lib/credential-store.sh, or anything path-shaped.
18
- #
19
- # Needs network inside the container: the scorecard's dependency metrics ask
20
- # the registry (advisories and signatures) and report an unreachable registry
21
- # as a failure rather than a skip, on the grounds that a supply-chain check
22
- # which goes green without reaching the registry is worse than no check.
23
- #
24
- # Exit codes: 0 = the job passed, 1 = the job failed, 2 = tooling missing
25
-
26
- set -uo pipefail
27
-
28
- REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
29
- cd "$REPO_ROOT" || { echo "FAIL: can't cd to repo root" >&2; exit 2; }
30
-
31
- if ! command -v act >/dev/null 2>&1; then
32
- cat >&2 <<'MSG'
33
- ✗ act is not installed - this gate needs it to run the Ubuntu job locally.
34
-
35
- brew install act # macOS
36
- # and a running Docker daemon (Docker Desktop, colima, orbstack)
37
-
38
- Why this is not bundled: it pulls a multi-gigabyte runner image. The rest of
39
- the gate chain (npm run gate) has no such dependency and stays the default.
40
- MSG
41
- exit 2
42
- fi
43
-
44
- if ! docker info >/dev/null 2>&1; then
45
- echo "✗ act is installed but no Docker daemon is reachable. Start Docker and retry." >&2
46
- exit 2
47
- fi
48
-
49
- echo "→ Running ci-lite on ubuntu-22.04 via act (this pulls an image on first run)"
50
- echo ""
51
-
52
- if act workflow_dispatch \
53
- -W .github/workflows/ci-lite.yml \
54
- -P ubuntu-latest=catthehacker/ubuntu:act-22.04; then
55
- echo ""
56
- echo "✓ ci-lite passed on Linux"
57
- exit 0
58
- fi
59
-
60
- echo "" >&2
61
- echo "✗ ci-lite failed on Linux. A pass on macOS does not cover this." >&2
62
- exit 1