@planu/cli 5.5.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/.planu-build.json +1 -1
  3. package/dist/engine/autopilot/bootstrap.js +1 -1
  4. package/dist/engine/cascade-hooks/core/append-releases.js +22 -12
  5. package/dist/engine/detection-utils.d.ts +1 -0
  6. package/dist/engine/detection-utils.js +33 -0
  7. package/dist/engine/doc-generator/portal/index.d.ts +1 -1
  8. package/dist/engine/doc-generator/portal/index.js +1 -1
  9. package/dist/engine/doc-generator/portal/portal-regenerator.d.ts +8 -3
  10. package/dist/engine/doc-generator/portal/portal-regenerator.js +16 -7
  11. package/dist/engine/evidence-gates/evidence-autofill.d.ts +1 -1
  12. package/dist/engine/evidence-gates/evidence-autofill.js +1 -1
  13. package/dist/engine/framework-detector.js +8 -6
  14. package/dist/engine/git/git-command-guard.d.ts +2 -0
  15. package/dist/engine/git/git-command-guard.js +43 -0
  16. package/dist/engine/git/planu-autocommit.js +2 -0
  17. package/dist/engine/handoff-packager.js +38 -2
  18. package/dist/engine/housekeeping/history-log.d.ts +1 -0
  19. package/dist/engine/housekeeping/history-log.js +58 -3
  20. package/dist/engine/housekeeping/index.d.ts +2 -1
  21. package/dist/engine/housekeeping/index.js +2 -1
  22. package/dist/engine/housekeeping/runtime-residue-sweep.d.ts +9 -0
  23. package/dist/engine/housekeeping/runtime-residue-sweep.js +57 -0
  24. package/dist/engine/next-spec-resolver/orchestration-planner.js +1 -1
  25. package/dist/engine/next-spec-resolver/session-writer.js +1 -1
  26. package/dist/engine/project-graph/cache.js +23 -3
  27. package/dist/engine/readiness-checker.js +1 -1
  28. package/dist/engine/sdd-flow/checkpoints.js +29 -2
  29. package/dist/engine/session/checkpoint-writer.d.ts +1 -1
  30. package/dist/engine/session/checkpoint-writer.js +6 -5
  31. package/dist/engine/session-state/writer.js +4 -3
  32. package/dist/engine/spec-format/lean-spec-generator.js +1 -1
  33. package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +8 -1
  34. package/dist/engine/spec-migrator/planu-canonical-policy.js +14 -13
  35. package/dist/engine/spec-migrator/strict-planu-cleanup.js +28 -3
  36. package/dist/engine/universal-rules/rules/planu-release-policy.js +1 -1
  37. package/dist/engine/validator/spec-compliance-runner.js +45 -0
  38. package/dist/hosts/claude-code/ux/mcp-resources.js +7 -23
  39. package/dist/resources/specs.js +12 -33
  40. package/dist/storage/current-project.d.ts +3 -0
  41. package/dist/storage/current-project.js +21 -0
  42. package/dist/storage/index.d.ts +1 -0
  43. package/dist/storage/index.js +1 -0
  44. package/dist/storage/migrations/canonical-storage.js +5 -0
  45. package/dist/storage/retention.d.ts +14 -0
  46. package/dist/storage/retention.js +279 -0
  47. package/dist/storage/spec-index.d.ts +23 -0
  48. package/dist/storage/spec-index.js +123 -0
  49. package/dist/storage/spec-store.d.ts +8 -3
  50. package/dist/storage/spec-store.js +76 -6
  51. package/dist/storage/storage-catalog.js +3 -3
  52. package/dist/storage/storage-layout.d.ts +8 -0
  53. package/dist/storage/storage-layout.js +9 -0
  54. package/dist/storage/transition-log.js +2 -0
  55. package/dist/tools/challenge-spec.js +10 -9
  56. package/dist/tools/create-spec.js +10 -0
  57. package/dist/tools/execute-sdd-flow.js +11 -2
  58. package/dist/tools/export-spec.js +2 -1
  59. package/dist/tools/force-status-analytics.js +2 -1
  60. package/dist/tools/generate-docs-site.js +2 -1
  61. package/dist/tools/generate-proposal.js +6 -2
  62. package/dist/tools/git/git-helpers.js +2 -0
  63. package/dist/tools/init-project/claude-md-generator.js +19 -2
  64. package/dist/tools/init-project/conventions-writer.d.ts +5 -2
  65. package/dist/tools/init-project/conventions-writer.js +18 -13
  66. package/dist/tools/init-project/git-setup.js +9 -0
  67. package/dist/tools/init-project/handler.js +10 -1
  68. package/dist/tools/init-project/legacy-root-migration.d.ts +15 -0
  69. package/dist/tools/init-project/legacy-root-migration.js +213 -0
  70. package/dist/tools/init-project/portable-index-reconciler.d.ts +1 -1
  71. package/dist/tools/init-project/portable-index-reconciler.js +109 -4
  72. package/dist/tools/init-project/runtime-residue.d.ts +2 -0
  73. package/dist/tools/init-project/runtime-residue.js +11 -0
  74. package/dist/tools/session-checkpoint.js +1 -1
  75. package/dist/tools/update-status/index.js +7 -1
  76. package/dist/tools/update-status-actions.d.ts +7 -1
  77. package/dist/tools/update-status-actions.js +10 -2
  78. package/dist/types/housekeeping.d.ts +4 -0
  79. package/dist/types/index.d.ts +1 -0
  80. package/dist/types/index.js +1 -0
  81. package/dist/types/retention.d.ts +12 -0
  82. package/dist/types/retention.js +3 -0
  83. package/dist/types/spec/core.d.ts +7 -0
  84. package/dist/types/spec/index-cache.d.ts +25 -0
  85. package/dist/types/spec/index-cache.js +3 -0
  86. package/dist/types/spec/index.d.ts +1 -0
  87. package/dist/types/spec/index.js +1 -0
  88. package/dist/types/spec/inputs.d.ts +2 -1
  89. package/dist/types/spec-format.d.ts +1 -0
  90. package/package.json +1 -1
  91. package/planu-plugin.json +1 -1
  92. package/scripts/lib/pending-release-file.mjs +20 -4
