@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.
- package/CHANGELOG.md +73 -0
- package/dist/.planu-build.json +1 -1
- package/dist/engine/autopilot/bootstrap.js +1 -1
- package/dist/engine/cascade-hooks/core/append-releases.js +22 -12
- package/dist/engine/detection-utils.d.ts +1 -0
- package/dist/engine/detection-utils.js +33 -0
- package/dist/engine/doc-generator/portal/index.d.ts +1 -1
- package/dist/engine/doc-generator/portal/index.js +1 -1
- package/dist/engine/doc-generator/portal/portal-regenerator.d.ts +8 -3
- package/dist/engine/doc-generator/portal/portal-regenerator.js +16 -7
- package/dist/engine/evidence-gates/evidence-autofill.d.ts +1 -1
- package/dist/engine/evidence-gates/evidence-autofill.js +1 -1
- package/dist/engine/framework-detector.js +8 -6
- package/dist/engine/git/git-command-guard.d.ts +2 -0
- package/dist/engine/git/git-command-guard.js +43 -0
- package/dist/engine/git/planu-autocommit.js +2 -0
- package/dist/engine/handoff-packager.js +38 -2
- package/dist/engine/housekeeping/history-log.d.ts +1 -0
- package/dist/engine/housekeeping/history-log.js +58 -3
- package/dist/engine/housekeeping/index.d.ts +2 -1
- package/dist/engine/housekeeping/index.js +2 -1
- package/dist/engine/housekeeping/runtime-residue-sweep.d.ts +9 -0
- package/dist/engine/housekeeping/runtime-residue-sweep.js +57 -0
- package/dist/engine/next-spec-resolver/orchestration-planner.js +1 -1
- package/dist/engine/next-spec-resolver/session-writer.js +1 -1
- package/dist/engine/project-graph/cache.js +23 -3
- package/dist/engine/readiness-checker.js +1 -1
- package/dist/engine/sdd-flow/checkpoints.js +29 -2
- package/dist/engine/session/checkpoint-writer.d.ts +1 -1
- package/dist/engine/session/checkpoint-writer.js +6 -5
- package/dist/engine/session-state/writer.js +4 -3
- package/dist/engine/spec-format/lean-spec-generator.js +1 -1
- package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +8 -1
- package/dist/engine/spec-migrator/planu-canonical-policy.js +14 -13
- package/dist/engine/spec-migrator/strict-planu-cleanup.js +28 -3
- package/dist/engine/universal-rules/rules/planu-release-policy.js +1 -1
- package/dist/engine/validator/spec-compliance-runner.js +45 -0
- package/dist/hosts/claude-code/ux/mcp-resources.js +7 -23
- package/dist/resources/specs.js +12 -33
- package/dist/storage/current-project.d.ts +3 -0
- package/dist/storage/current-project.js +21 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/migrations/canonical-storage.js +5 -0
- package/dist/storage/retention.d.ts +14 -0
- package/dist/storage/retention.js +279 -0
- package/dist/storage/spec-index.d.ts +23 -0
- package/dist/storage/spec-index.js +123 -0
- package/dist/storage/spec-store.d.ts +8 -3
- package/dist/storage/spec-store.js +76 -6
- package/dist/storage/storage-catalog.js +3 -3
- package/dist/storage/storage-layout.d.ts +8 -0
- package/dist/storage/storage-layout.js +9 -0
- package/dist/storage/transition-log.js +2 -0
- package/dist/tools/challenge-spec.js +10 -9
- package/dist/tools/create-spec.js +10 -0
- package/dist/tools/execute-sdd-flow.js +11 -2
- package/dist/tools/export-spec.js +2 -1
- package/dist/tools/force-status-analytics.js +2 -1
- package/dist/tools/generate-docs-site.js +2 -1
- package/dist/tools/generate-proposal.js +6 -2
- package/dist/tools/git/git-helpers.js +2 -0
- package/dist/tools/init-project/claude-md-generator.js +19 -2
- package/dist/tools/init-project/conventions-writer.d.ts +5 -2
- package/dist/tools/init-project/conventions-writer.js +18 -13
- package/dist/tools/init-project/git-setup.js +9 -0
- package/dist/tools/init-project/handler.js +10 -1
- package/dist/tools/init-project/legacy-root-migration.d.ts +15 -0
- package/dist/tools/init-project/legacy-root-migration.js +213 -0
- package/dist/tools/init-project/portable-index-reconciler.d.ts +1 -1
- package/dist/tools/init-project/portable-index-reconciler.js +109 -4
- package/dist/tools/init-project/runtime-residue.d.ts +2 -0
- package/dist/tools/init-project/runtime-residue.js +11 -0
- package/dist/tools/session-checkpoint.js +1 -1
- package/dist/tools/update-status/index.js +7 -1
- package/dist/tools/update-status-actions.d.ts +7 -1
- package/dist/tools/update-status-actions.js +10 -2
- package/dist/types/housekeeping.d.ts +4 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/retention.d.ts +12 -0
- package/dist/types/retention.js +3 -0
- package/dist/types/spec/core.d.ts +7 -0
- package/dist/types/spec/index-cache.d.ts +25 -0
- package/dist/types/spec/index-cache.js +3 -0
- package/dist/types/spec/index.d.ts +1 -0
- package/dist/types/spec/index.js +1 -0
- package/dist/types/spec/inputs.d.ts +2 -1
- package/dist/types/spec-format.d.ts +1 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
- package/scripts/lib/pending-release-file.mjs +20 -4
|
@@ -9,6 +9,7 @@ import { appendFile, mkdir } from 'node:fs/promises';
|
|
|
9
9
|
import { createReadStream } from 'node:fs';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import { projectDataDir } from './base-store.js';
|
|
12
|
+
import { enforceRetention } from './retention.js';
|
|
12
13
|
import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// Path helper
|
|
@@ -56,6 +57,7 @@ function computeEntrySha(entry) {
|
|
|
56
57
|
const writeQueues = new Map();
|
|
57
58
|
async function doAppend(filePath, input) {
|
|
58
59
|
await mkdir(dirname(filePath), { recursive: true });
|
|
60
|
+
await enforceRetention({ kind: 'transition-log', path: filePath });
|
|
59
61
|
let lastLine;
|
|
60
62
|
for await (const line of streamJsonlLines(filePath)) {
|
|
61
63
|
lastLine = line;
|
|
@@ -172,15 +172,7 @@ export async function handleChallengeSpec(args, server) {
|
|
|
172
172
|
if (!projectId) {
|
|
173
173
|
return missingProjectIdError;
|
|
174
174
|
}
|
|
175
|
-
// 1. Load
|
|
176
|
-
const spec = await specStore.getSpec(projectId, specId);
|
|
177
|
-
if (!spec) {
|
|
178
|
-
return {
|
|
179
|
-
content: [{ type: 'text', text: ti('spec.notFound', { id: specId }) }],
|
|
180
|
-
isError: true,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
// 2. Load project knowledge — SPEC-1011 Bug F: use disk fingerprint fallback
|
|
175
|
+
// 1. Load project knowledge — SPEC-1011 Bug F: use disk fingerprint fallback
|
|
184
176
|
let knowledge = await knowledgeStore.getKnowledge(projectId);
|
|
185
177
|
if (!knowledge && args.projectPath) {
|
|
186
178
|
const resolved = await resolveProjectFromPath(args.projectPath);
|
|
@@ -194,6 +186,15 @@ export async function handleChallengeSpec(args, server) {
|
|
|
194
186
|
isError: true,
|
|
195
187
|
};
|
|
196
188
|
}
|
|
189
|
+
// 2. Load spec — SPEC-1699: pass canonicalRoot so a stale specs.json record
|
|
190
|
+
// (e.g. spec.md hand-edited after the last write) is refreshed on read.
|
|
191
|
+
const spec = await specStore.getSpec(projectId, specId, knowledge.projectPath);
|
|
192
|
+
if (!spec) {
|
|
193
|
+
return {
|
|
194
|
+
content: [{ type: 'text', text: ti('spec.notFound', { id: specId }) }],
|
|
195
|
+
isError: true,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
197
198
|
// 2b. Load Constitution for compliance context
|
|
198
199
|
const constitution = await knowledgeStore.getConstitution(projectId);
|
|
199
200
|
// 3. Read spec content
|
|
@@ -2,6 +2,7 @@ import { checkGate } from '../engine/clarification-gate/gate.js';
|
|
|
2
2
|
import { upsertToken, hashQuestions } from '../engine/clarification-gate/token-store.js';
|
|
3
3
|
import { ti } from '../i18n/index.js';
|
|
4
4
|
import { knowledgeStore, specStore } from '../storage/index.js';
|
|
5
|
+
import { loadSpecIndex } from '../storage/spec-index.js';
|
|
5
6
|
import { readTechnologySelectionContract } from '../storage/technology-selection-store.js';
|
|
6
7
|
import { toolResult, interactiveResult } from './response-helpers.js';
|
|
7
8
|
import { readFile, stat as fsStat } from 'node:fs/promises';
|
|
@@ -9,6 +10,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
9
10
|
import { pathExistsByStat as pathExists } from '../core/shared/fs.js';
|
|
10
11
|
import { basename as pathBasename, dirname as pathDirname, isAbsolute as pathIsAbsolute, join as pathJoin, relative as pathRelative, sep as pathSeparator, } from 'node:path';
|
|
11
12
|
import { withFileLock } from '../storage/file-mutex.js';
|
|
13
|
+
import { enforceRetention } from '../storage/retention.js';
|
|
12
14
|
import { generateBranchName } from './create-spec-helpers.js';
|
|
13
15
|
import { buildSpecContext, buildSplitResult, computeNextSpecId, } from './create-spec/spec-builder.js';
|
|
14
16
|
import { validateConstitution } from './create-spec/constitution-validator.js';
|
|
@@ -635,6 +637,7 @@ async function commitIdempotencyEvidence(projectPath, key, claim, spec, specPath
|
|
|
635
637
|
await executionWriteFileExclusive(evidencePath, JSON.stringify(evidence, null, 2), {
|
|
636
638
|
encoding: 'utf-8',
|
|
637
639
|
});
|
|
640
|
+
await enforceRetention({ kind: 'idempotency', path: pathDirname(evidencePath) });
|
|
638
641
|
}
|
|
639
642
|
catch (error) {
|
|
640
643
|
if (error.code !== 'EEXIST') {
|
|
@@ -1688,6 +1691,13 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
1688
1691
|
}
|
|
1689
1692
|
await measureStep('specStore-createSpec', () => specStore.createSpec(projectId, spec));
|
|
1690
1693
|
storeCreated = true;
|
|
1694
|
+
// SPEC-1699: refresh the regenerable spec.md index cache file.
|
|
1695
|
+
// Best-effort — specStore.getSpec/listSpecs independently verify
|
|
1696
|
+
// spec.md's digest on every read that supplies canonicalRoot, so
|
|
1697
|
+
// a failed refresh here only lags this cache file, not a caller.
|
|
1698
|
+
await loadSpecIndex(resolvedPath).catch(() => {
|
|
1699
|
+
/* best-effort */
|
|
1700
|
+
});
|
|
1691
1701
|
assertExecutionCanCommit(criticalSignal);
|
|
1692
1702
|
await measureStep('commit-idempotency-evidence', () => commitIdempotencyEvidence(resolvedPath, idempotencyKey, idempotencyClaim, spec, specPath));
|
|
1693
1703
|
operationJournal.commitWithOutbox('create_spec', operationKey, committedResult, {
|
|
@@ -48,9 +48,18 @@ export async function handleExecuteSddFlow(args) {
|
|
|
48
48
|
if (specIds.length === 0 && !args.resumeFrom) {
|
|
49
49
|
return compactError('specIds is required when not resuming. Provide at least one approved spec ID.');
|
|
50
50
|
}
|
|
51
|
-
let state =
|
|
51
|
+
let state = null;
|
|
52
|
+
if (args.resumeFrom) {
|
|
53
|
+
try {
|
|
54
|
+
state = await loadCheckpoint(projectPath, args.resumeFrom);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return compactError(error instanceof Error ? error.message : String(error));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
52
60
|
if (args.resumeFrom && !state) {
|
|
53
|
-
return compactError(`Run state not found for runId: ${args.resumeFrom}.
|
|
61
|
+
return compactError(`Run state not found for runId: ${args.resumeFrom}. ` +
|
|
62
|
+
'Checked planu/.runtime/state/ and legacy planu/state/.');
|
|
54
63
|
}
|
|
55
64
|
if (!state) {
|
|
56
65
|
const runId = randomUUID();
|
|
@@ -8,6 +8,7 @@ import { stripFrontmatter } from '../engine/frontmatter-parser.js';
|
|
|
8
8
|
import { specToAgentReady, serializeAgentReady } from '../engine/agent-ready-exporter.js';
|
|
9
9
|
import { assertPathWithinBase } from '../engine/safety/assert-within-project.js';
|
|
10
10
|
import { compactResult, formatKeyValue } from './output-formatter.js';
|
|
11
|
+
import { projectRuntimeDir } from '../storage/storage-layout.js';
|
|
11
12
|
async function readFileContent(filePath) {
|
|
12
13
|
try {
|
|
13
14
|
const raw = await readFile(filePath, 'utf-8');
|
|
@@ -122,7 +123,7 @@ export async function handleExportSpec(params) {
|
|
|
122
123
|
});
|
|
123
124
|
// Write to output directory
|
|
124
125
|
const slug = toSlug(spec.title);
|
|
125
|
-
const outputDir = params.outputDir ?? join(params.projectPath, '
|
|
126
|
+
const outputDir = params.outputDir ?? join(projectRuntimeDir(params.projectPath, 'reports'), 'exports', slug);
|
|
126
127
|
let resolvedOutput;
|
|
127
128
|
try {
|
|
128
129
|
resolvedOutput = assertPathWithinBase(params.projectPath, outputDir);
|
|
@@ -4,6 +4,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|
|
4
4
|
import { readForceAnalytics } from '../storage/force-analytics-store.js';
|
|
5
5
|
import { resolveProjectPath } from '../storage/path-resolver.js';
|
|
6
6
|
import { hashProjectPath } from '../storage/base-store.js';
|
|
7
|
+
import { projectRuntimeDir } from '../storage/storage-layout.js';
|
|
7
8
|
function buildMarkdownReport(projectPath, entries, stats) {
|
|
8
9
|
const lines = [
|
|
9
10
|
'# Quality Exceptions Report',
|
|
@@ -42,7 +43,7 @@ export async function handleForceStatusAnalytics(params) {
|
|
|
42
43
|
let reportPath = null;
|
|
43
44
|
if (generateReport) {
|
|
44
45
|
try {
|
|
45
|
-
const reportDir =
|
|
46
|
+
const reportDir = projectRuntimeDir(projectPath, 'reports');
|
|
46
47
|
await mkdir(reportDir, { recursive: true });
|
|
47
48
|
reportPath = join(reportDir, 'quality-exceptions.md');
|
|
48
49
|
const markdown = buildMarkdownReport(projectPath, analytics.forcedTransitions, analytics.stats);
|
|
@@ -2,6 +2,7 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { rm, access } from 'node:fs/promises';
|
|
3
3
|
import { generateDocsSite } from '../engine/docs-site-generator/index.js';
|
|
4
4
|
import { compactResult } from './output-formatter.js';
|
|
5
|
+
import { projectRuntimeDir } from '../storage/storage-layout.js';
|
|
5
6
|
async function removeMisplacedDocsSite(projectPath) {
|
|
6
7
|
const stale = join(projectPath, 'docs-site');
|
|
7
8
|
try {
|
|
@@ -17,7 +18,7 @@ export async function handleGenerateDocsSite(params) {
|
|
|
17
18
|
const removed = await removeMisplacedDocsSite(params.projectPath);
|
|
18
19
|
const config = {
|
|
19
20
|
projectPath: params.projectPath,
|
|
20
|
-
outputDir: join(params.projectPath, '
|
|
21
|
+
outputDir: join(projectRuntimeDir(params.projectPath, 'cache'), 'docs-site'),
|
|
21
22
|
title: params.title,
|
|
22
23
|
};
|
|
23
24
|
const result = await generateDocsSite(config);
|
|
@@ -6,6 +6,7 @@ import { hashProjectPath, projectDataDir } from '../storage/base-store.js';
|
|
|
6
6
|
import { specStore, knowledgeStore, decisionStore, metricsStore } from '../storage/index.js';
|
|
7
7
|
import { loadAllRiskRegisters } from '../storage/risk-store.js';
|
|
8
8
|
import { generateProposal } from '../engine/doc-generator/proposal/proposal-generator.js';
|
|
9
|
+
import { projectRuntimeDir } from '../storage/storage-layout.js';
|
|
9
10
|
import { SpecIdSchema } from './schemas/index.js';
|
|
10
11
|
// ── Zod schema ────────────────────────────────────────────────────────────────
|
|
11
12
|
const PhaseSchema = z.object({
|
|
@@ -131,8 +132,11 @@ async function handleGenerateProposal(args) {
|
|
|
131
132
|
};
|
|
132
133
|
const html = generateProposal(proposalData);
|
|
133
134
|
// Resolve output path
|
|
134
|
-
const
|
|
135
|
-
|
|
135
|
+
const outputFilePath = args.outputPath
|
|
136
|
+
? isAbsolute(args.outputPath)
|
|
137
|
+
? args.outputPath
|
|
138
|
+
: join(args.projectPath, args.outputPath)
|
|
139
|
+
: join(projectRuntimeDir(args.projectPath, 'reports'), 'proposal.html');
|
|
136
140
|
await mkdir(dirname(outputFilePath), { recursive: true });
|
|
137
141
|
await writeFile(outputFilePath, html, 'utf-8');
|
|
138
142
|
const sizeKb = Math.round(html.length / 1024);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { knowledgeStore } from '../../storage/index.js';
|
|
2
2
|
import { runAbortableProcess } from '../../engine/abortable-process-runner.js';
|
|
3
|
+
import { assertSafeGitInvocation } from '../../engine/git/git-command-guard.js';
|
|
3
4
|
/** Default branch prefixes by spec type. */
|
|
4
5
|
export const DEFAULT_BRANCH_PREFIXES = {
|
|
5
6
|
feature: 'feat',
|
|
@@ -15,6 +16,7 @@ export const DEFAULT_PROTECTED_BRANCHES = ['main', 'master', 'develop', 'product
|
|
|
15
16
|
export const DEFAULT_STALENESS_THRESHOLD = 50;
|
|
16
17
|
/** Execute a git command in the project directory. */
|
|
17
18
|
export async function git(projectPath, args, options = {}) {
|
|
19
|
+
assertSafeGitInvocation(projectPath, args);
|
|
18
20
|
try {
|
|
19
21
|
const result = await runAbortableProcess('git', args, {
|
|
20
22
|
cwd: projectPath,
|
|
@@ -116,7 +116,7 @@ function buildArchitecture(knowledge) {
|
|
|
116
116
|
} // only monorepo=No is not useful alone
|
|
117
117
|
return wrap('architecture', `## Architecture\n${items.join('\n')}`);
|
|
118
118
|
}
|
|
119
|
-
function
|
|
119
|
+
function detectTestFrameworkFromCommand(testCommand) {
|
|
120
120
|
if (testCommand.includes(technologyValue('technology-vitest-a9127f'))) {
|
|
121
121
|
return technologyValue('technology-vitest-7fc39e');
|
|
122
122
|
}
|
|
@@ -143,11 +143,28 @@ function detectTestFramework(testCommand) {
|
|
|
143
143
|
}
|
|
144
144
|
return 'Unknown';
|
|
145
145
|
}
|
|
146
|
+
const TOOLING_TEST_FRAMEWORK_LABELS = {
|
|
147
|
+
[technologyValue('technology-vitest-a9127f')]: technologyValue('technology-vitest-7fc39e'),
|
|
148
|
+
[technologyValue('technology-jest-35e99f')]: technologyValue('technology-jest-1f30f0'),
|
|
149
|
+
mocha: 'Mocha',
|
|
150
|
+
[technologyValue('technology-pytest-2b0121')]: technologyValue('technology-pytest-2b0121'),
|
|
151
|
+
'cargo-test': 'cargo test',
|
|
152
|
+
};
|
|
153
|
+
function detectTestFramework(knowledge, testCommand) {
|
|
154
|
+
const toolingTesting = knowledge.tooling.testing;
|
|
155
|
+
if (toolingTesting) {
|
|
156
|
+
const label = TOOLING_TEST_FRAMEWORK_LABELS[toolingTesting];
|
|
157
|
+
if (label) {
|
|
158
|
+
return label;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return detectTestFrameworkFromCommand(testCommand);
|
|
162
|
+
}
|
|
146
163
|
function buildTesting(knowledge) {
|
|
147
164
|
if (!knowledge.testCommand) {
|
|
148
165
|
return null;
|
|
149
166
|
}
|
|
150
|
-
const framework = detectTestFramework(knowledge.testCommand);
|
|
167
|
+
const framework = detectTestFramework(knowledge, knowledge.testCommand);
|
|
151
168
|
const items = [
|
|
152
169
|
`- **Framework**: ${framework}`,
|
|
153
170
|
`- **Command**: \`${knowledge.testCommand}\``,
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { ProjectKnowledge } from '../../types/index.js';
|
|
2
|
+
export type ConventionsSource = Pick<ProjectKnowledge, 'stack' | 'language' | 'framework' | 'projectPath'>;
|
|
2
3
|
/**
|
|
3
4
|
* SPEC-530: Write .claude/rules/conventions.md if missing.
|
|
4
5
|
* Generates project-specific conventions from detected knowledge.
|
|
5
|
-
*
|
|
6
|
+
* SPEC-1699: pass force=true to regenerate even when the file already exists,
|
|
7
|
+
* e.g. after migrateLegacyPlanuRoot folds conventions.json into project.json.
|
|
8
|
+
* @returns true if the file was written, false if it already existed and force was false.
|
|
6
9
|
*/
|
|
7
|
-
export declare function generateConventionsMdIfMissing(projectPath: string, knowledge:
|
|
10
|
+
export declare function generateConventionsMdIfMissing(projectPath: string, knowledge: ConventionsSource, force?: boolean): Promise<boolean>;
|
|
8
11
|
//# sourceMappingURL=conventions-writer.d.ts.map
|
|
@@ -33,21 +33,26 @@ function buildConventionsContent(knowledge) {
|
|
|
33
33
|
/**
|
|
34
34
|
* SPEC-530: Write .claude/rules/conventions.md if missing.
|
|
35
35
|
* Generates project-specific conventions from detected knowledge.
|
|
36
|
-
*
|
|
36
|
+
* SPEC-1699: pass force=true to regenerate even when the file already exists,
|
|
37
|
+
* e.g. after migrateLegacyPlanuRoot folds conventions.json into project.json.
|
|
38
|
+
* @returns true if the file was written, false if it already existed and force was false.
|
|
37
39
|
*/
|
|
38
|
-
export async function generateConventionsMdIfMissing(projectPath, knowledge) {
|
|
40
|
+
export async function generateConventionsMdIfMissing(projectPath, knowledge, force = false) {
|
|
39
41
|
const conventionsPath = join(projectPath, CONVENTIONS_MD_PATH);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
assertEnglishOnlyArtifactText(content, 'rule');
|
|
49
|
-
await writeFile(conventionsPath, content, 'utf-8');
|
|
50
|
-
return true;
|
|
42
|
+
if (!force) {
|
|
43
|
+
try {
|
|
44
|
+
await access(conventionsPath);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* file absent — fall through and write it */
|
|
49
|
+
}
|
|
51
50
|
}
|
|
51
|
+
const rulesDir = join(projectPath, '.claude/rules');
|
|
52
|
+
await mkdir(rulesDir, { recursive: true });
|
|
53
|
+
const content = buildConventionsContent(knowledge);
|
|
54
|
+
assertEnglishOnlyArtifactText(content, 'rule');
|
|
55
|
+
await writeFile(conventionsPath, content, 'utf-8');
|
|
56
|
+
return true;
|
|
52
57
|
}
|
|
53
58
|
//# sourceMappingURL=conventions-writer.js.map
|
|
@@ -113,6 +113,7 @@ async function configureGitignore(projectPath) {
|
|
|
113
113
|
}
|
|
114
114
|
function buildPlanuIgnoreBlock() {
|
|
115
115
|
return [
|
|
116
|
+
'planu/.runtime/',
|
|
116
117
|
'planu/*.html',
|
|
117
118
|
'planu/status.json',
|
|
118
119
|
'planu/CHANGELOG.md',
|
|
@@ -122,6 +123,14 @@ function buildPlanuIgnoreBlock() {
|
|
|
122
123
|
'planu/data/',
|
|
123
124
|
'planu/state/',
|
|
124
125
|
'planu/.locks/',
|
|
126
|
+
// SPEC-1699: legacy root files/dir folded into project.json by init_project's
|
|
127
|
+
// migration — ignored so a transitional checkout never re-commits them.
|
|
128
|
+
'planu/conventions.json',
|
|
129
|
+
'planu/context.md',
|
|
130
|
+
'planu/session-context.md',
|
|
131
|
+
'planu/session.json',
|
|
132
|
+
'planu/technology-selection.json',
|
|
133
|
+
'planu/releases/',
|
|
125
134
|
'planu/specs/**',
|
|
126
135
|
'!planu/specs/**/',
|
|
127
136
|
'!planu/specs/**/spec.md',
|
|
@@ -46,6 +46,8 @@ import { reconcileInteractiveQuestionHooks } from '../reconcile-interactive-ques
|
|
|
46
46
|
import { installSessionSafeguardHook } from '../reconcile-session-safeguard-hook.js';
|
|
47
47
|
import { resolveNewProjectOnboarding } from '../../engine/onboarding/new-project-resolver.js';
|
|
48
48
|
import { interactiveResult } from '../response-helpers.js';
|
|
49
|
+
import { scheduleRuntimeResidueSweep } from './runtime-residue.js';
|
|
50
|
+
import { resolveLegacyMigration } from './legacy-root-migration.js';
|
|
49
51
|
/** Frontend framework groups — mutually exclusive. Two or more detected → multi-stack conflict. */
|
|
50
52
|
const FRONTEND_FRAMEWORK_GROUPS = [
|
|
51
53
|
[
|
|
@@ -213,6 +215,11 @@ export async function handleInitProject(params, server) {
|
|
|
213
215
|
// Check if project is already initialized
|
|
214
216
|
const existing = await knowledgeStore.getKnowledge(projectId);
|
|
215
217
|
const isUpdate = hadProjectIdentity || existing !== null;
|
|
218
|
+
// SPEC-1699: fold legacy planu/ root artifacts before the sweep below runs.
|
|
219
|
+
const legacyMigration = await resolveLegacyMigration(isUpdate, projectPath);
|
|
220
|
+
// SPEC-1699: Sweep runtime residue on every init_project run, including the
|
|
221
|
+
// already-initialized early-return path below — not just the fresh-init path.
|
|
222
|
+
scheduleRuntimeResidueSweep(projectPath);
|
|
216
223
|
const authorizedMigrations = params.authorizedMigrations ?? [];
|
|
217
224
|
if (isUpdate && authorizedMigrations.length === 0) {
|
|
218
225
|
const reconciliation = await reconcilePortableSpecIndex(projectPath, projectId);
|
|
@@ -233,6 +240,8 @@ export async function handleInitProject(params, server) {
|
|
|
233
240
|
importedSpecIds: reconciliation.importedSpecIds,
|
|
234
241
|
failures: reconciliation.failures,
|
|
235
242
|
skippedLegacy: reconciliation.skippedLegacy,
|
|
243
|
+
strayRepoRemovals: reconciliation.strayRepoRemovals,
|
|
244
|
+
migratedLegacyPaths: legacyMigration.migrated,
|
|
236
245
|
},
|
|
237
246
|
...(reconciliation.failures.length > 0 ? { isError: true } : {}),
|
|
238
247
|
};
|
|
@@ -462,7 +471,7 @@ export async function handleInitProject(params, server) {
|
|
|
462
471
|
/* best-effort */
|
|
463
472
|
});
|
|
464
473
|
// SPEC-193: Generate portal pages (fire-and-forget)
|
|
465
|
-
void withAudit(projectPath, 'init_project', 'regeneratePages', () => regeneratePages(projectPath, ['knowledge', 'risks', 'decisions']), (pages) => ({ pages
|
|
474
|
+
void withAudit(projectPath, 'init_project', 'regeneratePages', () => regeneratePages(projectPath, ['knowledge', 'risks', 'decisions']), (pages) => ({ pages })).catch(() => {
|
|
466
475
|
/* best-effort — never fail init */
|
|
467
476
|
});
|
|
468
477
|
// SPEC-645: Detect LLM client and cache in conventions.json (fire-and-forget)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface LegacyMigrationResult {
|
|
2
|
+
readonly migrated: readonly string[];
|
|
3
|
+
readonly warnings: readonly string[];
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Fold conventions.json, technology-selection.json, and releases/pending.json
|
|
7
|
+
* into project.json, then move context.md/session-context.md/session.json
|
|
8
|
+
* under planu/.runtime. Returns the migrated relative paths and any
|
|
9
|
+
* malformed-legacy-artifact warnings. Best-effort — a failure leaves the
|
|
10
|
+
* remaining legacy artifacts for a later retry.
|
|
11
|
+
*/
|
|
12
|
+
export declare function migrateLegacyPlanuRoot(projectPath: string): Promise<LegacyMigrationResult>;
|
|
13
|
+
/** Runs `migrateLegacyPlanuRoot` only for an already-initialized project; a no-op otherwise. */
|
|
14
|
+
export declare function resolveLegacyMigration(isUpdate: boolean, projectPath: string): Promise<LegacyMigrationResult>;
|
|
15
|
+
//# sourceMappingURL=legacy-root-migration.d.ts.map
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// tools/init-project/legacy-root-migration.ts — SPEC-1699
|
|
2
|
+
// Folds legacy planu/ root artifacts into their canonical SPEC-1699 locations:
|
|
3
|
+
// conventions.json and technology-selection.json fold into project.json,
|
|
4
|
+
// releases/pending.json folds into project.json's releases field, and
|
|
5
|
+
// context.md/session-context.md/session.json move under planu/.runtime.
|
|
6
|
+
// Best-effort and idempotent — a project with none of these legacy artifacts
|
|
7
|
+
// is a no-op. A malformed legacy file is left in place for manual repair
|
|
8
|
+
// instead of being silently treated as absent.
|
|
9
|
+
import { readFile, writeFile, rename, rm, mkdir } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { generateConventionsMdIfMissing } from './conventions-writer.js';
|
|
12
|
+
import { reportClassifiedDegradation } from '../../errors/classified-degradation.js';
|
|
13
|
+
import { pathExists } from '../../core/shared/fs.js';
|
|
14
|
+
async function readJsonFile(filePath, isExpectedShape) {
|
|
15
|
+
let raw;
|
|
16
|
+
try {
|
|
17
|
+
raw = await readFile(filePath, 'utf-8');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return { status: 'missing' };
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (!isExpectedShape(parsed)) {
|
|
25
|
+
return { status: 'malformed', error: `${filePath} does not have the expected shape` };
|
|
26
|
+
}
|
|
27
|
+
return { status: 'ok', value: parsed };
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
return { status: 'malformed', error: error instanceof Error ? error.message : String(error) };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function isJsonObject(value) {
|
|
34
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
function isJsonArray(value) {
|
|
37
|
+
return Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
function malformedWarning(filePath, result) {
|
|
40
|
+
return result.status === 'malformed'
|
|
41
|
+
? `Legacy artifact ${filePath} is malformed and was left in place: ${result.error}`
|
|
42
|
+
: null;
|
|
43
|
+
}
|
|
44
|
+
async function planConventionsFold(planuDir) {
|
|
45
|
+
const filePath = join(planuDir, 'conventions.json');
|
|
46
|
+
const result = await readJsonFile(filePath, isJsonObject);
|
|
47
|
+
if (result.status !== 'ok') {
|
|
48
|
+
return { step: null, warning: malformedWarning(filePath, result) };
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
step: {
|
|
52
|
+
projectField: 'conventions',
|
|
53
|
+
projectValue: result.value,
|
|
54
|
+
pathsToDelete: [filePath],
|
|
55
|
+
migratedPath: 'planu/conventions.json',
|
|
56
|
+
},
|
|
57
|
+
warning: null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function planTechnologySelectionFold(planuDir) {
|
|
61
|
+
const filePath = join(planuDir, 'technology-selection.json');
|
|
62
|
+
const result = await readJsonFile(filePath, isJsonObject);
|
|
63
|
+
if (result.status !== 'ok') {
|
|
64
|
+
return { step: null, warning: malformedWarning(filePath, result) };
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
step: {
|
|
68
|
+
projectField: 'technologySelection',
|
|
69
|
+
projectValue: result.value,
|
|
70
|
+
pathsToDelete: [filePath],
|
|
71
|
+
migratedPath: 'planu/technology-selection.json',
|
|
72
|
+
},
|
|
73
|
+
warning: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function dedupeReleaseEntriesById(entries) {
|
|
77
|
+
const deduped = new Map();
|
|
78
|
+
const undeduplicated = [];
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const id = isJsonObject(entry) && typeof entry.specId === 'string' ? entry.specId : undefined;
|
|
81
|
+
if (id === undefined) {
|
|
82
|
+
undeduplicated.push(entry);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
deduped.set(id, entry);
|
|
86
|
+
}
|
|
87
|
+
return [...undeduplicated, ...deduped.values()];
|
|
88
|
+
}
|
|
89
|
+
async function planReleasesFold(planuDir, existingReleases) {
|
|
90
|
+
const releasesDir = join(planuDir, 'releases');
|
|
91
|
+
const pendingPath = join(releasesDir, 'pending.json');
|
|
92
|
+
const result = await readJsonFile(pendingPath, isJsonArray);
|
|
93
|
+
if (result.status !== 'ok') {
|
|
94
|
+
return { step: null, warning: malformedWarning(pendingPath, result) };
|
|
95
|
+
}
|
|
96
|
+
const existing = Array.isArray(existingReleases) ? existingReleases : [];
|
|
97
|
+
return {
|
|
98
|
+
step: {
|
|
99
|
+
projectField: 'releases',
|
|
100
|
+
// Deduped by specId so an interrupted-and-retried migration (project.json
|
|
101
|
+
// already updated, planu/releases/ not yet deleted) never duplicates entries.
|
|
102
|
+
projectValue: dedupeReleaseEntriesById([...existing, ...result.value]),
|
|
103
|
+
pathsToDelete: [releasesDir],
|
|
104
|
+
migratedPath: 'planu/releases/pending.json',
|
|
105
|
+
},
|
|
106
|
+
warning: null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function extractConventionsSource(parsed, projectPath) {
|
|
110
|
+
const stackRaw = parsed.stack;
|
|
111
|
+
const stack = stackRaw !== null && typeof stackRaw === 'object' ? stackRaw : {};
|
|
112
|
+
const stackFields = [
|
|
113
|
+
stack.language,
|
|
114
|
+
stack.framework,
|
|
115
|
+
stack.testRunner,
|
|
116
|
+
stack.linter,
|
|
117
|
+
stack.formatter,
|
|
118
|
+
stack.bundler,
|
|
119
|
+
].filter((value) => typeof value === 'string');
|
|
120
|
+
return {
|
|
121
|
+
language: typeof stack.language === 'string' ? stack.language : 'unknown',
|
|
122
|
+
framework: typeof stack.framework === 'string' ? stack.framework : null,
|
|
123
|
+
stack: stackFields,
|
|
124
|
+
projectPath,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async function moveUnderRuntime(planuDir, fileName) {
|
|
128
|
+
const source = join(planuDir, fileName);
|
|
129
|
+
if (!(await pathExists(source))) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const runtimeDir = join(planuDir, '.runtime');
|
|
133
|
+
await mkdir(runtimeDir, { recursive: true });
|
|
134
|
+
await rename(source, join(runtimeDir, fileName));
|
|
135
|
+
return `planu/${fileName}`;
|
|
136
|
+
}
|
|
137
|
+
async function planJsonFolds(planuDir, project) {
|
|
138
|
+
const outcomes = await Promise.all([
|
|
139
|
+
planConventionsFold(planuDir),
|
|
140
|
+
planTechnologySelectionFold(planuDir),
|
|
141
|
+
planReleasesFold(planuDir, project.releases),
|
|
142
|
+
]);
|
|
143
|
+
return {
|
|
144
|
+
steps: outcomes
|
|
145
|
+
.map((outcome) => outcome.step)
|
|
146
|
+
.filter((step) => step !== null),
|
|
147
|
+
warnings: outcomes
|
|
148
|
+
.map((outcome) => outcome.warning)
|
|
149
|
+
.filter((warning) => warning !== null),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async function foldJsonArtifactsIntoProject(projectPath, planuDir, projectJsonPath, project) {
|
|
153
|
+
const { steps, warnings } = await planJsonFolds(planuDir, project);
|
|
154
|
+
if (steps.length === 0) {
|
|
155
|
+
return { migrated: [], warnings };
|
|
156
|
+
}
|
|
157
|
+
const nextProject = { ...project };
|
|
158
|
+
for (const step of steps) {
|
|
159
|
+
nextProject[step.projectField] = step.projectValue;
|
|
160
|
+
}
|
|
161
|
+
// Write project.json, then regenerate conventions.md, and only then delete
|
|
162
|
+
// the legacy source files — a crash between steps must never leave the
|
|
163
|
+
// canonical record updated with the legacy source already gone unread.
|
|
164
|
+
await writeFile(projectJsonPath, `${JSON.stringify(nextProject, null, 2)}\n`, 'utf-8');
|
|
165
|
+
const conventionsStep = steps.find((step) => step.projectField === 'conventions');
|
|
166
|
+
if (conventionsStep) {
|
|
167
|
+
await generateConventionsMdIfMissing(projectPath, extractConventionsSource(conventionsStep.projectValue, projectPath), true);
|
|
168
|
+
}
|
|
169
|
+
for (const step of steps) {
|
|
170
|
+
for (const pathToDelete of step.pathsToDelete) {
|
|
171
|
+
await rm(pathToDelete, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { migrated: steps.map((step) => step.migratedPath), warnings };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Fold conventions.json, technology-selection.json, and releases/pending.json
|
|
178
|
+
* into project.json, then move context.md/session-context.md/session.json
|
|
179
|
+
* under planu/.runtime. Returns the migrated relative paths and any
|
|
180
|
+
* malformed-legacy-artifact warnings. Best-effort — a failure leaves the
|
|
181
|
+
* remaining legacy artifacts for a later retry.
|
|
182
|
+
*/
|
|
183
|
+
export async function migrateLegacyPlanuRoot(projectPath) {
|
|
184
|
+
const planuDir = join(projectPath, 'planu');
|
|
185
|
+
const projectJsonPath = join(planuDir, 'project.json');
|
|
186
|
+
const migrated = [];
|
|
187
|
+
const warnings = [];
|
|
188
|
+
try {
|
|
189
|
+
const projectResult = await readJsonFile(projectJsonPath, isJsonObject);
|
|
190
|
+
const project = projectResult.status === 'ok' ? projectResult.value : {};
|
|
191
|
+
const folded = await foldJsonArtifactsIntoProject(projectPath, planuDir, projectJsonPath, project);
|
|
192
|
+
migrated.push(...folded.migrated);
|
|
193
|
+
warnings.push(...folded.warnings);
|
|
194
|
+
for (const fileName of ['context.md', 'session-context.md', 'session.json']) {
|
|
195
|
+
const movedPath = await moveUnderRuntime(planuDir, fileName);
|
|
196
|
+
if (movedPath) {
|
|
197
|
+
migrated.push(movedPath);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
warnings.push(`Legacy migration stopped early: ${error instanceof Error ? error.message : String(error)}`);
|
|
203
|
+
}
|
|
204
|
+
for (const warning of warnings) {
|
|
205
|
+
reportClassifiedDegradation('LEGACY_MIGRATION_ARTIFACT_WARNING', new Error(warning));
|
|
206
|
+
}
|
|
207
|
+
return { migrated, warnings };
|
|
208
|
+
}
|
|
209
|
+
/** Runs `migrateLegacyPlanuRoot` only for an already-initialized project; a no-op otherwise. */
|
|
210
|
+
export async function resolveLegacyMigration(isUpdate, projectPath) {
|
|
211
|
+
return isUpdate ? migrateLegacyPlanuRoot(projectPath) : { migrated: [], warnings: [] };
|
|
212
|
+
}
|
|
213
|
+
//# sourceMappingURL=legacy-root-migration.js.map
|
|
@@ -2,7 +2,7 @@ import type { FilesystemImportDeps, FilesystemImportFailure, PortableIndexReconc
|
|
|
2
2
|
/** Autopilot-first summary of fatal reconciliation failures: what failed, why, and the next action. */
|
|
3
3
|
export declare function describeReconciliationFailures(failures: FilesystemImportFailure[]): string;
|
|
4
4
|
/** Autopilot-first summary of a successful reconciliation, including any legacy skips. */
|
|
5
|
-
export declare function describeReconciliationSuccess(repositoryFilesChanged: string[], skippedLegacy: SkippedLegacyContract[]): string;
|
|
5
|
+
export declare function describeReconciliationSuccess(repositoryFilesChanged: string[], skippedLegacy: SkippedLegacyContract[], strayRepoRemovals?: string[]): string;
|
|
6
6
|
/** Autopilot-first message for a reconciliation outcome, success or fatal-failure. */
|
|
7
7
|
export declare function describeReconciliationOutcome(reconciliation: PortableIndexReconciliationResult, repositoryFilesChanged: string[]): string;
|
|
8
8
|
/** Rebuild the mutable external index from the repository-owned portable contracts. */
|