@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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,76 @@
1
+ ## [5.6.0] - 2026-08-31
2
+
3
+ ### Features
4
+ - feat(init-project): regenerate conventions.md from folded conventions.json (SPEC-1699)
5
+ - feat(init-project): fold legacy planu/ root artifacts on update (SPEC-1699)
6
+ - feat(init-project): gitignore legacy planu/ root files (SPEC-1699)
7
+ - feat(create-spec): refresh the regenerable spec index after writing spec.md (SPEC-1699)
8
+ - feat(storage): add regenerable spec.md index (SPEC-1699)
9
+
10
+ ### Bug Fixes
11
+ - fix(test): reconcile suites with SPEC-1699 runtime relocation and serialize frozen-lockfile suite
12
+ - fix(storage): classify retention and current-project catch degradations
13
+ - fix(release): require the exact tsgo package dir, not any node_modules content
14
+ - fix(release): drop CI=true from frozen-lockfile install, probe tsgo native package
15
+ - fix(SPEC-1696): accept inline YAML arrays for scenario tests in frontmatter
16
+ - fix(storage): close retention review gaps from Codex CHANGES_REQUIRED
17
+ - fix(storage): enforce retention budgets on runtime storage writers
18
+ - fix(SPEC-1694): report the actual test runner instead of Unknown
19
+ - fix(SPEC-1694): promote framework/database claims only from runtime dependencies
20
+ - fix: parse ordered-list markers in BDD criteria extraction (SPEC-1688)
21
+ - fix: repair release-gate collateral fallout from SPEC-1699 fold (blocker 6 sweep)
22
+ - fix: release.sh repoints version marker to planu/project.json (SPEC-1699 blocker 6)
23
+ - fix: reconcile-release-pending falls back to legacy pending.json (SPEC-1699 blocker 4)
24
+ - fix: legacy-root-migration idempotency and fail-safe malformed handling (SPEC-1699 blocker 3)
25
+ - fix: append-releases fails closed on ledger errors (SPEC-1699 blocker 5)
26
+ - fix: verify spec.md digest at the spec-store read choke point (SPEC-1699 blocker 2)
27
+ - fix(housekeeping): protect planu/.runtime as the canonical runtime home (SPEC-1699)
28
+ - fix: address implementation-review blockers for SPEC-1695 runtime relocation
29
+ - fix: reroute default proposal/export/docs-site outputs to planu/.runtime (SPEC-1695)
30
+ - fix: relocate runtime artifacts to planu/.runtime (SPEC-1695)
31
+
32
+ ### Refactoring
33
+ - refactor(release): fold pending release ledger into planu/project.json (SPEC-1699)
34
+ - refactor: relocate session.json and session-context.md under planu/.runtime (SPEC-1699)
35
+ - refactor: route spec resources through the current-project spec index (SPEC-1699)
36
+ - refactor: canonical planu/ set shrinks to project.json + specs/ (SPEC-1699)
37
+
38
+
39
+ ## [5.5.3] - 2026-08-30
40
+
41
+ ### Bug Fixes
42
+ - fix: reuse canonical pathExistsStrictByStat and give worktree-list test a real project path
43
+ - fix(SPEC-1682): honor Risks and Test Plan section headings in handoff packager
44
+ - fix(SPEC-1683): close guard bypass, fail-closed pristine check, TOCTOU-safe removal
45
+ - fix(SPEC-1683): git command guard and stray embedded repo self-heal
46
+
47
+ ### Chores
48
+ - chore(planu): SPEC-1685 implementing transition state
49
+
50
+
51
+ ## [5.5.2] - 2026-08-30
52
+
53
+ ### Bug Fixes
54
+ - fix: reuse canonical pathExistsStrictByStat and give worktree-list test a real project path
55
+ - fix(SPEC-1682): honor Risks and Test Plan section headings in handoff packager
56
+ - fix(SPEC-1683): close guard bypass, fail-closed pristine check, TOCTOU-safe removal
57
+ - fix(SPEC-1683): git command guard and stray embedded repo self-heal
58
+
59
+ ### Chores
60
+ - chore(planu): SPEC-1685 implementing transition state
61
+
62
+
63
+ ## [5.5.1] - 2026-08-30
64
+
65
+ ### Bug Fixes
66
+ - fix(SPEC-1682): honor Risks and Test Plan section headings in handoff packager
67
+ - fix(SPEC-1683): close guard bypass, fail-closed pristine check, TOCTOU-safe removal
68
+ - fix(SPEC-1683): git command guard and stray embedded repo self-heal
69
+
70
+ ### Chores
71
+ - chore(planu): SPEC-1685 implementing transition state
72
+
73
+
1
74
  ## [5.5.0] - 2026-08-29
