@planu/cli 3.9.1 → 3.9.3

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,24 @@
1
+ ## [3.9.3] - 2026-05-15
2
+
3
+ **Tarball SHA-256:** `cc411a544962db6b38087b081ea847f73042f280d2ee9600c971cb1f2778db73`
4
+
5
+ ### Bug Fixes
6
+ - fix(tests): update release smoke mocks for pnpm dlx
7
+ - fix(release): smoke test scoped cli with pnpm dlx
8
+
9
+
10
+ ## [3.9.2] - 2026-05-15
11
+
12
+ **Tarball SHA-256:** `3768f401213d28afacbca964d7318d079e92d5f4d454b78705f9995c272034ac`
13
+
14
+ ### Bug Fixes
15
+ - fix(release): allow own native core packages in license audit
16
+ - fix(tests): stabilize release preflight checks
17
+ - fix(release): support lock fallback without flock
18
+ - fix(specs): enforce English structured spec generation
19
+ - fix: stabilize mcp slim test suite
20
+
21
+
1
22
  ## [3.9.0] — 2026-05-12 — Single official SDD MCP surface
2
23
 
3
24
  ### Changed — Planu now exposes one focused MCP surface
@@ -3721,4 +3742,4 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
3721
3742
  - Mermaid diagram generation (architecture, sequence, state machine, ER, data flow)
3722
3743
  - Multi-language i18n (EN/ES/PT) for generated specs
3723
3744
  - Clean Architecture (hexagonal) — engine, tools, storage, types layers
3724
- - 10,857 tests with ≥95% coverage
3745
+ - 10,857 tests with ≥95% coverage
@@ -62,19 +62,24 @@ export async function detectProjectLanguage(projectPath) {
62
62
  // Codebase scanning
63
63
  // ---------------------------------------------------------------------------
64
64
  const MAX_SCAN_DEPTH = 5;
65
+ const MAX_SCANNED_SOURCE_FILES = 200;
65
66
  const EXCLUDED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '__pycache__', '.next']);
66
67
  /** Recursively counts total characters in source files up to a given depth. */
