@planu/cli 5.7.4 → 5.7.5

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 CHANGED
@@ -1,3 +1,13 @@
1
+ ## [5.7.5] - 2026-08-31
2
+
3
+ ### Bug Fixes
4
+ - fix(init-project): address implementation review blockers for SPEC-1716
5
+ - fix(init-project): neutralize stale legacy guidance and fold divergent technical.md residuals
6
+
7
+ ### Chores
8
+ - chore(planu): discard 29 triaged drafts and record SPEC-1716 cycle state
9
+
10
+
1
11
  ## [5.7.4] - 2026-08-31
2
12
 
3
13
  ### Bug Fixes
@@ -1 +1 @@
1
- {"schemaVersion":1,"commit":"bae55ef85d50f56c6406a9a52291bdff9fcca4e3"}
1
+ {"schemaVersion":1,"commit":"858730d3bc7c59966cbff5c34cae03fa636843eb"}
@@ -0,0 +1,26 @@
1
+ {
2
+ "hostSurfaceFiles": [
3
+ "CLAUDE.md",
4
+ "AGENTS.md",
5
+ ".cursorrules",
6
+ ".windsurfrules",
7
+ ".github/copilot-instructions.md",
8
+ ".clinerules",
9
+ ".cursor/rules/planu.mdc",
10
+ ".agents/skills/planu-sdd.md"
11
+ ],
12
+ "hostSurfaceDirectories": [{ "dir": ".claude/rules", "extension": ".md" }],
13
+ "legacyFilenameAllowlist": [
14
+ ".claude/rules/sdd-planu.md",
15
+ ".claude/rules/planu-workflow.md",
16
+ ".cursor/rules/planu.mdc",
17
+ ".agents/skills/planu-sdd.md"
18
+ ],
19
+ "managedByMarkers": ["Auto-generated by Planu", "Auto-generated by `init_project`", "managed-by: planu"],
20
+ "contradictoryPhrases": [
21
+ "spec.md + technical.md + progress.md",
22
+ "technical.md — architecture",
23
+ "keep technical.md and progress.md"
24
+ ],
25
+ "replacementGuidance": "Specs live in `planu/specs/SPEC-XXX-slug/spec.md`. Technical planning, file ownership, and progress live inline in `## Technical`, `## Files`, and `## Progress` sections. Do not create standalone technical.md or progress.md files."
26
+ }
@@ -1,10 +1,47 @@
1
1
  // engine/spec-migrator/fold-technical.ts — SPEC-752
2
2
  // Folds legacy technical.md body into spec.md's ## Technical section, then deletes technical.md.
3
3
  // Uses the same pattern as unified-migration.ts (SPEC-630).
4
- import { readFile } from 'node:fs/promises';
4
+ import { readFile, unlink } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { atomicWriteFile } from '../safety/atomic-write-file.js';
7
7
  import { safeUnlink } from './git-aware-fs.js';
8
+ import { isPlaceholderTechnical } from './placeholder-technical.js';
9
+ const FOLDED_HEADING = '### Folded from legacy technical.md';
10
+ /**
11
+ * Insert `techBody` under a `### Folded from legacy technical.md` subsection,
12
+ * placed right after the existing `## Technical` section (before the next
13
+ * top-level `## ` heading, or at the end of the document when none follows).
14
+ */
15
+ function foldIntoTechnicalSection(specContent, techBody) {
16
+ const headingMatch = /\n## Technical(\n|$)/.exec(specContent);
17
+ const insertion = `\n${FOLDED_HEADING}\n\n${techBody}\n`;
18
+ if (!headingMatch) {
19
+ return `${specContent.trimEnd()}\n\n## Technical\n${insertion}`;
20
+ }
21
+ const afterHeading = headingMatch.index + headingMatch[0].length;
22
+ const nextHeadingMatch = /\n## /.exec(specContent.slice(afterHeading));
23
+ if (!nextHeadingMatch) {
24
+ return `${specContent.trimEnd()}\n${insertion}`;
25
+ }
26
+ const insertAt = afterHeading + nextHeadingMatch.index;
27
+ return specContent.slice(0, insertAt) + insertion + specContent.slice(insertAt);
28
+ }
29
+ async function removeResidualTechnicalMd(techPath, projectPath) {
30
+ if (projectPath) {
31
+ // safeUnlink is already idempotent for a missing file (git rm --ignore-unmatch,
32
+ // unlink swallows ENOENT) and rethrows every other failure.
33
+ await safeUnlink(projectPath, techPath);
34
+ return;
35
+ }
36
+ try {
37
+ await unlink(techPath);
38
+ }
39
+ catch (err) {
40
+ if (err.code !== 'ENOENT') {
41
+ throw err;
42
+ }
43
+ }
44
+ }
8
45
  /**
9
46
  * Fold technical.md content into spec.md under a `## Technical` section.
10
47
  * Idempotent: returns already_unified if spec.md already has `## Technical`.
@@ -20,22 +57,32 @@ export async function foldTechnicalIntoSpec(specDir, projectPath) {
20
57
  const specContent = await readFile(specPath, 'utf-8');
21
58
  // Idempotency check: already has ## Technical section.
22
59
  // SPEC-1008: also delete any residual technical.md so the spec dir ends
23
- // up in the canonical single-file layout. Previously the early-return
24
- // left the legacy file behind forever.
60
+ // up in the canonical single-file layout.
61
+ // SPEC-1716: before deleting, read the residual divergent content is
62
+ // folded under a "Folded from legacy technical.md" subsection instead of
63
+ // silently dropped.
25
64
  if (/\n## Technical(\n|$)/.test(specContent)) {
65
+ let residualContent;
26
66
  try {
27
- if (projectPath) {
28
- await safeUnlink(projectPath, techPath);
29
- }
30
- else {
31
- const { unlink } = await import('node:fs/promises');
32
- await unlink(techPath);
33
- }
67
+ residualContent = await readFile(techPath, 'utf-8');
34
68
  }
35
69
  catch {
36
- /* technical.md already absent nothing to clean up */
70
+ return { ok: true, reason: 'already_unified' };
37
71
  }
