@planu/cli 4.3.20 → 4.3.22

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 (33) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/engine/spec-format/lean-technical-generator.js +5 -7
  3. package/dist/engine/spec-format/unified-spec-builder.js +1 -1
  4. package/dist/engine/spec-generator/fallback-generator.js +4 -30
  5. package/dist/engine/test-generators/contract-test-generator.d.ts +3 -2
  6. package/dist/engine/test-generators/contract-test-generator.js +8 -43
  7. package/dist/engine/test-generators/mock-generator.d.ts +1 -1
  8. package/dist/engine/test-generators/mock-generator.js +13 -31
  9. package/dist/tools/bump-spec-version.js +2 -1
  10. package/dist/tools/create-spec.js +30 -10
  11. package/dist/tools/generate-batch-script.js +2 -1
  12. package/dist/tools/generate-proposal.js +2 -1
  13. package/dist/tools/generate-tests/adapters/contract-testing-adapter.js +9 -3
  14. package/dist/tools/heal-spec-docs.js +96 -40
  15. package/dist/tools/migrate-tech/core-handlers.js +24 -72
  16. package/dist/tools/register-agent-squad-tools.js +2 -1
  17. package/dist/tools/register-platform-tools/design-stack-tools.js +10 -10
  18. package/dist/tools/register-platform-tools/lifecycle-infra-tools.js +8 -8
  19. package/dist/tools/register-spec-tools/analysis-tools.js +7 -7
  20. package/dist/tools/register-spec-tools/core-spec-tools.js +6 -6
  21. package/dist/tools/render-spec-for-provider.js +2 -1
  22. package/dist/tools/schemas/github.js +4 -3
  23. package/dist/tools/schemas/index.d.ts +1 -0
  24. package/dist/tools/schemas/index.js +1 -0
  25. package/dist/tools/schemas/spec-id.d.ts +7 -0
  26. package/dist/tools/schemas/spec-id.js +17 -0
  27. package/dist/tools/spec-prompt-handler.js +2 -1
  28. package/dist/tools/tool-registry/core-tools.js +3 -2
  29. package/dist/tools/tool-registry/group-infra.js +3 -3
  30. package/dist/tools/tool-registry/group-quality-compliance.js +11 -14
  31. package/package.json +9 -9
  32. package/planu-native.json +1 -1
  33. package/planu-plugin.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [4.3.22] - 2026-06-02
2
+
3
+ ### Bug Fixes
4
+ - fix: stop fabricating spec and test artifacts
5
+
6
+
7
+ ## [4.3.21] - 2026-06-02
8
+
9
+ ### Bug Fixes
10
+ - fix: enforce canonical spec ids and harden fallback specs
11
+
12
+
1
13
  ## [4.3.20] - 2026-05-27
2
14
 
3
15
  ### Bug Fixes
@@ -3,7 +3,11 @@
3
3
  /** Generate lean ## Technical content: YAML-like metadata + files section only. */