2
75
 
3
76
  ### Features
@@ -1 +1 @@
1
- {"schemaVersion":1,"commit":"2c3e47edf445a0c8422cbeec33a6a929edd764b5"}
1
+ {"schemaVersion":1,"commit":"fa03e03df9ac0a344ac184a06f67f4de5956d0d6"}
@@ -52,7 +52,7 @@ function registerImplementingCascadeListener() {
52
52
  });
53
53
  }
54
54
  // SPEC-649: registerDoneCascadeListener (optimize_context on done) removed — cascade reduced to
55
- // exactly 2 actions: session.json update + releases/pending.json append.
55
+ // exactly 2 actions: session.json update + project.json releases append.
56
56
  /**
57
57
  * SPEC-628: Fire-and-forget auto_fix_health on cron:daily.
58
58
  * Moved from update_status(done) cascade to reduce latency on done transitions.
@@ -35,13 +35,17 @@ export function normalizePendingReleaseEntries(value) {
35
35
  throw new Error('Pending release ledger is not an array');
36
36
  }
37
37
  const deduped = new Map();
38
+ const preserved = [];
38
39
  for (const entry of value) {
39
40
  if (!isValidPendingReleaseEntry(entry) || !hasParsableCompletionDate(entry)) {
41
+ // Unknown/invalid shape: never silently drop ledger data — pass it
42
+ // through verbatim so a human can inspect and fix it later.
43
+ preserved.push(entry);
40
44
  continue;
41
45
  }
42
46
  deduped.set(entry.specId, entry);
43
47
  }
44
- return [...deduped.values()];
48
+ return [...preserved, ...deduped.values()];
45
49
  }
46
50
  export function reconcilePublishedPendingEntries(entries, release, isCommitReachable = requireIsCommitReachable) {
47
51
  if (!release) {
@@ -78,16 +82,23 @@ async function captureHeadCommit(projectPath) {
78
82
  return undefined;
79
83
  }
80
84
  }
85
+ function extractReleasesField(parsedProject) {
86
+ return parsedProject !== null &&
87
+ typeof parsedProject === 'object' &&
88
+ !Array.isArray(parsedProject)
89
+ ? parsedProject.releases
90
+ : undefined;
91
+ }
81
92
  async function rewritePendingLedger(input) {
82
93
  let pendingList = [];
83
94
  try {
84
- const raw = await readFile(input.pendingPath, 'utf-8');
85
- pendingList = normalizePendingReleaseEntries(JSON.parse(raw));
95
+ const raw = await readFile(input.projectJsonPath, 'utf-8');
96
+ pendingList = normalizePendingReleaseEntries(extractReleasesField(JSON.parse(raw)) ?? []);
86
97
  }
87
98
  catch (error) {
88
99
  if (error.code !== 'ENOENT') {
89
100
  reportClassifiedDegradation('PENDING_RELEASE_LEDGER_UNREADABLE', error);
90
- return;
101
+ throw error instanceof Error ? error : new Error(String(error));
91
102
  }
92
103
  }
93
104
  pendingList = reconcilePublishedPendingEntries(pendingList, input.releaseInfo, input.isCommitReachable).filter((entry) => entry.specId !== input.specId);
@@ -97,7 +108,7 @@ async function rewritePendingLedger(input) {
97
108
  completedAt: new Date().toISOString().substring(0, 10),
98
109
  ...(input.implementationCommit ? { implementationCommit: input.implementationCommit } : {}),
99
110
  });
100
- await input.writePendingReleaseLedger(input.pendingPath, pendingList);
111
+ await input.writePendingReleaseLedger(input.projectJsonPath, pendingList);
101
112
  }
102
113
  async function actuallyAppendReleasesPending(ctx, opts) {
103
114
  const { projectPath, projectId, specId } = ctx;
@@ -116,19 +127,19 @@ async function actuallyAppendReleasesPending(ctx, opts) {
116
127
  return;
117
128
  }
118
129
  const { isCommitReachable, resolveLatestReleaseTag, withPendingReleaseLock, writePendingReleaseLedger, } = helpers;
119
- const releasesDir = join(projectPath, 'planu', 'releases');
120
- const pendingPath = join(releasesDir, 'pending.json');
130
+ const planuDir = join(projectPath, 'planu');
131
+ const projectJsonPath = join(planuDir, 'project.json');
121
132
  await new Promise((resolve, reject) => {
122
133
  opts.signal.addEventListener('abort', () => {
123
134
  reject(new Error('CoreActionTimeoutError: append-releases-pending timed out'));
124
135
  });
125
136
  (async () => {
126
- await mkdir(releasesDir, { recursive: true });
137
+ await mkdir(planuDir, { recursive: true });
127
138
  const spec = await specStore.getSpec(projectId, specId);
128
139
  const releaseInfo = await resolveLatestReleaseTag(projectPath);
129
140
  const implementationCommit = await captureHeadCommit(projectPath);
130
- await withPendingReleaseLock(pendingPath, { timeoutMs: 1_500, signal: opts.signal }, () => rewritePendingLedger({
131
- pendingPath,
141
+ await withPendingReleaseLock(projectJsonPath, { timeoutMs: 1_500, signal: opts.signal }, () => rewritePendingLedger({
142
+ projectJsonPath,
132
143
  specId,
133
144
  title: spec?.title ?? specId,
134
145
  implementationCommit,
@@ -150,8 +161,7 @@ async function actuallyAppendReleasesPending(ctx, opts) {
150
161
  resolve();
151
162
  })
152
163
  .catch((err) => {
153
- void err; // best-effort surface as result, not throw
154
- resolve();
164
+ reject(err instanceof Error ? err : new Error(String(err)));
155
165
  });
156
166
  });
157
167
  }
@@ -4,6 +4,7 @@ export { fileExists };
4
4
  declare const IGNORE_DIRS: string[];
5
5
  export declare function hasAnyFile(basePath: string, patterns: string[]): Promise<boolean>;
6
6
  export declare function collectDependencies(projectPath: string): Promise<Set<string>>;
7
+ export declare function collectRuntimeDependencies(projectPath: string): Promise<Set<string>>;
7
8
  export declare function loadStackSignatures(): Promise<StackSignatures>;
8
9
  export declare function loadArchPatterns(): Promise<ArchPatternsFile>;
9
10
  export declare function readJsonFile(filePath: string): Promise<Record<string, unknown> | null>;
@@ -53,6 +53,17 @@ export async function collectDependencies(projectPath) {
53
53
  await collectPhpDeps(projectPath, deps);
54
54
  return deps;
55
55
  }
56
+ export async function collectRuntimeDependencies(projectPath) {
57
+ const deps = new Set();
58
+ await collectNodeRuntimeDeps(projectPath, deps);
59
+ await collectPythonRequirements(projectPath, deps);
60
+ await collectPyprojectDeps(projectPath, deps);
61
+ await collectGoDeps(projectPath, deps);
62
+ await collectRubyDeps(projectPath, deps);
63
+ await collectRustDeps(projectPath, deps);
64
+ await collectPhpRuntimeDeps(projectPath, deps);
65
+ return deps;
66
+ }
56
67
  async function collectNodeDeps(projectPath, deps) {
57
68
  try {
58
69
  const pkgJson = JSON.parse(await readFile(join(projectPath, 'package.json'), 'utf-8'));
@@ -67,6 +78,17 @@ async function collectNodeDeps(projectPath, deps) {
67
78
  // No package.json
68
79
  }
69
80
  }
81
+ async function collectNodeRuntimeDeps(projectPath, deps) {
82
+ try {
83
+ const pkgJson = JSON.parse(await readFile(join(projectPath, 'package.json'), 'utf-8'));
84
+ for (const dep of Object.keys(pkgJson.dependencies ?? {})) {
85
+ deps.add(dep);
86
+ }
87
+ }
88
+ catch {
89
+ // No package.json
90
+ }
91
+ }
70
92
  async function collectPythonRequirements(projectPath, deps) {
71
93
  try {
72
94
  const content = await readFile(join(projectPath, 'requirements.txt'), 'utf-8');
@@ -181,6 +203,17 @@ async function collectPhpDeps(projectPath, deps) {
181
203
  // No composer.json
182
204
  }
183
205
  }
206
+ async function collectPhpRuntimeDeps(projectPath, deps) {
207
+ try {
208
+ const composerJson = JSON.parse(await readFile(join(projectPath, 'composer.json'), 'utf-8'));
209
+ for (const dep of Object.keys(composerJson.require ?? {})) {
210
+ deps.add(dep);
211
+ }
212
+ }
213
+ catch {
214
+ // No composer.json
215
+ }
216
+ }
184
217
  export async function loadStackSignatures() {
185
218
  try {
186
219
  const configPath = new URL('../config/stack-signatures.json', import.meta.url);
@@ -16,7 +16,7 @@ export { computeVelocity } from './data-aggregators/velocity-aggregator.js';
16
16
  export { computeBurndown } from './data-aggregators/burndown-aggregator.js';
17
17
  export { detectBottlenecks } from './data-aggregators/bottleneck-detector.js';
18
18
  export { computeAccuracy } from './data-aggregators/accuracy-aggregator.js';
19
- export { scheduleRegeneration, regeneratePages } from './portal-regenerator.js';
19
+ export { scheduleRegeneration, regeneratePages, portalPageOutputPath, } from './portal-regenerator.js';
20
20
  export { notifyStoreChange } from './regen-hook.js';
21
21
  export { getAffectedPages } from './dirty-flag-map.js';
22
22
  //# sourceMappingURL=index.d.ts.map
@@ -19,7 +19,7 @@ export { computeBurndown } from './data-aggregators/burndown-aggregator.js';
19
19
  export { detectBottlenecks } from './data-aggregators/bottleneck-detector.js';
20
20
  export { computeAccuracy } from './data-aggregators/accuracy-aggregator.js';
21
21
  // SPEC-146 — Auto-regeneration on store changes
22
- export { scheduleRegeneration, regeneratePages } from './portal-regenerator.js';
22
+ export { scheduleRegeneration, regeneratePages, portalPageOutputPath, } from './portal-regenerator.js';
23
23
  export { notifyStoreChange } from './regen-hook.js';
24
24
  export { getAffectedPages } from './dirty-flag-map.js';
25
25
  //# sourceMappingURL=index.js.map
@@ -1,13 +1,18 @@
1
- import type { StoreType } from '../../../types/portal.js';
1
+ import type { RegeneratedPortalPage, StoreType } from '../../../types/portal.js';
2
2
  /**
3
3
  * Schedule a debounced regeneration (500 ms window).
4
4
  * Multiple calls within the window are coalesced per project.
5
5
  */