67
- async function countCodebaseChars(dirPath, depth) {
68
+ async function countCodebaseChars(dirPath, depth, filesScanned = 0) {
68
69
  if (depth > MAX_SCAN_DEPTH) {
69
- return 0;
70
+ return { chars: 0, files: filesScanned };
71
+ }
72
+ if (filesScanned >= MAX_SCANNED_SOURCE_FILES) {
73
+ return { chars: 0, files: filesScanned };
70
74
  }
71
75
  let totalChars = 0;
76
+ let totalFiles = filesScanned;
72
77
  let entries;
73
78
  try {
74
79
  entries = await readdir(dirPath, { withFileTypes: true, encoding: 'utf-8' });
75
80
  }
76
81
  catch {
77
- return 0;
82
+ return { chars: 0, files: totalFiles };
78
83
  }
79
84
  for (const entry of entries) {
80
85
  const entryName = entry.name;
@@ -83,19 +88,25 @@ async function countCodebaseChars(dirPath, depth) {
83
88
  }
84
89
  const fullPath = join(dirPath, entryName);
85
90
  if (entry.isDirectory()) {
86
- totalChars += await countCodebaseChars(fullPath, depth + 1);
91
+ const nested = await countCodebaseChars(fullPath, depth + 1, totalFiles);
92
+ totalChars += nested.chars;
93
+ totalFiles = nested.files;
87
94
  }
88
95
  else if (entry.isFile() && isSourceFile(entryName)) {
96
+ if (totalFiles >= MAX_SCANNED_SOURCE_FILES) {
97
+ break;
98
+ }
89
99
  try {
90
100
  const content = await readFile(fullPath, 'utf-8');
91
101
  totalChars += content.length;
102
+ totalFiles++;
92
103
  }
93
104
  catch {
94
105
  // Skip unreadable files
95
106
  }
96
107
  }
97
108
  }
98
- return totalChars;
109
+ return { chars: totalChars, files: totalFiles };
99
110
  }
100
111
  /** Returns true if the file extension is a known source file type. */
101
112
  function isSourceFile(filename) {
@@ -120,7 +131,7 @@ export async function estimateInputTokens(specContent, projectPath, config) {
120
131
  // 2. Codebase context tokens (optional)
121
132
  let codebaseContextTokens = 0;
122
133
  if (projectPath) {
123
- const codebaseChars = await countCodebaseChars(projectPath, 0);
134
+ const { chars: codebaseChars } = await countCodebaseChars(projectPath, 0);
124
135
  codebaseContextTokens = Math.ceil(codebaseChars / charsPerToken);
125
136
  }
126
137
  const inputTokens = specContentTokens + codebaseContextTokens + toolDefinitionsTokens;
@@ -1,11 +1,11 @@
1
1
  // engine/autopilot/crash-shield-rate-limiter.ts — SPEC-628: Rate-limit crash scans to max once per 6h per project
2
2
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
3
  import { join, dirname } from 'node:path';
4
- import { hashProjectPath } from '../../storage/base-store.js';
4
+ import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
5
5
  const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
6
6
  function stateFilePath(projectPath) {
7
7
  const projectId = hashProjectPath(projectPath);
8
- return join('data', 'projects', projectId, 'crash-shield-state.json');
8
+ return join(projectDataDir(projectId), 'crash-shield-state.json');
9
9
  }
10
10
  /**
11
11
  * Returns { skip: true, reason } if a crash scan was recorded within maxAgeMs.
@@ -1,14 +1,14 @@
1
1
  // engine/session-safeguard/learnings-buffer.ts — SPEC-585: Append/flush learnings buffer
2
- import { open, readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { open, readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { hashProjectPath } from '../../storage/base-store.js';
4
+ import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
5
5
  const BUFFER_VERSION = 1;
6
6
  const MEMORY_MD_PATH = '/Users/kevin/.claude/projects/-Users-kevin-Documents-desarrollo-sdd-mcp-server/memory/MEMORY.md';
7
- const MAX_RETRIES = 3;
7
+ const MAX_RETRIES = 50;
8
8
  const JITTER_MS = 100;
9
9
  function bufferPath(projectPath) {
10
10
  const hash = hashProjectPath(projectPath);
11
- return join('data', 'projects', hash, 'learnings-buffer.json');
11
+ return join(projectDataDir(hash), 'learnings-buffer.json');
12
12
  }
13
13
  function emptyBuffer() {
14
14
  return { version: BUFFER_VERSION, entries: [] };
@@ -40,7 +40,6 @@ export async function appendToBuffer(projectPath, entry) {
40
40
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
41
41
  try {
42
42
  const fh = await open(lockFile, 'wx');
43
- await fh.close();
44
43
  try {
45
44
  const buffer = await readBuffer(projectPath);
46
45
  buffer.entries.push(entry);
@@ -48,7 +47,12 @@ export async function appendToBuffer(projectPath, entry) {
48
47
  }
49
48
  finally {
50
49
  try {
51
- const { unlink } = await import('node:fs/promises');
50
+ await fh.close();
51
+ }
52
+ catch {
53
+ /* best-effort */
54
+ }
55
+ try {
52
56
  await unlink(lockFile);
53
57
  }
54
58
  catch {
@@ -68,15 +72,9 @@ export async function appendToBuffer(projectPath, entry) {
68
72
  await new Promise((resolve) => setTimeout(resolve, JITTER_MS + Math.random() * JITTER_MS));
69
73
  }
70
74
  }
71
- // Fallback: direct write without lock
72
- try {
73
- const buffer = await readBuffer(projectPath);
74
- buffer.entries.push(entry);
75
- await writeBuffer(projectPath, buffer);
76
- }
77
- catch {
78
- throw lastError;
79
- }
75
+ throw lastError instanceof Error
76
+ ? lastError
77
+ : new Error('Unable to acquire learnings buffer lock');
80
78
  }
81
79
  /** Build the markdown bullet for an entry to append to MEMORY.md. */
82
80
  function entryToMarkdown(entry) {
@@ -42,6 +42,11 @@ export function generateLeanSpecContent(input) {
42
42
  ` model: ${estimation.recommendedModel}`,
43
43
  `model: ${model}`,
44
44
  `budget: ${String(budget)}`,
45
+ ...(spec.generatedWithModel ? [`generatedWithModel: ${spec.generatedWithModel}`] : []),
46
+ ...(spec.generatedAt ? [`generatedAt: ${spec.generatedAt}`] : []),
47
+ ...(spec.qualityWarnings && spec.qualityWarnings.length > 0
48
+ ? [`qualityWarnings: ${JSON.stringify(spec.qualityWarnings)}`]
49
+ : []),
45
50
  ];
46
51
  let acLines;
47
52
  if (acFormat === 'bdd') {
@@ -0,0 +1,5 @@
1
+ import type { SpecContentGenerator, SpecGenerationRequest, SpecGenerationResult } from '../../types/index.js';
2
+ export declare class FallbackGenerator implements SpecContentGenerator {
3
+ generate(request: SpecGenerationRequest): Promise<SpecGenerationResult>;
4
+ }
5
+ //# sourceMappingURL=fallback-generator.d.ts.map
@@ -0,0 +1,120 @@
1
+ const MAX_SUMMARY_LENGTH = 700;
2
+ export class FallbackGenerator {
3
+ generate(request) {
4
+ const slug = slugify(request.title);
5
+ const pascalName = toPascalCase(request.title);
6
+ const sourceSummary = normalizeSummary(request.description, request.title);
7
+ const contextLine = buildContextLine(request);
8
+ const technicalSection = buildTechnicalSection(slug, pascalName, contextLine);
9
+ const specBody = [
10
+ '## Problem',
11
+ sourceSummary,
12
+ '',
13
+ '## Solution',
14
+ `Implement ${request.title} with explicit behavior, project-scoped file ownership, and verification gates. ${contextLine}`,
15
+ '',
16
+ '## Acceptance Criteria',
17
+ '',
18
+ `Scenario: ${request.title} happy path`,
19
+ 'GIVEN the relevant user or system context exists',
20
+ `WHEN the requested ${request.title} behavior is executed`,
21
+ 'THEN the expected outcome from the request is observable',
22
+ '',
23
+ `Scenario: ${request.title} failure handling`,
24
+ 'GIVEN invalid input or an unavailable dependency',
25
+ `WHEN the requested ${request.title} behavior runs`,
26
+ 'THEN the system returns a clear failure state without corrupting data',
27
+ '',
28
+ `Scenario: ${request.title} verification`,
29
+ 'GIVEN the implementation is complete',
30
+ 'WHEN the configured verification commands run',
31
+ 'THEN tests and lint pass without regressions',
32
+ '',
33
+ technicalSection,
34
+ ].join('\n');
35
+ return Promise.resolve({
36
+ specBody,
37
+ technicalSection,
38
+ generatedWithModel: 'deterministic-fallback',
39
+ generatedAt: new Date().toISOString(),
40
+ qualityWarnings: [
41
+ 'Fallback generator used. Review candidate file paths and replace placeholders with exact code owners before approval.',
42
+ ],
43
+ fallbackReason: 'No external model generator is configured for create_spec.',
44
+ });
45
+ }
46
+ }
47
+ function normalizeSummary(description, title) {
48
+ const compact = description
49
+ .replace(/\r\n/g, '\n')
50
+ .split('\n')
51
+ .map((line) => line.trim())
52
+ .filter((line) => line.length > 0 && !line.startsWith('- [ ]'))
53
+ .join(' ')
54
+ .replace(/\s+/g, ' ')
55
+ .trim();
56
+ const summary = compact.length > 0
57
+ ? compact.slice(0, MAX_SUMMARY_LENGTH)
58
+ : `The project needs ${title} defined with enough implementation detail to proceed safely.`;
59
+ return summary.endsWith('.') ? summary : `${summary}.`;
60
+ }
61
+ function buildContextLine(request) {
62
+ const parts = [
63
+ request.projectContext?.language,
64
+ request.projectContext?.framework,
65
+ request.projectContext?.architecture,
66
+ ].filter((part) => part !== undefined && part.trim().length > 0);
67
+ if (parts.length === 0) {
68
+ return 'Use the existing project architecture and conventions.';
69
+ }
70
+ return `Use the existing ${parts.join(' / ')} project conventions.`;
71
+ }
72
+ function buildTechnicalSection(slug, pascalName, contextLine) {
73
+ return [
74
+ '## Technical',
75
+ '',
76
+ '### Implementation Notes',
77
+ `- ${contextLine}`,
78
+ '- Replace candidate paths with exact files before moving the spec to approved.',
79
+ '',
80
+ '## Files',
81
+ '',
82
+ '### Create',
83
+ `- src/${slug}.ts (candidate path; replace with exact owner during planning)`,
84
+ '### Modify',
85
+ '- src/index.ts (candidate integration point; replace with exact owner during planning)',
86
+ '### Test',
87
+ `- tests/${slug}.test.ts (focused coverage for the behavior and failure path)`,
88
+ '',
89
+ '## Type Signatures',
90
+ '```typescript',
91
+ `interface ${pascalName}Input {`,
92
+ ' readonly requestId?: string;',
93
+ '}',
94
+ '',
95
+ `interface ${pascalName}Result {`,
96
+ ' readonly ok: boolean;',
97
+ ' readonly reason?: string;',
98
+ '}',
99
+ '```',
100
+ '',
101
+ '## Verification',
102
+ '- pnpm typecheck',
103
+ '- pnpm lint',
104
+ '- pnpm test',
105
+ ].join('\n');
106
+ }
107
+ function slugify(input) {
108
+ const slug = input
109
+ .toLowerCase()
110
+ .replace(/[^a-z0-9]+/g, '-')
111
+ .replace(/^-+|-+$/g, '');
112
+ return slug.length > 0 ? slug : 'generated-spec';
113
+ }
114
+ function toPascalCase(input) {
115
+ const words = input.match(/[a-zA-Z0-9]+/g) ?? ['Generated', 'Spec'];
116
+ return words
117
+ .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`)
118
+ .join('');
119
+ }
120
+ //# sourceMappingURL=fallback-generator.js.map
@@ -0,0 +1,2 @@
1
+ export { FallbackGenerator } from './fallback-generator.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export { FallbackGenerator } from './fallback-generator.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Validate that persisted spec documentation is authored in English.
3
+ *
4
+ * The detector is intentionally conservative: it ignores code fences, inline code,
5
+ * file paths, markdown headings, and BDD keywords before looking for common
6
+ * Spanish/Portuguese function words. It should block obvious non-English prose
7
+ * without punishing short technical descriptions.
8
+ */
9
+ export declare function validateEnglishOnlySpecText(text: string): {
10
+ ok: boolean;
11
+ detectedLanguage: 'en' | 'es' | 'pt' | 'unknown';
12
+ reason?: string;
13
+ signals: string[];
14
+ };
15
+ //# sourceMappingURL=english-only.d.ts.map
@@ -0,0 +1,142 @@
1
+ // engine/spec-language/english-only.ts — deterministic guard for English-only spec docs.
2
+ const MIN_PROSE_TOKENS = 8;
3
+ const MIN_LANGUAGE_HITS = 2;
4
+ const MIN_LANGUAGE_RATIO = 0.08;
5
+ const NON_ENGLISH_SIGNATURES = {
6
+ es: new Set([
7
+ 'abajo',
8
+ 'acepta',
9
+ 'actual',
10
+ 'actualiza',
11
+ 'cada',
12
+ 'como',
13
+ 'con',
14
+ 'cuando',
15
+ 'debajo',
16
+ 'de',
17
+ 'del',
18
+ 'dentro',
19
+ 'devuelve',
20
+ 'diseñan',
21
+ 'el',
22
+ 'en',
23
+ 'entre',
24
+ 'fija',
25
+ 'hacia',
26
+ 'hay',
27
+ 'la',
28
+ 'las',
29
+ 'lista',
30
+ 'los',
31
+ 'mismo',
32
+ 'muestra',
33
+ 'no',
34
+ 'oculta',
35
+ 'para',
36
+ 'por',
37
+ 'queda',
38
+ 'que',
39
+ 'renderiza',
40
+ 'reporta',
41
+ 'se',
42
+ 'sin',
43
+ 'usuario',
44
+ 'usa',
45
+ ]),
46
+ pt: new Set([
47
+ 'abaixo',
48
+ 'aceita',
49
+ 'atualiza',
50
+ 'cada',
51
+ 'com',
52
+ 'como',
53
+ 'quando',
54
+ 'da',
55
+ 'das',
56
+ 'de',
57
+ 'dentro',
58
+ 'devolve',
59
+ 'do',
60
+ 'dos',
61
+ 'em',
62
+ 'entre',
63
+ 'fica',
64
+ 'há',
65
+ 'lista',
66
+ 'mesmo',
67
+ 'mostra',
68
+ 'não',
69
+ 'oculta',
70
+ 'o',
71
+ 'os',
72
+ 'para',
73
+ 'por',
74
+ 'que',
75
+ 'renderiza',
76
+ 'sem',
77
+ 'usuário',
78
+ 'usa',
79
+ ]),
80
+ };
81
+ /**
82
+ * Validate that persisted spec documentation is authored in English.
83
+ *
84
+ * The detector is intentionally conservative: it ignores code fences, inline code,
85
+ * file paths, markdown headings, and BDD keywords before looking for common
86
+ * Spanish/Portuguese function words. It should block obvious non-English prose
87
+ * without punishing short technical descriptions.
88
+ */
89
+ export function validateEnglishOnlySpecText(text) {
90
+ const tokens = tokenizeProse(text);
91
+ if (tokens.length < MIN_PROSE_TOKENS) {
92
+ return { ok: true, detectedLanguage: 'unknown', signals: [] };
93
+ }
94
+ const scored = Object.keys(NON_ENGLISH_SIGNATURES).map((lang) => {
95
+ const signature = NON_ENGLISH_SIGNATURES[lang];
96
+ const hits = tokens.filter((token) => signature.has(token));
97
+ return {
98
+ lang,
99
+ hits,
100
+ ratio: hits.length / tokens.length,
101
+ };
102
+ });
103
+ const best = scored.sort((a, b) => b.hits.length - a.hits.length || b.ratio - a.ratio)[0];
104
+ if (!best || best.hits.length < MIN_LANGUAGE_HITS || best.ratio < MIN_LANGUAGE_RATIO) {
105
+ return { ok: true, detectedLanguage: 'en', signals: [] };
106
+ }
107
+ const signals = [...new Set(best.hits)].slice(0, 8);
108
+ return {
109
+ ok: false,
110
+ detectedLanguage: best.lang,
111
+ signals,
112
+ reason: `Spec documents must be written in English. Detected ${languageName(best.lang)} prose ` +
113
+ `signals: ${signals.join(', ')}.`,
114
+ };
115
+ }
116
+ function tokenizeProse(text) {
117
+ const withoutCode = text
118
+ .replace(/```[\s\S]*?```/g, ' ')
119
+ .replace(/~~~[\s\S]*?~~~/g, ' ')
120
+ .replace(/`[^`]*`/g, ' ')
121
+ .replace(/\b[a-zA-Z][\w.-]*(?:\/[\w.-]+)+\b/g, ' ')
122
+ .replace(/^---[\s\S]*?---/m, ' ');
123
+ return withoutCode
124
+ .toLowerCase()
125
+ .normalize('NFC')
126
+ .split(/[^\p{L}]+/u)
127
+ .filter((token) => token.length > 1)
128
+ .filter((token) => !isIgnoredToken(token));
129
+ }
130
+ function isIgnoredToken(token) {
131
+ return (token === 'given' ||
132
+ token === 'when' ||
133
+ token === 'then' ||
134
+ token === 'and' ||
135
+ token === 'or' ||
136
+ token === 'ac' ||
137
+ token === 'bdd');
138
+ }
139
+ function languageName(lang) {
140
+ return lang === 'es' ? 'Spanish' : 'Portuguese';
141
+ }
142
+ //# sourceMappingURL=english-only.js.map
@@ -28,6 +28,20 @@ export async function migrateToUnified(specDir, projectPath) {
28
28
  try {
29
29
  const specContent = await readFile(specPath, 'utf-8');
30
30
  if (/\n## Technical(\n|$)/.test(specContent)) {
31
+ try {
32
+ await readFile(techPath, 'utf-8');
33
+ if (projectPath) {
34
+ await safeUnlink(projectPath, techPath);
35
+ }
36
+ else {
37
+ const { unlink } = await import('node:fs/promises');
38
+ await unlink(techPath);
39
+ }
40
+ return { specDir, merged: true, reason: 'already-unified' };
41
+ }
42
+ catch {
43
+ /* no residual technical.md */
44
+ }
31
45
  return { specDir, merged: false, reason: 'already-unified' };
32
46
  }
33
47
  let techContent;
@@ -125,7 +125,7 @@ export async function reconcileStatusFromDisk(projectPath, projectId) {
125
125
  reconcileReason: `count mismatch: status.json had ${String(current.totalSpecs)}, disk has ${String(diskTotal)}`,
126
126
  };
127
127
  try {
128
- await writeJsonSafe(statusPath, updated);
128
+ await writeJsonSafe(statusPath, updated, { verify: false });
129
129
  }
130
130
  catch {
131
131
  // Best-effort — if write fails, return result without throwing
package/dist/index.js CHANGED
@@ -63,6 +63,7 @@ const server = createMcpServer();
63
63
  async function setupPostHandshake(handshakeGate) {
64
64
  // Wait for initialize + notifications/initialized before firing any notifications.
65
65
  await handshakeGate;
66
+ const envKey = process.env.SDD_LICENSE_KEY;
66
67
  const [{ bootstrapAutopilotHandlers }, { registerAgentSquadHandlers }, { startupSync }, { checkForUpdates }, { setConnectedClient }, { handleActivateLicense }, { GroupManager }, { setGroupManager }, { registerSddTools, OFFICIAL_SDD_TOOL_NAMES },] = await Promise.all([
67
68
  import('./engine/autopilot/bootstrap.js'),
68
69
  import('./tools/register-agent-squad-tools.js'),
@@ -109,7 +110,6 @@ async function setupPostHandshake(handshakeGate) {
109
110
  console.error('[Planu] Startup spec state sync failed:', err);
110
111
  });
111
112
  // Auto-activate license from environment variable if present.
112
- const envKey = process.env.SDD_LICENSE_KEY;
113
113
  if (envKey) {
114
114
  const { licenseStore: ls } = await import('./storage/index.js');
115
115
  const existingState = await ls.getLicenseState().catch(() => null);
@@ -17,6 +17,8 @@ import { runAutoPostCreatePipeline, formatPipelineLines } from './create-spec/au
17
17
  import { generateLeanSpecContent } from '../engine/spec-format/lean-spec-generator.js';
18
18
  import { generateLeanTechnicalContent } from '../engine/spec-format/lean-technical-generator.js';
19
19
  import { buildUnifiedSpecContent } from '../engine/spec-format/unified-spec-builder.js';
20
+ import { validateEnglishOnlySpecText } from '../engine/spec-language/english-only.js';
21
+ import { FallbackGenerator } from '../engine/spec-generator/index.js';
20
22
  import { analyzeProjectForSpec, getEmptyAutopilotResult, } from './create-spec/autopilot-analyzer.js';
21
23
  import { AutopilotSummaryCollector } from '../engine/autopilot/summary-collector.js';
22
24
  import { trackCost } from '../engine/cost-tracking/operation-tracker.js';
@@ -327,6 +329,28 @@ async function findRecentlyWrittenSpecs(projectPath, windowMs) {
327
329
  }
328
330
  const DESCRIPTION_MAX_CHARS = 10_000;
329
331
  const DESCRIPTION_EXCEED_MSG = 'Description exceeds 10000 chars. Create with a summary (<3k) and use reconcile_spec to add the rest.';
332
+ function checkEnglishOnlyInput(params) {
333
+ const validation = validateEnglishOnlySpecText(`${params.title}\n\n${params.description}`);
334
+ if (validation.ok) {
335
+ return null;
336
+ }
337
+ return {
338
+ content: [
339
+ {
340
+ type: 'text',
341
+ text: `English-only spec gate blocked create_spec.\n\n${validation.reason ?? 'Non-English prose detected.'}\n\n` +
342
+ 'Rewrite the title and description in English, then call create_spec again. User-facing responses may be localized, but spec.md must be English.',
343
+ },
344
+ ],
345
+ isError: true,
346
+ structuredContent: {
347
+ error: 'SPEC_LANGUAGE_GATE_BLOCKED',
348
+ detectedLanguage: validation.detectedLanguage,
349
+ signals: validation.signals,
350
+ fixHint: 'Rewrite title and description in English before creating the spec.',
351
+ },
352
+ };
353
+ }
330
354
  // eslint-disable-next-line max-lines-per-function
331
355
  export async function handleCreateSpec(inputParams, server) {
332
356
  // SPEC-781: Friendly error for description exceeding 10000 chars (replaces cryptic Zod error)
@@ -363,6 +387,10 @@ export async function handleCreateSpec(inputParams, server) {
363
387
  inputParams.specId) {
364
388
  return handleAgentTeamSynthesis(inputParams.specId, resolvedPath, inputParams.agentTeamFindings);
365
389
  }
390
+ const languageGate = checkEnglishOnlyInput(resolvedInputParams);
391
+ if (languageGate) {
392
+ return languageGate;
393
+ }
366
394
  // SPEC-665: Fire-and-forget TTL refresh for conventions.json (never blocks create_spec)
367
395
  void import('../engine/conventions-ttl.js').then(({ triggerConventionsTtlRefresh }) => {
368
396
  void triggerConventionsTtlRefresh(resolvedPath).catch(() => {
@@ -463,9 +491,26 @@ export async function handleCreateSpec(inputParams, server) {
463
491
  await measureStep('mkdir-specDir', () => mkdir(specDir, { recursive: true }));
464
492
  // SPEC-586: Filter suggested criteria by spec tags/target/scope before injection
465
493
  const filteredCriteria = await filterCriteriaByTags(autopilot.suggestedCriteria, spec.tags, spec.target, spec.scope).catch(() => autopilot.suggestedCriteria);
494
+ const specGenerator = new FallbackGenerator();
495
+ const generatedSpec = await measureStep('generateSpecBody', () => specGenerator.generate({
496
+ title: spec.title,
497
+ description,
498
+ type: spec.type,
499
+ scope: spec.scope,
500
+ target: spec.target,
501
+ acFormat: params.acFormat,
502
+ projectContext: {
503
+ language: knowledge?.language ?? undefined,
504
+ framework: knowledge?.framework ?? undefined,
505
+ architecture: knowledge?.architecture.primary ?? undefined,
506
+ },
507
+ }));
508
+ spec.generatedWithModel = generatedSpec.generatedWithModel;
509
+ spec.generatedAt = generatedSpec.generatedAt;
510
+ spec.qualityWarnings = generatedSpec.qualityWarnings;
466
511
  const leanSpec = generateLeanSpecContent({
467
512
  spec,
468
- description,
513
+ description: generatedSpec.specBody,
469
514
  estimation,
470
515
  extraCriteria: filteredCriteria,
471
516
  acFormat: params.acFormat,
@@ -339,14 +339,16 @@ export async function handleInitProject(params, server) {
339
339
  void withAudit(projectPath, 'init_project', 'configurePlanuHooks', () => configurePlanuHooks(projectPath)).catch(() => {
340
340
  /* best-effort */
341
341
  });
342
- // SPEC-779: Install universal rules catalog (fire-and-forget)
343
- void (async () => {
342
+ // SPEC-779: Install universal rules catalog before returning so init_project
343
+ // has no delayed project mutations after the tool result is delivered.
344
+ try {
344
345
  const hostHint = detectHost();
345
346
  const host = hostHint === 'codex' ? 'codex' : hostHint === 'gemini' ? 'gemini' : 'claude-code';
346
347
  await installUniversalRules(projectPath, host);
347
- })().catch(() => {
348
+ }
349
+ catch {
348
350
  /* best-effort */
349
- });
351
+ }
350
352
  // SPEC-566: Auto-configure Claude Code keybindings (shift+enter → submit) — fire-and-forget
351
353
  void configureClaudeKeybindings()
352
354
  .then((configured) => configured ? globalStore.updateGlobalConfig({ keybindingsConfigured: true }) : undefined)
@@ -3,7 +3,6 @@ import { specStore, knowledgeStore } from '../storage/index.js';
3
3
  import { buildListSpecsSummary } from '../engine/human-summary.js';
4
4
  import { dirname } from 'node:path';
5
5
  import { checkBundledVersionGap } from '../engine/version-detector/bundled-version-checker.js';
6
- import { checkAndFixBundledVersion } from '../engine/mcp-config/mcp-config-writer.js';
7
6
  import { readLastModifiedTimestamp, formatRelativeDate } from '../engine/spec-changelog/index.js';
8
7
  import { discoverAndFlattenSpecs, filterUnprefixedSpecs, importFilesystemSpecs, migrateSpecFolderNames, reconcileSpecPaths, scanForAmbiguousCriteria, } from '../engine/spec-migrator.js';
9
8
  import { detectDrift, formatDriftMessage } from '../engine/spec-migrator/drift-detector.js';
@@ -109,15 +108,8 @@ export async function handleListSpecs(params) {
109
108
  const collector = new AutopilotSummaryCollector();
110
109
  // SPEC-756: lazy version check — fire-and-forget, non-blocking
111
110
  if (params.projectPath) {
112
- const lazyPath = params.projectPath;
113
111
  checkBundledVersionGap(2000)
114
- .then((gap) => {
115
- if (gap.gap) {
116
- checkAndFixBundledVersion(lazyPath, gap.current, gap.latest).catch(() => {
117
- /* best-effort — never block list_specs */
118
- });
119
- }
120
- })
112
+ .then(() => undefined)
121
113
  .catch(() => {
122
114
  /* best-effort */
123
115
  });
@@ -151,21 +143,6 @@ export async function handleListSpecs(params) {
151
143
  if (knowledge.projectPath) {
152
144
  await autoDiscoverProject(projectId, knowledge.projectPath, collector);
153
145
  }
154
- // SPEC-768: Auto-migrate legacy technical.md into unified spec.md (non-blocking)
155
- if (knowledge.projectPath) {
156
- try {
157
- const { runSsrBackMigration } = await import('../engine/spec-migrator/ssr-back-migration.js');
158
- await runSsrBackMigration({
159
- projectPath: knowledge.projectPath,
160
- dryRun: false,
161
- foldProgressMd: true,
162
- allowDoneSpecs: false,
163
- });
164
- }
165
- catch {
166
- /* best-effort — never block list_specs */
167
- }
168
- }
169
146
  // Get all specs
170
147
  let specs = await specStore.listSpecs(projectId);
171
148
  // Apply filters
@@ -59,12 +59,15 @@ export declare function checkDorGate(spec: Spec, specId: string, projectId: stri
59
59
  */
60
60
  export declare function checkAmbiguityGate(spec: Spec, newStatus: SpecStatus): Promise<ToolResult | null>;
61
61
  /**
62
- * SPEC-769: Readiness gate — block 'approved' when spec quality is too low.
62
+ * SPEC-769/SPEC-492: Readiness gate — block 'review'/'approved' when spec
63
+ * quality is too low or the persisted spec document is not English.
63
64
  * Best-effort: if spec.md cannot be read, the gate is skipped (returns no block).
64
65
  *
65
66
  * - 0 criteria → InteractiveQuestion (hard block, no force path)
66
- * - score < 70 && !forceApprove → InteractiveQuestion with 3 options
67
- * - score < 70 && forceApprove → proceed, qualityWarnings populated
67
+ * - score < 70 on approved && !forceApprove → InteractiveQuestion with 3 options
68
+ * - score < 70 on approved && forceApprove → proceed, qualityWarnings populated
69
+ * - score < 70 on review → block and keep draft
70
+ * - non-English prose → hard block
68
71
  * - score >= 70 → proceed, no warnings
69
72
  */
70
73
  export declare function checkReadinessGate(spec: Spec, newStatus: SpecStatus, forceApprove: boolean | undefined): Promise<ReadinessGateOutput>;
@@ -5,6 +5,7 @@ import { validateDoR } from '../../engine/dor-dod.js';
5
5
  import { dispatchFeedbackEvent } from '../learn.js';
6
6
  import { scoreAmbiguityFromPath } from '../../engine/ambiguity-scorer.js';
7
7
  import { checkReadinessInternal } from '../../engine/readiness-checker.js';
8
+ import { validateEnglishOnlySpecText } from '../../engine/spec-language/english-only.js';
8
9
  /**
9
10
  * Valid state transitions for spec lifecycle.
10
11
  * draft -> review -> approved -> implementing -> done
@@ -173,16 +174,19 @@ export async function checkAmbiguityGate(spec, newStatus) {
173
174
  };
174
175
  }
175
176
  /**
176
- * SPEC-769: Readiness gate — block 'approved' when spec quality is too low.
177
+ * SPEC-769/SPEC-492: Readiness gate — block 'review'/'approved' when spec
178
+ * quality is too low or the persisted spec document is not English.
177
179
  * Best-effort: if spec.md cannot be read, the gate is skipped (returns no block).
178
180
  *
179
181
  * - 0 criteria → InteractiveQuestion (hard block, no force path)
180
- * - score < 70 && !forceApprove → InteractiveQuestion with 3 options
181
- * - score < 70 && forceApprove → proceed, qualityWarnings populated
182
+ * - score < 70 on approved && !forceApprove → InteractiveQuestion with 3 options
183
+ * - score < 70 on approved && forceApprove → proceed, qualityWarnings populated
184
+ * - score < 70 on review → block and keep draft
185
+ * - non-English prose → hard block
182
186
  * - score >= 70 → proceed, no warnings
183
187
  */
184
188
  export async function checkReadinessGate(spec, newStatus, forceApprove) {
185
- if (newStatus !== 'approved' || !spec.specPath) {
189
+ if ((newStatus !== 'review' && newStatus !== 'approved') || !spec.specPath) {
186
190
  return { blockResult: null, qualityWarnings: [] };
187
191
  }
188
192
  let body;
@@ -197,6 +201,29 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
197
201
  if (typeof body !== 'string') {
198
202
  return { blockResult: null, qualityWarnings: [] };
199
203
  }
204
+ const languageValidation = validateEnglishOnlySpecText(body);
205
+ if (!languageValidation.ok) {
206
+ return {
207
+ blockResult: {
208
+ content: [
209
+ {
210
+ type: 'text',
211
+ text: `English-only spec gate blocked transition to ${newStatus}.\n\n` +
212
+ `${languageValidation.reason ?? 'Non-English prose detected.'}\n\n` +
213
+ 'Rewrite spec.md in English before moving it forward.',
214
+ },
215
+ ],
216
+ isError: true,
217
+ structuredContent: {
218
+ error: 'SPEC_LANGUAGE_GATE_BLOCKED',
219
+ detectedLanguage: languageValidation.detectedLanguage,
220
+ signals: languageValidation.signals,
221
+ fixHint: 'Rewrite spec.md in English before review or approval.',
222
+ },
223
+ },
224
+ qualityWarnings: [],
225
+ };
226
+ }
200
227
  let score;
201
228
  let warnings;
202
229
  let criteriaCount;
@@ -207,6 +234,27 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
207
234
  return { blockResult: null, qualityWarnings: [] };
208
235
  }
209
236
  if (criteriaCount === 0) {
237
+ if (newStatus === 'review') {
238
+ return {
239
+ blockResult: {
240
+ content: [
241
+ {
242
+ type: 'text',
243
+ text: 'Readiness gate blocked transition to review: spec has 0 acceptance criteria.',
244
+ },
245
+ ],
246
+ isError: true,
247
+ structuredContent: {
248
+ error: 'READINESS_GATE_BLOCKED',
249
+ score,
250
+ threshold: 70,
251
+ warnings,
252
+ fixHint: 'Add at least 3 testable acceptance criteria before moving to review.',
253
+ },
254
+ },
255
+ qualityWarnings: [],
256
+ };
257
+ }
210
258
  const question = {
211
259
  header: 'Spec incomplete',
212
260
  question: 'This spec has 0 acceptance criteria. Add criteria before approving — transition blocked.',
@@ -244,6 +292,29 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
244
292
  if (score >= 70) {
245
293
  return { blockResult: null, qualityWarnings: [] };
246
294
  }
295
+ if (newStatus === 'review') {
296
+ const warningsSummary = warnings.slice(0, 3).join('; ');
297
+ return {
298
+ blockResult: {
299
+ content: [
300
+ {
301
+ type: 'text',
302
+ text: `Readiness gate blocked transition to review: score ${String(score)}/100 — below 70 threshold. ` +
303
+ `Warnings: ${warningsSummary}`,
304
+ },
305
+ ],
306
+ isError: true,
307
+ structuredContent: {
308
+ error: 'READINESS_GATE_BLOCKED',
309
+ score,
310
+ threshold: 70,
311
+ warnings,
312
+ fixHint: 'Complete the spec before moving from draft to review.',
313
+ },
314
+ },
315
+ qualityWarnings: [],
316
+ };
317
+ }
247
318
  // Score < 70
248
319
  if (forceApprove) {
249
320
  // Caller wants to force through — proceed with warnings attached
@@ -59,6 +59,7 @@ export * from './hooks.js';
59
59
  export * from './webhook.js';
60
60
  export * from './ci.js';
61
61
  export * from './spec-templates.js';
62
+ export * from './spec-generator.js';
62
63
  export * from './registry.js';
63
64
  export * from './tool-groups.js';
64
65
  export * from './code-transforms.js';
@@ -60,6 +60,7 @@ export * from './hooks.js';
60
60
  export * from './webhook.js';
61
61
  export * from './ci.js';
62
62
  export * from './spec-templates.js';
63
+ export * from './spec-generator.js';
63
64
  export * from './registry.js';
64
65
  export * from './tool-groups.js';
65
66
  export * from './code-transforms.js';
@@ -57,6 +57,12 @@ export interface Spec {
57
57
  model?: 'haiku' | 'sonnet' | 'opus';
58
58
  /** SPEC-630: token budget derived from devHours. */
59
59
  budget?: 800 | 2000 | 4000;
60
+ /** SPEC-766: Generator that produced the initial spec body. */
61
+ generatedWithModel?: string;
62
+ /** SPEC-766: ISO timestamp for initial spec body generation. */
63
+ generatedAt?: string;
64
+ /** SPEC-766: Non-blocking quality warnings emitted by the generator. */
65
+ qualityWarnings?: string[];
60
66
  /** SPEC-686: Implementation priority for ROI scoring. Defaults to 'medium'. */
61
67
  priority?: 'high' | 'medium' | 'low';
62
68
  /**
@@ -0,0 +1,26 @@
1
+ import type { SpecScope, SpecTarget, SpecType } from './common/index.js';
2
+ export interface SpecGenerationRequest {
3
+ title: string;
4
+ description: string;
5
+ type: SpecType;
6
+ scope: SpecScope;
7
+ target: SpecTarget;
8
+ acFormat?: 'checkbox' | 'bdd';
9
+ projectContext?: {
10
+ language?: string;
11
+ framework?: string;
12
+ architecture?: string;
13
+ };
14
+ }
15
+ export interface SpecGenerationResult {
16
+ specBody: string;
17
+ technicalSection: string;
18
+ generatedWithModel: string;
19
+ generatedAt: string;
20
+ qualityWarnings: string[];
21
+ fallbackReason?: string;
22
+ }
23
+ export interface SpecContentGenerator {
24
+ generate(request: SpecGenerationRequest): Promise<SpecGenerationResult>;
25
+ }
26
+ //# sourceMappingURL=spec-generator.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=spec-generator.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "3.9.1",
3
+ "version": "3.9.3",
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",