4
4
  export function generateLeanTechnicalContent(input) {
5
5
  const { specId, filesToCreate = [], filesToModify = [], filesToTest = [] } = input;
6
- const lines = ['---', `spec: ${specId}`, '---', '', '## Files', ''];
6
+ const lines = ['---', `spec: ${specId}`, '---'];
7
+ if (filesToCreate.length === 0 && filesToModify.length === 0 && filesToTest.length === 0) {
8
+ return lines.join('\n');
9
+ }
10
+ lines.push('', '## Files', '');
7
11
  if (filesToCreate.length > 0) {
8
12
  lines.push('### Create', '');
9
13
  for (const f of filesToCreate) {
@@ -25,12 +29,6 @@ export function generateLeanTechnicalContent(input) {
25
29
  }
26
30
  lines.push('');
27
31
  }
28
- // If no files specified, add placeholder
29
- if (filesToCreate.length === 0 && filesToModify.length === 0 && filesToTest.length === 0) {
30
- lines.push('### Create', '', '- (to be determined)', '');
31
- lines.push('### Modify', '', '- (to be determined)', '');
32
- lines.push('### Test', '', '- (to be determined)', '');
33
- }
34
32
  return lines.join('\n');
35
33
  }
36
34
  //# sourceMappingURL=lean-technical-generator.js.map
@@ -13,7 +13,7 @@
13
13
  * inject sections the user did not author themselves.
14
14
  */
15
15
  export function buildUnifiedSpecContent(leanSpecBody, leanTechnicalBody) {
16
- const technicalBodyRaw = leanTechnicalBody.replace(/^---\n[\s\S]*?\n---\n/, '').trim();
16
+ const technicalBodyRaw = leanTechnicalBody.replace(/^---\n[\s\S]*?\n---(?:\n|$)/, '').trim();
17
17
  if (technicalBodyRaw.length === 0) {
18
18
  return leanSpecBody.endsWith('\n') ? leanSpecBody : `${leanSpecBody}\n`;
19
19
  }
@@ -2,17 +2,14 @@ export class FallbackGenerator {
2
2
  generate(request) {
3
3
  const sourceDescription = normalizeSourceDescription(request.description, request.title);
4
4
  const contextLine = buildContextLine(request);
5
- const technicalSection = buildTechnicalSection(contextLine);
5
+ const technicalSection = '';
6
6
  const specBody = [
7
7
  '## Problem',
8
8
  sourceDescription,
9
9
  '',
10
10
  '## Solution',
11
- 'Use the preserved source description above as the authoritative requirements body. ' +
12
- 'Do not infer implementation ownership beyond details explicitly provided in the source. ' +
13
- contextLine,
14
- '',
15
- technicalSection,
11
+ 'Use the preserved source description above as the authoritative requirements body.',
12
+ contextLine,
16
13
  ].join('\n');
17
14
  return Promise.resolve({
18
15
  specBody,
@@ -20,7 +17,7 @@ export class FallbackGenerator {
20
17
  generatedWithModel: 'deterministic-fallback',
21
18
  generatedAt: new Date().toISOString(),
22
19
  qualityWarnings: [
23
- 'Fallback generator used. Source description was preserved; no implementation files, type signatures, or ownership were inferred.',
20
+ 'Fallback generator used. Source description was preserved; technical ownership must come from explicit source paths or local project analysis.',
24
21
  ],
25
22
  fallbackReason: 'No external model generator is configured for create_spec.',
26
23
  });
@@ -44,27 +41,4 @@ function buildContextLine(request) {
44
41
  }
45
42
  return `Use the existing ${parts.join(' / ')} project conventions.`;
46
43
  }
47
- function buildTechnicalSection(contextLine) {
48
- return [
49
- '## Technical',
50
- '',
51
- '### Implementation Notes',
52
- `- ${contextLine}`,
53
- '- Preserve the source requirements as the contract until a reviewer or agent adds exact implementation ownership.',
54
- '',
55
- '## Files',
56
- '',
57
- '### Create',
58
- '- _Not specified in source description._',
59
- '### Modify',
60
- '- _Not specified in source description._',
61
- '### Test',
62
- '- _Not specified in source description._',
63
- '',
64
- '## Verification',
65
- '- pnpm typecheck',
66
- '- pnpm lint',
67
- '- pnpm test',
68
- ].join('\n');
69
- }
70
44
  //# sourceMappingURL=fallback-generator.js.map
@@ -5,9 +5,10 @@ import type { ContractFormat, ContractSpec, ContractSpecEndpoint, ProjectKnowled
5
5
  */
6
6
  export declare function detectContractFormat(knowledge: ProjectKnowledge): ContractFormat;
7
7
  /**
8
- * Build a sample set of endpoints from a spec title (for scaffolding purposes).
8
+ * Legacy helper retained for API compatibility. Contract generation must not
9
+ * infer endpoints from a title; callers must pass observed endpoints instead.
9
10
  */
10
- export declare function buildSampleEndpoints(specTitle: string): ContractSpecEndpoint[];
11
+ export declare function buildSampleEndpoints(_specTitle: string): ContractSpecEndpoint[];
11
12
  /**
12
13
  * Generate a contract spec for the given spec title and knowledge.
13
14
  * Format is auto-detected from project stack.
@@ -21,49 +21,11 @@ export function detectContractFormat(knowledge) {
21
21
  }
22
22
  // === Contract generation functions ===
23
23
  /**
24
- * Build a sample set of endpoints from a spec title (for scaffolding purposes).
24
+ * Legacy helper retained for API compatibility. Contract generation must not
25
+ * infer endpoints from a title; callers must pass observed endpoints instead.
25
26
  */
26
- export function buildSampleEndpoints(specTitle) {
27
- const resource = specTitle
28
- .toLowerCase()
29
- .replace(/[^a-z0-9]/g, '-')
30
- .replace(/-+/g, '-');
31
- return [
32
- {
33
- path: `/api/${resource}`,
34
- method: 'GET',
35
- requestSchema: {},
36
- responseSchema: {
37
- type: 'array',
38
- items: { type: 'object', properties: { id: { type: 'string' } } },
39
- },
40
- statusCodes: [200, 401, 500],
41
- },
42
- {
43
- path: `/api/${resource}`,
44
- method: 'POST',
45
- requestSchema: {
46
- type: 'object',
47
- required: ['name'],
48
- properties: { name: { type: 'string' } },
49
- },
50
- responseSchema: {
51
- type: 'object',
52
- properties: { id: { type: 'string' }, name: { type: 'string' } },
53
- },
54
- statusCodes: [201, 400, 401, 422, 500],
55
- },
56
- {
57
- path: `/api/${resource}/{id}`,
58
- method: 'GET',
59
- requestSchema: {},
60
- responseSchema: {
61
- type: 'object',
62
- properties: { id: { type: 'string' }, name: { type: 'string' } },
63
- },
64
- statusCodes: [200, 401, 404, 500],
65
- },
66
- ];
27
+ export function buildSampleEndpoints(_specTitle) {
28
+ return [];
67
29
  }
68
30
  // === OpenAPI contract generation ===
69
31
  function buildOpenApiYaml(specTitle, endpoints) {
@@ -244,7 +206,10 @@ export const ${name}Router = router({
244
206
  */
245
207
  export function generateContractSpec(specTitle, knowledge) {
246
208
  const format = detectContractFormat(knowledge);
247
- const endpoints = buildSampleEndpoints(specTitle);
209
+ const endpoints = [];
210
+ if (endpoints.length === 0) {
211
+ return { format, content: '', endpoints };
212
+ }
248
213
  let content;
249
214
  switch (format) {
250
215
  case 'pact':
@@ -2,7 +2,7 @@ import type { ApiMockDefinition, ContractSpecEndpoint, MockServerTool } from '..
2
2
  /**
3
3
  * Generate mock definitions from a list of endpoints.
4
4
  */
5
- export declare function generateMockDefinitions(endpoints: ContractSpecEndpoint[], specTitle: string): ApiMockDefinition[];
5
+ export declare function generateMockDefinitions(endpoints: ContractSpecEndpoint[], _specTitle: string): ApiMockDefinition[];
6
6
  /**
7
7
  * Generate mock server content for the given tool.
8
8
  */
@@ -1,23 +1,5 @@
1
1
  // engine/test-generators/mock-generator.ts
2
2
  // SPEC-018: Mock generation for API endpoints (realistic data, delays, error responses)
3
- // === Realistic sample data generators ===
4
- function generateSampleId() {
5
- return '550e8400-e29b-41d4-a716-446655440000';
6
- }
7
- function generateSampleData(resource) {
8
- return {
9
- id: generateSampleId(),
10
- name: `Sample ${resource}`,
11
- description: `Auto-generated sample for ${resource} (Planu SPEC-018)`,
12
- status: 'active',
13
- createdAt: '2025-01-15T10:30:00.000Z',
14
- updatedAt: '2025-01-15T10:30:00.000Z',
15
- email: `user@example.com`,
16
- count: 42,
17
- tags: ['sample', 'generated'],
18
- metadata: { source: 'planu', version: '1.0' },
19
- };
20
- }
21
3
  const ERROR_RESPONSES = [
22
4
  {
23
5
  status: 400,
@@ -56,16 +38,11 @@ function getErrorResponsesForEndpoint(statusCodes) {
56
38
  /**
57
39
  * Build an ApiMockDefinition from a ContractSpecEndpoint.
58
40
  */
59
- function buildMockFromEndpoint(endpoint, resource) {
60
- const sampleData = endpoint.method === 'GET' && endpoint.path.endsWith('}')
61
- ? generateSampleData(resource)
62
- : endpoint.method === 'GET'
63
- ? { items: [generateSampleData(resource)], total: 1, page: 1, pageSize: 20 }
64
- : generateSampleData(resource);
41
+ function buildMockFromEndpoint(endpoint) {
65
42
  return {
66
43
  endpoint: endpoint.path,
67
44
  method: endpoint.method,
68
- sampleData,
45
+ sampleData: endpoint.responseSchema ?? {},
69
46
  delayMs: 250,
70
47
  errorResponses: getErrorResponsesForEndpoint(endpoint.statusCodes),
71
48
  };
@@ -73,15 +50,14 @@ function buildMockFromEndpoint(endpoint, resource) {
73
50
  /**
74
51
  * Generate mock definitions from a list of endpoints.
75
52
  */
76
- export function generateMockDefinitions(endpoints, specTitle) {
77
- const resource = specTitle
78
- .toLowerCase()
79
- .replace(/[^a-z0-9]/g, '-')
80
- .replace(/-+/g, '-');
81
- return endpoints.map((ep) => buildMockFromEndpoint(ep, resource));
53
+ export function generateMockDefinitions(endpoints, _specTitle) {
54
+ return endpoints.map((ep) => buildMockFromEndpoint(ep));
82
55
  }
83
56
  // === MSW (Mock Service Worker) content generation ===
84
57
  function buildMswMockContent(mocks, specTitle) {
58
+ if (mocks.length === 0) {
59
+ return '';
60
+ }
85
61
  const handlerLines = [];
86
62
  for (const mock of mocks) {
87
63
  const method = mock.method.toLowerCase();
@@ -109,6 +85,9 @@ ${mocks
109
85
  }
110
86
  // === json-server content generation ===
111
87
  function buildJsonServerContent(mocks, specTitle) {
88
+ if (mocks.length === 0) {
89
+ return '';
90
+ }
112
91
  const db = {};
113
92
  const resource = specTitle
114
93
  .toLowerCase()
@@ -133,6 +112,9 @@ ${JSON.stringify(db, null, 2)}
133
112
  }
134
113
  // === WireMock content generation ===
135
114
  function buildWiremockContent(mocks, _specTitle) {
115
+ if (mocks.length === 0) {
116
+ return '';
117
+ }
136
118
  const stubs = mocks.map((mock) => ({
137
119
  request: {
138
120
  method: mock.method,
@@ -7,6 +7,7 @@ import { getSpec } from '../storage/spec-store.js';
7
7
  import { safeTracked } from './safe-handler.js';
8
8
  import { bumpSpecVersion } from '../engine/spec-versioning/bump-version.js';
9
9
  import { resolveProjectId } from './resolve-project-id.js';
10
+ import { SpecIdSchema } from './schemas/index.js';
10
11
  // SPEC-747: Grant unlock token after successful bump
11
12
  import { grantUnlock } from '../engine/freeze/unlock-token.js';
12
13
  // ---------------------------------------------------------------------------
@@ -25,7 +26,7 @@ const BumpSpecVersionSchema = {
25
26
  .max(500)
26
27
  .optional()
27
28
  .describe('Project ID (hash). Required if projectPath is not provided.'),
28
- specId: z.string().min(1).max(500).describe('Spec ID to bump, e.g. SPEC-042.'),
29
+ specId: SpecIdSchema.describe('Spec ID to bump, e.g. SPEC-042.'),
29
30
  kind: z
30
31
  .enum(['patch', 'minor', 'major'])
31
32
  .describe('Bump kind: patch (typo/format), minor (new criterion/scope expansion), major (breaking change).'),
@@ -16,7 +16,8 @@ import { compactObj } from '../engine/compact-obj.js';
16
16
  import { buildCreateSpecSummary } from '../engine/human-summary.js';
17
17
  import { runAutoPostCreatePipeline } from './create-spec/auto-pipeline.js';
18
18
  import { generateLeanSpecContent } from '../engine/spec-format/lean-spec-generator.js';
19
- import { generateLeanTechnicalContent } from '../engine/spec-format/lean-technical-generator.js';
19
+ import { generateLeanTechnicalContent, } from '../engine/spec-format/lean-technical-generator.js';
20
+ import { extractFilesFromSpecBody } from '../engine/spec-format/technical-md-populator.js';
20
21
  import { buildUnifiedSpecContent } from '../engine/spec-format/unified-spec-builder.js';
21
22
  import { validateEnglishOnlySpecText } from '../engine/spec-language/english-only.js';
22
23
  import { ApiKeyResolver, FallbackGenerator, OpusGenerator, } from '../engine/spec-generator/index.js';
@@ -104,6 +105,22 @@ function handleClarification(_server, description, knowledge, autopilot, params)
104
105
  earlyReturn: interactiveResult(questions, 'The description needs more detail before a spec can be created. Answer the questions, then retry create_spec with clarificationAnswers or an enriched description.', 'universal'),
105
106
  };
106
107
  }
108
+ function hasFileEntries(files) {
109
+ return files.create.length + files.modify.length + files.test.length > 0;
110
+ }
111
+ async function resolveTechnicalFiles(input) {
112
+ const generatedSource = [input.generatedTechnicalSection, input.generatedSpecBody]
113
+ .filter((part) => part.trim().length > 0)
114
+ .join('\n\n');
115
+ const extracted = await extractFilesFromSpecBody(generatedSource, input.projectPath);
116
+ if (extracted !== null && hasFileEntries(extracted)) {
117
+ return extracted;
118
+ }
119
+ if (input.fallbackReason !== undefined && hasFileEntries(input.autopilot.suggestedFiles)) {
120
+ return input.autopilot.suggestedFiles;
121
+ }
122
+ return { create: [], modify: [], test: [] };
123
+ }
107
124
  const HIGH_RISK_WARNING = 'High-risk spec — consider: ' +
108
125
  '1) Is the approach technically feasible within the project constraints? ' +
109
126
  '2) What edge cases (network failure, concurrent writes, empty state) could break this? ' +
@@ -558,15 +575,18 @@ export async function handleCreateSpec(inputParams, server) {
558
575
  extraCriteria: filteredCriteria,
559
576
  acFormat: params.acFormat,
560
577
  });
561
- // SPEC-1011 Bug E: only inject autopilot-guessed files into ## Files body when the
562
- // description explicitly contains a ## Files section. Otherwise, keep the spec body
563
- // at the placeholder. Suggestions surface only in autopilotSummary.suggestedFiles[].
564
- const descriptionHasFilesSection = /^##\s+Files\b/m.test(description);
578
+ const technicalFiles = await measureStep('resolveTechnicalFiles', () => resolveTechnicalFiles({
579
+ generatedSpecBody: generatedSpec.specBody,
580
+ generatedTechnicalSection: generatedSpec.technicalSection,
581
+ projectPath: params.projectPath ?? '',
582
+ autopilot,
583
+ fallbackReason: generatedSpec.fallbackReason,
584
+ }));
565
585
  const leanTechnical = generateLeanTechnicalContent({
566
586
  specId: spec.id,
567
- filesToCreate: descriptionHasFilesSection ? autopilot.suggestedFiles.create : [],
568
- filesToModify: descriptionHasFilesSection ? autopilot.suggestedFiles.modify : [],
569
- filesToTest: descriptionHasFilesSection ? autopilot.suggestedFiles.test : [],
587
+ filesToCreate: technicalFiles.create,
588
+ filesToModify: technicalFiles.modify,
589
+ filesToTest: technicalFiles.test,
570
590
  });
571
591
  // SPEC-709: write unified spec.md from origin — no separate technical.md.
572
592
  // The legacy two-file output is preserved by appending the technical body
@@ -1071,8 +1091,8 @@ export async function handleCreateSpec(inputParams, server) {
1071
1091
  catch {
1072
1092
  // best-effort — token issuance failure must not block spec creation
1073
1093
  }
1074
- // SPEC-1011 Bug E: surface autopilot-guessed files in response payload (the ONLY surface
1075
- // for guesses when description omits ## Files). Never written into spec.md body.
1094
+ // SPEC-1011 Bug E / fallback hardening: surface local file analysis in the response payload.
1095
+ // Those paths are also written into ## Files only when used as technical evidence.
1076
1096
  const suggestedFilesPayload = autopilot.suggestedFiles.create.length +
1077
1097
  autopilot.suggestedFiles.modify.length +
1078
1098
  autopilot.suggestedFiles.test.length >
@@ -1,11 +1,12 @@
1
1
  // tools/generate-batch-script.ts — Handle generate_batch_script tool calls (SPEC-253)
2
2
  import { z } from 'zod';
3
3
  import { generateBatchScript } from '../engine/batch-script-generator/index.js';
4
+ import { SpecIdSchema } from './schemas/index.js';
4
5
  export const GenerateBatchScriptInputSchema = z.object({
5
6
  projectPath: z.string().describe('Absolute path to the project root'),
6
7
  taskDescription: z.string().describe('What claude -p should do to each file'),
7
8
  fileGlob: z.string().describe("Glob pattern for files, e.g. 'src/**/*.ts'"),
8
- specId: z.string().optional().describe('SPEC ID for naming the output script, e.g. SPEC-253'),
9
+ specId: SpecIdSchema.optional().describe('SPEC ID for naming the output script, e.g. SPEC-253'),
9
10
  allowedTools: z
10
11
  .array(z.string())
11
12
  .optional()
@@ -6,10 +6,11 @@ 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 { SpecIdSchema } from './schemas/index.js';
9
10
  // ── Zod schema ────────────────────────────────────────────────────────────────
10
11
  const PhaseSchema = z.object({
11
12
  name: z.string().describe('Phase name (e.g. "Phase 1 — Core", "MVP")'),
12
- specIds: z.array(z.string()).describe('List of spec IDs included in this phase'),
13
+ specIds: z.array(SpecIdSchema).describe('List of spec IDs included in this phase'),
13
14
  color: z
14
15
  .string()
15
16
  .optional()
@@ -56,10 +56,16 @@ export function buildContractMockSummaryLines(specTitle, knowledge) {
56
56
  '### Mock Server',
57
57
  '',
58
58
  `**Tool:** ${tool} (auto-detected)`,
59
- `**Mocks:** ${endpoints.length} endpoint mocks with realistic sample data`,
60
- '**Features:** Simulated delay (250ms), error scenarios (400/401/403/404/500)',
59
+ `**Mocks:** ${endpoints.length} endpoint mocks generated from explicit contracts`,
60
+ ...(endpoints.length === 0
61
+ ? [
62
+ '> No endpoints were provided; Planu did not fabricate API paths, status codes, or mock payloads.',
63
+ ]
64
+ : []),
61
65
  '',
62
- '> Frontend developers can use these mocks to work independently of the backend.',
66
+ ...(endpoints.length > 0
67
+ ? ['> Frontend developers can use these mocks to work independently of the backend.']
68
+ : []),
63
69
  '',
64
70
  ];
65
71
  return lines;
@@ -2,7 +2,7 @@
2
2
  // SPEC-715: heal_spec_docs is now the SINGLE tool that performs migrations.
3
3
  // list_specs is read-only; migrations were moved here.
4
4
  // SPEC-724: Added dryRun, backup flags and Jaccard goal-scenario drift warning.
5
- import { readFile, readdir } from 'node:fs/promises';
5
+ import { readFile, readdir, rm } from 'node:fs/promises';
6
6
  import { atomicWriteFile } from '../engine/safety/atomic-write-file.js';
7
7
  import { join } from 'node:path';
8
8
  import { hashProjectPath } from '../storage/base-store.js';
@@ -46,33 +46,13 @@ function isPlaceholderTechnical(content) {
46
46
  }
47
47
  return false;
48
48
  }
49
- // ---------------------------------------------------------------------------
50
- // File entry generation
51
- // ---------------------------------------------------------------------------
52
- function buildFileEntries(type, isDone) {
53
- const status = isDone ? '(done)' : '(pending)';
54
- // SPEC-579: never fabricate paths from title slug — produces garbage like
55
- // `src/tools/<spanish-title-slug>.ts` for paths that do not exist.
56
- // Always return placeholders; real paths are populated manually or by
57
- // detection tools (scan_project, link_pr_to_spec, sync_spec_from_code).
58
- if (type === 'refactor') {
59
- return [
60
- `- modify: (to be determined by implementation) ${status}`,
61
- `- test: (to be determined) ${status}`,
62
- ];
63
- }
64
- if (type === 'bugfix' || type === 'fix') {
65
- return [
66
- `- modify: (affected files TBD) ${status}`,
67
- `- test: tests/ (regression test) ${status}`,
68
- ];
69
- }
70
- return [`- modify: (to be determined) ${status}`];
49
+ function hasInlineTechnicalFilesPlaceholder(content) {
50
+ return /^##\s+Technical\b[\s\S]*?^##\s+Files\b[\s\S]*?(?:\(pending\)|\(to be determined\)|\bTBD\b|--[a-z])/im.test(content);
71
51
  }
72
52
  // ---------------------------------------------------------------------------
73
- // Technical section writer (SPEC-1010 PR-C: writes into spec.md ## Technical)
53
+ // File entry generation
74
54
  // ---------------------------------------------------------------------------
75
- async function writeTechnicalMd(specDir, specId, type, isDone, projectPath, specBody) {
55
+ async function writeTechnicalMd(specDir, isDone, projectPath, specBody) {
76
56
  // SPEC-586: Try to extract real file paths from spec body before using placeholders
77
57
  let extractedBody = null;
78
58
  if (projectPath && specBody !== undefined) {
@@ -102,18 +82,13 @@ async function writeTechnicalMd(specDir, specId, type, isDone, projectPath, spec
102
82
  // best-effort — fall through to placeholder
103
83
  }
104
84
  }
105
- const fileEntries = buildFileEntries(type, isDone);
106
- // Build the body for the ## Technical section (no outer heading — replaceSectionInSpec adds it)
107
- const technicalBody = extractedBody ??
108
- [
109
- `## Files — ${specId}`,
110
- ...fileEntries,
111
- '',
112
- '## Notes',
113
- `> Auto-generated by heal_spec_docs (SPEC-493). Review and update paths as needed.`,
114
- '',
115
- ].join('\n');
116
85
  const specMdPath = join(specDir, 'spec.md');
86
+ if (extractedBody === null) {
87
+ const removedInline = await removeTechnicalSectionIfPlaceholder(specMdPath);
88
+ const removedLegacy = await removeLegacyTechnicalPlaceholder(join(specDir, 'technical.md'));
89
+ return removedInline || removedLegacy;
90
+ }
91
+ const technicalBody = extractedBody;
117
92
  // SPEC-579: idempotency check — skip write if section already matches.
118
93
  try {
119
94
  const existing = await readFile(specMdPath, 'utf-8');
@@ -128,13 +103,74 @@ async function writeTechnicalMd(specDir, specId, type, isDone, projectPath, spec
128
103
  await replaceSectionInSpec(specMdPath, 'Technical', technicalBody);
129
104
  return true;
130
105
  }
106
+ async function removeLegacyTechnicalPlaceholder(technicalPath) {
107
+ let legacy;
108
+ try {
109
+ legacy = await readFile(technicalPath, 'utf-8');
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ if (!isPlaceholderTechnical(legacy)) {
115
+ return false;
116
+ }
117
+ await rm(technicalPath, { force: true });
118
+ return true;
119
+ }
120
+ async function removeTechnicalSectionIfPlaceholder(specMdPath) {
121
+ let existing;
122
+ try {
123
+ existing = await readFile(specMdPath, 'utf-8');
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ const currentTechnical = extractTechnicalBodyLocal(existing);
129
+ if (currentTechnical === '' || !isPlaceholderTechnical(currentTechnical)) {
130
+ return false;
131
+ }
132
+ const updated = removeTopLevelSection(existing, 'Technical');
133
+ if (updated === existing) {
134
+ return false;
135
+ }
136
+ await atomicWriteFile(specMdPath, updated);
137
+ return true;
138
+ }
139
+ function removeTopLevelSection(content, sectionName) {
140
+ const normalized = content.replace(/\r\n/g, '\n');
141
+ const masked = normalized.replace(/(```|~~~)[\s\S]*?\1/g, (block) => block.replace(/[^\n]/g, ' '));
142
+ const headingRe = new RegExp(`^##\\s+${escapeRegex(sectionName)}\\b.*$`, 'm');
143
+ const match = headingRe.exec(masked);
144
+ if (!match) {
145
+ return content;
146
+ }
147
+ const afterHeading = match.index + match[0].length;
148
+ const nextRe = /^##\s+\S/gm;
149
+ nextRe.lastIndex = afterHeading;
150
+ const next = nextRe.exec(masked);
151
+ const before = normalized.slice(0, match.index).trimEnd();
152
+ const after = next ? normalized.slice(next.index).trimStart() : '';
153
+ if (before.length === 0) {
154
+ return after.length > 0 ? `${after.trimEnd()}\n` : '';
155
+ }
156
+ if (after.length === 0) {
157
+ return `${before}\n`;
158
+ }
159
+ return `${before}\n\n${after.trimEnd()}\n`;
160
+ }
161
+ function escapeRegex(input) {
162
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
163
+ }
131
164
  /** Local extraction of ## Technical body to avoid circular imports. */
132
165
  function extractTechnicalBodyLocal(content) {
166
+ return extractSectionBodyLocal(content, 'Technical') ?? '';
167
+ }
168
+ function extractSectionBodyLocal(content, sectionName) {
133
169
  const normalized = content.replace(/\r\n/g, '\n');
134
170
  const masked = normalized.replace(/(```|~~~)[\s\S]*?\1/g, (block) => block.replace(/[^\n]/g, ' '));
135
- const headingMatch = /^##\s+Technical\b.*$/m.exec(masked);
171
+ const headingMatch = new RegExp(`^##\\s+${escapeRegex(sectionName)}\\b.*$`, 'm').exec(masked);
136
172
  if (!headingMatch) {
137
- return '';
173
+ return null;
138
174
  }
139
175
  const afterHeading = headingMatch.index + headingMatch[0].length;
140
176
  const nextRe = /^##\s+\S/gm;
@@ -271,9 +307,11 @@ export async function handleHealSpecDocs(params) {
271
307
  let legacyContent = null;
272
308
  // Determine whether healing is needed
273
309
  let needsHeal;
310
+ const inlinePlaceholder = technicalInSpecMd !== null &&
311
+ (isPlaceholderTechnical(technicalInSpecMd) || hasInlineTechnicalFilesPlaceholder(spec.body));
274
312
  if (technicalInSpecMd !== null) {
275
313
  // Check if the inline section is a placeholder
276
- needsHeal = isPlaceholderTechnical(technicalInSpecMd);
314
+ needsHeal = inlinePlaceholder;
277
315
  }
278
316
  else {
279
317
  // No inline section — try standalone technical.md (legacy)
@@ -326,9 +364,27 @@ export async function handleHealSpecDocs(params) {
326
364
  }
327
365
  }
328
366
  }
367
+ if (inlinePlaceholder) {
368
+ const specContent = await readFile(specMdPath, 'utf-8').catch(() => '');
369
+ let updated = removeTopLevelSection(specContent, 'Technical');
370
+ const filesBody = extractSectionBodyLocal(updated, 'Files');
371
+ if (filesBody !== null && isPlaceholderTechnical(filesBody)) {
372
+ updated = removeTopLevelSection(updated, 'Files');
373
+ }
374
+ if (updated !== specContent) {
375
+ await atomicWriteFile(specMdPath, updated);
376
+ healed += 1;
377
+ }
378
+ continue;
379
+ }
380
+ if (legacyContent !== null && isPlaceholderTechnical(legacyContent)) {
381
+ await rm(technicalPath, { force: true });
382
+ healed += 1;
383
+ continue;
384
+ }
329
385
  const isDone = spec.status === 'done' || spec.status === 'implemented';
330
386
  try {
331
- const wrote = await writeTechnicalMd(spec.dir, spec.specId, spec.type, isDone, projectPath, spec.body);
387
+ const wrote = await writeTechnicalMd(spec.dir, isDone, projectPath, spec.body);
332
388
  if (wrote) {
333
389
  healed += 1;
334
390
  }