6
6
  export declare function scheduleRegeneration(projectPath: string, changedStores: StoreType[]): void;
7
+ /**
8
+ * SPEC-1695: Absolute path of a portal page's HTML output, under the
9
+ * per-project storage-layout cache directory (planu/.runtime/cache).
10
+ */
11
+ export declare function portalPageOutputPath(projectPath: string, page: RegeneratedPortalPage): string;
7
12
  /**
8
13
  * Immediately regenerate all portal pages affected by the given store changes.
9
- * Creates planu/ directory if it does not exist.
10
- * Returns the list of page keys that were successfully written.
14
+ * Creates the storage-layout cache directory if it does not exist.
15
+ * Returns the absolute output paths of the pages that were successfully written.
11
16
  * Never throws.
12
17
  */
13
18
  export declare function regeneratePages(projectPath: string, changedStores: StoreType[]): Promise<string[]>;
@@ -3,6 +3,8 @@ import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
5
5
  import { hashProjectPath } from '../../../storage/base-store.js';
6
+ // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1699
7
+ import { projectRuntimeDir } from '../../../storage/storage-layout.js';
6
8
  import { getAffectedPages } from './dirty-flag-map.js';
7
9
  import { withAudit } from '../../../engine/autopilot/audit-logger.js';
8
10
  // Debounce state keyed by projectPath to avoid cross-project interference
