@planu/cli 5.3.13 → 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 (50) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/engine/cascade-hooks/hooks/housekeeping-on-done.hook.js +2 -3
  3. package/dist/engine/handoff-packager.d.ts +2 -2
  4. package/dist/engine/handoff-packager.js +5 -4
  5. package/dist/engine/housekeeping/find-stale-branches.d.ts +3 -3
  6. package/dist/engine/housekeeping/find-stale-branches.js +25 -29
  7. package/dist/engine/housekeeping/sweep-runner.js +105 -19
  8. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  9. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +8 -8
  10. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  11. package/dist/engine/planu-core.darwin-x64.node.sbom.json +8 -8
  12. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  13. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +8 -8
  14. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  15. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +8 -8
  16. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  17. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +8 -8
  18. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  19. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +8 -8
  20. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  21. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +8 -8
  22. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  23. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +8 -8
  24. package/dist/engine/spec-migrator/planu-root-cleaner.d.ts +2 -2
  25. package/dist/engine/spec-migrator/planu-root-cleaner.js +3 -2
  26. package/dist/engine/spec-migrator/strict-planu-cleanup.d.ts +2 -2
  27. package/dist/engine/spec-migrator/strict-planu-cleanup.js +18 -2
  28. package/dist/tools/git/cleanup-ops.d.ts +2 -2
  29. package/dist/tools/git/cleanup-ops.js +123 -58
  30. package/dist/tools/github-release-handler.js +3 -12
  31. package/dist/tools/housekeeping-sweep.d.ts +1 -0
  32. package/dist/tools/housekeeping-sweep.js +17 -10
  33. package/dist/tools/init-project/git-setup.d.ts +1 -0
  34. package/dist/tools/init-project/git-setup.js +7 -23
  35. package/dist/tools/init-project/handler.js +4 -1
  36. package/dist/tools/init-project/migration-runner.js +8 -1
  37. package/dist/tools/init-project/scaffold-writer.d.ts +1 -0
  38. package/dist/tools/init-project/scaffold-writer.js +1 -0
  39. package/dist/tools/update-status/batch.js +1 -1
  40. package/dist/tools/update-status/index.js +1 -0
  41. package/dist/tools/update-status-actions.d.ts +2 -2
  42. package/dist/tools/update-status-actions.js +20 -10
  43. package/dist/types/git.d.ts +19 -0
  44. package/dist/types/housekeeping.d.ts +9 -1
  45. package/dist/types/index.d.ts +1 -0
  46. package/dist/types/index.js +1 -0
  47. package/dist/types/spec-format.d.ts +3 -0
  48. package/package.json +11 -10
  49. package/planu-native.json +1 -1
  50. package/planu-plugin.json +1 -1
@@ -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. */
@@ -59,35 +59,19 @@ export async function runGitSetup(projectPath, projectId) {
59
59
  // Auto-configure .gitignore (best-effort)
60
60
  const gitignoreUpdated = await configureGitignore(projectPath);
61
61
  // SPEC-646: Remove planu/*.html from git tracking — they are regenerable, not source files
62
- if (gitRepoDetected) {
63
- await untrackHtmlFiles(projectPath);
64
- }
65
- return { hooksInstalled, gitFlowType, gitignoreUpdated, gitRepoDetected };
62
+ const layoutOffenders = gitRepoDetected ? await untrackHtmlFiles(projectPath) : [];
63
+ return { hooksInstalled, gitFlowType, gitignoreUpdated, gitRepoDetected, layoutOffenders };
66
64
  }
67
65
  /** SPEC-646: Delete legacy planu/*.html files from disk and remove from git index.
68
66
  * These files are regenerable — committing them wastes ≥1MB per repo clone. */
