@planu/cli 4.3.20 → 4.3.21

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,9 @@
1
+ ## [4.3.21] - 2026-06-02
2
+
3
+ ### Bug Fixes
4
+ - fix: enforce canonical spec ids and harden fallback specs
5
+
6
+
1
7
  ## [4.3.20] - 2026-05-27
2
8
 
3
9
  ### Bug Fixes
@@ -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
@@ -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()
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { safeLicensed, safeTracked } from './safe-handler.js';
3
+ import { SpecIdSchema } from './schemas/index.js';
3
4
  import { onAutopilotEvent } from '../engine/autopilot/event-bus.js';
4
5
  import { dispatchSquadForPhase } from '../engine/agent-squad/dispatcher.js';
5
6
  import { getSquadConfig, setAgentEnabled, getSpecRunHistory, getSquadRuns, } from '../storage/agent-squad-store.js';
@@ -143,7 +144,7 @@ export function registerAgentSquadTools(server) {
143
144
  'which agents ran, their verdict, and when.',
144
145
  inputSchema: {
145
146
  projectPath: z.string().min(1).max(4096).describe('Absolute path to the project root.'),
146
- specId: z.string().min(1).max(100).describe('Spec ID to query (e.g. SPEC-550).'),
147
+ specId: SpecIdSchema.describe('Spec ID to query (e.g. SPEC-550).'),
147
148
  },
148
149
  }, safeTracked('agent_run_history', async (args) => {
149
150
  const { projectPath, specId } = args;
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { t } from '../../i18n/index.js';
3
3
  import { safeTracked, safeLicensed } from '../safe-handler.js';
4
- import { AgentPlatformEnum, SubAgentFrameworkEnum, ChallengeSpecFocusEnum, } from '../schemas/index.js';
4
+ import { AgentPlatformEnum, SubAgentFrameworkEnum, ChallengeSpecFocusEnum, SpecIdSchema, } from '../schemas/index.js';
5
5
  import { handleGenerateADR } from '../generate-adr.js';
6
6
  import { handleDesignSchema } from '../design-schema.js';
7
7
  import { handleDefineUIContract } from '../define-ui-contract.js';
@@ -25,7 +25,7 @@ export function registerDesignStackTools(server) {
25
25
  description: t('tools.generate_adr.description'),
26
26
  annotations: { readOnlyHint: true },
27
27
  inputSchema: {
28
- specId: z.string().max(500).describe('Spec ID'),
28
+ specId: SpecIdSchema.describe('Spec ID'),
29
29
  projectId: z.string().max(500).describe('Project ID'),
30
30
  decisions: z
31
31
  .array(z.string().max(10_000))
@@ -39,7 +39,7 @@ export function registerDesignStackTools(server) {
39
39
  description: t('tools.design_schema.description'),
40
40
  annotations: { readOnlyHint: true },
41
41
  inputSchema: {
42
- specId: z.string().max(500).describe('Spec ID'),
42
+ specId: SpecIdSchema.describe('Spec ID'),
43
43
  projectId: z.string().max(500).describe('Project ID'),
44
44
  description: z
45
45
  .string()
@@ -53,7 +53,7 @@ export function registerDesignStackTools(server) {
53
53
  description: t('tools.define_ui_contract.description'),
54
54
  annotations: { readOnlyHint: true },
55
55
  inputSchema: {
56
- specId: z.string().max(500).describe('Spec ID'),
56
+ specId: SpecIdSchema.describe('Spec ID'),
57
57
  projectId: z.string().max(500).describe('Project ID'),
58
58
  screens: z
59
59
  .array(z.string().max(500))
@@ -67,7 +67,7 @@ export function registerDesignStackTools(server) {
67
67
  description: t('tools.challenge_spec.description'),
68
68
  annotations: { readOnlyHint: true },
69
69
  inputSchema: {
70
- specId: z.string().max(500).describe('Spec ID to challenge'),
70
+ specId: SpecIdSchema.describe('Spec ID to challenge'),
71
71
  projectId: z
72
72
  .string()
73
73
  .max(500)
@@ -90,7 +90,7 @@ export function registerDesignStackTools(server) {
90
90
  description: t('tools.generate_execution_plan.description'),
91
91
  annotations: { readOnlyHint: true },
92
92
  inputSchema: {
93
- specId: z.string().max(500).describe('Spec ID'),
93
+ specId: SpecIdSchema.describe('Spec ID'),
94
94
  projectId: z.string().max(500).describe('Project ID'),
95
95
  },
96
96
  }, safeTracked('generate_execution_plan', async (args) => handleGenerateExecutionPlan(args)));
@@ -145,7 +145,7 @@ export function registerDesignStackTools(server) {
145
145
  .describe('Skill type: project-rules | domain-skill | workflow | convention'),
146
146
  name: z.string().max(500).describe('Skill name'),
147
147
  description: z.string().max(10_000).describe('Skill description'),
148
- specId: z.string().max(500).optional().describe('Spec ID for context'),
148
+ specId: SpecIdSchema.optional().describe('Spec ID for context'),
149
149
  },
150
150
  }, safeLicensed('generate_skill', async (args) => handleGenerateSkill(args)));
151
151
  // 28. detect_agent
@@ -208,10 +208,10 @@ export function registerDesignStackTools(server) {
208
208
  annotations: { readOnlyHint: true },
209
209
  inputSchema: {
210
210
  specIds: z
211
- .array(z.string().max(500))
211
+ .array(SpecIdSchema)
212
212
  .min(1)
213
213
  .max(100)
214
- .describe('List of spec IDs to run in parallel (e.g. ["SPEC-013", "SPEC-014a"])'),
214
+ .describe('List of canonical spec IDs to run in parallel (e.g. ["SPEC-013", "SPEC-014"])'),
215
215
  projectPath: z.string().max(4096).describe('Absolute path to the project root'),
216
216
  mainBranch: z.string().max(500).optional().describe('Main branch name (default: main)'),
217
217
  baseBranch: z
@@ -226,7 +226,7 @@ export function registerDesignStackTools(server) {
226
226
  description: 'Adversarial review — argues against the spec from first principles before approval. Runs pre-mortem, surfaces hidden assumptions, proposes alternatives.',
227
227
  annotations: { readOnlyHint: true },
228
228
  inputSchema: {
229
- specId: z.string().max(500).describe('Spec ID to red-team'),
229
+ specId: SpecIdSchema.describe('Spec ID to red-team'),
230
230
  projectId: z.string().max(500).describe('Project ID'),
231
231
  intensity: z
232
232
  .enum(['light', 'full'])
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { t } from '../../i18n/index.js';
3
3
  import { safeTracked, safeLicensed } from '../safe-handler.js';
4
- import { DocumentationTypeEnum, GitActionEnum, DevLifecycleCategoryEnum, PMPlatformEnum, PMActionEnum, OrchestrateActionEnum, ManageContextActionEnum, ContextBudgetActionEnum, } from '../schemas/index.js';
4
+ import { DocumentationTypeEnum, GitActionEnum, DevLifecycleCategoryEnum, PMPlatformEnum, PMActionEnum, OrchestrateActionEnum, ManageContextActionEnum, ContextBudgetActionEnum, SpecIdSchema, } from '../schemas/index.js';
5
5
  import { handleSuggestTooling } from '../suggest-tooling.js';
6
6
  import { handleGenerateTests } from '../generate-tests.js';
7
7
  import { handleGenerateDocs } from '../generate-docs.js';
@@ -21,7 +21,7 @@ export function registerLifecycleInfraTools(server) {
21
21
  annotations: { readOnlyHint: true },
22
22
  inputSchema: {
23
23
  projectId: z.string().max(500).describe('Project ID'),
24
- specId: z.string().max(500).optional().describe('Spec ID for context'),
24
+ specId: SpecIdSchema.optional().describe('Spec ID for context'),
25
25
  categories: z
26
26
  .array(DevLifecycleCategoryEnum)
27
27
  .optional()
@@ -33,7 +33,7 @@ export function registerLifecycleInfraTools(server) {
33
33
  description: t('tools.generate_tests.description'),
34
34
  annotations: { readOnlyHint: true },
35
35
  inputSchema: {
36
- specId: z.string().max(500).describe('Spec ID'),
36
+ specId: SpecIdSchema.describe('Spec ID'),
37
37
  projectId: z.string().max(500).describe('Project ID'),
38
38
  autoGenerate: z.boolean().optional().describe('Auto-generate test files (default: false)'),
39
39
  },
@@ -49,7 +49,7 @@ export function registerLifecycleInfraTools(server) {
49
49
  .max(100)
50
50
  .describe('Types of documentation to generate'),
51
51
  docType: DocumentationTypeEnum.optional().describe('Single documentation type for routing — shorthand for types: [docType]'),
52
- specId: z.string().max(500).optional().describe('Spec ID for context'),
52
+ specId: SpecIdSchema.optional().describe('Spec ID for context'),
53
53
  audience: z
54
54
  .enum(['end-user', 'developer', 'stakeholder', 'ops'])
55
55
  .optional()
@@ -88,7 +88,7 @@ export function registerLifecycleInfraTools(server) {
88
88
  inputSchema: {
89
89
  projectId: z.string().max(500).describe('Project ID'),
90
90
  action: GitActionEnum.describe('Git action to perform'),
91
- specId: z.string().max(500).optional().describe('Spec ID for context'),
91
+ specId: SpecIdSchema.optional().describe('Spec ID for context'),
92
92
  config: z
93
93
  .object({
94
94
  branchPrefix: z.record(z.string().max(500), z.string().max(500)).optional(),
@@ -113,10 +113,10 @@ export function registerLifecycleInfraTools(server) {
113
113
  projectId: z.string().max(500).describe('Project ID'),
114
114
  action: OrchestrateActionEnum.describe('Orchestration action'),
115
115
  sessionId: z.string().max(500).optional().describe('Agent session ID'),
116
- specId: z.string().max(500).optional().describe('Spec ID for context'),
116
+ specId: SpecIdSchema.optional().describe('Spec ID for context'),
117
117
  resourcePath: z.string().max(4096).optional().describe('Resource path to lock/unlock'),
118
118
  specIds: z
119
- .array(z.string().max(500))
119
+ .array(SpecIdSchema)
120
120
  .max(100)
121
121
  .optional()
122
122
  .describe('List of spec IDs for pre-check'),
@@ -163,7 +163,7 @@ export function registerLifecycleInfraTools(server) {
163
163
  projectId: z.string().max(500).describe('Project ID'),
164
164
  platform: PMPlatformEnum.describe('PM platform'),
165
165
  action: PMActionEnum.describe('Integration action'),
166
- specId: z.string().max(500).optional().describe('Spec ID'),
166
+ specId: SpecIdSchema.optional().describe('Spec ID'),
167
167
  config: z
168
168
  .record(z.string().max(500), z.string().max(10_000))
169
169
  .optional()
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { t } from '../../i18n/index.js';
3
3
  import { safeTracked, safeLicensed } from '../safe-handler.js';
4
- import { ChecklistFocusEnum, AuditCategoryEnum } from '../schemas/index.js';
4
+ import { ChecklistFocusEnum, AuditCategoryEnum, SpecIdSchema } from '../schemas/index.js';
5
5
  import { resolveProjectId, missingProjectIdError } from '../resolve-project-id.js';
6
6
  import { handleDetectDrift } from '../detect-drift.js';
7
7
  import { handleSummarizeSpec } from '../summarize-spec.js';
@@ -27,7 +27,7 @@ export function registerAnalysisTools(server) {
27
27
  server.registerTool('detect_drift', {
28
28
  description: t('tools.detect_drift.description'),
29
29
  inputSchema: {
30
- specId: z.string().min(1).max(500).describe('Spec ID to check'),
30
+ specId: SpecIdSchema.describe('Spec ID to check'),
31
31
  projectId: z.string().max(500).optional().describe('Project ID hash. Prefer projectPath.'),
32
32
  projectPath: z
33
33
  .string()
@@ -56,7 +56,7 @@ export function registerAnalysisTools(server) {
56
56
  description: t('tools.summarize_spec.description'),
57
57
  annotations: { readOnlyHint: true },
58
58
  inputSchema: {
59
- specId: z.string().min(1).max(500).describe('Spec ID to summarize'),
59
+ specId: SpecIdSchema.describe('Spec ID to summarize'),
60
60
  projectId: z.string().max(500).optional().describe('Project ID hash. Prefer projectPath.'),
61
61
  projectPath: z
62
62
  .string()
@@ -79,7 +79,7 @@ export function registerAnalysisTools(server) {
79
79
  server.registerTool('generate_checklist', {
80
80
  description: t('tools.generate_checklist.description'),
81
81
  inputSchema: {
82
- specId: z.string().min(1).max(500).describe('Spec ID'),
82
+ specId: SpecIdSchema.describe('Spec ID'),
83
83
  projectId: z.string().max(500).optional().describe('Project ID hash. Prefer projectPath.'),
84
84
  projectPath: z
85
85
  .string()
@@ -103,7 +103,7 @@ export function registerAnalysisTools(server) {
103
103
  server.registerTool('reconcile_spec', {
104
104
  description: t('tools.reconcile_spec.description'),
105
105
  inputSchema: {
106
- specId: z.string().min(1).max(500).describe('Spec ID to reconcile'),
106
+ specId: SpecIdSchema.describe('Spec ID to reconcile'),
107
107
  projectId: z.string().max(500).optional().describe('Project ID hash. Prefer projectPath.'),
108
108
  projectPath: z
109
109
  .string()
@@ -176,7 +176,7 @@ export function registerAnalysisTools(server) {
176
176
  .max(100)
177
177
  .optional()
178
178
  .describe('Audit categories to check'),
179
- specId: z.string().max(500).optional().describe('Spec ID to audit against'),
179
+ specId: SpecIdSchema.optional().describe('Spec ID to audit against'),
180
180
  },
181
181
  }, safeLicensed('audit', async (args) => {
182
182
  const pid = resolveProjectId(args);
@@ -231,7 +231,7 @@ export function registerAnalysisTools(server) {
231
231
  .max(4096)
232
232
  .optional()
233
233
  .describe('Absolute path to project root. Derives projectId automatically.'),
234
- specId: z.string().max(500).optional().describe('Spec ID for context-specific suggestions'),
234
+ specId: SpecIdSchema.optional().describe('Spec ID for context-specific suggestions'),
235
235
  },
236
236
  }, safeLicensed('suggest_mcps', async (args) => {
237
237
  const pid = resolveProjectId(args);
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { t } from '../../i18n/index.js';
3
3
  import { safeTracked, safeLicensed } from '../safe-handler.js';
4
- import { SupportedLocaleEnum, SpecStatusEnum, SpecTypeEnum, SpecScopeEnum, SpecTargetEnum, ExperienceLevelEnum, WorkModeEnum, ListSpecsOutputSchema, EstimateOutputSchema, ValidateOutputSchema, } from '../schemas/index.js';
4
+ import { SupportedLocaleEnum, SpecStatusEnum, SpecTypeEnum, SpecScopeEnum, SpecTargetEnum, ExperienceLevelEnum, WorkModeEnum, ListSpecsOutputSchema, EstimateOutputSchema, ValidateOutputSchema, SpecIdSchema, } from '../schemas/index.js';
5
5
  import { handleSetLocale } from '../set-locale.js';
6
6
  import { handleInitProject } from '../init-project.js';
7
7
  import { handleSetWorkMode } from '../set-work-mode-handler.js';
@@ -274,7 +274,7 @@ export function registerCoreSpecTools(server) {
274
274
  server.registerTool('update_status', {
275
275
  description: t('tools.update_status.description'),
276
276
  inputSchema: {
277
- specId: z.string().min(1).max(500).describe('Spec ID to update'),
277
+ specId: SpecIdSchema.describe('Spec ID to update'),
278
278
  projectId: z
279
279
  .string()
280
280
  .max(500)
@@ -379,7 +379,7 @@ export function registerCoreSpecTools(server) {
379
379
  server.registerTool('update_status_batch', {
380
380
  description: 'Batch update many specs to the same status in one MCP execution. Uses the same transition rules as update_status and reports updated/skipped/failed per spec.',
381
381
  inputSchema: {
382
- specIds: z.array(z.string().min(1).max(500)).min(1).describe('Spec IDs to update'),
382
+ specIds: z.array(SpecIdSchema).min(1).describe('Spec IDs to update'),
383
383
  projectId: z.string().max(500).optional().describe('Project ID, if known'),
384
384
  projectPath: z
385
385
  .string()
@@ -399,7 +399,7 @@ export function registerCoreSpecTools(server) {
399
399
  server.registerTool('estimate', {
400
400
  description: t('tools.estimate.description'),
401
401
  inputSchema: {
402
- specId: z.string().max(500).describe('Spec ID to estimate'),
402
+ specId: SpecIdSchema.describe('Spec ID to estimate'),
403
403
  projectId: z.string().max(500).describe('Project ID'),
404
404
  },
405
405
  outputSchema: EstimateOutputSchema,
@@ -420,7 +420,7 @@ export function registerCoreSpecTools(server) {
420
420
  server.registerTool('validate', {
421
421
  description: t('tools.validate.description'),
422
422
  inputSchema: {
423
- specId: z.string().max(500).describe('Spec ID to validate'),
423
+ specId: SpecIdSchema.describe('Spec ID to validate'),
424
424
  projectId: z
425
425
  .string()
426
426
  .max(500)
@@ -441,7 +441,7 @@ export function registerCoreSpecTools(server) {
441
441
  'and missing parameter type annotations. Returns a health score (0-100) and actionable suggestions.',
442
442
  inputSchema: {
443
443
  projectPath: z.string().max(4096).describe('Absolute path to the project root'),
444
- specId: z.string().min(1).max(500).describe('Spec ID being validated'),
444
+ specId: SpecIdSchema.describe('Spec ID being validated'),
445
445
  waiver: z
446
446
  .array(z.string().max(4096))
447
447
  .max(100)
@@ -8,9 +8,10 @@ import { detectLLMClient } from '../engine/client-detection.js';
8
8
  import { getRegisteredProviders, getRenderer, llmClientToProvider, } from '../engine/provider-adapters/registry.js';
9
9
  import { loadSpecForRendering } from '../engine/provider-adapters/shared/spec-loader.js';
10
10
  import { compactError, compactResult, formatKeyValue } from './output-formatter.js';
11
+ import { SpecIdSchema } from './schemas/index.js';
11
12
  const PROVIDER_VALUES = ['claude', 'gpt4', 'gemini', 'markdown'];
12
13
  export const RenderSpecForProviderInputSchema = z.object({
13
- specId: z.string().describe('Spec identifier, e.g. "SPEC-670"'),
14
+ specId: SpecIdSchema.describe('Spec identifier, e.g. "SPEC-670"'),
14
15
  projectPath: z.string().describe('Absolute path to the project root'),
15
16
  provider: z
16
17
  .enum(PROVIDER_VALUES)
@@ -1,7 +1,8 @@
1
1
  // schemas/github.ts — Zod schemas for GitHub integration tools (SPEC-086).
2
2
  import { z } from 'zod';
3
+ import { SpecIdSchema } from './spec-id.js';
3
4
  export const CreatePRFromSpecSchema = z.object({
4
- specId: z.string().min(1).max(500).describe('ID of the spec (e.g., SPEC-086)'),
5
+ specId: SpecIdSchema.describe('ID of the spec (e.g., SPEC-086)'),
5
6
  baseBranch: z
6
7
  .string()
7
8
  .max(500)
@@ -11,7 +12,7 @@ export const CreatePRFromSpecSchema = z.object({
11
12
  });
12
13
  export const PRStatusSchema = z.object({
13
14
  prNumber: z.number().optional().describe('Specific PR number to query'),
14
- specId: z.string().max(500).optional().describe('Spec ID to find related PRs (e.g., SPEC-086)'),
15
+ specId: SpecIdSchema.optional().describe('Spec ID to find related PRs (e.g., SPEC-086)'),
15
16
  view: z.enum(['prs', 'milestones']).default('prs').describe('View mode: prs | milestones'),
16
17
  });
17
18
  export const ReviewPRSchema = z.object({
@@ -55,7 +56,7 @@ export const GenerateChangelogSchema = z.object({
55
56
  .describe('Write the generated changelog to CHANGELOG.md (prepends to existing file). Default: false.'),
56
57
  });
57
58
  export const CreateIssueFromSpecSchema = z.object({
58
- specId: z.string().min(1).max(500).describe('ID of the spec (e.g., SPEC-086)'),
59
+ specId: SpecIdSchema.describe('ID of the spec (e.g., SPEC-086)'),
59
60
  criteriaIds: z
60
61
  .array(z.string().max(500))
61
62
  .max(1000)
@@ -1,4 +1,5 @@
1
1
  export { SupportedLocaleEnum, SpecStatusEnum, SpecTypeEnum, SpecScopeEnum, SpecTargetEnum, ExperienceLevelEnum, WorkModeEnum, } from './spec.js';
2
+ export { SPEC_ID_PATTERN, SPEC_DIR_NAME_PATTERN, SpecIdSchema, SpecDirNameSchema, isCanonicalSpecId, } from './spec-id.js';
2
3
  export { AuditCategoryEnum, ChecklistFocusEnum, ChallengeSpecFocusEnum } from './analysis.js';
3
4
  export { AgentPlatformEnum, SubAgentFrameworkEnum } from './agents.js';
4
5
  export { DocumentationTypeEnum, GitActionEnum, DevLifecycleCategoryEnum, PMPlatformEnum, PMActionEnum, } from './lifecycle.js';
@@ -1,5 +1,6 @@
1
1
  // schemas/index.ts — Barrel re-export for all Zod enum schemas.
2
2
  export { SupportedLocaleEnum, SpecStatusEnum, SpecTypeEnum, SpecScopeEnum, SpecTargetEnum, ExperienceLevelEnum, WorkModeEnum, } from './spec.js';
3
+ export { SPEC_ID_PATTERN, SPEC_DIR_NAME_PATTERN, SpecIdSchema, SpecDirNameSchema, isCanonicalSpecId, } from './spec-id.js';
3
4
  export { AuditCategoryEnum, ChecklistFocusEnum, ChallengeSpecFocusEnum } from './analysis.js';
4
5
  export { AgentPlatformEnum, SubAgentFrameworkEnum } from './agents.js';
5
6
  export { DocumentationTypeEnum, GitActionEnum, DevLifecycleCategoryEnum, PMPlatformEnum, PMActionEnum, } from './lifecycle.js';
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod';
2
+ export declare const SPEC_ID_PATTERN: RegExp;
3
+ export declare const SPEC_DIR_NAME_PATTERN: RegExp;
4
+ export declare const SpecIdSchema: z.ZodString;
5
+ export declare const SpecDirNameSchema: z.ZodString;
6
+ export declare function isCanonicalSpecId(value: string): boolean;
7
+ //# sourceMappingURL=spec-id.d.ts.map
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+ export const SPEC_ID_PATTERN = /^SPEC-\d+$/;
3
+ export const SPEC_DIR_NAME_PATTERN = /^SPEC-\d+(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?$/;
4
+ export const SpecIdSchema = z
5
+ .string()
6
+ .min(1)
7
+ .max(50)
8
+ .regex(SPEC_ID_PATTERN, 'Spec ID must use canonical numeric format like SPEC-042.');
9
+ export const SpecDirNameSchema = z
10
+ .string()
11
+ .min(1)
12
+ .max(200)
13
+ .regex(SPEC_DIR_NAME_PATTERN, 'Spec directory must start with a canonical numeric ID like SPEC-042 or SPEC-042-my-feature.');
14
+ export function isCanonicalSpecId(value) {
15
+ return SPEC_ID_PATTERN.test(value);
16
+ }
17
+ //# sourceMappingURL=spec-id.js.map
@@ -4,6 +4,7 @@ import { getSpec } from '../storage/spec-store.js';
4
4
  import { hashProjectPath } from '../storage/base-store.js';
5
5
  import { generatePromptFromSpec } from '../engine/spec-prompt-generator.js';
6
6
  import { compactResult } from './output-formatter.js';
7
+ import { SpecIdSchema } from './schemas/index.js';
7
8
  // ---------------------------------------------------------------------------
8
9
  // In-memory registry of registered prompts for this session
9
10
  // ---------------------------------------------------------------------------
@@ -12,7 +13,7 @@ export const registeredPrompts = new Map();
12
13
  // Zod schemas
13
14
  // ---------------------------------------------------------------------------
14
15
  const ExposeSpecAsPromptInputSchema = {
15
- specId: z.string().describe('The spec ID to expose as an MCP prompt (e.g. "SPEC-042")'),
16
+ specId: SpecIdSchema.describe('The spec ID to expose as an MCP prompt (e.g. "SPEC-042")'),
16
17
  projectPath: z.string().describe('Absolute path to the project root where the spec is stored'),
17
18
  promptName: z
18
19
  .string()
@@ -10,6 +10,7 @@
10
10
  // To add a tool: import its handler + schema, add an entry to the array.
11
11
  // To read them: import coreToolsRegistry from this file.
12
12
  import { z } from 'zod';
13
+ import { SpecIdSchema } from '../schemas/index.js';
13
14
  // ── Handlers ────────────────────────────────────────────────────────────────
14
15
  import { GenerateBatchScriptInputSchema, handleGenerateBatchScript, } from '../generate-batch-script.js';
15
16
  import { GenerateAutomationGuideInputSchema, handleGenerateAutomationGuide, } from '../generate-automation-guide.js';
@@ -373,7 +374,7 @@ const coreToolsRegistry = [
373
374
  '(structured JSON optimized for autonomous coding agents: Devin, Kiro, SWE-agent, generic). ' +
374
375
  'Writes files to .specify/specs/{slug}/ by default.',
375
376
  schema: {
376
- specId: z.string().min(1).max(500).describe('Spec ID to export (e.g. "SPEC-001")'),
377
+ specId: SpecIdSchema.describe('Spec ID to export (e.g. "SPEC-001")'),
377
378
  projectPath: z.string().min(1).max(2000).describe('Absolute path to the project root'),
378
379
  format: z
379
380
  .enum(['spec-kit', 'agent-ready'])
@@ -687,7 +688,7 @@ const coreToolsRegistry = [
687
688
  'detects drift, and updates the ## Progress section of spec.md with real coverage data. ' +
688
689
  'Works entirely offline — pure local file analysis.',
689
690
  schema: {
690
- specId: z.string().min(1).max(500).describe('Spec ID, e.g. SPEC-042'),
691
+ specId: SpecIdSchema.describe('Spec ID, e.g. SPEC-042'),
691
692
  projectPath: z.string().min(1).max(4096).describe('Absolute path to project root'),
692
693
  },
693
694
  handler: async (args) => handleReconcileSpecLiving({
@@ -13,7 +13,7 @@ import { safe, safeLicensed, safeTracked } from '../safe-handler.js';
13
13
  // ── License tools (register-license-tools.ts) ───────────────────────────────
14
14
  import { handleActivateLicense, handleDeactivateLicense } from '../activate-license.js';
15
15
  import { handleLicenseStatus } from '../license-status.js';
16
- import { LicenseStatusOutputSchema } from '../schemas/index.js';
16
+ import { LicenseStatusOutputSchema, SpecIdSchema } from '../schemas/index.js';
17
17
  // ── OAuth tools (register-oauth-tools.ts / register-configure-oauth-tool.ts) ─
18
18
  import { handleStartOAuthFlow, handleOAuthStatus, StartOAuthFlowInputSchema, OAuthStatusInputSchema, } from '../oauth-handler.js';
19
19
  import { handleConfigureOAuth } from '../configure-oauth-handler.js';
@@ -386,7 +386,7 @@ export function registerInfraGroupTools(server) {
386
386
  inputSchema: {
387
387
  projectPath: z.string().min(1).describe('Absolute path to the project root'),
388
388
  provider: deployProviderEnum,
389
- specId: z.string().optional().describe('Optional spec ID for traceability'),
389
+ specId: SpecIdSchema.optional().describe('Optional spec ID for traceability'),
390
390
  environment: z
391
391
  .enum(['production', 'preview'])
392
392
  .optional()
@@ -484,7 +484,7 @@ export function registerInfraGroupTools(server) {
484
484
  .string()
485
485
  .min(1)
486
486
  .describe('Absolute path to the project root (e.g. "/home/user/my-app").'),
487
- specId: z.string().min(1).describe('Spec ID to validate (e.g. "SPEC-042").'),
487
+ specId: SpecIdSchema.describe('Spec ID to validate (e.g. "SPEC-042").'),
488
488
  baseUrl: z
489
489
  .string()
490
490
  .min(1)
@@ -49,7 +49,7 @@ import { handleDefineLintRule, handleListLintRules, handleRunSpecLint, handleDel
49
49
  // ── Readiness (register-readiness-tools.ts) ───────────────────────────────────
50
50
  import { handleCheckReadiness } from '../check-readiness.js';
51
51
  import { handlePackageHandoff } from '../package-handoff.js';
52
- import { CheckReadinessOutputSchema } from '../schemas/index.js';
52
+ import { CheckReadinessOutputSchema, SpecIdSchema } from '../schemas/index.js';
53
53
  // ── Approval (register-approval-tools.ts) ────────────────────────────────────
54
54
  import { handleConfigureApprovalPolicy, handleApproveSpec, handleRequestChanges, handleApprovalStatus, handleIssueReviewerToken, } from '../approval-handler.js';
55
55
  // ── SPEC-961: Architecture lint ─────────────────────────────────────────────
@@ -250,7 +250,7 @@ export function registerQualityComplianceGroupTools(s) {
250
250
  description: '[DEPRECATED] Use check_compliance with mode=verify instead. Checks whether implemented code matches an approved spec. Score >=80 = compliant, 60-79 = partial, <60 = non-compliant.',
251
251
  annotations: { readOnlyHint: false },
252
252
  inputSchema: {
253
- specId: z.string().max(500).describe('Spec ID to verify (e.g. SPEC-042).'),
253
+ specId: SpecIdSchema.describe('Spec ID to verify (e.g. SPEC-042).'),
254
254
  projectPath: z
255
255
  .string()
256
256
  .max(4096)
@@ -393,7 +393,7 @@ export function registerQualityComplianceGroupTools(s) {
393
393
  description: t('tools.security_check.description'),
394
394
  annotations: { readOnlyHint: true },
395
395
  inputSchema: {
396
- specId: z.string().min(1).max(500).describe('Spec ID to analyze'),
396
+ specId: SpecIdSchema.describe('Spec ID to analyze'),
397
397
  ...projectIdSchema,
398
398
  subcommand: z
399
399
  .enum(['analyze', 'score', 'drift'])
@@ -526,7 +526,7 @@ export function registerQualityComplianceGroupTools(s) {
526
526
  'Use library="all" to generate schemas for all three libraries at once.',
527
527
  inputSchema: {
528
528
  projectPath: z.string().min(1).max(4096).describe('Absolute path to the project root.'),
529
- specId: z.string().min(1).max(500).describe('Spec ID to analyze (e.g. SPEC-042).'),
529
+ specId: SpecIdSchema.describe('Spec ID to analyze (e.g. SPEC-042).'),
530
530
  library: z
531
531
  .enum(['zod', 'yup', 'valibot', 'all'])
532
532
  .describe('Schema library. Values: zod, yup, valibot, all.'),
@@ -547,7 +547,7 @@ export function registerQualityComplianceGroupTools(s) {
547
547
  '"tanstack-query" for React Query hooks.',
548
548
  inputSchema: {
549
549
  projectPath: z.string().min(1).max(4096).describe('Absolute path to the project root.'),
550
- specId: z.string().min(1).max(500).describe('Spec ID to analyze (e.g. SPEC-042).'),
550
+ specId: SpecIdSchema.describe('Spec ID to analyze (e.g. SPEC-042).'),
551
551
  framework: z
552
552
  .enum(['fetch', 'tanstack-query'])
553
553
  .describe('Client framework. Values: fetch, tanstack-query.'),
@@ -583,10 +583,7 @@ export function registerQualityComplianceGroupTools(s) {
583
583
  annotations: { readOnlyHint: true },
584
584
  inputSchema: {
585
585
  projectPath: z.string().describe('Absolute path to the project root'),
586
- specId: z
587
- .string()
588
- .optional()
589
- .describe('SPEC-XXX ID to generate acceptance criteria stubs from'),
586
+ specId: SpecIdSchema.optional().describe('Spec ID to generate acceptance criteria stubs from'),
590
587
  registerFile: z
591
588
  .string()
592
589
  .optional()
@@ -598,7 +595,7 @@ export function registerQualityComplianceGroupTools(s) {
598
595
  annotations: { readOnlyHint: true },
599
596
  inputSchema: {
600
597
  projectPath: z.string().describe('Absolute path to the project root'),
601
- specId: z.string().optional().describe('SPEC-XXX ID to check readiness/completion for'),
598
+ specId: SpecIdSchema.optional().describe('Spec ID to check readiness/completion for'),
602
599
  },
603
600
  }, async (params) => handleTddStatus(params));
604
601
  // ── Coverage ─────────────────────────────────────────────────────────────────
@@ -841,7 +838,7 @@ export function registerQualityComplianceGroupTools(s) {
841
838
  description: t('tools.check_readiness.description'),
842
839
  inputSchema: {
843
840
  ...projectIdSchema,
844
- specId: z.string().min(1).max(500).describe('Spec ID to evaluate'),
841
+ specId: SpecIdSchema.describe('Spec ID to evaluate'),
845
842
  mode: z
846
843
  .enum(['strict', 'lenient'])
847
844
  .optional()
@@ -962,7 +959,7 @@ export function registerQualityComplianceGroupTools(s) {
962
959
  description: t('tools.package_handoff.description'),
963
960
  inputSchema: {
964
961
  ...projectIdSchema,
965
- specId: z.string().min(1).max(500).describe('Spec ID to generate handoff package for'),
962
+ specId: SpecIdSchema.describe('Spec ID to generate handoff package for'),
966
963
  },
967
964
  }, safeLicensed('package_handoff', withProject((args) => handlePackageHandoff(args))));
968
965
  // ── Approval ─────────────────────────────────────────────────────────────────
@@ -1020,7 +1017,7 @@ export function registerQualityComplianceGroupTools(s) {
1020
1017
  'When the required number of approvals is reached, the approval gate opens.',
1021
1018
  inputSchema: {
1022
1019
  projectPath: z.string().min(1).max(4096).describe('Absolute path to the project root.'),
1023
- specId: z.string().min(1).max(500).describe('Spec ID to approve (e.g. SPEC-042).'),
1020
+ specId: SpecIdSchema.describe('Spec ID to approve (e.g. SPEC-042).'),
1024
1021
  reviewer: z
1025
1022
  .string()
1026
1023
  .describe('Name, email, or username of the reviewer approving the spec.'),
@@ -1073,7 +1070,7 @@ export function registerQualityComplianceGroupTools(s) {
1073
1070
  'time in review, and SLA breach warning if applicable.',
1074
1071
  inputSchema: {
1075
1072
  projectPath: z.string().min(1).max(4096).describe('Absolute path to the project root.'),
1076
- specId: z.string().min(1).max(500).describe('Spec ID to check (e.g. SPEC-042).'),
1073
+ specId: SpecIdSchema.describe('Spec ID to check (e.g. SPEC-042).'),
1077
1074
  },
1078
1075
  annotations: { title: 'Approval Status', readOnlyHint: true },
1079
1076
  }, safeTracked('approval_status', (args) => handleApprovalStatus(args)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.3.20",
3
+ "version": "4.3.21",
4
4
  "description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,14 +34,14 @@
34
34
  "packageName": "@planu/core"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@planu/core-darwin-arm64": "4.3.20",
38
- "@planu/core-darwin-x64": "4.3.20",
39
- "@planu/core-linux-arm64-gnu": "4.3.20",
40
- "@planu/core-linux-arm64-musl": "4.3.20",
41
- "@planu/core-linux-x64-gnu": "4.3.20",
42
- "@planu/core-linux-x64-musl": "4.3.20",
43
- "@planu/core-win32-arm64-msvc": "4.3.20",
44
- "@planu/core-win32-x64-msvc": "4.3.20"
37
+ "@planu/core-darwin-arm64": "4.3.21",
38
+ "@planu/core-darwin-x64": "4.3.21",
39
+ "@planu/core-linux-arm64-gnu": "4.3.21",
40
+ "@planu/core-linux-arm64-musl": "4.3.21",
41
+ "@planu/core-linux-x64-gnu": "4.3.21",
42
+ "@planu/core-linux-x64-musl": "4.3.21",
43
+ "@planu/core-win32-arm64-msvc": "4.3.21",
44
+ "@planu/core-win32-x64-msvc": "4.3.21"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=24.0.0"
package/planu-native.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dev.planu.native",
3
3
  "displayName": "Planu Native Lightweight Surface",
4
- "version": "4.3.20",
4
+ "version": "4.3.21",
5
5
  "packageName": "@planu/cli",
6
6
  "modes": {
7
7
  "lightweight": {
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": "4.3.20",
5
+ "version": "4.3.21",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": [
8
8
  "npx",