38
- return { ok: true, reason: 'already_unified' };
72
+ const residualBody = residualContent.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
73
+ const alreadyContained = residualBody.length === 0 ||
74
+ specContent.includes(residualBody) ||
75
+ isPlaceholderTechnical(residualBody);
76
+ if (alreadyContained) {
77
+ await removeResidualTechnicalMd(techPath, projectPath);
78
+ return { ok: true, reason: 'already_unified' };
79
+ }
80
+ const unified = foldIntoTechnicalSection(specContent, residualBody);
81
+ const before = Buffer.byteLength(specContent, 'utf-8');
82
+ const after = Buffer.byteLength(unified, 'utf-8');
83
+ await atomicWriteFile(specPath, unified);
84
+ await removeResidualTechnicalMd(techPath, projectPath);
85
+ return { ok: true, reason: 'folded_residual', byteDelta: after - before };
39
86
  }
40
87
  // Try reading technical.md
41
88
  let techContent;
@@ -51,13 +98,7 @@ export async function foldTechnicalIntoSpec(specDir, projectPath) {
51
98
  const before = Buffer.byteLength(specContent, 'utf-8');
52
99
  const after = Buffer.byteLength(unified, 'utf-8');
53
100
  await atomicWriteFile(specPath, unified);
54
- if (projectPath) {
55
- await safeUnlink(projectPath, techPath);
56
- }
57
- else {
58
- const { unlink } = await import('node:fs/promises');
59
- await unlink(techPath);
60
- }
101
+ await removeResidualTechnicalMd(techPath, projectPath);
61
102
  return { ok: true, reason: 'ok', byteDelta: after - before };
62
103
  }