69
67
  async function untrackHtmlFiles(projectPath) {
70
68
  const htmlFiles = ['planu/index.html', 'planu/roadmap.html'];
71
- // Remove from git index if tracked (best-effort)
72
69
  try {
73
70
  const { stdout } = await git(projectPath, ['ls-files', ...htmlFiles]);
74
- const tracked = stdout.trim().split('\n').filter(Boolean);
75
- if (tracked.length > 0) {
76
- await git(projectPath, ['rm', '--force', ...tracked]);
77
- }
71
+ return stdout.trim().split('\n').filter(Boolean);
78
72
  }
79
73
  catch {
80
- /* not a git repo or files not tracked */
81
- }
82
- // Also delete any remaining files from disk (e.g. not tracked but still present)
83
- for (const rel of htmlFiles) {
84
- try {
85
- const { unlink } = await import('node:fs/promises');
86
- await unlink(join(projectPath, rel));
87
- }
88
- catch {
89
- /* file doesn't exist or can't be deleted — skip */
90
- }
74
+ return [];
91
75
  }
92
76
  }
93
77
  /** SPEC-466: Also exported for list_specs auto-cleanup. */
@@ -95,11 +79,11 @@ export async function configureGitignoreForPlanu(projectPath) {
95
79
  return configureGitignore(projectPath);
96
80
  }
