@planu/cli 5.3.14 → 5.3.15

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 (48) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/engine/cascade-hooks/hooks/housekeeping-on-done.hook.js +2 -3
  3. package/dist/engine/housekeeping/find-stale-branches.d.ts +3 -3
  4. package/dist/engine/housekeeping/find-stale-branches.js +25 -29
  5. package/dist/engine/housekeeping/sweep-runner.js +105 -19
  6. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  7. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
  8. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  9. package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
  10. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  11. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
  12. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  13. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
  14. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  15. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
  16. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  17. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
  18. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  19. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
  20. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  21. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
  22. package/dist/engine/spec-migrator/planu-root-cleaner.d.ts +2 -2
  23. package/dist/engine/spec-migrator/planu-root-cleaner.js +3 -2
  24. package/dist/engine/spec-migrator/strict-planu-cleanup.d.ts +2 -2
  25. package/dist/engine/spec-migrator/strict-planu-cleanup.js +18 -2
  26. package/dist/tools/git/cleanup-ops.d.ts +2 -2
  27. package/dist/tools/git/cleanup-ops.js +123 -58
  28. package/dist/tools/github-release-handler.js +3 -12
  29. package/dist/tools/housekeeping-sweep.d.ts +1 -0
  30. package/dist/tools/housekeeping-sweep.js +17 -10
  31. package/dist/tools/init-project/git-setup.d.ts +1 -0
  32. package/dist/tools/init-project/git-setup.js +7 -23
  33. package/dist/tools/init-project/handler.js +4 -1
  34. package/dist/tools/init-project/migration-runner.js +8 -1
  35. package/dist/tools/init-project/scaffold-writer.d.ts +1 -0
  36. package/dist/tools/init-project/scaffold-writer.js +1 -0
  37. package/dist/tools/update-status/batch.js +1 -1
  38. package/dist/tools/update-status/index.js +1 -0
  39. package/dist/tools/update-status-actions.d.ts +2 -2
  40. package/dist/tools/update-status-actions.js +20 -10
  41. package/dist/types/git.d.ts +19 -0
  42. package/dist/types/housekeeping.d.ts +9 -1
  43. package/dist/types/index.d.ts +1 -0
  44. package/dist/types/index.js +1 -0
  45. package/dist/types/spec-format.d.ts +3 -0
  46. package/package.json +11 -10
  47. package/planu-native.json +1 -1
  48. package/planu-plugin.json +1 -1
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.14",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.15",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.14",
10
+ "version": "5.3.15",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "ab11f8980f5b174b9c2d5b6806daa3d356a12e841b2eaf668c106a3c45cd816e"
14
+ "content": "ef2493926703d865ea8673d8357a238058a32a64325050d3880518d8884f25f2"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.14",
1109
+ "ref": "pkg:cargo/planu-core@5.3.15",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.3.14",
4
- "engineVersion": "5.3.14",
5
- "buildId": "1-5.3.14-x86_64-unknown-linux-musl",
3
+ "cliVersion": "5.3.15",
4
+ "engineVersion": "5.3.15",
5
+ "buildId": "1-5.3.15-x86_64-unknown-linux-musl",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "linux",
17
17
  "arch": "x64",
18
18
  "libc": "musl",
