@planu/cli 5.3.14 → 5.3.16
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.
- package/CHANGELOG.md +20 -0
- package/dist/engine/cascade-hooks/hooks/housekeeping-on-done.hook.js +2 -3
- package/dist/engine/execution/validate-job-executor.js +51 -44
- package/dist/engine/housekeeping/find-stale-branches.d.ts +3 -3
- package/dist/engine/housekeeping/find-stale-branches.js +25 -29
- package/dist/engine/housekeeping/sweep-runner.js +105 -19
- package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
- package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
- package/dist/engine/spec-migrator/planu-root-cleaner.d.ts +2 -2
- package/dist/engine/spec-migrator/planu-root-cleaner.js +3 -2
- package/dist/engine/spec-migrator/strict-planu-cleanup.d.ts +2 -2
- package/dist/engine/spec-migrator/strict-planu-cleanup.js +18 -2
- package/dist/engine/validation/validation-freshness.d.ts +7 -0
- package/dist/engine/validation/validation-freshness.js +46 -5
- package/dist/tools/git/cleanup-ops.d.ts +2 -2
- package/dist/tools/git/cleanup-ops.js +123 -58
- package/dist/tools/github-release-handler.js +3 -12
- package/dist/tools/housekeeping-sweep.d.ts +1 -0
- package/dist/tools/housekeeping-sweep.js +17 -10
- package/dist/tools/init-project/git-setup.d.ts +1 -0
- package/dist/tools/init-project/git-setup.js +7 -23
- package/dist/tools/init-project/handler.js +4 -1
- package/dist/tools/init-project/migration-runner.js +8 -1
- package/dist/tools/init-project/scaffold-writer.d.ts +1 -0
- package/dist/tools/init-project/scaffold-writer.js +1 -0
- package/dist/tools/update-status/batch.js +1 -1
- package/dist/tools/update-status/index.js +1 -0
- package/dist/tools/update-status-actions.d.ts +2 -2
- package/dist/tools/update-status-actions.js +20 -10
- package/dist/types/git.d.ts +19 -0
- package/dist/types/housekeeping.d.ts +9 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/spec-format.d.ts +3 -0
- package/package.json +11 -10
- package/planu-native.json +1 -1
- 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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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.
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
82
|
+
const targetPath = join(projectPath, '.gitignore');
|
|
99
83
|
try {
|
|
100
84
|
let gitignoreContent = '';
|
|
101
85
|
try {
|
|
102
|
-
gitignoreContent = await readFile(
|
|
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(
|
|
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));
|
|
@@ -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-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
262
|
+
return cleanPlanuRoot(join(projectPath, 'planu'));
|
|
263
263
|
})(),
|
|
264
264
|
(async () => {
|
|
265
265
|
const { runHousekeepingSweep } = await import('../engine/housekeeping/index.js');
|
|
266
|
-
|
|
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.
|
package/dist/types/git.d.ts
CHANGED
|
@@ -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
|
-
|
|
135
|
+
authority?: HousekeepingAuthority;
|
|
128
136
|
aggressive?: boolean;
|
|
129
137
|
includeStashes?: boolean;
|
|
130
138
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/types/index.js
CHANGED
|
@@ -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 {
|