97
81
  async function configureGitignore(projectPath) {
98
- const gitignorePath = join(projectPath, '.gitignore');
82
+ const targetPath = join(projectPath, '.gitignore');
99
83
  try {
100
84
  let gitignoreContent = '';
101
85
  try {
102
- gitignoreContent = await readFile(gitignorePath, 'utf-8');
86
+ gitignoreContent = await readFile(targetPath, 'utf-8');
103
87
  }
104
88
  catch {
105
89
  /* file doesn't exist */
@@ -145,7 +129,7 @@ async function configureGitignore(projectPath) {
145
129
  const addition = linesToAdd.length > 0
146
130
  ? `${separator}# Planu (auto-configured)\n${linesToAdd.join('\n')}\n`
147
131
  : '';
148
- await writeFile(gitignorePath, gitignoreContent + addition, 'utf-8');
132
+ await writeFile(targetPath, gitignoreContent + addition, 'utf-8');
149
133
  updated = true;
150
134
  }
151
135
  return updated;
@@ -428,7 +428,7 @@ export async function handleInitProject(params, server) {
428
428
  const { skillsAutoInstalled, skillsPendingInstall, skillsSkipped } = await orchestrateSkillInstalls(recommendedSkills, projectPath, autoInstallFromConfig);
429
429
  // Write scaffold files: rules, git setup, constitution, CLAUDE.md, lint, architecture rules
430
430
  const scaffoldResult = await runScaffoldWriter(projectPath, projectId, knowledge, recommendedSkills, params.permissionsMode, params.pluginsMode, autoInstallFromConfig);
431
- const { platform, generatedRules, rulesWritten, additionalFilesWritten, hooksInstalled, gitFlowType, gitignoreUpdated, gitRepoDetected, constitutionInitialized, claudeMdUpdated, eslintCreated, prettierCreated, lintSuggestions, architectureRulesWritten, architectureRulesSkipped, planuWorkflowInjected, planuHooksConfigured, planuRulesWritten, gitAutoStageInjected, } = scaffoldResult;
431
+ const { platform, generatedRules, rulesWritten, additionalFilesWritten, hooksInstalled, gitFlowType, gitignoreUpdated, gitRepoDetected, layoutOffenders, constitutionInitialized, claudeMdUpdated, eslintCreated, prettierCreated, lintSuggestions, architectureRulesWritten, architectureRulesSkipped, planuWorkflowInjected, planuHooksConfigured, planuRulesWritten, gitAutoStageInjected, } = scaffoldResult;
432
432
  // SPEC-444: Inject proactive behavior rules into project CLAUDE.md
433
433
  const proactiveRulesInjected = await injectProactiveRules(join(projectPath, 'CLAUDE.md'), '1.22.0')
434
434
  .then(() => true)
@@ -545,6 +545,9 @@ export async function handleInitProject(params, server) {
545
545
  if (gitignoreUpdated) {
546
546
  collector.pushOk('gitignore', 'Updated .gitignore with planu/ and project rules');
547
547
  }
548
+ if (layoutOffenders.length > 0) {
549
+ collector.pushOk('planu-layout-offenders', layoutOffenders.join(', '));
550
+ }
548
551
  if (skillsAutoInstalled.length > 0) {
549
552
  collector.pushOk('skills-installed', `Auto-installed skills: ${skillsAutoInstalled.join(', ')}`);
550
553
  }
@@ -166,7 +166,14 @@ export async function runSpecMigrations(projectPath, projectId, knowledge, optio
166
166
  // SPEC-1017: strict managed planu/ cleanup after all legacy migrations.
167
167
  try {
168
168
  const { runStrictPlanuCleanup } = await import('../../engine/spec-migrator/index.js');
169
- await runStrictPlanuCleanup(projectPath);
169
+ const cleanup = await runStrictPlanuCleanup(projectPath);
170
+ for (const path of cleanup.proposed) {
171
+ criticalMigrationFailures.push({
172
+ phase: 'strict-planu-validate',
173
+ severity: 'critical',
174
+ message: `Non-canonical planu path requires manual review: ${path}`,
175
+ });
176
+ }
170
177
  }
171
178
  catch (err) {
172
179
  criticalMigrationFailures.push(issueFromError('strict-planu-cleanup', 'critical', err));
@@ -11,6 +11,7 @@ export interface ScaffoldWriteResult {
11
11
  gitFlowType: string;
12
12
  gitignoreUpdated: boolean;
13
13
  gitRepoDetected: boolean;
14
+ layoutOffenders: string[];
14
15
  constitutionInitialized: boolean;
15
16
  claudeMdUpdated: boolean;
16
17
  eslintCreated: boolean;
@@ -383,6 +383,7 @@ export async function runScaffoldWriter(projectPath, projectId, knowledge, recom
383
383
  gitFlowType: gitResult.gitFlowType,
384
384
  gitignoreUpdated: gitResult.gitignoreUpdated,
385
385
  gitRepoDetected: gitResult.gitRepoDetected,
386
+ layoutOffenders: gitResult.layoutOffenders,
386
387
  constitutionInitialized,
387
388
  claudeMdUpdated,
388
389
  eslintCreated,
@@ -150,7 +150,7 @@ export async function handleUpdateStatusBatch(input) {
150
150
  updated,
151
151
  skipped,
152
152
  failed,
153
- sideEffectsFlushed: ['strict-planu-cleanup'],
153
+ sideEffectsFlushed: ['strict-planu-validate'],
154
154
  ...(aggregatedNextAction ? { nextAction: aggregatedNextAction } : {}),
155
155
  },
156
156
  };
@@ -1446,6 +1446,7 @@ export async function handleUpdateStatus(params, server) {
1446
1446
  if (doneActions?.prSuggestion) {
1447
1447
  collector.pushOk('pr-created', doneActions.prSuggestion.title);
1448
1448
  }
1449
+ collector.pushOk('housekeeping', 'Housekeeping: pending cleanup proposals — run housekeeping_sweep to review');
1449
1450
  }
1450
1451
  if (newStatus === 'approved' && versionSnapshotTag) {
1451
1452
  collector.pushOk('version-snapshot', `Snapshot queued: ${versionSnapshotTag}`);
@@ -1,4 +1,4 @@
1
- import type { ConstitutionViolation } from '../types/index.js';
1
+ import type { ConstitutionViolation, DoneSideEffectsReport } from '../types/index.js';
2
2
  export declare function runImplementingActions(projectId: string, specId: string, options?: {
3
3
  deferSideEffects?: boolean;
4
4
  projectPath?: string;
@@ -24,7 +24,7 @@ export declare function runDoneActions(projectId: string, specId: string, gitBra
24
24
  autopilotSummary: string[];
25
25
  }>;
26
26
  /** Run cleanup and state refreshes only after the done transition is durable. */
27
- export declare function runDoneSideEffects(projectId: string, specId: string, gitBranch: string | undefined, transitionProjectPath?: string): Promise<void>;
27
+ export declare function runDoneSideEffects(projectId: string, specId: string, gitBranch: string | undefined, transitionProjectPath?: string): Promise<DoneSideEffectsReport>;
28
28
  /**
29
29
  * Best-effort constitution compliance check for status transitions.
30
30
  * Returns warnings (never blocks the transition).
@@ -241,7 +241,7 @@ export async function runDoneSideEffects(projectId, specId, gitBranch, transitio
241
241
  const results = await Promise.allSettled([
242
242
  (async () => {
243
243
  const { cleanupSpecOnDone } = await import('./git/cleanup-ops.js');
244
- await withAudit(projectPath, 'update_status(done)', 'cleanupSpecOnDone', () => cleanupSpecOnDone(projectPath, specId, gitBranch));
244
+ return withAudit(projectPath, 'update_status(done)', 'cleanupSpecOnDone', () => cleanupSpecOnDone(projectPath, specId, gitBranch));
245
245
  })(),
246
246
  (async () => {
247
247
  const { removeSpecFromSession } = await import('../engine/session-state/writer.js');
@@ -259,11 +259,11 @@ export async function runDoneSideEffects(projectId, specId, gitBranch, transitio
259
259
  (async () => {
260
260
  const { join } = await import('node:path');
261
261
  const { cleanPlanuRoot } = await import('../engine/spec-migrator/planu-root-cleaner.js');
262
- await cleanPlanuRoot(join(projectPath, 'planu'));
262
+ return cleanPlanuRoot(join(projectPath, 'planu'));
263
263
  })(),
264
264
  (async () => {
265
265
  const { runHousekeepingSweep } = await import('../engine/housekeeping/index.js');
266
- await runHousekeepingSweep({ projectPath, dryRun: false });
266
+ return runHousekeepingSweep({ projectPath });
267
267
  })(),
268
268
  (async () => {
269
269
  if (hasPending(specId, 'generateSessionContext')) {
@@ -278,18 +278,28 @@ export async function runDoneSideEffects(projectId, specId, gitBranch, transitio
278
278
  clearPending(specId, 'generateSessionContext');
279
279
  }
280
280
  })(),
281
- (async () => {
282
- const { glob } = await import('glob');
283
- const { unlink } = await import('node:fs/promises');
284
- const specFiles = await glob(`planu/specs/${specId}-*/prompt.md`, { cwd: projectPath, absolute: true });
285
- const exactFiles = await glob(`planu/specs/${specId}/prompt.md`, { cwd: projectPath, absolute: true });
286
- await Promise.allSettled([...specFiles, ...exactFiles].map((file) => unlink(file)));
287
- })(),
288
281
  ]);
289
282
  const failures = results.filter((result) => result.status === 'rejected');
290
283
  if (failures.length > 0) {
291
284
  throw new AggregateError(normalizeRejectedReasons(failures), `${String(failures.length)} done side effect(s) failed`);
292
285
  }
286
+ const cleanup = results[0].status === 'fulfilled' ? results[0].value : null;
287
+ const planuCleanup = results[4].status === 'fulfilled' ? results[4].value : null;
288
+ const housekeeping = results[5].status === 'fulfilled' ? results[5].value : null;
289
+ return {
290
+ cleanupProposals: cleanup?.proposals ?? [],
291
+ housekeepingProposal: housekeeping ?? {
292
+ mode: 'report',
293
+ sweepAt: new Date().toISOString(),
294
+ projectPath,
295
+ wouldDelete: { branches: [], worktrees: [], stashes: [] },
296
+ kept: { branches: [] },
297
+ executed: false,
298
+ deleted: { branches: 0, worktrees: 0, stashes: 0, backups: 0 },
299
+ errors: [],
300
+ },
301
+ planuLayoutOffenders: planuCleanup?.proposed ?? [],
302
+ };
293
303
  }
294
304
  /**
295
305
  * Best-effort constitution compliance check for status transitions.
@@ -1,5 +1,6 @@
1
1
  import type { GitAction } from './common/index.js';
2
2
  import type { GeneratedDocument } from './docs.js';
3
+ import type { HousekeepingReport } from './housekeeping.js';
3
4
  export interface GitConfig {
4
5
  branchPrefix?: Record<string, string>;
5
6
  commitFormat?: 'conventional' | 'spec-id' | 'custom';
@@ -173,12 +174,30 @@ export interface CleanupReport {
173
174
  message: string;
174
175
  autoCompleted?: string[];
175
176
  }
177
+ export type CleanupProposalKind = 'worktree' | 'local-branch' | 'remote-branch';
178
+ export type CleanupMergedState = 'merged' | 'not_merged' | 'unknown';
179
+ export type CleanupSuggestedAction = 'delete' | 'keep' | 'inspect';
180
+ export interface SpecDoneCleanupProposal {
181
+ kind: CleanupProposalKind;
182
+ ref: string;
183
+ worktreeClean: boolean | null;
184
+ mergedState: CleanupMergedState;
185
+ reason: string;
186
+ suggestedAction: CleanupSuggestedAction;
187
+ }
176
188
  export interface SpecDoneCleanupResult {
189
+ mode: 'report' | 'execute';
190
+ proposals: SpecDoneCleanupProposal[];
177
191
  worktreeRemoved: string | null;
178
192
  localBranchRemoved: string | null;
179
193
  remoteBranchRemoved: string | null;
180
194
  errors: string[];
181
195
  }
196
+ export interface DoneSideEffectsReport {
197
+ cleanupProposals: SpecDoneCleanupProposal[];
198
+ housekeepingProposal: HousekeepingReport;
199
+ planuLayoutOffenders: string[];
200
+ }
182
201
  export interface GitHubIssueImportResult {
183
202
  action: 'import-issue';
184
203
  specId: string;
@@ -36,7 +36,14 @@ export interface StaleStashInfo {
36
36
  */
37
37
  reason: 'age' | 'orphan-branch' | 'orphan-merged';
38
38
  }
39
+ export type HousekeepingAuthority = {
40
+ mode: 'report';
41
+ } | {
42
+ mode: 'execute';
43
+ deleteRemoteRefs: boolean;
44
+ };
39
45
  export interface HousekeepingReport {
46
+ mode: 'report' | 'execute';
40
47
  /** ISO timestamp of when the sweep ran. */
41
48
  sweepAt: string;
42
49
  /** Project path that was analysed. */
@@ -61,6 +68,7 @@ export interface HousekeepingReport {
61
68
  /** SPEC-771: stale .bak.* files removed from planu/specs/. */
62
69
  backups: number;
63
70
  };
71
+ errors: string[];
64
72
  }
65
73
  export type OrphanMarkdownAction = 'deleted_matched' | 'imported_then_deleted' | 'skipped';
66
74
  export interface OrphanMarkdownEntry {
@@ -124,7 +132,7 @@ export interface FindStaleStashesInput {
124
132
  }
125
133
  export interface RunHousekeepingSweepInput {
126
134
  projectPath: string;
127
- dryRun?: boolean;
135
+ authority?: HousekeepingAuthority;
128
136
  aggressive?: boolean;
129
137
  includeStashes?: boolean;
130
138
  }
@@ -141,6 +141,7 @@ export * from './red-team.js';
141
141
  export * from './update-notifier.js';
142
142
  export * from './batch-script.js';
143
143
  export * from './hooks-advanced.js';
144
+ export * from './housekeeping.js';
144
145
  export * from './codex-integration.js';
145
146
  export * from './gemini-integration.js';
146
147
  export * from './ai-integration.js';
@@ -138,6 +138,7 @@ export * from './red-team.js';
138
138
  export * from './update-notifier.js';
139
139
  export * from './batch-script.js';
140
140
  export * from './hooks-advanced.js';
141
+ export * from './housekeeping.js';
141
142
  export * from './codex-integration.js';
142
143
  export * from './gemini-integration.js';
143
144
  export * from './ai-integration.js';
@@ -64,6 +64,7 @@ export interface CleanupResult {
64
64
  deletedRootFiles: string[];
65
65
  deletedSpecFiles: string[];
66
66
  totalDeleted: number;
67
+ proposed: string[];
67
68
  }
68
69
  export interface PlanuCanonicalPathPolicy {
69
70
  readonly canonicalRootFiles: readonly string[];
@@ -75,8 +76,10 @@ export interface PlanuCanonicalPathPolicy {
75
76
  readonly legacyMergeBeforeDeleteFiles: readonly string[];
76
77
  }
77
78
  export interface StrictPlanuCleanupResult {
79
+ mode: 'report' | 'execute';
78
80
  deleted: string[];
79
81
  merged: string[];
82
+ proposed: string[];
80
83
  gitignoreUpdated: boolean;
81
84
  }
82
85
  export interface StrictPlanuValidationResult {