19
- "artifactSha256": "c23996625c6b938358150cf4179453e12e2e0fd13e8eea0e84f5fcc85c55e150",
19
+ "artifactSha256": "be770ad8fff0c70d1adf1599af7b60a9e0055e133890476e08e04c88c3f28c69",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.linux-x64-musl.node.sbom.json",
23
- "sha256": "6dc9f80ab3206c95cc8a9f3eff39bf21f21e8fbb30e0cf31c4dbcd26e095c675"
23
+ "sha256": "efd3cf900ecd753f0bee1eba0c6ecc8bd21d4f405e5d346f0fbe58972cf65da4"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "a847a7442e7735a9f4f2a9878eccbd52fc54509b",
27
+ "sourceCommit": "14948c630bfb7a355391c10563934cc79e8bb167",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "813Dm95ogKfJ0gWnnob2MqAjFqrZiGqBFsOKm9fbJB1y3MWxQn1ntV1t/eONh0I1r6qA/LJFarprcALWHEMbAA=="
36
+ "value": "tiDSdVl5hTKNRg88FbcmEwUh4rXFh7OGfMzb9fV3BqwBAwSA/XktD4rXe4YZyBy75Kebo11zW+gp3DMnf+KECA=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.14",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.15",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.14",
10
+ "version": "5.3.15",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "c23996625c6b938358150cf4179453e12e2e0fd13e8eea0e84f5fcc85c55e150"
14
+ "content": "be770ad8fff0c70d1adf1599af7b60a9e0055e133890476e08e04c88c3f28c69"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.14",
1109
+ "ref": "pkg:cargo/planu-core@5.3.15",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.3.14",
4
- "engineVersion": "5.3.14",
5
- "buildId": "1-5.3.14-aarch64-pc-windows-msvc",
3
+ "cliVersion": "5.3.15",
4
+ "engineVersion": "5.3.15",
5
+ "buildId": "1-5.3.15-aarch64-pc-windows-msvc",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "win32",
17
17
  "arch": "arm64",
18
18
  "libc": null,
19
- "artifactSha256": "0668394e6f892080771e2f878c2f75d7692f7d2db03c543bdda4fd2fbbf27bd4",
19
+ "artifactSha256": "583b397fb748d8ce083c4b7d412800789e8b08d6cfcdf51dbb00ca8f16b790c3",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.win32-arm64-msvc.node.sbom.json",
23
- "sha256": "8bd0170c6b52aed934ad9d080190e2d63cf012bd6d6f132449e599e1770c60fa"
23
+ "sha256": "ed94ce39f49489d682bc80cce95eb3ef04bafd64ed7fd7d8ea6def8b12c9a458"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "a847a7442e7735a9f4f2a9878eccbd52fc54509b",
27
+ "sourceCommit": "14948c630bfb7a355391c10563934cc79e8bb167",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "Pse3AY39p2tC7McGWVjvSCG/TOprOtT1W9dvf0XwP/ZWVEd4fVCQUwrTyoXKGAwXCOp4vQjViiE+CvHuSkQ2Dw=="
36
+ "value": "gaOwCaYMRwh0TEujBu7Rabl255fJETQEAo4i+NFVpamCPo5YWa2sknnxsbm/ZM4iX9YLD1W5S1MeYFUcwVrIAg=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.14",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.15",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.14",
10
+ "version": "5.3.15",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "0668394e6f892080771e2f878c2f75d7692f7d2db03c543bdda4fd2fbbf27bd4"
14
+ "content": "583b397fb748d8ce083c4b7d412800789e8b08d6cfcdf51dbb00ca8f16b790c3"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.14",
1109
+ "ref": "pkg:cargo/planu-core@5.3.15",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.3.14",
4
- "engineVersion": "5.3.14",
5
- "buildId": "1-5.3.14-x86_64-pc-windows-msvc",
3
+ "cliVersion": "5.3.15",
4
+ "engineVersion": "5.3.15",
5
+ "buildId": "1-5.3.15-x86_64-pc-windows-msvc",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "win32",
17
17
  "arch": "x64",
18
18
  "libc": null,
19
- "artifactSha256": "60f4555904763ce92bb3193d78ce261c98334ff4630466214ba8ab1c257a2482",
19
+ "artifactSha256": "612e2d929b12f1011da69ddcc598cbd251123294b52cfe7c04cd921e18d46ad7",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.win32-x64-msvc.node.sbom.json",
23
- "sha256": "4754b2b3a274f43a3ee5d1bcece2ae4c8a02373bf9ba646291dce44574304df5"
23
+ "sha256": "b6f3af124aeadba537605434bc406ac76f0bd798244edfbe31566b5011989207"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "a847a7442e7735a9f4f2a9878eccbd52fc54509b",
27
+ "sourceCommit": "14948c630bfb7a355391c10563934cc79e8bb167",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "KLoEL/AX7y2xHycyQKsFA2zksyqZ8uDBBixdAqVnSTf5Oa/aEUsOS7CbH2dt3vpY034HSgbZO319iN+ws/taBA=="
36
+ "value": "2W3TfOtk1zUPWqx2o2a9DmJPT1rSEBL0kLVOrZfggV/B/hEWC5KgLZs0Cy9w6kE5QeWTeGQZ1spl12XVCdAZDQ=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.14",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.15",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.14",
10
+ "version": "5.3.15",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "60f4555904763ce92bb3193d78ce261c98334ff4630466214ba8ab1c257a2482"
14
+ "content": "612e2d929b12f1011da69ddcc598cbd251123294b52cfe7c04cd921e18d46ad7"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.14",
1109
+ "ref": "pkg:cargo/planu-core@5.3.15",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,5 +1,5 @@
1
- import type { CleanupResult } from '../../types/index.js';
1
+ import type { CleanupResult, HousekeepingAuthority } from '../../types/index.js';
2
2
  export type { CleanupResult };