63
104
  catch (err) {
@@ -0,0 +1,2 @@
1
+ export declare function isPlaceholderTechnical(content: string): boolean;
2
+ //# sourceMappingURL=placeholder-technical.d.ts.map
@@ -0,0 +1,18 @@
1
+ // engine/spec-migrator/placeholder-technical.ts — SPEC-1716
2
+ // Shared placeholder-stub detector for technical.md bodies. Lives in engine/ so
3
+ // both engine (fold-technical.ts) and tools (heal-spec-docs.ts) can import it —
4
+ // engine/ must never import from tools/.
5
+ export function isPlaceholderTechnical(content) {
6
+ if (content.includes('(pending)')) {
7
+ return true;
8
+ }
9
+ if (/--[a-z]/.test(content)) {
10
+ return true;
11
+ }
12
+ if (content.includes('(to be determined)') &&
13
+ !/\.(ts|tsx|js|jsx|py|rb|go|java|cs)/.test(content)) {
14
+ return true;
15
+ }
16
+ return false;
17
+ }
18
+ //# sourceMappingURL=placeholder-technical.js.map
@@ -13,6 +13,7 @@ import { replaceSectionInSpec } from '../engine/spec-format/replace-section.js';
13
13
  import { executionRemove } from '../engine/execution/deadline-io.js';
14
14
  import { buildMigrationDirective, scanLegacyArtifacts, } from '../engine/spec-migrator/legacy-classifier.js';
15
15
  import { escapeRegex } from '../core/shared/strings.js';
16
+ import { isPlaceholderTechnical } from '../engine/spec-migrator/placeholder-technical.js';
16
17
  const SPANISH_INDICATORS = [
17
18
  'de',
18
19
  'del',
@@ -36,19 +37,6 @@ const SPANISH_INDICATORS = [
36
37
  // ---------------------------------------------------------------------------
37
38
  // Placeholder detection
38
39
  // ---------------------------------------------------------------------------
39
- function isPlaceholderTechnical(content) {
40
- if (content.includes('(pending)')) {
41
- return true;
42
- }
43
- if (/--[a-z]/.test(content)) {
44
- return true;
45
- }
46
- if (content.includes('(to be determined)') &&
47
- !/\.(ts|tsx|js|jsx|py|rb|go|java|cs)/.test(content)) {
48
- return true;
49
- }
50
- return false;
51
- }
52
40
  function hasInlineTechnicalFilesPlaceholder(content) {
53
41
  return /^##\s+Technical\b[\s\S]*?^##\s+Files\b[\s\S]*?(?:\(pending\)|\(to be determined\)|\bTBD\b|--[a-z])/im.test(content);
54
42
  }
@@ -21,8 +21,9 @@ import { regeneratePages } from '../../engine/doc-generator/portal/index.js';
21
21
  import { detectStackPatterns } from './stack-detector.js';
22
22
  import { buildProjectConfig } from './config-builder.js';
23
23
  import { runSpecMigrations, listProjectSpecs } from './migration-runner.js';
24
- import { reconcilePortableSpecIndex, describeReconciliationOutcome, } from './portable-index-reconciler.js';
24
+ import { reconcilePortableSpecIndex, buildAlreadyInitializedResult, buildAuthorizedMigrationResult, } from './portable-index-reconciler.js';
25
25
  import { refreshCoreHostAssets } from './host-assets-writer.js';
26
+ import { scanLegacyGuidance, summarizeLegacyGuidanceScan } from './legacy-guidance-scanner.js';
26
27
  import { runScaffoldWriter } from './scaffold-writer.js';
27
28
  import { readAutoInstallFlag, orchestrateSkillInstalls, runHealthCheckWithBaseline, runConventionScanSafe, fireTelemetry, } from './lifecycle-helpers.js';
28
29
  import { injectProactiveRules } from '../../engine/claude-md-injector/index.js';
@@ -223,28 +224,12 @@ export async function handleInitProject(params, server) {
223
224
  const authorizedMigrations = params.authorizedMigrations ?? [];
224
225
  if (isUpdate && authorizedMigrations.length === 0) {
225
226
  const reconciliation = await reconcilePortableSpecIndex(projectPath, projectId);
226
- const repositoryFilesChanged = reconciliation.failures.length === 0 ? await refreshCoreHostAssets(projectPath) : [];
227
- return {
228
- content: [
229
- {
230
- type: 'text',
231
- text: describeReconciliationOutcome(reconciliation, repositoryFilesChanged),
232
- },
233
- ],
234
- structuredContent: {
235
- projectId,
236
- ...(reconciliation.failures.length === 0 ? { projectPath } : {}),
237
- isUpdate: true,
238
- repositoryFilesChanged,
239
- authorizedMigrations: [],
240
- importedSpecIds: reconciliation.importedSpecIds,
241
- failures: reconciliation.failures,
242
- skippedLegacy: reconciliation.skippedLegacy,
243
- strayRepoRemovals: reconciliation.strayRepoRemovals,
244
- migratedLegacyPaths: legacyMigration.migrated,
245
- },
246
- ...(reconciliation.failures.length > 0 ? { isError: true } : {}),
247
- };
227
+ const canRefresh = reconciliation.failures.length === 0;
228
+ const legacyGuidanceScan = canRefresh
229
+ ? await scanLegacyGuidance(projectPath)
230
+ : { healed: [], warnings: [] };
231
+ const refreshedAssets = canRefresh ? await refreshCoreHostAssets(projectPath) : [];
232
+ return buildAlreadyInitializedResult(projectId, reconciliation, legacyMigration.migrated, refreshedAssets, legacyGuidanceScan, projectPath);
248
233
  }
249
234
  if (isUpdate) {
250
235
  if (existing === null) {
@@ -261,26 +246,8 @@ export async function handleInitProject(params, server) {
261
246
  const migrationResult = await runSpecMigrations(projectPath, projectId, existing, {
262
247
  repositoryMigration: 'planu-spec-format-v1',
263
248
  });
264
- return {
265
- content: [
266
- {
267
- type: 'text',
268
- text: migrationResult.criticalMigrationFailures.length === 0
269
- ? 'Authorized planu-spec-format-v1 migration completed.'
270
- : `Authorized migration completed with ${String(migrationResult.criticalMigrationFailures.length)} blocking issue(s).`,
271
- },
272
- ],
273
- structuredContent: {
274
- projectId,
275
- projectPath,
276
- isUpdate: true,
277
- authorizedMigrations,
278
- repositoryFilesChanged: migrationResult.changedPaths ?? [],
279
- migrationReportPath: migrationResult.migrationReportPath,
280
- criticalMigrationFailures: migrationResult.criticalMigrationFailures,
281
- },
282
- ...(migrationResult.criticalMigrationFailures.length > 0 ? { isError: true } : {}),
283
- };
249
+ const legacyGuidanceScan = await scanLegacyGuidance(projectPath).catch(() => ({ healed: [], warnings: [] }));
250
+ return buildAuthorizedMigrationResult(projectId, projectPath, authorizedMigrations, migrationResult, legacyGuidanceScan);
284
251
  }
285
252
  // Get global config for defaults
286
253
  const globalConfig = await globalStore.getGlobalConfig();
@@ -439,6 +406,7 @@ export async function handleInitProject(params, server) {
439
406
  // Write scaffold files: rules, git setup, constitution, CLAUDE.md, lint, architecture rules
440
407
  const scaffoldResult = await runScaffoldWriter(projectPath, projectId, knowledge, recommendedSkills, params.permissionsMode, params.pluginsMode, autoInstallFromConfig);
441
408
  const { platform, generatedRules, rulesWritten, additionalFilesWritten, hooksInstalled, gitFlowType, gitignoreUpdated, gitRepoDetected, layoutOffenders, constitutionInitialized, claudeMdUpdated, eslintCreated, prettierCreated, lintSuggestions, architectureRulesWritten, architectureRulesSkipped, planuWorkflowInjected, planuHooksConfigured, planuRulesWritten, gitAutoStageInjected, } = scaffoldResult;
409
+ const legacyGuidanceScan = await scanLegacyGuidance(projectPath).catch(() => ({ healed: [], warnings: [] }));
442
410
  // SPEC-444: Inject proactive behavior rules into project CLAUDE.md
443
411
  const proactiveRulesInjected = await injectProactiveRules(join(projectPath, 'CLAUDE.md'), '1.22.0')
444
412
  .then(() => true)
@@ -561,6 +529,7 @@ export async function handleInitProject(params, server) {
561
529
  if (skillsAutoInstalled.length > 0) {
562
530
  collector.pushOk('skills-installed', `Auto-installed skills: ${skillsAutoInstalled.join(', ')}`);
563
531
  }
532
+ summarizeLegacyGuidanceScan(collector, legacyGuidanceScan);
564
533
  const toolResult = buildInitProjectResult({
565
534
  projectId,
566
535
  projectPath,
@@ -620,8 +589,12 @@ export async function handleInitProject(params, server) {
620
589
  branchInfo,
621
590
  });
622
591
  if (toolResult.structuredContent) {
623
- toolResult.structuredContent.repositoryFilesChanged = migrationChangedPaths;
624
- toolResult.structuredContent.authorizedMigrations = authorizedMigrations;
592
+ Object.assign(toolResult.structuredContent, {
593
+ repositoryFilesChanged: [...migrationChangedPaths, ...legacyGuidanceScan.healed],
594
+ authorizedMigrations,
595
+ legacyGuidanceHealed: legacyGuidanceScan.healed,
596
+ legacyGuidanceWarnings: legacyGuidanceScan.warnings,
597
+ });
625
598
  }
626
599
  // Inject autopilotSummary into structuredContent
627
600
  if (collector.hasEntries() && toolResult.structuredContent) {
@@ -0,0 +1,12 @@
1
+ import type { AutopilotSummaryCollector } from '../../engine/autopilot/summary-collector.js';
2
+ import type { LegacyGuidanceScanResult } from '../../types/index.js';
3
+ /**
4
+ * Scan the bounded set of Planu-installed host guidance surfaces for stale
5
+ * directives that contradict the current spec.md-only contract, and
6
+ * neutralize what can be neutralized fail-closed. Never touches files
7
+ * outside the inventoried scan surface (no repo-wide walk).
8
+ */
9
+ export declare function scanLegacyGuidance(projectPath: string): Promise<LegacyGuidanceScanResult>;
10
+ /** Records a legacy-guidance scan outcome onto an init_project autopilot summary. */
11
+ export declare function summarizeLegacyGuidanceScan(collector: AutopilotSummaryCollector, scan: LegacyGuidanceScanResult): void;
12
+ //# sourceMappingURL=legacy-guidance-scanner.d.ts.map
@@ -0,0 +1,176 @@
1
+ // tools/init-project/legacy-guidance-scanner.ts — SPEC-1716
2
+ // Neutralizes stale Planu-installed guidance that instructs agents to create
3
+ // legacy standalone technical.md/progress.md files. Fail-closed by tiers:
4
+ // Tier 1 — delimited Planu block markers: rewrite only the block content.
5
+ // Tier 2 — composite fingerprint (allowlisted legacy filename + managed-by
6
+ // header): replace the whole file, backing up the prior content.
7
+ // Tier 3 — contradictory phrase alone: warning only, zero mutation.
8
+ // Malformed markers (opening without closing) also fail closed: warning only.
9
+ import { readFile, readdir } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { atomicWriteFile } from '../../engine/safety/atomic-write-file.js';
12
+ import { resolveContainedProjectFile } from '../../engine/safety/contained-project-file.js';
13
+ import legacyGuidanceRegistry from '../../config/registries/legacy-guidance.json' with { type: 'json' };
14
+ const REGISTRY = legacyGuidanceRegistry;
15
+ async function resolveScanSurface(projectPath) {
16
+ const files = [...REGISTRY.hostSurfaceFiles];
17
+ for (const { dir, extension } of REGISTRY.hostSurfaceDirectories) {
18
+ let entries;
19
+ try {
20
+ entries = await readdir(join(projectPath, dir), { withFileTypes: true });
21
+ }
22
+ catch {
23
+ continue;
24
+ }
25
+ for (const entry of entries) {
26
+ if (entry.isFile() && entry.name.endsWith(extension)) {
27
+ files.push(`${dir}/${entry.name}`);
28
+ }
29
+ }
30
+ }
31
+ return files;
32
+ }
33
+ function containsContradictoryPhrase(text) {
34
+ return REGISTRY.contradictoryPhrases.find((phrase) => text.includes(phrase));
35
+ }
36
+ const OPEN_MARKER_RE = /<!--\s*(planu[\w:-]*)\s*-->/g;
37
+ /**
38
+ * Locate delimited `<!-- planu... --> ... <!-- /planu... -->` (or repeated-tag,
39
+ * as emitted by claude-md-generator.ts wrap()) blocks. Returns `null` when an
40
+ * opening marker has no matching close — the malformed-marker fail-closed case.
41
+ *
42
+ * Uses its own cursor (a fresh RegExp with `re.lastIndex` advanced past each
43
+ * close marker) instead of `matchAll` over the whole string — a repeated-tag
44
+ * close is itself a valid open-marker match, so scanning the string without
45
+ * skipping past it would misread that close as the next block's opener.
46
+ */
47
+ function findPlanuBlocks(content) {
48
+ const blocks = [];
49
+ const re = new RegExp(OPEN_MARKER_RE.source, 'g');
50
+ let match;
51
+ while ((match = re.exec(content)) !== null) {
52
+ const id = match[1];
53
+ if (id === undefined) {
54
+ continue;
55
+ }
56
+ const openEnd = match.index + match[0].length;
57
+ const closeTag = `<!-- /${id} -->`;
58
+ const repeatTag = match[0];
59
+ const closeTagStart = content.indexOf(closeTag, openEnd);
60
+ const repeatTagStart = content.indexOf(repeatTag, openEnd);
61
+ const closeStart = [closeTagStart, repeatTagStart]
62
+ .filter((idx) => idx !== -1)
63
+ .sort((a, b) => a - b)[0];
64
+ if (closeStart === undefined) {
65
+ return null;
66
+ }
67
+ const closeLength = closeStart === closeTagStart ? closeTag.length : repeatTag.length;
68
+ blocks.push({ id, openEnd, closeStart });
69
+ re.lastIndex = closeStart + closeLength;
70
+ }
71
+ return blocks;
72
+ }
73
+ async function scanTier1(absPath, relPath, content) {
74
+ const blocks = findPlanuBlocks(content);
75
+ if (blocks === null) {
76
+ return {
77
+ kind: 'warning',
78
+ file: relPath,
79
+ reason: 'malformed Planu block marker: an opening marker has no matching closing marker',
80
+ };
81
+ }
82
+ const dirty = blocks.some(({ openEnd, closeStart }) => containsContradictoryPhrase(content.slice(openEnd, closeStart)));
83
+ if (!dirty) {
84
+ return { kind: 'clean' };
85
+ }
86
+ let rewritten = '';
87
+ let cursor = 0;
88
+ for (const { openEnd, closeStart } of blocks) {
89
+ const body = content.slice(openEnd, closeStart);
90
+ rewritten += content.slice(cursor, openEnd);
91
+ rewritten += containsContradictoryPhrase(body) ? `\n${REGISTRY.replacementGuidance}\n` : body;
92
+ cursor = closeStart;
93
+ }
94
+ rewritten += content.slice(cursor);
95
+ await atomicWriteFile(absPath, rewritten);
96
+ return { kind: 'healed', file: relPath };
97
+ }
98
+ async function scanTier2Or3(absPath, relPath, content) {
99
+ const phrase = containsContradictoryPhrase(content);
100
+ if (!phrase) {
101
+ return { kind: 'clean' };
102
+ }
103
+ const strongFingerprint = REGISTRY.legacyFilenameAllowlist.includes(relPath) &&
104
+ REGISTRY.managedByMarkers.some((marker) => content.includes(marker));
105
+ if (!strongFingerprint) {
106
+ return {
107
+ kind: 'warning',
108
+ file: relPath,
109
+ reason: `contains contradictory legacy guidance phrase: "${phrase}"`,
110
+ };
111
+ }
112
+ await atomicWriteFile(`${absPath}.planu-backup`, content);
113
+ await atomicWriteFile(absPath, `${REGISTRY.replacementGuidance}\n`);
114
+ return { kind: 'healed', file: relPath };
115
+ }
116
+ async function scanFile(projectPath, relPath) {
117
+ const absPath = join(projectPath, relPath);
118
+ let content;
119
+ try {
120
+ content = await readFile(absPath, 'utf-8');
121
+ }
122
+ catch {
123
+ return { kind: 'clean' };
124
+ }
125
+ // Fail closed if the surface entry (or an ancestor directory, e.g. a
126
+ // symlinked .claude/rules) resolves outside the project root — never let a
127
+ // mutation follow a symlink off the repo.
128
+ try {
129
+ await resolveContainedProjectFile(projectPath, absPath);
130
+ }
131
+ catch {
132
+ return {
133
+ kind: 'warning',
134
+ file: relPath,
135
+ reason: 'path escapes the project directory (symlink containment check failed)',
136
+ };
137
+ }
138
+ const hasMarkers = OPEN_MARKER_RE.test(content);
139
+ OPEN_MARKER_RE.lastIndex = 0;
140
+ return hasMarkers
141
+ ? scanTier1(absPath, relPath, content)
142
+ : scanTier2Or3(absPath, relPath, content);
143
+ }
144
+ /**
145
+ * Scan the bounded set of Planu-installed host guidance surfaces for stale
146
+ * directives that contradict the current spec.md-only contract, and
147
+ * neutralize what can be neutralized fail-closed. Never touches files
148
+ * outside the inventoried scan surface (no repo-wide walk).
149
+ */
150
+ export async function scanLegacyGuidance(projectPath) {
151
+ const files = await resolveScanSurface(projectPath);
152
+ const healed = [];
153
+ const warnings = [];
154
+ for (const relPath of files) {
155
+ const outcome = await scanFile(projectPath, relPath);
156
+ if (outcome.kind === 'healed') {
157
+ healed.push(outcome.file);
158
+ }
159
+ else if (outcome.kind === 'warning') {
160
+ warnings.push({ file: outcome.file, reason: outcome.reason });
161
+ }
162
+ }
163
+ return { healed, warnings };
164
+ }
165
+ /** Records a legacy-guidance scan outcome onto an init_project autopilot summary. */
166
+ export function summarizeLegacyGuidanceScan(collector, scan) {
167
+ if (scan.healed.length > 0) {
168
+ collector.pushOk('legacy-guidance-healed', `Neutralized stale Planu guidance in: ${scan.healed.join(', ')}`);
169
+ }
170
+ if (scan.warnings.length > 0) {
171
+ collector.pushSkipped('legacy-guidance-warnings', `Left untouched (weak fingerprint, needs manual review): ${scan.warnings
172
+ .map((w) => w.file)
173
+ .join(', ')}`);
174
+ }
175
+ }
176
+ //# sourceMappingURL=legacy-guidance-scanner.js.map
@@ -1,10 +1,29 @@
1
- import type { FilesystemImportDeps, FilesystemImportFailure, PortableIndexReconciliationResult, SkippedLegacyContract } from '../../types/index.js';
1
+ import type { FilesystemImportDeps, FilesystemImportFailure, LegacyGuidanceScanResult, PortableIndexReconciliationResult, SkippedLegacyContract } from '../../types/index.js';
2
+ import type { ToolResult } from '../../types/common/primitives.js';
2
3
  /** Autopilot-first summary of fatal reconciliation failures: what failed, why, and the next action. */
3
4
  export declare function describeReconciliationFailures(failures: FilesystemImportFailure[]): string;
4
5
  /** Autopilot-first summary of a successful reconciliation, including any legacy skips. */
5
6
  export declare function describeReconciliationSuccess(repositoryFilesChanged: string[], skippedLegacy: SkippedLegacyContract[], strayRepoRemovals?: string[]): string;
6
7
  /** Autopilot-first message for a reconciliation outcome, success or fatal-failure. */
7
8
  export declare function describeReconciliationOutcome(reconciliation: PortableIndexReconciliationResult, repositoryFilesChanged: string[]): string;
9
+ /**
10
+ * SPEC-1716: Build the already-initialized `init_project` result, folding the
11
+ * legacy-guidance scan's healed paths into the reported repository file changes.
12
+ */
13
+ export declare function buildAlreadyInitializedResult(projectId: string, reconciliation: PortableIndexReconciliationResult, legacyMigratedPaths: readonly string[], refreshedAssets: readonly string[], legacyGuidanceScan: LegacyGuidanceScanResult, projectPath: string): ToolResult;
14
+ /** The subset of `MigrationRunResult` (migration-runner.ts) needed to report an authorized migration. */
15
+ interface AuthorizedMigrationSummary {
16
+ criticalMigrationFailures: unknown[];
17
+ changedPaths?: string[];
18
+ migrationReportPath: string | null;
19
+ }
20
+ /**
21
+ * SPEC-1716: Build the authorized-migration `init_project` result, folding the
22
+ * legacy-guidance scan's healed paths into the reported repository file changes
23
+ * the same way the already-initialized fast path does.
24
+ */
25
+ export declare function buildAuthorizedMigrationResult(projectId: string, projectPath: string, authorizedMigrations: readonly string[], migrationResult: AuthorizedMigrationSummary, legacyGuidanceScan: LegacyGuidanceScanResult): ToolResult;
8
26
  /** Rebuild the mutable external index from the repository-owned portable contracts. */
9
27
  export declare function reconcilePortableSpecIndex(projectPath: string, projectId: string, deps?: FilesystemImportDeps): Promise<PortableIndexReconciliationResult>;
28
+ export {};
10
29
  //# sourceMappingURL=portable-index-reconciler.d.ts.map
@@ -156,6 +156,67 @@ export function describeReconciliationOutcome(reconciliation, repositoryFilesCha
156
156
  ? describeReconciliationSuccess(repositoryFilesChanged, reconciliation.skippedLegacy, reconciliation.strayRepoRemovals)
157
157
  : describeReconciliationFailures(reconciliation.failures);
158
158
  }
159
+ /**
160
+ * SPEC-1716: Build the already-initialized `init_project` result, folding the
161
+ * legacy-guidance scan's healed paths into the reported repository file changes.
162
+ */
163
+ export function buildAlreadyInitializedResult(projectId, reconciliation, legacyMigratedPaths, refreshedAssets, legacyGuidanceScan, projectPath) {
164
+ const repositoryFilesChanged = [...refreshedAssets, ...legacyGuidanceScan.healed];
165
+ const baseText = describeReconciliationOutcome(reconciliation, [...refreshedAssets]);
166
+ const healedGuidanceSuffix = legacyGuidanceScan.healed.length === 0
167
+ ? ''
168
+ : ` Healed stale legacy guidance in: ${legacyGuidanceScan.healed.join(', ')}.`;
169
+ return {
170
+ content: [{ type: 'text', text: baseText + healedGuidanceSuffix }],
171
+ structuredContent: {
172
+ projectId,
173
+ ...(reconciliation.failures.length === 0 ? { projectPath } : {}),
174
+ isUpdate: true,
175
+ repositoryFilesChanged,
176
+ authorizedMigrations: [],
177
+ importedSpecIds: reconciliation.importedSpecIds,
178
+ failures: reconciliation.failures,
179
+ skippedLegacy: reconciliation.skippedLegacy,
180
+ strayRepoRemovals: reconciliation.strayRepoRemovals,
181
+ migratedLegacyPaths: legacyMigratedPaths,
182
+ legacyGuidanceHealed: legacyGuidanceScan.healed,
183
+ legacyGuidanceWarnings: legacyGuidanceScan.warnings,
184
+ },
185
+ ...(reconciliation.failures.length > 0 ? { isError: true } : {}),
186
+ };
187
+ }
188
+ /**
189
+ * SPEC-1716: Build the authorized-migration `init_project` result, folding the
190
+ * legacy-guidance scan's healed paths into the reported repository file changes
191
+ * the same way the already-initialized fast path does.
192
+ */
193
+ export function buildAuthorizedMigrationResult(projectId, projectPath, authorizedMigrations, migrationResult, legacyGuidanceScan) {
194
+ const hasCriticalFailures = migrationResult.criticalMigrationFailures.length > 0;
195
+ const healedGuidanceSuffix = legacyGuidanceScan.healed.length === 0
196
+ ? ''
197
+ : ` Healed stale legacy guidance in: ${legacyGuidanceScan.healed.join(', ')}.`;
198
+ const baseText = hasCriticalFailures
199
+ ? `Authorized migration completed with ${String(migrationResult.criticalMigrationFailures.length)} blocking issue(s).`
200
+ : 'Authorized planu-spec-format-v1 migration completed.';
201
+ return {
202
+ content: [{ type: 'text', text: baseText + healedGuidanceSuffix }],
203
+ structuredContent: {
204
+ projectId,
205
+ projectPath,
206
+ isUpdate: true,
207
+ authorizedMigrations,
208
+ repositoryFilesChanged: [
209
+ ...(migrationResult.changedPaths ?? []),
210
+ ...legacyGuidanceScan.healed,
211
+ ],
212
+ migrationReportPath: migrationResult.migrationReportPath,
213
+ criticalMigrationFailures: migrationResult.criticalMigrationFailures,
214
+ legacyGuidanceHealed: legacyGuidanceScan.healed,
215
+ legacyGuidanceWarnings: legacyGuidanceScan.warnings,
216
+ },
217
+ ...(hasCriticalFailures ? { isError: true } : {}),
218
+ };
219
+ }
159
220
  /** Rebuild the mutable external index from the repository-owned portable contracts. */
160
221
  export async function reconcilePortableSpecIndex(projectPath, projectId, deps = {
161
222
  listSpecs: specStore.listSpecs,
@@ -278,6 +278,7 @@ export * from './reviewer-tokens.js';
278
278
  export * from './coach.js';
279
279
  export * from './tdd-strict.js';
280
280
  export * from './ssr-migration.js';
281
+ export * from './legacy-guidance.js';
281
282
  export * from './workspace-overview.js';
282
283
  export * from './mcp-config.js';
283
284
  export * from './security/index.js';
@@ -275,6 +275,7 @@ export * from './reviewer-tokens.js';
275
275
  export * from './coach.js';
276
276
  export * from './tdd-strict.js';
277
277
  export * from './ssr-migration.js';
278
+ export * from './legacy-guidance.js';
278
279
  // SPEC-753: workspace_overview + reconcile_status_json types
279
280
  export * from './workspace-overview.js';
280
281
  // SPEC-756: MCP config auto-update types
@@ -0,0 +1,20 @@
1
+ export interface LegacyGuidanceWarning {
2
+ file: string;
3
+ reason: string;
4
+ }
5
+ export interface LegacyGuidanceScanResult {
6
+ healed: string[];
7
+ warnings: LegacyGuidanceWarning[];
8
+ }
9
+ export interface LegacyGuidanceRegistry {
10
+ hostSurfaceFiles: string[];
11
+ hostSurfaceDirectories: {
12
+ dir: string;
13
+ extension: string;
14
+ }[];
15
+ legacyFilenameAllowlist: string[];
16
+ managedByMarkers: string[];
17
+ contradictoryPhrases: string[];
18
+ replacementGuidance: string;
19
+ }
20
+ //# sourceMappingURL=legacy-guidance.d.ts.map
@@ -0,0 +1,3 @@
1
+ // types/legacy-guidance.ts — SPEC-1716: stale Planu-installed guidance scanner
2
+ export {};
3
+ //# sourceMappingURL=legacy-guidance.js.map
@@ -1,7 +1,7 @@
1
1
  /** Result of folding technical.md into spec.md ## Technical section. */
2
2
  export interface FoldTechnicalResult {
3
3
  ok: boolean;
4
- reason: 'already_unified' | 'no_technical_md' | 'ok' | 'error';
4
+ reason: 'already_unified' | 'no_technical_md' | 'ok' | 'error' | 'folded_residual';
5
5
  byteDelta?: number;
6
6
  error?: string;
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.7.4",
3
+ "version": "5.7.5",
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.7.4",
5
+ "version": "5.7.5",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",