@@ -30,16 +32,24 @@ export function scheduleRegeneration(projectPath, changedStores) {
30
32
  }, 500);
31
33
  debounceTimers.set(projectPath, timer);
32
34
  }
35
+ /**
36
+ * SPEC-1695: Absolute path of a portal page's HTML output, under the
37
+ * per-project storage-layout cache directory (planu/.runtime/cache).
38
+ */
39
+ export function portalPageOutputPath(projectPath, page) {
40
+ const filename = page === 'index' ? 'index.html' : `${page}.html`;
41
+ return join(projectRuntimeDir(projectPath, 'cache'), filename);
42
+ }
33
43
  /**
34
44
  * Immediately regenerate all portal pages affected by the given store changes.
35
- * Creates planu/ directory if it does not exist.
36
- * Returns the list of page keys that were successfully written.
45
+ * Creates the storage-layout cache directory if it does not exist.
46
+ * Returns the absolute output paths of the pages that were successfully written.
37
47
  * Never throws.
38
48
  */
39
49
  export async function regeneratePages(projectPath, changedStores) {
40
- const planuDir = join(projectPath, 'planu');
50
+ const cacheDir = projectRuntimeDir(projectPath, 'cache');
41
51
  try {
42
- await mkdir(planuDir, { recursive: true });
52
+ await mkdir(cacheDir, { recursive: true });
43
53
  }
44
54
  catch {
45
55
  return [];
@@ -51,10 +61,9 @@ export async function regeneratePages(projectPath, changedStores) {
51
61
  await withAudit(projectPath, 'portal_regeneration', `regeneratePage(${page})`, async () => {
52
62
  const html = await generatePage(projectPath, page);
53
63
  if (html) {
54
- const filename = page === 'index' ? 'index.html' : `${page}.html`;
55
- const filePath = join(planuDir, filename);
64
+ const filePath = portalPageOutputPath(projectPath, page);
56
65
  await writeFile(filePath, html, 'utf-8');
57
- regenerated.push(page);
66
+ regenerated.push(filePath);
58
67
  }
59
68
  });
60
69
  }
@@ -1,6 +1,6 @@
1
1
  import type { AutofillTraceabilityMatrixArgs, AutofillTraceabilityMatrixResult } from '../../types/evidence-autofill.js';
2
2
  /** Exact Planu lifecycle files every spec branch mutates; never counted as implementation drift. */
3
- export declare const PLANU_BOOKKEEPING_FILES: readonly ['planu/context.md', 'planu/session-context.md', 'planu/session.json', 'planu/status.json', 'planu/releases/pending.json', 'planu/conventions.json'];
3
+ export declare const PLANU_BOOKKEEPING_FILES: readonly ['planu/context.md', 'planu/session-context.md', 'planu/session.json', 'planu/status.json', 'planu/project.json', 'planu/conventions.json'];
4
4
  /** Normalizes a spec.md path (absolute or already repo-relative) to a repo-relative,
5
5
  * forward-slash path so it can be compared against `git diff` output. */
6
6
  export declare function toRepoRelativePath(specPath: string, projectPath?: string): string;
@@ -42,7 +42,7 @@ export const PLANU_BOOKKEEPING_FILES = [
42
42
  'planu/session-context.md',
43
43
  'planu/session.json',
44
44
  'planu/status.json',
45
- 'planu/releases/pending.json',
45
+ 'planu/project.json',
46
46
  'planu/conventions.json',
47
47
  ];
48
48
  const OTHER_SPEC_MD_PATTERN = /^planu\/specs\/[^/]+\/spec\.md$/;
@@ -1,7 +1,7 @@
1
1
  import { technologyValue } from './technology-registry.js';
2
2
  // Planu — Framework, ORM, and database detection
3
3
  import { join } from 'node:path';
4
- import { fileExists, hasAnyFile, collectDependencies, readJsonFile } from './detection-utils.js';
4
+ import { fileExists, hasAnyFile, collectDependencies, collectRuntimeDependencies, readJsonFile, } from './detection-utils.js';
5
5
  /**
6
6
  * Detect the primary framework from indicator files and dependencies.
7
7
  */
@@ -14,8 +14,9 @@ export async function detectFramework(projectPath, signatures) {
14
14
  }
15
15
  }
16
16
  }