3
3
  /** Remove all non-canonical files from planu/ root and per-spec directories. */
4
- export declare function cleanPlanuRoot(planuDir: string, projectPath?: string): Promise<CleanupResult>;
4
+ export declare function cleanPlanuRoot(planuDir: string, projectPath?: string, authority?: HousekeepingAuthority): Promise<CleanupResult>;
5
5
  //# sourceMappingURL=planu-root-cleaner.d.ts.map
@@ -2,9 +2,9 @@
2
2
  import { dirname } from 'node:path';
3
3
  import { runStrictPlanuCleanup } from './strict-planu-cleanup.js';
4
4
  /** Remove all non-canonical files from planu/ root and per-spec directories. */
5
- export async function cleanPlanuRoot(planuDir, projectPath) {
5
+ export async function cleanPlanuRoot(planuDir, projectPath, authority = { mode: 'report' }) {
6
6
  const root = projectPath ?? dirname(planuDir);
7
- const strict = await runStrictPlanuCleanup(root);
7
+ const strict = await runStrictPlanuCleanup(root, authority);
8
8
  const deletedRootFiles = [];
9
9
  const deletedSpecFiles = [];
10
10
  for (const path of [...strict.deleted, ...strict.merged]) {
@@ -20,6 +20,7 @@ export async function cleanPlanuRoot(planuDir, projectPath) {
20
20
  deletedRootFiles,
21
21
  deletedSpecFiles,
22
22
  totalDeleted: strict.deleted.length + strict.merged.length,
23
+ proposed: strict.proposed,
23
24
  };
24
25
  }
25
26
  //# sourceMappingURL=planu-root-cleaner.js.map
@@ -1,6 +1,6 @@
1
- import type { StrictPlanuCleanupResult, StrictPlanuValidationOptions, StrictPlanuValidationResult } from '../../types/index.js';
1
+ import type { StrictPlanuCleanupResult, StrictPlanuValidationOptions, StrictPlanuValidationResult, HousekeepingAuthority } from '../../types/index.js';
2
2
  import { PLANU_CANONICAL_POLICY } from './planu-canonical-policy.js';
3
- export declare function runStrictPlanuCleanup(projectPath: string): Promise<StrictPlanuCleanupResult>;
3
+ export declare function runStrictPlanuCleanup(projectPath: string, authority?: HousekeepingAuthority): Promise<StrictPlanuCleanupResult>;
4
4
  export declare function validateStrictPlanuLayout(projectPath: string, options?: StrictPlanuValidationOptions): Promise<StrictPlanuValidationResult>;
5
5
  export { PLANU_CANONICAL_POLICY };
6
6
  //# sourceMappingURL=strict-planu-cleanup.d.ts.map
@@ -139,8 +139,24 @@ async function shouldDeleteOrphanSpecDir(specDir) {
139
139
  // Keeping them without spec.md leaves a permanently non-canonical orphan behind.
140
140
  return !entries.includes('spec.md');
141
141
  }
142
- export async function runStrictPlanuCleanup(projectPath) {
143
- const result = { deleted: [], merged: [], gitignoreUpdated: false };
142
+ export async function runStrictPlanuCleanup(projectPath, authority = { mode: 'report' }) {
143
+ if (authority.mode === 'report') {
144
+ const validation = await validateStrictPlanuLayout(projectPath);
145
+ return {
146
+ mode: 'report',
147
+ deleted: [],
148
+ merged: [],
149
+ proposed: validation.offenders,
150
+ gitignoreUpdated: false,
151
+ };
152
+ }
153
+ const result = {
154
+ mode: 'execute',
155
+ deleted: [],
156
+ merged: [],
157
+ proposed: [],
158
+ gitignoreUpdated: false,
159
+ };
144
160
  const planuDir = join(projectPath, 'planu');
145
161
  if (!existsSync(planuDir)) {
146
162
  return result;
@@ -1,4 +1,4 @@
1
- import type { ToolResult, GitConfig, SpecDoneCleanupResult } from '../../types/index.js';
1
+ import type { ToolResult, GitConfig, HousekeepingAuthority, SpecDoneCleanupResult } from '../../types/index.js';
2
2
  /**
3
3
  * AC-02: Eliminate gone branches + remote prune.
4
4
  * AC-03: List stale stashes (>7d); drop if force=true.
@@ -11,5 +11,5 @@ export declare function handleCleanup(projectId: string, config?: GitConfig): Pr
11
11
  * AC-01: When a spec is marked done, clean its worktree, local branch, and remote branch.
12
12
  * Best-effort — never throws. Caller should fire-and-forget or log result.
13
13
  */
14
- export declare function cleanupSpecOnDone(projectPath: string, specId: string, gitBranch: string | undefined): Promise<SpecDoneCleanupResult>;
14
+ export declare function cleanupSpecOnDone(projectPath: string, specId: string, gitBranch: string | undefined, authority?: HousekeepingAuthority): Promise<SpecDoneCleanupResult>;
15
15
  //# sourceMappingURL=cleanup-ops.d.ts.map
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import { readdir } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { git, resolveProjectPath } from './git-helpers.js';
6
+ import { runAbortableProcess } from '../../engine/abortable-process-runner.js';
6
7
  import { compactResult, compactError } from '../output-formatter.js';
7
8
  const WORKTREES_DIR = '.claude/worktrees';
8
9
  const STALE_DAYS_THRESHOLD = 7;
@@ -48,9 +49,9 @@ async function getMergedBranches(projectPath) {
48
49
  return new Set();
49
50
  }
50
51
  /** Remove a worktree — idempotent (ignores "not a worktree" errors). */
51
- async function removeWorktree(projectPath, worktreePath) {
52
+ async function removeWorktree(projectPath, worktreePath, force) {
52
53
  try {
53
- await git(projectPath, ['worktree', 'remove', '--force', worktreePath]);
54
+ await git(projectPath, ['worktree', 'remove', ...(force ? ['--force'] : []), worktreePath]);
54
55
  }
55
56
  catch (err) {
56
57
  const msg = err instanceof Error ? err.message : String(err);
@@ -97,7 +98,7 @@ async function cleanupOrphanWorktrees(projectPath, mergedBranches) {
97
98
  continue;
98
99
  }
99
100
  try {
100
- await removeWorktree(projectPath, wt.path);
101
+ await removeWorktree(projectPath, wt.path, true);
101
102
  removed.push(wt.path);
102
103
  }
103
104
  catch (err) {
@@ -337,85 +338,149 @@ async function findSpecWorktree(projectPath, specId) {
337
338
  * AC-01: When a spec is marked done, clean its worktree, local branch, and remote branch.
338
339
  * Best-effort — never throws. Caller should fire-and-forget or log result.
339
340
  */
340
- export async function cleanupSpecOnDone(projectPath, specId, gitBranch) {
341
+ export async function cleanupSpecOnDone(projectPath, specId, gitBranch, authority = { mode: 'report' }) {
341
342
  const result = {
343
+ mode: authority.mode,
344
+ proposals: [],
342
345
  worktreeRemoved: null,
343
346
  localBranchRemoved: null,
344
347
  remoteBranchRemoved: null,
345
348
  errors: [],
346
349
  };
347
- // 1. Remove worktree if it exists
348
350
  const worktreePath = await findSpecWorktree(projectPath, specId);
351
+ const mergedState = gitBranch ? await getBranchMergedState(projectPath, gitBranch) : 'unknown';
352
+ if (worktreePath) {
353
+ const worktreeClean = await isWorktreeClean(worktreePath);
354
+ result.proposals.push({
355
+ kind: 'worktree',
356
+ ref: worktreePath,
357
+ worktreeClean,
358
+ mergedState,
359
+ reason: `spec ${specId} is done`,
360
+ suggestedAction: worktreeClean ? 'delete' : 'inspect',
361
+ });
362
+ }
363
+ if (gitBranch) {
364
+ const currentBranch = await getCurrentBranch(projectPath);
365
+ const suggestedAction = mergedState === 'merged' && currentBranch !== gitBranch ? 'delete' : 'keep';
366
+ result.proposals.push({
367
+ kind: 'local-branch',
368
+ ref: gitBranch,
369
+ worktreeClean: null,
370
+ mergedState,
371
+ reason: `spec ${specId} is done`,
372
+ suggestedAction,
373
+ });
374
+ if (await remoteBranchExists(projectPath, gitBranch)) {
375
+ result.proposals.push({
376
+ kind: 'remote-branch',
377
+ ref: `origin/${gitBranch}`,
378
+ worktreeClean: null,
379
+ mergedState,
380
+ reason: `spec ${specId} is done`,
381
+ suggestedAction: mergedState === 'merged' ? 'delete' : 'keep',
382
+ });
383
+ }
384
+ }
385
+ if (authority.mode === 'report') {
386
+ return result;
387
+ }
349
388
  if (worktreePath) {
350
389
  try {
351
- await removeWorktree(projectPath, worktreePath);
390
+ await removeWorktree(projectPath, worktreePath, false);
352
391
  result.worktreeRemoved = worktreePath;
353
392
  }
354
393
  catch (err) {
355
394
  result.errors.push(`worktree: ${err instanceof Error ? err.message : String(err)}`);
356
395
  }
357
396
  }
358
- if (!gitBranch) {
397
+ if (!gitBranch || mergedState !== 'merged') {
359
398
  return result;
360
399
  }
361
- // 2. Remove local branch if merged
362
- try {
363
- const { stdout } = await git(projectPath, ['branch', '--merged', 'develop']);
364
- const merged = new Set(stdout
365
- .split('\n')
366
- .map((b) => b.replace(/^\*?\s+/, '').trim())
367
- .filter(Boolean));
368
- const isMerged = merged.has(gitBranch);
369
- // Also check main as fallback
370
- let isMergedMain = false;
371
- if (!isMerged) {
372
- try {
373
- const { stdout: mainOut } = await git(projectPath, ['branch', '--merged', 'main']);
374
- const mergedMain = new Set(mainOut
375
- .split('\n')
376
- .map((b) => b.replace(/^\*?\s+/, '').trim())
377
- .filter(Boolean));
378
- isMergedMain = mergedMain.has(gitBranch);
379
- }
380
- catch {
381
- // ignore
382
- }
400
+ if ((await getCurrentBranch(projectPath)) !== gitBranch) {
401
+ try {
402
+ await git(projectPath, ['branch', '-d', gitBranch]);
403
+ result.localBranchRemoved = gitBranch;
383
404
  }
384
- if (isMerged || isMergedMain) {
385
- // Never delete current branch
386
- const { stdout: headOut } = await git(projectPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
387
- if (headOut.trim() !== gitBranch) {
388
- try {
389
- await git(projectPath, ['branch', '-d', gitBranch]);
390
- result.localBranchRemoved = gitBranch;
391
- }
392
- catch {
393
- // Force-delete if needed
394
- try {
395
- await git(projectPath, ['branch', '-D', gitBranch]);
396
- result.localBranchRemoved = gitBranch;
397
- }
398
- catch (err) {
399
- result.errors.push(`local branch: ${err instanceof Error ? err.message : String(err)}`);
400
- }
401
- }
402
- }
405
+ catch (err) {
406
+ result.errors.push(`local branch: ${err instanceof Error ? err.message : String(err)}`);
403
407
  }
404
408
  }
405
- catch (err) {
406
- result.errors.push(`merged check: ${err instanceof Error ? err.message : String(err)}`);
407
- }
408
- // 3. Remove remote branch if it exists
409
- try {
410
- const { stdout } = await git(projectPath, ['ls-remote', '--heads', 'origin', gitBranch]);
411
- if (stdout.trim()) {
409
+ if (authority.deleteRemoteRefs && (await remoteBranchExists(projectPath, gitBranch))) {
410
+ try {
412
411
  await git(projectPath, ['push', 'origin', '--delete', gitBranch]);
413
412
  result.remoteBranchRemoved = `origin/${gitBranch}`;
414
413
  }
415
- }
416
- catch (err) {
417
- result.errors.push(`remote branch: ${err instanceof Error ? err.message : String(err)}`);
414
+ catch (err) {
415
+ result.errors.push(`remote branch: ${err instanceof Error ? err.message : String(err)}`);
416
+ }
418
417
  }
419
418
  return result;
420
419
  }
420
+ async function isAncestorOf(projectPath, branch, base) {
421
+ try {
422
+ const result = await runAbortableProcess('git', ['merge-base', '--is-ancestor', branch, base], {
423
+ cwd: projectPath,
424
+ timeoutMs: 30_000,
425
+ maxBufferBytes: 1024 * 1024,
426
+ });
427
+ if (result.status === 0) {
428
+ return 'yes';
429
+ }
430
+ return result.status === 1 && !result.error ? 'no' : 'indeterminate';
431
+ }
432
+ catch {
433
+ return 'indeterminate';
434
+ }
435
+ }
436
+ async function getBranchMergedState(projectPath, branch) {
437
+ let sawNotMerged = false;
438
+ let indeterminate = false;
439
+ for (const base of ['main', 'develop', 'master']) {
440
+ try {
441
+ await git(projectPath, ['rev-parse', '--verify', base]);
442
+ }
443
+ catch {
444
+ continue;
445
+ }
446
+ const ancestry = await isAncestorOf(projectPath, branch, base);
447
+ if (ancestry === 'yes') {
448
+ return 'merged';
449
+ }
450
+ if (ancestry === 'no') {
451
+ sawNotMerged = true;
452
+ }
453
+ else {
454
+ indeterminate = true;
455
+ }
456
+ }
457
+ return sawNotMerged && !indeterminate ? 'not_merged' : 'unknown';
458
+ }
459
+ async function isWorktreeClean(worktreePath) {
460
+ try {
461
+ const { stdout } = await git(worktreePath, ['status', '--porcelain']);
462
+ return stdout.trim() === '';
463
+ }
464
+ catch {
465
+ return null;
466
+ }
467
+ }
468
+ async function getCurrentBranch(projectPath) {
469
+ try {
470
+ const { stdout } = await git(projectPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
471
+ return stdout.trim() || null;
472
+ }
473
+ catch {
474
+ return null;
475
+ }
476
+ }
477
+ async function remoteBranchExists(projectPath, branch) {
478
+ try {
479
+ const { stdout } = await git(projectPath, ['ls-remote', '--heads', 'origin', branch]);
480
+ return stdout.trim().length > 0;
481
+ }
482
+ catch {
483
+ return false;
484
+ }
485
+ }
421
486
  //# sourceMappingURL=cleanup-ops.js.map
@@ -209,13 +209,12 @@ export async function handleCreateRelease(args, exec = defaultExec) {
209
209
  const { runHousekeepingSweep } = await import('../engine/housekeeping/index.js');
210
210
  const sweepReport = await runHousekeepingSweep({
211
211
  projectPath: releasePath,
212
- dryRun: false,
213
212
  aggressive: false,
214
213
  includeStashes: false,
215
214
  });
216
- if (sweepReport.deleted.branches > 0 || sweepReport.deleted.worktrees > 0) {
217
- outputLines.push(` Housekeeping: deleted ${String(sweepReport.deleted.branches)} branch(es), ` +
218
- `${String(sweepReport.deleted.worktrees)} worktree(s)`);
215
+ if (sweepReport.wouldDelete.branches.length > 0 ||
216
+ sweepReport.wouldDelete.worktrees.length > 0) {
217
+ outputLines.push(` Housekeeping: ${String(sweepReport.wouldDelete.branches.length)} branch(es), ${String(sweepReport.wouldDelete.worktrees.length)} worktree(s) proposed — run housekeeping_sweep`);
219
218
  }
220
219
  }
221
220
  catch {
@@ -247,14 +246,6 @@ export async function handleCreateRelease(args, exec = defaultExec) {
247
246
  validateScore: null,
248
247
  allSpecs,
249
248
  }, { disabledByConfig: new Set(), disabledByEnv: new Set() });
250
- // SPEC-790: Auto-trigger housekeeping_sweep after release to clean branches/worktrees
251
- try {
252
- const { runHousekeepingSweep } = await import('../engine/housekeeping/index.js');
253
- void runHousekeepingSweep({ projectPath: releasePath, dryRun: false });
254
- }
255
- catch {
256
- /* best-effort — housekeeping never blocks the release */
257
- }
258
249
  }
259
250
  catch {
260
251
  /* best-effort — release_completed cascade never blocks the release response */
@@ -3,6 +3,7 @@ import type { ToolResult } from '../types/index.js';
3
3
  export declare const HousekeepingSweepInputSchema: z.ZodObject<{
4
4
  projectPath: z.ZodOptional<z.ZodString>;
5
5
  dryRun: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
6
+ deleteRemoteRefs: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
6
7
  aggressive: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
7
8
  includeStashes: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
8
9
  }, z.core.$strip>;
@@ -19,6 +19,11 @@ export const HousekeepingSweepInputSchema = z.object({
19
19
  .optional()
20
20
  .default(true)
21
21
  .describe('When true (default), only report — no deletions performed'),
22
+ deleteRemoteRefs: z
23
+ .boolean()
24
+ .optional()
25
+ .default(false)
26
+ .describe('When true with dryRun false, allow deletion of merged origin branches'),
22
27
  aggressive: z
23
28
  .boolean()
24
29
  .optional()
@@ -107,9 +112,13 @@ function formatReportWithEphemeral(report, orphan, ephemeral) {
107
112
  export async function handleHousekeepingSweep(args, resolvedProjectPath) {
108
113
  const projectPath = args.projectPath ?? resolvedProjectPath;
109
114
  const dryRun = args.dryRun ?? true;
115
+ const deleteRemoteRefs = args.deleteRemoteRefs ?? false;
116
+ const authority = dryRun
117
+ ? { mode: 'report' }
118
+ : { mode: 'execute', deleteRemoteRefs };
110
119
  const report = await runHousekeepingSweep({
111
120
  projectPath,
112
- dryRun,
121
+ authority,
113
122
  aggressive: args.aggressive ?? false,
114
123
  includeStashes: args.includeStashes ?? false,
115
124
  });
@@ -145,15 +154,13 @@ export async function handleHousekeepingSweep(args, resolvedProjectPath) {
145
154
  }
146
155
  let strictPlanuCleanup = null;
147
156
  try {
148
- const { runStrictPlanuCleanup, validateStrictPlanuLayout } = await import('../engine/spec-migrator/index.js');
149
- if (!dryRun) {
150
- const cleanup = await runStrictPlanuCleanup(projectPath);
151
- strictPlanuCleanup = { deleted: cleanup.deleted, merged: cleanup.merged, offenders: [] };
152
- }
153
- else {
154
- const validation = await validateStrictPlanuLayout(projectPath);
155
- strictPlanuCleanup = { deleted: [], merged: [], offenders: validation.offenders };
156
- }
157
+ const { runStrictPlanuCleanup } = await import('../engine/spec-migrator/index.js');
158
+ const cleanup = await runStrictPlanuCleanup(projectPath, authority);
159
+ strictPlanuCleanup = {
160
+ deleted: cleanup.deleted,
161
+ merged: cleanup.merged,
162
+ offenders: cleanup.proposed,
163
+ };
157
164
  }
158
165
  catch {
159
166
  /* best-effort — never block housekeeping_sweep */
@@ -3,6 +3,7 @@ export interface GitSetupResult {
3
3
  gitFlowType: string;
4
4
  gitignoreUpdated: boolean;
5
5
  gitRepoDetected: boolean;
6
+ layoutOffenders: string[];
6
7
  }
7
8
  /** SPEC-001 + SPEC-539: Setup git hooks, detect branching model, and configure .gitignore.
8
9
  * SPEC-539: Only installs hooks if a git repository is detected — never creates .git. */