@webpieces/rules-config 0.4.508 → 0.4.510

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.
@@ -1,116 +1,30 @@
1
1
  import { WorktreeService } from './worktrees';
2
- export declare class MergedBranch {
3
- branch: string;
4
- pr: number;
5
- constructor(branch: string, pr: number);
6
- }
7
- /**
8
- * WHY a branch is dead, or why it was spared — as a STABLE token, alongside the English prose.
9
- *
10
- * `sha`/`commits`/`prState` (below) let a human SEE a spared branch. This adds the other half: a token
11
- * saying WHICH KIND of spared it is. Every spared branch used to report the identical string
12
- * `no merged PR found — a human must decide`, and in one observed repo that one string covered three
13
- * genuinely different situations — a PR CLOSED UNMERGED whose work landed under a later number, a
14
- * branch that NEVER had a PR and holds the only copy of its commits, and content already in main.
15
- * Reporting all three identically is why nobody ever decided, and why the pile grew to 6 branches
16
- * against a cap of 5. A token lets wp-cleanup GROUP them and ask a question that can be answered.
17
- */
18
- export declare const CLASSIFICATION_MERGED_PR = "merged-pr";
19
- export declare const CLASSIFICATION_BACKUP_OF_MERGED = "backup-of-merged";
20
- export declare const CLASSIFICATION_NO_COMMITS = "no-commits";
21
- export declare const CLASSIFICATION_SUPERSEDED = "superseded";
22
- export declare const CLASSIFICATION_CONTENT_IN_MAIN = "content-already-in-main";
23
- export declare const CLASSIFICATION_NEVER_PROPOSED = "never-proposed";
24
- export declare const CLASSIFICATION_IN_USE = "in-use";
25
- /**
26
- * WORKTREE-only classifications. A worktree verdict borrows every token above (its branch is what is
27
- * being judged), but three of its outcomes have no branch analogue at all, and lumping them under
28
- * `in-use` would tell wp-cleanup to shut up about exactly the ones a human might want to act on.
29
- *
30
- * - PRUNABLE — the directory is already gone; `git worktree prune` is the reap, not `remove`.
31
- * - LOCKED — a human ran `git worktree lock`. Explicitly "do not touch"; never promptable.
32
- * - CURRENT — the worktree the command is running IN. Removing your own cwd is a self-destruct.
33
- * - DETACHED — detached HEAD, so there is no branch to judge and no branch to archive.
34
- */
35
- export declare const CLASSIFICATION_PRUNABLE = "prunable-worktree";
36
- export declare const CLASSIFICATION_LOCKED = "locked-worktree";
37
- export declare const CLASSIFICATION_CURRENT = "current-worktree";
38
- export declare const CLASSIFICATION_DETACHED = "detached-worktree";
39
- export declare const PROMPTABLE_CLASSIFICATIONS: readonly string[];
40
- /**
41
- * A local branch and the verdict on it. `pr` is 0 when no merged PR backs the verdict (a `keep`).
42
- *
43
- * `sha`, `commits` and `prState` exist for the SPARED branches specifically. When nothing is
44
- * auto-reapable the branch cap has nothing safe to offer, and its only remaining advice was "raise
45
- * maxLocalBranches" / "set turnOffRuleUntilEpoch" — both of which loosen the rule. An agent with no
46
- * human present took the config edit, which is the exact failure the cap exists to prevent. To ask a
47
- * human "may I delete these?" instead, the guard has to be able to SHOW what it would delete: tip SHA
48
- * (so the delete is recoverable), PR state (closed? never opened?) and how much unique work is on it.
49
- *
50
- * All three are computed in the DETACHED refresher and read straight off merged-branches.json — the
51
- * blocking hook path never recomputes them. Defaulted so a cache written by an older release (and
52
- * every existing call site) revives without them.
53
- */
54
- export declare class DeletableBranch {
55
- branch: string;
56
- reason: string;
57
- pr: number;
58
- /** Short tip SHA — what makes the delete undoable (`git branch <name> <sha>`). '' when unknown. */
59
- sha: string;
60
- /** Commits on this branch that are not on origin/main. -1 when it could not be established. */
61
- commits: number;
62
- /** GitHub's state for the PR whose head is this branch: MERGED / CLOSED / OPEN, or '' for none. */
63
- prState: string;
64
- /**
65
- * One of the CLASSIFICATION_* tokens. Defaulted like the fields above so every pre-existing call
66
- * site — and every cache written by an older release — still constructs; an unclassified revived
67
- * entry reads as 'never-proposed', the most conservative of the spared verdicts.
68
- */
69
- classification: string;
70
- constructor(branch: string, reason: string, pr: number, sha?: string, commits?: number, prState?: string, classification?: string);
71
- }
72
- /**
73
- * A worktree and the verdict on it. Carries `path` (what `git worktree remove` takes) AND `branch`
74
- * (what `git branch -D` takes afterwards) because reaping a worktree is always those two steps, in
75
- * that order — git refuses to delete a branch that is still checked out somewhere.
76
- *
77
- * `classification` is the same STABLE token the branch verdicts carry, plus the four worktree-only
78
- * ones above. It exists for the same reason it does on DeletableBranch: `deletable` answers "may the
79
- * tooling reap this unattended?", and everything false used to collapse into one undifferentiated
80
- * "spared" that a human could not rule on. With a token, WorktreeReaper knows whether the reap is a
81
- * `prune` or a `remove`, and wp-cleanup knows which spared worktrees are worth ASKING about.
82
- * Defaulted so every pre-existing call site and every cache written by an older release still builds;
83
- * an unclassified revived entry reads as 'never-proposed', the most conservative spared verdict.
84
- */
85
- export declare class DeletableWorktree {
86
- path: string;
87
- branch: string;
88
- reason: string;
89
- pr: number;
90
- deletable: boolean;
91
- classification: string;
92
- constructor(path: string, branch: string, reason: string, pr: number, deletable: boolean, classification?: string);
93
- }
94
- /**
95
- * `deletable` is PRECOMPUTED so the consumer just deletes the list — no re-deriving, no judgement
96
- * call at block time. `keep` carries the branches we refuse to touch (no merged PR found), each with
97
- * its reason, so a human can see what was spared and why.
98
- *
99
- * `worktrees` is the parallel verdict list for the SECOND budget (see worktrees.ts): every linked
100
- * worktree, each flagged deletable or not. Both budgets are reaped from this one cache file.
101
- */
102
- export declare class MergedBranchesCache {
103
- timestamp: string;
104
- deletable: DeletableBranch[];
105
- keep: DeletableBranch[];
106
- worktrees: DeletableWorktree[];
107
- constructor(timestamp: string, deletable: DeletableBranch[], keep: DeletableBranch[], worktrees?: DeletableWorktree[]);
108
- }
2
+ import { CacheFreshness, MergedBranchesCache } from './merged-branch-verdicts';
3
+ export { MergedBranch, DeletableBranch, DeletableWorktree, MergedBranchesCache, CacheFreshness, CACHE_STALE_AFTER_MS, CLASSIFICATION_MERGED_PR, CLASSIFICATION_BACKUP_OF_MERGED, CLASSIFICATION_NO_COMMITS, CLASSIFICATION_SUPERSEDED, CLASSIFICATION_CONTENT_IN_MAIN, CLASSIFICATION_NEVER_PROPOSED, CLASSIFICATION_IN_USE, CLASSIFICATION_PRUNABLE, CLASSIFICATION_LOCKED, CLASSIFICATION_CURRENT, CLASSIFICATION_DETACHED, PROMPTABLE_CLASSIFICATIONS, } from './merged-branch-verdicts';
109
4
  export declare class MergedBranchesService {
110
5
  private readonly worktrees;
111
6
  constructor(worktrees?: WorktreeService);
112
7
  mergedBranchesPath(repoRoot: string): string;
113
8
  readMergedBranches(repoRoot: string): MergedBranchesCache | null;
9
+ /**
10
+ * How old is this cache, and may its numbers be quoted as fact?
11
+ *
12
+ * `now` is a parameter purely so the specs can pin it; every caller passes nothing.
13
+ */
14
+ freshness(cache: MergedBranchesCache, now?: number): CacheFreshness;
15
+ /**
16
+ * Drop every cached verdict whose subject no longer exists, using only LOCAL, instant git reads.
17
+ *
18
+ * The verdicts themselves are NOT re-derived here — that is the detached refresher's job and it
19
+ * needs the network. This answers a different and much cheaper question: does this branch / this
20
+ * worktree still exist at all? A cache written before a `git branch -D` or a `git worktree remove`
21
+ * keeps naming things that are gone, and a reader that counts those entries reports a repo that
22
+ * does not exist (observed: 8 parked branches asserted against an actual 1) and then blocks on it.
23
+ *
24
+ * Fails toward the cache: if git cannot answer (empty branch list), the cache is returned untouched
25
+ * rather than emptied, since "git failed" must never read as "everything was deleted".
26
+ */
27
+ reconcile(repoRoot: string, cache: MergedBranchesCache): MergedBranchesCache;
114
28
  writeMergedBranches(repoRoot: string, cache: MergedBranchesCache): void;
115
29
  /**
116
30
  * Every local branch except `main`. Uses `git for-each-ref`, NOT `git branch` — the latter is a
@@ -128,10 +42,11 @@ export declare class MergedBranchesService {
128
42
  * clone and is not a thing you can remove.
129
43
  *
130
44
  * A worktree is deletable when its directory is already gone (`prunable`), or when its branch is
131
- * dead by the very same proofs the branch cap uses (merged PR, backup-of-merged, or zero commits of
132
- * its own). It is spared when it is LOCKED (a human said "do not touch"), when it is the worktree we
133
- * are standing in right now (removing your own cwd is not a thing to suggest to an agent), or when
134
- * its branch still holds unmerged work.
45
+ * dead by the very same proof the branch cap uses a MERGED PR, its own or that of the branch it
46
+ * snapshots. Nothing else. It is spared when it is LOCKED (a human said "do not touch"), when it is
47
+ * the worktree we are standing in right now (removing your own cwd is not a thing to suggest to an
48
+ * agent), or when its branch is anything short of provably merged — INCLUDING a branch with no
49
+ * commits yet, which is what every worktree looks like while an agent is working in it.
135
50
  */
136
51
  private classifyWorktrees;
137
52
  /**
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MergedBranchesService = exports.MergedBranchesCache = exports.DeletableWorktree = exports.DeletableBranch = exports.PROMPTABLE_CLASSIFICATIONS = exports.CLASSIFICATION_DETACHED = exports.CLASSIFICATION_CURRENT = exports.CLASSIFICATION_LOCKED = exports.CLASSIFICATION_PRUNABLE = exports.CLASSIFICATION_IN_USE = exports.CLASSIFICATION_NEVER_PROPOSED = exports.CLASSIFICATION_CONTENT_IN_MAIN = exports.CLASSIFICATION_SUPERSEDED = exports.CLASSIFICATION_NO_COMMITS = exports.CLASSIFICATION_BACKUP_OF_MERGED = exports.CLASSIFICATION_MERGED_PR = exports.MergedBranch = void 0;
3
+ exports.MergedBranchesService = exports.PROMPTABLE_CLASSIFICATIONS = exports.CLASSIFICATION_DETACHED = exports.CLASSIFICATION_CURRENT = exports.CLASSIFICATION_LOCKED = exports.CLASSIFICATION_PRUNABLE = exports.CLASSIFICATION_IN_USE = exports.CLASSIFICATION_NEVER_PROPOSED = exports.CLASSIFICATION_CONTENT_IN_MAIN = exports.CLASSIFICATION_SUPERSEDED = exports.CLASSIFICATION_NO_COMMITS = exports.CLASSIFICATION_BACKUP_OF_MERGED = exports.CLASSIFICATION_MERGED_PR = exports.CACHE_STALE_AFTER_MS = exports.CacheFreshness = exports.MergedBranchesCache = exports.DeletableWorktree = exports.DeletableBranch = exports.MergedBranch = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const child_process_1 = require("child_process");
6
6
  const fs = tslib_1.__importStar(require("fs"));
@@ -9,6 +9,26 @@ const inversify_1 = require("inversify");
9
9
  const constants_1 = require("./constants");
10
10
  const to_error_1 = require("./to-error");
11
11
  const worktrees_1 = require("./worktrees");
12
+ const merged_branch_verdicts_1 = require("./merged-branch-verdicts");
13
+ var merged_branch_verdicts_2 = require("./merged-branch-verdicts");
14
+ Object.defineProperty(exports, "MergedBranch", { enumerable: true, get: function () { return merged_branch_verdicts_2.MergedBranch; } });
15
+ Object.defineProperty(exports, "DeletableBranch", { enumerable: true, get: function () { return merged_branch_verdicts_2.DeletableBranch; } });
16
+ Object.defineProperty(exports, "DeletableWorktree", { enumerable: true, get: function () { return merged_branch_verdicts_2.DeletableWorktree; } });
17
+ Object.defineProperty(exports, "MergedBranchesCache", { enumerable: true, get: function () { return merged_branch_verdicts_2.MergedBranchesCache; } });
18
+ Object.defineProperty(exports, "CacheFreshness", { enumerable: true, get: function () { return merged_branch_verdicts_2.CacheFreshness; } });
19
+ Object.defineProperty(exports, "CACHE_STALE_AFTER_MS", { enumerable: true, get: function () { return merged_branch_verdicts_2.CACHE_STALE_AFTER_MS; } });
20
+ Object.defineProperty(exports, "CLASSIFICATION_MERGED_PR", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_MERGED_PR; } });
21
+ Object.defineProperty(exports, "CLASSIFICATION_BACKUP_OF_MERGED", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_BACKUP_OF_MERGED; } });
22
+ Object.defineProperty(exports, "CLASSIFICATION_NO_COMMITS", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_NO_COMMITS; } });
23
+ Object.defineProperty(exports, "CLASSIFICATION_SUPERSEDED", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_SUPERSEDED; } });
24
+ Object.defineProperty(exports, "CLASSIFICATION_CONTENT_IN_MAIN", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_CONTENT_IN_MAIN; } });
25
+ Object.defineProperty(exports, "CLASSIFICATION_NEVER_PROPOSED", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_NEVER_PROPOSED; } });
26
+ Object.defineProperty(exports, "CLASSIFICATION_IN_USE", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_IN_USE; } });
27
+ Object.defineProperty(exports, "CLASSIFICATION_PRUNABLE", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_PRUNABLE; } });
28
+ Object.defineProperty(exports, "CLASSIFICATION_LOCKED", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_LOCKED; } });
29
+ Object.defineProperty(exports, "CLASSIFICATION_CURRENT", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_CURRENT; } });
30
+ Object.defineProperty(exports, "CLASSIFICATION_DETACHED", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_DETACHED; } });
31
+ Object.defineProperty(exports, "PROMPTABLE_CLASSIFICATIONS", { enumerable: true, get: function () { return merged_branch_verdicts_2.PROMPTABLE_CLASSIFICATIONS; } });
12
32
  /**
13
33
  * The "which local branches are dead?" cache.
14
34
  *
@@ -31,152 +51,6 @@ const MERGED_PR_LOOKUP_LIMIT = 100;
31
51
  // (base → baseSquash / basewp2 / basePreMerge3). GitHub has never seen these SHAs, so no PR will ever
32
52
  // name them — they can only be reaped by stripping back to the base branch they were cloned from.
33
53
  const BACKUP_SUFFIX = /(?:Squash|PreMerge\d*|wp\d+)$/;
34
- // Data-only (per CLAUDE.md, classes for data).
35
- class MergedBranch {
36
- branch;
37
- pr;
38
- constructor(branch, pr) {
39
- this.branch = branch;
40
- this.pr = pr;
41
- }
42
- }
43
- exports.MergedBranch = MergedBranch;
44
- /**
45
- * WHY a branch is dead, or why it was spared — as a STABLE token, alongside the English prose.
46
- *
47
- * `sha`/`commits`/`prState` (below) let a human SEE a spared branch. This adds the other half: a token
48
- * saying WHICH KIND of spared it is. Every spared branch used to report the identical string
49
- * `no merged PR found — a human must decide`, and in one observed repo that one string covered three
50
- * genuinely different situations — a PR CLOSED UNMERGED whose work landed under a later number, a
51
- * branch that NEVER had a PR and holds the only copy of its commits, and content already in main.
52
- * Reporting all three identically is why nobody ever decided, and why the pile grew to 6 branches
53
- * against a cap of 5. A token lets wp-cleanup GROUP them and ask a question that can be answered.
54
- */
55
- // Dead by proof — these are auto-deletable.
56
- exports.CLASSIFICATION_MERGED_PR = 'merged-pr';
57
- exports.CLASSIFICATION_BACKUP_OF_MERGED = 'backup-of-merged';
58
- exports.CLASSIFICATION_NO_COMMITS = 'no-commits';
59
- // Spared, but a human should be ASKED — in descending order of "obviously fine to delete".
60
- exports.CLASSIFICATION_SUPERSEDED = 'superseded';
61
- exports.CLASSIFICATION_CONTENT_IN_MAIN = 'content-already-in-main';
62
- exports.CLASSIFICATION_NEVER_PROPOSED = 'never-proposed';
63
- // Spared for a mechanical reason, not a judgement call (checked out somewhere).
64
- exports.CLASSIFICATION_IN_USE = 'in-use';
65
- /**
66
- * WORKTREE-only classifications. A worktree verdict borrows every token above (its branch is what is
67
- * being judged), but three of its outcomes have no branch analogue at all, and lumping them under
68
- * `in-use` would tell wp-cleanup to shut up about exactly the ones a human might want to act on.
69
- *
70
- * - PRUNABLE — the directory is already gone; `git worktree prune` is the reap, not `remove`.
71
- * - LOCKED — a human ran `git worktree lock`. Explicitly "do not touch"; never promptable.
72
- * - CURRENT — the worktree the command is running IN. Removing your own cwd is a self-destruct.
73
- * - DETACHED — detached HEAD, so there is no branch to judge and no branch to archive.
74
- */
75
- exports.CLASSIFICATION_PRUNABLE = 'prunable-worktree';
76
- exports.CLASSIFICATION_LOCKED = 'locked-worktree';
77
- exports.CLASSIFICATION_CURRENT = 'current-worktree';
78
- exports.CLASSIFICATION_DETACHED = 'detached-worktree';
79
- // Spared classifications a human can meaningfully rule on, most-safe first. wp-cleanup prompts in
80
- // exactly this order so the easy yeses come before the ones that need thought.
81
- exports.PROMPTABLE_CLASSIFICATIONS = [
82
- exports.CLASSIFICATION_SUPERSEDED,
83
- exports.CLASSIFICATION_CONTENT_IN_MAIN,
84
- exports.CLASSIFICATION_NEVER_PROPOSED,
85
- ];
86
- /**
87
- * A local branch and the verdict on it. `pr` is 0 when no merged PR backs the verdict (a `keep`).
88
- *
89
- * `sha`, `commits` and `prState` exist for the SPARED branches specifically. When nothing is
90
- * auto-reapable the branch cap has nothing safe to offer, and its only remaining advice was "raise
91
- * maxLocalBranches" / "set turnOffRuleUntilEpoch" — both of which loosen the rule. An agent with no
92
- * human present took the config edit, which is the exact failure the cap exists to prevent. To ask a
93
- * human "may I delete these?" instead, the guard has to be able to SHOW what it would delete: tip SHA
94
- * (so the delete is recoverable), PR state (closed? never opened?) and how much unique work is on it.
95
- *
96
- * All three are computed in the DETACHED refresher and read straight off merged-branches.json — the
97
- * blocking hook path never recomputes them. Defaulted so a cache written by an older release (and
98
- * every existing call site) revives without them.
99
- */
100
- class DeletableBranch {
101
- branch;
102
- reason;
103
- pr;
104
- /** Short tip SHA — what makes the delete undoable (`git branch <name> <sha>`). '' when unknown. */
105
- sha;
106
- /** Commits on this branch that are not on origin/main. -1 when it could not be established. */
107
- commits;
108
- /** GitHub's state for the PR whose head is this branch: MERGED / CLOSED / OPEN, or '' for none. */
109
- prState;
110
- /**
111
- * One of the CLASSIFICATION_* tokens. Defaulted like the fields above so every pre-existing call
112
- * site — and every cache written by an older release — still constructs; an unclassified revived
113
- * entry reads as 'never-proposed', the most conservative of the spared verdicts.
114
- */
115
- classification;
116
- // eslint-disable-next-line @typescript-eslint/max-params
117
- constructor(branch, reason, pr, sha = '', commits = -1, prState = '', classification = exports.CLASSIFICATION_NEVER_PROPOSED) {
118
- this.branch = branch;
119
- this.reason = reason;
120
- this.pr = pr;
121
- this.sha = sha;
122
- this.commits = commits;
123
- this.prState = prState;
124
- this.classification = classification;
125
- }
126
- }
127
- exports.DeletableBranch = DeletableBranch;
128
- /**
129
- * A worktree and the verdict on it. Carries `path` (what `git worktree remove` takes) AND `branch`
130
- * (what `git branch -D` takes afterwards) because reaping a worktree is always those two steps, in
131
- * that order — git refuses to delete a branch that is still checked out somewhere.
132
- *
133
- * `classification` is the same STABLE token the branch verdicts carry, plus the four worktree-only
134
- * ones above. It exists for the same reason it does on DeletableBranch: `deletable` answers "may the
135
- * tooling reap this unattended?", and everything false used to collapse into one undifferentiated
136
- * "spared" that a human could not rule on. With a token, WorktreeReaper knows whether the reap is a
137
- * `prune` or a `remove`, and wp-cleanup knows which spared worktrees are worth ASKING about.
138
- * Defaulted so every pre-existing call site and every cache written by an older release still builds;
139
- * an unclassified revived entry reads as 'never-proposed', the most conservative spared verdict.
140
- */
141
- class DeletableWorktree {
142
- path;
143
- branch;
144
- reason;
145
- pr;
146
- deletable;
147
- classification;
148
- // eslint-disable-next-line @typescript-eslint/max-params
149
- constructor(path, branch, reason, pr, deletable, classification = exports.CLASSIFICATION_NEVER_PROPOSED) {
150
- this.path = path;
151
- this.branch = branch;
152
- this.reason = reason;
153
- this.pr = pr;
154
- this.deletable = deletable;
155
- this.classification = classification;
156
- }
157
- }
158
- exports.DeletableWorktree = DeletableWorktree;
159
- /**
160
- * `deletable` is PRECOMPUTED so the consumer just deletes the list — no re-deriving, no judgement
161
- * call at block time. `keep` carries the branches we refuse to touch (no merged PR found), each with
162
- * its reason, so a human can see what was spared and why.
163
- *
164
- * `worktrees` is the parallel verdict list for the SECOND budget (see worktrees.ts): every linked
165
- * worktree, each flagged deletable or not. Both budgets are reaped from this one cache file.
166
- */
167
- class MergedBranchesCache {
168
- timestamp;
169
- deletable;
170
- keep;
171
- worktrees;
172
- constructor(timestamp, deletable, keep, worktrees = []) {
173
- this.timestamp = timestamp;
174
- this.deletable = deletable;
175
- this.keep = keep;
176
- this.worktrees = worktrees;
177
- }
178
- }
179
- exports.MergedBranchesCache = MergedBranchesCache;
180
54
  /**
181
55
  * Internal: the bulk PR lookup, indexed.
182
56
  *
@@ -236,7 +110,7 @@ let MergedBranchesService = class MergedBranchesService {
236
110
  if (!fs.existsSync(cachePath))
237
111
  return null;
238
112
  const raw = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
239
- return new MergedBranchesCache(raw.timestamp ?? '', this.reviveList(raw.deletable), this.reviveList(raw.keep), this.reviveWorktrees(raw.worktrees));
113
+ return new merged_branch_verdicts_1.MergedBranchesCache(raw.timestamp ?? '', this.reviveList(raw.deletable), this.reviveList(raw.keep), this.reviveWorktrees(raw.worktrees));
240
114
  }
241
115
  catch (err) {
242
116
  const error = (0, to_error_1.toError)(err);
@@ -244,6 +118,39 @@ let MergedBranchesService = class MergedBranchesService {
244
118
  return null;
245
119
  }
246
120
  }
121
+ /**
122
+ * How old is this cache, and may its numbers be quoted as fact?
123
+ *
124
+ * `now` is a parameter purely so the specs can pin it; every caller passes nothing.
125
+ */
126
+ freshness(cache, now = Date.now()) {
127
+ const written = Date.parse(cache.timestamp);
128
+ if (Number.isNaN(written))
129
+ return new merged_branch_verdicts_1.CacheFreshness(true, -1, cache.timestamp);
130
+ const ageMs = Math.max(0, now - written);
131
+ const ageMinutes = Math.floor(ageMs / 60000);
132
+ return new merged_branch_verdicts_1.CacheFreshness(ageMs > merged_branch_verdicts_1.CACHE_STALE_AFTER_MS, ageMinutes, cache.timestamp);
133
+ }
134
+ /**
135
+ * Drop every cached verdict whose subject no longer exists, using only LOCAL, instant git reads.
136
+ *
137
+ * The verdicts themselves are NOT re-derived here — that is the detached refresher's job and it
138
+ * needs the network. This answers a different and much cheaper question: does this branch / this
139
+ * worktree still exist at all? A cache written before a `git branch -D` or a `git worktree remove`
140
+ * keeps naming things that are gone, and a reader that counts those entries reports a repo that
141
+ * does not exist (observed: 8 parked branches asserted against an actual 1) and then blocks on it.
142
+ *
143
+ * Fails toward the cache: if git cannot answer (empty branch list), the cache is returned untouched
144
+ * rather than emptied, since "git failed" must never read as "everything was deleted".
145
+ */
146
+ reconcile(repoRoot, cache) {
147
+ const live = new Set(this.localBranches(repoRoot));
148
+ const trees = this.worktrees.listWorktrees(repoRoot);
149
+ const livePaths = new Set(trees.map((tree) => tree.path));
150
+ const keepBranch = (entry) => live.size === 0 || live.has(entry.branch);
151
+ const keepTree = (entry) => trees.length === 0 || livePaths.has(entry.path);
152
+ return new merged_branch_verdicts_1.MergedBranchesCache(cache.timestamp, cache.deletable.filter(keepBranch), cache.keep.filter(keepBranch), cache.worktrees.filter(keepTree));
153
+ }
247
154
  writeMergedBranches(repoRoot, cache) {
248
155
  const cachePath = this.mergedBranchesPath(repoRoot);
249
156
  fs.mkdirSync(path.dirname(cachePath), { recursive: true });
@@ -295,7 +202,7 @@ let MergedBranchesService = class MergedBranchesService {
295
202
  // worktree list below is what actually reaps it, and a human should see the connection.
296
203
  const heldAt = holder.get(branch);
297
204
  if (heldAt !== undefined) {
298
- keep.push(new DeletableBranch(branch, `checked out in worktree '${heldAt}' — remove that worktree before deleting the branch`, verdict.entry.pr, verdict.entry.sha, verdict.entry.commits, verdict.entry.prState, exports.CLASSIFICATION_IN_USE));
205
+ keep.push(new merged_branch_verdicts_1.DeletableBranch(branch, `checked out in worktree '${heldAt}' — remove that worktree before deleting the branch`, verdict.entry.pr, verdict.entry.sha, verdict.entry.commits, verdict.entry.prState, merged_branch_verdicts_1.CLASSIFICATION_IN_USE));
299
206
  continue;
300
207
  }
301
208
  if (verdict.deletable)
@@ -304,17 +211,18 @@ let MergedBranchesService = class MergedBranchesService {
304
211
  keep.push(verdict.entry);
305
212
  }
306
213
  const worktrees = this.classifyWorktrees(repoRoot, trees, prs);
307
- return new MergedBranchesCache(new Date().toISOString(), deletable, keep, worktrees);
214
+ return new merged_branch_verdicts_1.MergedBranchesCache(new Date().toISOString(), deletable, keep, worktrees);
308
215
  }
309
216
  /**
310
217
  * Verdicts for the worktree budget. The main worktree is excluded outright — it is the primary
311
218
  * clone and is not a thing you can remove.
312
219
  *
313
220
  * A worktree is deletable when its directory is already gone (`prunable`), or when its branch is
314
- * dead by the very same proofs the branch cap uses (merged PR, backup-of-merged, or zero commits of
315
- * its own). It is spared when it is LOCKED (a human said "do not touch"), when it is the worktree we
316
- * are standing in right now (removing your own cwd is not a thing to suggest to an agent), or when
317
- * its branch still holds unmerged work.
221
+ * dead by the very same proof the branch cap uses a MERGED PR, its own or that of the branch it
222
+ * snapshots. Nothing else. It is spared when it is LOCKED (a human said "do not touch"), when it is
223
+ * the worktree we are standing in right now (removing your own cwd is not a thing to suggest to an
224
+ * agent), or when its branch is anything short of provably merged — INCLUDING a branch with no
225
+ * commits yet, which is what every worktree looks like while an agent is working in it.
318
226
  */
319
227
  classifyWorktrees(repoRoot, trees, prs) {
320
228
  const out = [];
@@ -322,26 +230,26 @@ let MergedBranchesService = class MergedBranchesService {
322
230
  if (tree.isMain)
323
231
  continue;
324
232
  if (tree.prunable) {
325
- out.push(new DeletableWorktree(tree.path, tree.branch, 'its directory is gone — `git worktree prune` clears it', 0, true, exports.CLASSIFICATION_PRUNABLE));
233
+ out.push(new merged_branch_verdicts_1.DeletableWorktree(tree.path, tree.branch, 'its directory is gone — `git worktree prune` clears it', 0, true, merged_branch_verdicts_1.CLASSIFICATION_PRUNABLE));
326
234
  continue;
327
235
  }
328
236
  if (tree.locked) {
329
- out.push(new DeletableWorktree(tree.path, tree.branch, 'locked by a human — do not touch', 0, false, exports.CLASSIFICATION_LOCKED));
237
+ out.push(new merged_branch_verdicts_1.DeletableWorktree(tree.path, tree.branch, 'locked by a human — do not touch', 0, false, merged_branch_verdicts_1.CLASSIFICATION_LOCKED));
330
238
  continue;
331
239
  }
332
240
  if (tree.path === repoRoot) {
333
- out.push(new DeletableWorktree(tree.path, tree.branch, 'you are standing in it', 0, false, exports.CLASSIFICATION_CURRENT));
241
+ out.push(new merged_branch_verdicts_1.DeletableWorktree(tree.path, tree.branch, 'you are standing in it', 0, false, merged_branch_verdicts_1.CLASSIFICATION_CURRENT));
334
242
  continue;
335
243
  }
336
244
  if (tree.branch === '') {
337
- out.push(new DeletableWorktree(tree.path, '', 'detached HEAD — no branch to check, so a human must decide', 0, false, exports.CLASSIFICATION_DETACHED));
245
+ out.push(new merged_branch_verdicts_1.DeletableWorktree(tree.path, '', 'detached HEAD — no branch to check, so a human must decide', 0, false, merged_branch_verdicts_1.CLASSIFICATION_DETACHED));
338
246
  continue;
339
247
  }
340
248
  // The branch's OWN classification token rides along unchanged. That is what lets wp-cleanup
341
249
  // group probably-dead worktrees exactly the way it groups probably-dead branches, instead of
342
250
  // inventing a second, parallel notion of "how dead is this".
343
251
  const verdict = this.classify(repoRoot, tree.branch, prs);
344
- out.push(new DeletableWorktree(tree.path, tree.branch, verdict.entry.reason, verdict.entry.pr, verdict.deletable, verdict.entry.classification));
252
+ out.push(new merged_branch_verdicts_1.DeletableWorktree(tree.path, tree.branch, verdict.entry.reason, verdict.entry.pr, verdict.deletable, verdict.entry.classification));
345
253
  }
346
254
  return out;
347
255
  }
@@ -360,21 +268,24 @@ let MergedBranchesService = class MergedBranchesService {
360
268
  const prState = known ? known.state : '';
361
269
  const own = prs.merged.get(branch);
362
270
  if (own !== undefined) {
363
- return new Verdict(true, new DeletableBranch(branch, `PR #${String(own)} merged`, own, sha, commits, prState || 'MERGED', exports.CLASSIFICATION_MERGED_PR));
271
+ return new Verdict(true, new merged_branch_verdicts_1.DeletableBranch(branch, `PR #${String(own)} merged`, own, sha, commits, prState || 'MERGED', merged_branch_verdicts_1.CLASSIFICATION_MERGED_PR));
364
272
  }
365
273
  const base = branch.replace(BACKUP_SUFFIX, '');
366
274
  if (base !== branch && base.length > 0) {
367
275
  const basePr = prs.merged.get(base);
368
276
  if (basePr !== undefined) {
369
- return new Verdict(true, new DeletableBranch(branch, `squash-merge backup of '${base}' (PR #${String(basePr)} merged) — its job is done`, basePr, sha, commits, prState, exports.CLASSIFICATION_BACKUP_OF_MERGED));
277
+ return new Verdict(true, new merged_branch_verdicts_1.DeletableBranch(branch, `squash-merge backup of '${base}' (PR #${String(basePr)} merged) — its job is done`, basePr, sha, commits, prState, merged_branch_verdicts_1.CLASSIFICATION_BACKUP_OF_MERGED));
370
278
  }
371
279
  }
372
- // The one git-local signal that squash-merge CANNOT corrupt: a branch with zero commits of its
373
- // own holds no work, so deleting it can lose nothing. (Squash breaks patch-id and ancestry, so
374
- // "are these commits in main?" is unanswerable from git but "are there any commits at all?"
375
- // is exact.) These are the husks left behind by branching and then never committing.
280
+ // Zero commits of its own. This is SPARED, not deletable see CLASSIFICATION_NO_COMMITS for
281
+ // why. It is a real signal (the branch is identical to origin/main), but it is equally the
282
+ // signature of a worktree an agent created thirty seconds ago and has not committed in yet,
283
+ // and there is no git-local way to tell those two apart. So it becomes a question for a human
284
+ // (wp-cleanup prompts on it) instead of an unattended delete.
376
285
  if (commits === 0) {
377
- return new Verdict(true, new DeletableBranch(branch, 'no commits of its own — identical to origin/main', 0, sha, 0, prState, exports.CLASSIFICATION_NO_COMMITS));
286
+ return new Verdict(false, new merged_branch_verdicts_1.DeletableBranch(branch, 'no commits of its own — identical to origin/main. That is either an abandoned husk OR a '
287
+ + 'worktree/branch someone is working in right now and has not committed to yet, so it is '
288
+ + 'never reaped unattended', 0, sha, 0, prState, merged_branch_verdicts_1.CLASSIFICATION_NO_COMMITS));
378
289
  }
379
290
  return new Verdict(false, this.classifySpared(repoRoot, branch, prs, sha, commits, prState));
380
291
  }
@@ -391,20 +302,20 @@ let MergedBranchesService = class MergedBranchesService {
391
302
  // A PR that CLOSED without merging, in a repo that has merged later PRs, is near-certainly the
392
303
  // abandoned first attempt at work that landed under a different number.
393
304
  if (known !== undefined && known.state === 'CLOSED' && prs.latestMergedPr > known.number) {
394
- return new DeletableBranch(branch, `PR #${String(known.number)} was CLOSED UNMERGED and later PRs (through ` +
395
- `#${String(prs.latestMergedPr)}) have merged — near-certainly superseded`, known.number, sha, commits, prState, exports.CLASSIFICATION_SUPERSEDED);
305
+ return new merged_branch_verdicts_1.DeletableBranch(branch, `PR #${String(known.number)} was CLOSED UNMERGED and later PRs (through ` +
306
+ `#${String(prs.latestMergedPr)}) have merged — near-certainly superseded`, known.number, sha, commits, prState, merged_branch_verdicts_1.CLASSIFICATION_SUPERSEDED);
396
307
  }
397
308
  // `git cherry` compares by PATCH-ID, so it survives cherry-picks and rebases. It does NOT
398
309
  // survive a squash — which is exactly why this is a spared verdict and not a deletable proof.
399
310
  if (this.contentAlreadyInMain(repoRoot, branch)) {
400
- return new DeletableBranch(branch, `all ${String(commits)} commit(s) already have an equivalent in origin/main ` +
401
- `(git cherry) — content is not unique`, known ? known.number : 0, sha, commits, prState, exports.CLASSIFICATION_CONTENT_IN_MAIN);
311
+ return new merged_branch_verdicts_1.DeletableBranch(branch, `all ${String(commits)} commit(s) already have an equivalent in origin/main ` +
312
+ `(git cherry) — content is not unique`, known ? known.number : 0, sha, commits, prState, merged_branch_verdicts_1.CLASSIFICATION_CONTENT_IN_MAIN);
402
313
  }
403
314
  const why = known
404
315
  ? `PR #${String(known.number)} is ${known.state} (not merged); holds ` +
405
316
  `${String(commits)} unique commit(s) — a human must decide`
406
317
  : `never had a PR; holds ${String(commits)} unique commit(s) that may be the only copy in existence`;
407
- return new DeletableBranch(branch, why, 0, sha, commits, prState, exports.CLASSIFICATION_NEVER_PROPOSED);
318
+ return new merged_branch_verdicts_1.DeletableBranch(branch, why, 0, sha, commits, prState, merged_branch_verdicts_1.CLASSIFICATION_NEVER_PROPOSED);
408
319
  }
409
320
  /**
410
321
  * True when EVERY commit on the branch has a patch-equivalent already in origin/main.
@@ -463,7 +374,7 @@ let MergedBranchesService = class MergedBranchesService {
463
374
  const branch = entry.headRefName ?? '';
464
375
  const pr = entry.number ?? 0;
465
376
  if (branch !== '' && pr > 0)
466
- out.push(new MergedBranch(branch, pr));
377
+ out.push(new merged_branch_verdicts_1.MergedBranch(branch, pr));
467
378
  }
468
379
  return out;
469
380
  }
@@ -512,12 +423,12 @@ let MergedBranchesService = class MergedBranchesService {
512
423
  reviveList(raw) {
513
424
  if (!raw)
514
425
  return [];
515
- return raw.map((entry) => new DeletableBranch(entry.branch ?? '', entry.reason ?? '', entry.pr ?? 0, entry.sha ?? '', entry.commits ?? -1, entry.prState ?? '', entry.classification ?? exports.CLASSIFICATION_NEVER_PROPOSED));
426
+ return raw.map((entry) => new merged_branch_verdicts_1.DeletableBranch(entry.branch ?? '', entry.reason ?? '', entry.pr ?? 0, entry.sha ?? '', entry.commits ?? -1, entry.prState ?? '', entry.classification ?? merged_branch_verdicts_1.CLASSIFICATION_NEVER_PROPOSED));
516
427
  }
517
428
  reviveWorktrees(raw) {
518
429
  if (!raw)
519
430
  return [];
520
- return raw.map((entry) => new DeletableWorktree(entry.path ?? '', entry.branch ?? '', entry.reason ?? '', entry.pr ?? 0, entry.deletable ?? false, entry.classification ?? exports.CLASSIFICATION_NEVER_PROPOSED));
431
+ return raw.map((entry) => new merged_branch_verdicts_1.DeletableWorktree(entry.path ?? '', entry.branch ?? '', entry.reason ?? '', entry.pr ?? 0, entry.deletable ?? false, entry.classification ?? merged_branch_verdicts_1.CLASSIFICATION_NEVER_PROPOSED));
521
432
  }
522
433
  // Run a command capturing trimmed stdout; ok=false on spawn failure or non-zero exit.
523
434
  capture(repoRoot, cmd, args) {