17
- // Check dependencies
18
- const deps = await collectDependencies(projectPath);
17
+ // Check runtime dependencies only — a framework declared solely in
18
+ // devDependencies is tooling, not the project's runtime stack.
19
+ const deps = await collectRuntimeDependencies(projectPath);
19
20
  for (const [fw, sig] of Object.entries(signatures.frameworks)) {
20
21
  for (const dep of sig.dependencies) {
21
22
  if (deps.has(dep)) {
@@ -55,9 +56,9 @@ export async function detectFrameworkVersion(projectPath, framework, signatures)
55
56
  * Detect the database engine from indicator files and dependencies.
56
57
  */
57
58
  export async function detectDatabase(projectPath, signatures) {
58
- const deps = await collectDependencies(projectPath);
59
+ const deps = await collectRuntimeDependencies(projectPath);
59
60
  for (const [, sig] of Object.entries(signatures.databases)) {
60
- // Check indicator files
61
+ // Check indicator files (migration dirs, connection configs)
61
62
  for (const indicator of sig.indicators) {
62
63
  if (await hasAnyFile(projectPath, [indicator])) {
63
64
  if (isKnownEngine(sig.engine)) {
@@ -65,7 +66,8 @@ export async function detectDatabase(projectPath, signatures) {
65
66
  }
66
67
  }
67
68
  }
68
- // Check dependencies
69
+ // Check runtime dependencies only — a driver declared solely in
70
+ // devDependencies never produces a Database claim.
69
71
  for (const dep of sig.dependencies) {
70
72
  if (deps.has(dep)) {
71
73
  if (isKnownEngine(sig.engine)) {
@@ -0,0 +1,2 @@
1
+ export declare function assertSafeGitInvocation(cwd: string, args: readonly string[]): void;
2
+ //# sourceMappingURL=git-command-guard.d.ts.map
@@ -0,0 +1,43 @@
1
+ import { normalize, sep } from 'node:path';
2
+ const VALUE_TAKING_GLOBAL_OPTIONS = new Set([
3
+ '-C',
4
+ '-c',
5
+ '--config-env',
6
+ '--exec-path',
7
+ '--git-dir',
8
+ '--work-tree',
9
+ '--namespace',
10
+ '--super-prefix',
11
+ '--attr-source',
12
+ '--list-cmds',
13
+ ]);
14
+ const REPO_CREATING_SUBCOMMANDS = new Set(['init', 'clone']);
15
+ function resolveSubcommand(args) {
16
+ let index = 0;
17
+ while (index < args.length) {
18
+ const arg = args[index];
19
+ if (!arg?.startsWith('-')) {
20
+ return arg;
21
+ }
22
+ if (!arg.includes('=') && VALUE_TAKING_GLOBAL_OPTIONS.has(arg)) {
23
+ index += 2;
24
+ continue;
25
+ }
26
+ index += 1;
27
+ }
28
+ return undefined;
29
+ }
30
+ function cwdTargetsSpecFolder(cwd) {
31
+ const segments = normalize(cwd).split(sep);
32
+ return segments.some((segment, i) => segment === 'planu' && segments[i + 1] === 'specs');
33
+ }
34
+ export function assertSafeGitInvocation(cwd, args) {
35
+ const subcommand = resolveSubcommand(args);
36
+ if (subcommand !== undefined && REPO_CREATING_SUBCOMMANDS.has(subcommand)) {
37
+ throw new Error(`Refusing to run repo-creating git subcommand "${subcommand}" (cwd: ${cwd}). Planu's git wrappers never create git repositories.`);
38
+ }
39
+ if (cwdTargetsSpecFolder(cwd)) {
40
+ throw new Error(`Refusing to run git with cwd inside a planu/specs folder (cwd: ${cwd}). Git commands must target the project root, never a spec folder.`);
41
+ }
42
+ }
43
+ //# sourceMappingURL=git-command-guard.js.map
@@ -2,11 +2,13 @@
2
2
  import { access } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { runAbortableProcess } from '../abortable-process-runner.js';
5
+ import { assertSafeGitInvocation } from './git-command-guard.js';
5
6
  export function isPlanuAutocommitEnabled() {
6
7
  return process.env.PLANU_ENABLE_AUTOCOMMIT === 'true';
7
8
  }
8
9
  /** Run git through the request-aware process-group supervisor. */
9
10
  async function runGit(cwd, args) {
11
+ assertSafeGitInvocation(cwd, args);
10
12
  const result = await runAbortableProcess('git', args, {
11
13
  cwd,
12
14
  maxBufferBytes: 1024 * 1024,
@@ -484,10 +484,46 @@ function extractLinesByKeywords(content, keywords) {
484
484
  .filter((line) => line.length > 0 && !/^#{1,6}\s/.test(line) && keywords.test(line));
485
485
  return [...new Set(lines)].slice(0, 12);
486
486
  }
487
+ const LIST_ITEM_PATTERN = /^(?:[-*]|\d+\.)\s+(?:\[[ x]\]\s+)?(.+)$/;
488
+ function sectionBullets(content, headingRegex) {
489
+ const lines = content.split('\n');
490
+ const bullets = [];
491
+ let inSection = false;
492
+ for (const line of lines) {
493
+ const trimmed = line.trim();
494
+ if (headingRegex.test(trimmed)) {
495
+ inSection = true;
496
+ continue;
497
+ }
498
+ if (/^#{1,6}\s/.test(trimmed)) {
499
+ inSection = false;
500
+ continue;
501
+ }
502
+ if (!inSection) {
503
+ continue;
504
+ }
505
+ const match = LIST_ITEM_PATTERN.exec(trimmed);
506
+ if (match?.[1]) {
507
+ bullets.push(match[1].trim());
508
+ }
509
+ }
510
+ return bullets;
511
+ }
512
+ function dedupeMerge(primary, fallback) {
513
+ const seen = new Set();
514
+ const merged = [];
515
+ for (const item of [...primary, ...fallback]) {
516
+ if (!seen.has(item)) {
517
+ seen.add(item);
518
+ merged.push(item);
519
+ }
520
+ }
521
+ return merged.slice(0, 12);
522
+ }
487
523
  function extractOperationalSections(content, spec) {
488
524
  return {
489
- testPlan: extractLinesByKeywords(content, /\b(test|typecheck|lint|vitest|playwright|verification|validate|pnpm|npm)\b/i),
490
- risks: extractLinesByKeywords(content, /\b(risk|edge|failure|security|drift|migration|breaking|compatibility|rollback)\b/i),
525
+ testPlan: dedupeMerge(sectionBullets(content, /^#{2,6}\s*test plan\b/i), extractLinesByKeywords(content, /\b(test|typecheck|lint|vitest|playwright|verification|validate|pnpm|npm)\b/i)),
526
+ risks: dedupeMerge(sectionBullets(content, /^#{2,6}\s*risks?\b/i), extractLinesByKeywords(content, /\b(risk|edge|failure|security|drift|migration|breaking|compatibility|rollback)\b/i)),
491
527
  ownership: extractLinesByKeywords(content, /\b(owner|ownership|wave|agent|reviewer|arbiter|files?|responsible)\b/i),
492
528
  currentState: `Spec ${spec.id} is ${spec.status}; implementation must follow the approved spec artifact, not chat history.`,
493
529
  nextAction: lifecycleNextAction(spec.status),
@@ -1,4 +1,5 @@
1
1
  import type { HousekeepingHistoryEntry } from '../../types/housekeeping.js';
2
2
  export declare function housekeepingHistoryPath(projectPath: string): string;
3
+ export declare function migrateLegacyHousekeepingHistory(projectPath: string): Promise<string | null>;
3
4
  export declare function appendHousekeepingEntry(projectPath: string, input: Omit<HousekeepingHistoryEntry, 'id' | 'deletedAt' | 'prevSha' | 'sha256'>): Promise<HousekeepingHistoryEntry>;
4
5
  //# sourceMappingURL=history-log.d.ts.map
@@ -1,14 +1,68 @@
1
1
  // engine/housekeeping/history-log.ts — SPEC-751
2
2
  // Hash-chained JSONL backup log for deleted housekeeping items.
3
3
  // Mirrors the SPEC-723/734 transition-log pattern for recoverability.
4
+ import { existsSync } from 'node:fs';
4
5
  import { createHash, randomUUID } from 'node:crypto';
5
- import { appendFile, readFile, mkdir } from 'node:fs/promises';
6
+ import { appendFile, readFile, mkdir, rm, writeFile } from 'node:fs/promises';
6
7
  import { dirname, join } from 'node:path';
8
+ // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1699
9
+ import { projectRuntimeDir } from '../../storage/storage-layout.js';
7
10
  // ---------------------------------------------------------------------------
8
- // Path helper
11
+ // Path helpers
9
12
  // ---------------------------------------------------------------------------
13
+ const HISTORY_FILENAME = '.housekeeping-history.jsonl';
10
14
  export function housekeepingHistoryPath(projectPath) {
11
- return join(projectPath, 'planu', '.housekeeping-history.jsonl');
15
+ return join(projectRuntimeDir(projectPath, 'state'), HISTORY_FILENAME);
16
+ }
17
+ function legacyHousekeepingHistoryPath(projectPath) {
18
+ return join(projectPath, 'planu', HISTORY_FILENAME);
19
+ }
20
+ /**
21
+ * SPEC-1695: One-time migration of the legacy planu/.housekeeping-history.jsonl
22
+ * into the storage-layout location. Copies content, verifies it round-tripped,
23
+ * then deletes the legacy file. Idempotent — a missing legacy file or an
24
+ * already-populated new file is a no-op.
25
+ * Returns the legacy path when a migration happened, otherwise null.
26
+ */
27
+ function entryId(line) {
28
+ try {
29
+ return JSON.parse(line).id ?? null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ function jsonlLines(content) {
36
+ return content.split('\n').filter((line) => line.trim().length > 0);
37
+ }
38
+ export async function migrateLegacyHousekeepingHistory(projectPath) {
39
+ const legacyPath = legacyHousekeepingHistoryPath(projectPath);
40
+ const newPath = housekeepingHistoryPath(projectPath);
41
+ if (!existsSync(legacyPath)) {
42
+ return null;
43
+ }
44
+ const legacyContent = await readFile(legacyPath, 'utf-8');
45
+ if (!existsSync(newPath)) {
46
+ await mkdir(dirname(newPath), { recursive: true });
47
+ await writeFile(newPath, legacyContent, 'utf-8');
48
+ const verified = await readFile(newPath, 'utf-8');
49
+ if (verified !== legacyContent) {
50
+ throw new Error('[Planu] housekeeping history migration verification failed');
51
+ }
52
+ await rm(legacyPath, { force: true });
53
+ return legacyPath;
54
+ }
55
+ const destinationContent = await readFile(newPath, 'utf-8');
56
+ const destinationIds = new Set(jsonlLines(destinationContent).map(entryId));
57
+ const missingLines = jsonlLines(legacyContent).filter((line) => {
58
+ const id = entryId(line);
59
+ return id === null || !destinationIds.has(id);
60
+ });
61
+ if (missingLines.length > 0) {
62
+ await appendFile(newPath, missingLines.map((line) => `${line}\n`).join(''), 'utf-8');
63
+ }
64
+ await rm(legacyPath, { force: true });
65
+ return legacyPath;
12
66
  }
13
67
  // ---------------------------------------------------------------------------
14
68
  // Hash chain helpers
@@ -47,6 +101,7 @@ async function readLastSha(filePath) {
47
101
  }
48
102
  }
49
103
  async function doAppend(filePath, input) {
104
+ await migrateLegacyHousekeepingHistory(input.projectPath);
50
105
  await mkdir(dirname(filePath), { recursive: true });
51
106
  const prevSha = await readLastSha(filePath);
52
107
  const partial = {
@@ -2,7 +2,8 @@ export { findStaleBranches } from './find-stale-branches.js';
2
2
  export { findStaleWorktrees } from './find-stale-worktrees.js';
3
3
  export { findStaleStashes } from './find-stale-stashes.js';
4
4
  export { runHousekeepingSweep } from './sweep-runner.js';
5
- export { appendHousekeepingEntry, housekeepingHistoryPath } from './history-log.js';
5
+ export { appendHousekeepingEntry, housekeepingHistoryPath, migrateLegacyHousekeepingHistory, } from './history-log.js';
6
+ export { sweepRuntimeResidue } from './runtime-residue-sweep.js';
6
7
  export { cleanOrphanBrainstormingMarkdowns } from './orphan-brainstorming-cleaner.js';
7
8
  export { cleanEphemeralArtifacts } from './ephemeral-artifacts-cleaner.js';
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -4,7 +4,8 @@ export { findStaleBranches } from './find-stale-branches.js';
4
4
  export { findStaleWorktrees } from './find-stale-worktrees.js';
5
5
  export { findStaleStashes } from './find-stale-stashes.js';
6
6
  export { runHousekeepingSweep } from './sweep-runner.js';
7
- export { appendHousekeepingEntry, housekeepingHistoryPath } from './history-log.js';
7
+ export { appendHousekeepingEntry, housekeepingHistoryPath, migrateLegacyHousekeepingHistory, } from './history-log.js';
8
+ export { sweepRuntimeResidue } from './runtime-residue-sweep.js';
8
9
  export { cleanOrphanBrainstormingMarkdowns } from './orphan-brainstorming-cleaner.js';
9
10
  export { cleanEphemeralArtifacts } from './ephemeral-artifacts-cleaner.js';
10
11
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,9 @@
1
+ import type { RuntimeResidueSweepResult } from '../../types/housekeeping.js';
2
+ /**
3
+ * Delete every legacy runtime-residue path from planu/ without ever touching
4
+ * canonicalRootFiles / canonicalRootDirs. Idempotent — a checkout with no
5
+ * residue returns an empty removed list. Wired into init_project and the
6
+ * session-start housekeeping path so existing client checkouts self-heal.
7
+ */
8
+ export declare function sweepRuntimeResidue(projectPath: string): Promise<RuntimeResidueSweepResult>;
9
+ //# sourceMappingURL=runtime-residue-sweep.d.ts.map