@@ -1,5 +1,99 @@
1
+ import { readdir, rename, rm, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { pathExistsStrictByStat } from '../../core/shared/fs.js';
1
4
  import { globalStore, specStore } from '../../storage/index.js';
2
5
  import { importFilesystemSpecs } from '../../engine/spec-migrator/filesystem-import.js';
6
+ function isErrnoCode(error, code) {
7
+ return (error instanceof Error && 'code' in error && error.code === code);
8
+ }
9
+ async function countFilesRecursive(dir) {
10
+ let entries;
11
+ try {
12
+ entries = await readdir(dir, { withFileTypes: true });
13
+ }
14
+ catch (error) {
15
+ if (isErrnoCode(error, 'ENOENT')) {
16
+ return 0;
17
+ }
18
+ throw error;
19
+ }
20
+ let count = 0;
21
+ for (const entry of entries) {
22
+ count += entry.isDirectory()
23
+ ? await countFilesRecursive(join(dir, entry.name))
24
+ : Number(entry.isFile());
25
+ }
26
+ return count;
27
+ }
28
+ async function isPristineInitRepo(gitDir) {
29
+ try {
30
+ const [objectsCount, refsCount, hasPackedRefs, hasIndex, logsCount, hasAlternates] = await Promise.all([
31
+ countFilesRecursive(join(gitDir, 'objects')),
32
+ countFilesRecursive(join(gitDir, 'refs')),
33
+ pathExistsStrictByStat(join(gitDir, 'packed-refs')),
34
+ pathExistsStrictByStat(join(gitDir, 'index')),
35
+ countFilesRecursive(join(gitDir, 'logs')),
36
+ pathExistsStrictByStat(join(gitDir, 'objects', 'info', 'alternates')),
37
+ ]);
38
+ return (objectsCount === 0 &&
39
+ refsCount === 0 &&
40
+ !hasPackedRefs &&
41
+ !hasIndex &&
42
+ logsCount === 0 &&
43
+ !hasAlternates);
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ async function quarantineVerifyAndDelete(gitDir, specDirectory) {
50
+ const quarantinePath = join(specDirectory, `.git.stray-${String(process.pid)}`);
51
+ try {
52
+ await rename(gitDir, quarantinePath);
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ if (await isPristineInitRepo(quarantinePath)) {
58
+ await rm(quarantinePath, { recursive: true, force: true });
59
+ return true;
60
+ }
61
+ await rename(quarantinePath, gitDir).catch(() => undefined);
62
+ return false;
63
+ }
64
+ async function healStraySpecRepos(projectPath) {
65
+ const specsRoot = join(projectPath, 'planu', 'specs');
66
+ let entries;
67
+ try {
68
+ entries = await readdir(specsRoot, { withFileTypes: true });
69
+ }
70
+ catch {
71
+ return { removals: [], failures: [] };
72
+ }
73
+ const removals = [];
74
+ const failures = [];
75
+ for (const entry of entries) {
76
+ if (!entry.isDirectory()) {
77
+ continue;
78
+ }
79
+ const specDirectory = join(specsRoot, entry.name);
80
+ const gitDir = join(specDirectory, '.git');
81
+ const gitDirStat = await stat(gitDir).catch(() => undefined);
82
+ if (!gitDirStat?.isDirectory()) {
83
+ continue;
84
+ }
85
+ const relativePath = join('planu', 'specs', entry.name, '.git');
86
+ const isCandidate = await isPristineInitRepo(gitDir);
87
+ const removed = isCandidate && (await quarantineVerifyAndDelete(gitDir, specDirectory));
88
+ if (removed) {
89
+ removals.push(relativePath);
90
+ }
91
+ else {
92
+ failures.push({ specId: entry.name, path: relativePath, code: 'STRAY_REPO_NOT_PRISTINE' });
93
+ }
94
+ }
95
+ return { removals, failures };
96
+ }
3
97
  function classifyFailures(failures, indexedSpecIds) {
4
98
  const remainingFailures = [];
5
99
  const skippedLegacy = [];
@@ -24,6 +118,7 @@ const NEXT_ACTION_BY_CODE = {
24
118
  INVALID_CONTRACT: 'add the required portable frontmatter fields (id, title, type, scope, status, risk, target, difficulty)',
25
119
  CREATE_FAILED: 'retry init_project once the underlying store write succeeds',
26
120
  INCOMPATIBLE_IDENTITY: 'reconcile the spec identity (id, title, slug, uuid) with the indexed record',
121
+ STRAY_REPO_NOT_PRISTINE: 'inspect the embedded .git manually — it holds refs, staged state, or history and was left untouched',
27
122
  };
28
123
  /** Autopilot-first summary of fatal reconciliation failures: what failed, why, and the next action. */
29
124
  export function describeReconciliationFailures(failures) {
@@ -43,19 +138,22 @@ export function describeReconciliationFailures(failures) {
43
138
  return `Portable spec index reconciliation failed for ${String(failures.length)} contract(s) (${codeSummary}). Affected: ${topPaths}${remainder}. Next: ${nextActions}.`;
44
139
  }
45
140
  /** Autopilot-first summary of a successful reconciliation, including any legacy skips. */
46
- export function describeReconciliationSuccess(repositoryFilesChanged, skippedLegacy) {
141
+ export function describeReconciliationSuccess(repositoryFilesChanged, skippedLegacy, strayRepoRemovals = []) {
47
142
  const base = repositoryFilesChanged.length === 0
48
143
  ? 'Project already initialized. Safe update reconciled the portable spec index without changing repository files.'
49
144
  : `Project already initialized. Safe update reconciled the portable spec index and refreshed ${String(repositoryFilesChanged.length)} core host asset file(s).`;
50
145
  const skippedSuffix = skippedLegacy.length === 0
51
146
  ? ''
52
147
  : ` Skipped ${String(skippedLegacy.length)} already-indexed legacy contract(s).`;
53
- return base + skippedSuffix;
148
+ const strayRepoSuffix = strayRepoRemovals.length === 0
149
+ ? ''
150
+ : ` Removed ${String(strayRepoRemovals.length)} stray embedded git repositor${strayRepoRemovals.length === 1 ? 'y' : 'ies'} from spec folders.`;
151
+ return base + skippedSuffix + strayRepoSuffix;
54
152
  }
55
153
  /** Autopilot-first message for a reconciliation outcome, success or fatal-failure. */
56
154
  export function describeReconciliationOutcome(reconciliation, repositoryFilesChanged) {
57
155
  return reconciliation.failures.length === 0
58
- ? describeReconciliationSuccess(repositoryFilesChanged, reconciliation.skippedLegacy)
156
+ ? describeReconciliationSuccess(repositoryFilesChanged, reconciliation.skippedLegacy, reconciliation.strayRepoRemovals)
59
157
  : describeReconciliationFailures(reconciliation.failures);
60
158
  }
61
159
  /** Rebuild the mutable external index from the repository-owned portable contracts. */
@@ -70,6 +168,13 @@ export async function reconcilePortableSpecIndex(projectPath, projectId, deps =
70
168
  });
71
169
  const indexedSpecIds = new Set((await deps.listSpecs(projectId)).map((spec) => spec.id));
72
170
  const { failures, skippedLegacy } = classifyFailures(result.failures, indexedSpecIds);
73
- return { ...result, failures, skippedLegacy, repositoryFilesChanged: [] };
171
+ const { removals: strayRepoRemovals, failures: strayRepoFailures } = await healStraySpecRepos(projectPath);
172
+ return {
173
+ ...result,
174
+ failures: [...failures, ...strayRepoFailures],
175
+ skippedLegacy,
176
+ repositoryFilesChanged: [],
177
+ strayRepoRemovals,
178
+ };
74
179
  }
75
180
  //# sourceMappingURL=portable-index-reconciler.js.map
@@ -0,0 +1,2 @@
1
+ export declare function scheduleRuntimeResidueSweep(projectPath: string): void;
2
+ //# sourceMappingURL=runtime-residue.d.ts.map
@@ -0,0 +1,11 @@
1
+ // tools/init-project/runtime-residue.ts — SPEC-1695
2
+ // Fire-and-forget trigger for the runtime-residue sweep, called from init_project
3
+ // so existing client checkouts self-heal without a manual cleanup step.
4
+ import { withAudit } from '../../engine/autopilot/audit-logger.js';
5
+ import { sweepRuntimeResidue } from '../../engine/housekeeping/runtime-residue-sweep.js';
6
+ export function scheduleRuntimeResidueSweep(projectPath) {
7
+ void withAudit(projectPath, 'init_project', 'sweepRuntimeResidue', () => sweepRuntimeResidue(projectPath), (result) => ({ removed: result.removed.length })).catch(() => {
8
+ /* best-effort */
9
+ });
10
+ }
11
+ //# sourceMappingURL=runtime-residue.js.map
@@ -9,7 +9,7 @@ import { compactResult } from './output-formatter.js';
9
9
  export async function handleSessionCheckpoint(args) {
10
10
  const snapshot = sessionTracker.getSnapshot();
11
11
  await writeCheckpoint(args.projectPath, snapshot);
12
- return compactResult(`Session checkpoint written to ${args.projectPath}/planu/session-context.md\n` +
12
+ return compactResult(`Session checkpoint written to ${args.projectPath}/planu/.runtime/session-context.md\n` +
13
13
  `Calls recorded: ${String(snapshot.callCount)}, ` +
14
14
  `files tracked: ${String(snapshot.modifiedFiles.length)}.`);
15
15
  }
@@ -7,7 +7,7 @@ import { rollbackTransitionSpec, transitionSpec, } from '../../engine/spec-state
7
7
  import { calculateAccuracy } from '../../engine/estimator.js';
8
8
  import { cascadeCheck } from '../../engine/spec-versioner.js';
9
9
  import { runComplianceGates } from '../update-status-convention-gate.js';
10
- import { runDoneActions, runDoneSideEffects, runImplementingActions, runImplementingSideEffects, } from '../update-status-actions.js';
10
+ import { runDoneActions, runDoneSideEffects, runImplementingActions, runImplementingSideEffects, pruneRuntimeArtifactsOnTerminal, } from '../update-status-actions.js';
11
11
  import { compactObj } from '../../engine/compact-obj.js';
12
12
  import { checkTransition, checkDorGate, checkAmbiguityGate, checkReadinessGate, checkChallengeGate, checkImplementationDependencyGate, checkSpecArtifactCommittedGate, resolveAutoAdvanceSteps, isReverseTransition, validateReverseTransition, } from './transition-guard.js';
13
13
  import { checkApprovedDepGate } from '../../engine/dep-guard/index.js';
@@ -1426,6 +1426,12 @@ export async function handleUpdateStatus(params, server) {
1426
1426
  /* reliability-optional: TERMINAL_VALIDATION_REPORT — canonical receipt is authoritative */
1427
1427
  reportClassifiedDegradation('TERMINAL_VALIDATION_REPORT', error);
1428
1428
  });
1429
+ try {
1430
+ await pruneRuntimeArtifactsOnTerminal(projectId, specId);
1431
+ }
1432
+ catch (error) {
1433
+ reportClassifiedDegradation('RETENTION_TERMINAL_PRUNE', error);
1434
+ }
1429
1435
  }
1430
1436
  const allSpecs = await specStore.listSpecs(projectId);
1431
1437
  const cascadeResult = cascadeCheck(specId, allSpecs, newStatus);
@@ -1,4 +1,10 @@
1
- import type { ConstitutionViolation, DoneSideEffectsReport } from '../types/index.js';
1
+ import type { ConstitutionViolation, DoneSideEffectsReport, RetentionResult } from '../types/index.js';
2
+ /**
3
+ * Remove handoff packages, evidence bundles, and archived context for a spec
4
+ * that just reached a terminal status (done or discarded). Never touches
5
+ * planu/specs/ — the canonical spec.md lives outside runtime storage.
6
+ */
7
+ export declare function pruneRuntimeArtifactsOnTerminal(projectId: string, specId: string): Promise<RetentionResult>;
2
8
  export declare function runImplementingActions(projectId: string, specId: string, options?: {
3
9
  deferSideEffects?: boolean;
4
10
  projectPath?: string;
@@ -1,7 +1,15 @@
1
- import { specStore, knowledgeStore } from '../storage/index.js';
1
+ import { specStore, knowledgeStore, projectDataDir, enforceRetention } from '../storage/index.js';
2
2
  import { checkConstitutionCompliance } from './create-spec-tech.js';
3
3
  import { withAudit } from '../engine/autopilot/audit-logger.js';
4
4
  import { hasPending, markPending, clearPending } from '../engine/autopilot/cascade-deduplicator.js';
5
+ /**
6
+ * Remove handoff packages, evidence bundles, and archived context for a spec
7
+ * that just reached a terminal status (done or discarded). Never touches
8
+ * planu/specs/ — the canonical spec.md lives outside runtime storage.
9
+ */
10
+ export async function pruneRuntimeArtifactsOnTerminal(projectId, specId) {
11
+ return enforceRetention({ kind: 'spec-artifacts', path: projectDataDir(projectId), specId });
12
+ }
5
13
  function normalizeRejectedReasons(results) {
6
14
  return results.map((result) => {
7
15
  const reason = result.reason;
@@ -210,7 +218,7 @@ export async function runDoneActions(projectId, specId, gitBranch, options = {})
210
218
  // SPEC-649: slim cascade — 2 actions only
211
219
  const autopilotSummary = [
212
220
  'session.json: refresh queued',
213
- 'releases/pending.json: refresh queued',
221
+ 'project.json releases: refresh queued',
214
222
  ];
215
223
  return {
216
224
  mergeWarning,
@@ -146,4 +146,8 @@ export interface HousekeepingHistoryEntry {
146
146
  prevSha: string;
147
147
  sha256: string;
148
148
  }
149
+ export interface RuntimeResidueSweepResult {
150
+ /** Absolute paths of every legacy runtime artifact removed from planu/. */
151
+ removed: string[];
152
+ }
149
153
  //# sourceMappingURL=housekeeping.d.ts.map
@@ -293,5 +293,6 @@ export * from './host-tool-filter.js';
293
293
  export * from './reconcile.js';
294
294
  export * from './release-pipeline.js';
295
295
  export * from './network-policy.js';
296
+ export * from './retention.js';
296
297
  export type { VerifiedImplementationReviewResult, VerifiedImplementationReviewOk, VerifiedImplementationReviewErr, ImplementationReviewRejectionReason, } from './handoff-artifacts.js';
297
298
  //# sourceMappingURL=index.d.ts.map
@@ -304,4 +304,5 @@ export * from './reconcile.js';
304
304
  // SPEC-1012: Release spec closer types
305
305
  export * from './release-pipeline.js';
306
306
  export * from './network-policy.js';
307
+ export * from './retention.js';
307
308
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,12 @@
1
+ export interface RetentionResult {
2
+ readonly freedBytes: number;
3
+ readonly removedPaths: readonly string[];
4
+ }
5
+ export type RetentionKind = 'transition-log' | 'graph-cache' | 'spec-store-backup' | 'idempotency' | 'snapshot' | 'spec-artifacts';
6
+ export interface EnforceRetentionInput {
7
+ readonly kind: RetentionKind;
8
+ readonly path: string;
9
+ readonly protectedPaths?: readonly string[];
10
+ readonly specId?: string;
11
+ }
12
+ //# sourceMappingURL=retention.d.ts.map
@@ -0,0 +1,3 @@
1
+ // types/retention.ts — SPEC-1697: retention budgets for runtime storage artifacts
2
+ export {};
3
+ //# sourceMappingURL=retention.js.map
@@ -139,6 +139,13 @@ export interface Spec {
139
139
  * Contains failure scenarios, addressed count, and overall risk.
140
140
  */
141
141
  challengeReport?: ChallengeReport;
142
+ /**
143
+ * SPEC-1699: sha256 digest of the spec.md content this record's title was
144
+ * last synced from. Absent or mismatched against the current spec.md means
145
+ * the record is stale and is refreshed on the next read that supplies a
146
+ * canonicalRoot (see spec-store.ts refreshSpecFromDisk).
147
+ */
148
+ contentDigest?: string;
142
149
  }
143
150
  /** SPEC-964: Result of running challenge_spec on a spec. */
144
151
  export interface ChallengeReport {
@@ -0,0 +1,25 @@
1
+ /** One spec's fields as parsed from its canonical spec.md, plus a content digest. */
2
+ export interface SpecIndexRecord {
3
+ readonly id: string;
4
+ readonly title: string;
5
+ readonly status: string;
6
+ readonly type: string;
7
+ readonly target: string;
8
+ readonly scope: string;
9
+ readonly difficulty: number | null;
10
+ readonly risk: string;
11
+ readonly createdAt: string | null;
12
+ readonly updatedAt: string;
13
+ readonly specPath: string;
14
+ readonly digest: string;
15
+ /** GIVEN/WHEN/THEN acceptance-criteria lines parsed from the spec.md body. */
16
+ readonly scenarios: readonly string[];
17
+ /** `TEST:` marker contents parsed from the spec.md body. */
18
+ readonly tests: readonly string[];
19
+ }
20
+ /** Regenerable cache persisted at planu/.runtime/index.json. spec.md is authoritative. */
21
+ export interface SpecIndex {
22
+ readonly generatedAt: string;
23
+ readonly specs: Readonly<Record<string, SpecIndexRecord>>;
24
+ }
25
+ //# sourceMappingURL=index-cache.d.ts.map
@@ -0,0 +1,3 @@
1
+ // Planu — Regenerable spec.md read-index types (SPEC-1699)
2
+ export {};
3
+ //# sourceMappingURL=index-cache.js.map
@@ -6,4 +6,5 @@ export * from './agent.js';
6
6
  export * from './repair.js';
7
7
  export * from './health-score.js';
8
8
  export * from './versioning.js';
9
+ export * from './index-cache.js';
9
10
  //# sourceMappingURL=index.d.ts.map
@@ -8,4 +8,5 @@ export * from './agent.js';
8
8
  export * from './repair.js';
9
9
  export * from './health-score.js';
10
10
  export * from './versioning.js';
11
+ export * from './index-cache.js';
11
12
  //# sourceMappingURL=index.js.map
@@ -215,7 +215,7 @@ export interface SpecMigrationDeps {
215
215
  listSpecs: (projectId: string) => Promise<Spec[]>;
216
216
  updateSpec: (projectId: string, specId: string, updates: Partial<Spec>) => Promise<Spec>;
217
217
  }
218
- export type FilesystemImportFailureCode = 'READ_FAILED' | 'INVALID_CONTRACT' | 'CREATE_FAILED' | 'INCOMPATIBLE_IDENTITY';
218
+ export type FilesystemImportFailureCode = 'READ_FAILED' | 'INVALID_CONTRACT' | 'CREATE_FAILED' | 'INCOMPATIBLE_IDENTITY' | 'STRAY_REPO_NOT_PRISTINE';
219
219
  export interface FilesystemImportFailure {
220
220
  specId: string;
221
221
  path: string;
@@ -242,6 +242,7 @@ export interface SkippedLegacyContract {
242
242
  export interface PortableIndexReconciliationResult extends FilesystemImportResult {
243
243
  repositoryFilesChanged: string[];
244
244
  skippedLegacy: SkippedLegacyContract[];
245
+ strayRepoRemovals: string[];
245
246
  }
246
247
  export interface ImportEntryContext {
247
248
  projectPath: string;
@@ -72,6 +72,7 @@ export interface PlanuCanonicalPathPolicy {
72
72
  readonly canonicalSpecFiles: readonly string[];
73
73
  readonly canonicalSpecDirs: readonly string[];
74
74
  readonly forbiddenHostAssetRootDirs: readonly string[];
75
+ readonly runtimeHomeDir: string;
75
76
  readonly generatedRuntimePatterns: readonly string[];
76
77
  readonly legacyMergeBeforeDeleteFiles: readonly string[];
77
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.5.0",
3
+ "version": "5.6.0",
4
4
  "description": "Planu — MCP Server for Spec Driven Development. Cross-platform (Linux/macOS/Windows, x64/arm64).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/planu-plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dev.planu.cli",
3
3
  "displayName": "Planu — Spec Driven Development",
4
4
  "description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
5
- "version": "5.5.0",
5
+ "version": "5.6.0",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",
@@ -116,15 +116,31 @@ export function isCommitReachable(repoRoot, commit, ontoCommit) {
116
116
  throw new Error(`git merge-base --is-ancestor exited with status ${String(result.status)}`);
117
117
  }
118
118
 
119
- export async function writePendingReleaseLedger(pendingPath, entries) {
120
- const temporaryPath = `${pendingPath}.${process.pid}.${Date.now()}.tmp`;
119
+ async function readJsonObject(path) {
121
120
  try {
122
- await writeFile(temporaryPath, `${JSON.stringify(entries, null, 2)}\n`, {
121
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
122
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
123
+ } catch (error) {
124
+ if (error?.code === 'ENOENT') return {};
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * SPEC-1699: writes `entries` into the `releases` field of the committed
131
+ * planu/project.json, preserving every other field already on disk.
132
+ */
133
+ export async function writePendingReleaseLedger(projectJsonPath, entries) {
134
+ const project = await readJsonObject(projectJsonPath);
135
+ const nextProject = { ...project, releases: entries };
136
+ const temporaryPath = `${projectJsonPath}.${process.pid}.${Date.now()}.tmp`;
137
+ try {
138
+ await writeFile(temporaryPath, `${JSON.stringify(nextProject, null, 2)}\n`, {
123
139
  encoding: 'utf8',
124
140
  flag: 'wx',
125
141
  mode: 0o644,
126
142
  });
127
- await rename(temporaryPath, pendingPath);
143
+ await rename(temporaryPath, projectJsonPath);
128
144
  } catch (error) {
129
145
  await rm(temporaryPath, { force: true });
130
146
  throw error;