@planu/cli 4.11.2 → 4.11.4

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 (48) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/config/elicitation-questions.json +5 -17
  3. package/dist/engine/elicitation/question-generator.js +10 -3
  4. package/dist/engine/implementation-contract/evaluator.js +4 -1
  5. package/dist/engine/implementation-contract/renderer.js +17 -39
  6. package/dist/engine/lifecycle-hints.d.ts +1 -1
  7. package/dist/engine/lifecycle-hints.js +13 -29
  8. package/dist/engine/spec-format/lean-spec-generator.d.ts +2 -2
  9. package/dist/engine/spec-format/lean-spec-generator.js +9 -10
  10. package/dist/engine/spec-generator/fallback-generator.js +13 -20
  11. package/dist/engine/spec-grounding/contract.d.ts +5 -0
  12. package/dist/engine/spec-grounding/contract.js +10 -6
  13. package/dist/engine/spec-migrator/lean-migration.js +9 -16
  14. package/dist/engine/web-fetcher/stack-advisor.d.ts +2 -2
  15. package/dist/engine/web-fetcher/stack-advisor.js +78 -17
  16. package/dist/tools/challenge-spec/challenge-report.d.ts +16 -0
  17. package/dist/tools/challenge-spec/challenge-report.js +120 -0
  18. package/dist/tools/challenge-spec.js +13 -8
  19. package/dist/tools/clarify-requirements/multiple-choice.js +1 -1
  20. package/dist/tools/clarify-requirements/questions-context.js +11 -11
  21. package/dist/tools/clarify-requirements/questions.js +3 -23
  22. package/dist/tools/clarify-requirements.js +38 -0
  23. package/dist/tools/create-spec/autopilot-analyzer.d.ts +1 -1
  24. package/dist/tools/create-spec/autopilot-analyzer.js +29 -54
  25. package/dist/tools/create-spec/post-creation.js +23 -4
  26. package/dist/tools/create-spec.js +47 -144
  27. package/dist/tools/elicit-requirements-handler.js +20 -12
  28. package/dist/tools/register-spec-tools/core-spec-tools.js +11 -2
  29. package/dist/tools/suggest-stack.js +49 -13
  30. package/dist/tools/update-status/dod-gates.d.ts +8 -0
  31. package/dist/tools/update-status/dod-gates.js +68 -43
  32. package/dist/tools/update-status/index.js +109 -74
  33. package/dist/tools/update-status/qa-gate.js +26 -0
  34. package/dist/tools/update-status/transition-guard.d.ts +2 -2
  35. package/dist/tools/update-status/transition-guard.js +14 -7
  36. package/dist/tools/validate-lint.d.ts +1 -1
  37. package/dist/tools/validate-lint.js +39 -1
  38. package/dist/types/spec/core.d.ts +20 -0
  39. package/dist/types/spec/inputs.d.ts +1 -1
  40. package/dist/types/stack/index.d.ts +1 -1
  41. package/dist/types/stack/recommend.d.ts +10 -0
  42. package/package.json +9 -9
  43. package/planu-native.json +1 -1
  44. package/planu-plugin.json +1 -1
  45. package/dist/engine/elicitation/non-interactive-defaults.d.ts +0 -9
  46. package/dist/engine/elicitation/non-interactive-defaults.js +0 -28
  47. package/dist/tools/clarify-requirements/interview-mode.d.ts +0 -42
  48. package/dist/tools/clarify-requirements/interview-mode.js +0 -342
@@ -19,7 +19,7 @@ export function getEmptyAutopilotResult() {
19
19
  * Analyze the project to enrich a spec with real file paths and patterns.
20
20
  * Runs automatically during create_spec — no user interaction needed.
21
21
  */
22
- export async function analyzeProjectForSpec(projectPath, description, title, knowledge) {
22
+ export async function analyzeProjectForSpec(projectPath, description, title, _knowledge) {
23
23
  const result = {
24
24
  suggestedFiles: { create: [], modify: [], test: [] },
25
25
  detectedPatterns: [],
@@ -68,10 +68,8 @@ export async function analyzeProjectForSpec(projectPath, description, title, kno
68
68
  catch {
69
69
  // Best-effort — file scanning failure doesn't block spec creation
70
70
  }
71
- // Detect stack patterns and suggest relevant criteria
72
- if (knowledge) {
73
- result.detectedPatterns = detectPatterns(knowledge, description);
74
- }
71
+ // Detect patterns only from affirmed request wording.
72
+ result.detectedPatterns = detectPatterns(description);
75
73
  return result;
76
74
  }
77
75
  /** Check if description contains technical terms (stack, framework, or code references). */
@@ -89,7 +87,23 @@ function hasTechnicalTerms(description) {
89
87
  /\.\w{2,4}\b/, // file extensions like .ts, .py
90
88
  /src\/|tests\/|lib\/|app\//, // file paths
91
89
  ];
92
- return techTerms.some((re) => re.test(description));
90
+ return techTerms.some((pattern) => hasAffirmedPattern(description, pattern));
91
+ }
92
+ const NEGATIVE_CONTEXT_RE = /\b(?:no|not|never|without|exclude|excluding|forbid|forbidden|remove|avoid|must not|should not|do not|does not|don't|doesn't|out[- ]of[- ]scope|unsupported|unrelated|false positive)\b/i;
93
+ const INCIDENTAL_CONTEXT_RE = /\b(?:example|for example|e\.g\.|quoted|regression|incorrectly inferred|must not infer)\b/i;
94
+ function hasAffirmedPattern(description, pattern) {
95
+ const withoutQuotedText = description
96
+ .replace(/```[\s\S]*?```/g, ' ')
97
+ .replace(/`[^`]+`/g, ' ')
98
+ .replace(/"[^"]+"|'[^']+'/g, ' ');
99
+ return withoutQuotedText.split(/(?:\r?\n|[.;!?]+)/).some((segment) => {
100
+ const match = new RegExp(pattern.source, pattern.flags.replace('g', '')).exec(segment);
101
+ if (!match) {
102
+ return false;
103
+ }
104
+ const prefix = segment.slice(0, match.index);
105
+ return !NEGATIVE_CONTEXT_RE.test(prefix) && !INCIDENTAL_CONTEXT_RE.test(prefix);
106
+ });
93
107
  }
94
108
  /**
95
109
  * SPEC-619 AC1: Detect broad-scope descriptions that mention 2+ major subsystems.
@@ -100,8 +114,7 @@ function hasTechnicalTerms(description) {
100
114
  * one feature, not two independent systems).
101
115
  */
102
116
  export function detectBroadScope(description) {
103
- const lower = description.toLowerCase();
104
- const wordCount = lower.trim().split(/\s+/).length;
117
+ const wordCount = description.trim().split(/\s+/).length;
105
118
  const subsystems = [
106
119
  /\b(auth|authentication|login|signup|sign.?up|register|oauth|sso)\b/,
107
120
  /\b(payment|billing|invoice|subscription|stripe|checkout|pricing)\b/,
@@ -114,7 +127,7 @@ export function detectBroadScope(description) {
114
127
  /\b(user.?management|profile|account|role|permission|rbac)\b/,
115
128
  /\b(chat|messaging|real.?time|websocket|feed|timeline)\b/,
116
129
  ];
117
- const matched = subsystems.filter((re) => re.test(lower));
130
+ const matched = subsystems.filter((pattern) => hasAffirmedPattern(description, pattern));
118
131
  // Longer descriptions are specific enough to reference adjacent domain terms
119
132
  // without being genuinely broad (e.g. "Stripe billing + webhook handler" = one feature).
120
133
  const threshold = wordCount >= 20 ? 3 : 2;
@@ -135,62 +148,24 @@ function inferTestPath(srcPath) {
135
148
  return `tests/${withoutExt}.test${ext}`;
136
149
  }
137
150
  /** Detect relevant patterns from project knowledge. */
138
- function detectPatterns(knowledge, description) {
151
+ function detectPatterns(description) {
139
152
  const patterns = [];
140
- const desc = description.toLowerCase();
141
- const hasDatabaseIntent = desc.includes('data') ||
142
- desc.includes('database') ||
143
- desc.includes('tabla') ||
144
- desc.includes('schema') ||
145
- desc.includes('rls') ||
146
- desc.includes('rpc') ||
147
- desc.includes('migration') ||
148
- desc.includes('persistence') ||
149
- desc.includes('data access');
150
- const hasUiIntent = desc.includes('component') ||
151
- desc.includes('ui') ||
152
- desc.includes('page') ||
153
- desc.includes('screen') ||
154
- desc.includes('form') ||
155
- desc.includes('layout') ||
156
- desc.includes('style') ||
157
- desc.includes('button') ||
158
- desc.includes('modal') ||
159
- desc.includes('dialog');
153
+ const hasDatabaseIntent = hasAffirmedPattern(description, /\b(?:data|database|tabla|schema|rls|rpc|migration|persistence|data access)\b/i);
160
154
  if (hasDatabaseIntent) {
161
155
  patterns.push('database');
162
156
  }
163
- if (knowledge.framework?.toLowerCase().includes('next') && desc.includes('page')) {
164
- patterns.push('nextjs-pages');
165
- }
166
- if (knowledge.framework?.toLowerCase().includes('react') && hasUiIntent) {
167
- patterns.push('react-components');
168
- }
169
- if (desc.includes('api') || desc.includes('endpoint')) {
157
+ if (hasAffirmedPattern(description, /\b(?:api|endpoint)\b/i)) {
170
158
  patterns.push('api-endpoint');
171
159
  }
172
- if (desc.includes('auth') || desc.includes('login')) {
160
+ if (hasAffirmedPattern(description, /\b(?:auth|authentication|login)\b/i)) {
173
161
  patterns.push('authentication');
174
162
  }
175
- if (desc.includes('test') || desc.includes('coverage')) {
163
+ if (hasAffirmedPattern(description, /\b(?:test|testing|coverage)\b/i)) {
176
164
  patterns.push('testing');
177
165
  }
178
- if (knowledge.stack.some((s) => s.toLowerCase().includes('supabase')) && hasDatabaseIntent) {
179
- patterns.push('supabase');
180
- }
181
166
  // SPEC-535: Detect LLM/foundation model features for EU AI Act Article 53-55 compliance
182
- if (desc.includes('llm') ||
183
- desc.includes('ai ') ||
184
- desc.includes(' ai') ||
185
- desc.includes('claude') ||
186
- desc.includes('openai') ||
187
- desc.includes('gpt') ||
188
- desc.includes('gemini') ||
189
- desc.includes('anthropic') ||
190
- desc.includes('chatbot') ||
191
- desc.includes('language model') ||
192
- desc.includes('foundation model') ||
193
- (desc.includes('chat') && desc.includes('model'))) {
167
+ if (hasAffirmedPattern(description, /\b(?:llm|ai|claude|openai|gpt|gemini|anthropic|chatbot|language model|foundation model)\b/i) ||
168
+ (hasAffirmedPattern(description, /\bchat\b/i) && hasAffirmedPattern(description, /\bmodel\b/i))) {
194
169
  patterns.push('llm-feature');
195
170
  }
196
171
  return patterns;
@@ -137,7 +137,23 @@ const TECH_STACK_PATTERNS = [
137
137
  ];
138
138
  /** Returns true if description references any known technology or version. */
139
139
  function hasTechStackReferences(description) {
140
- return TECH_STACK_PATTERNS.some((p) => p.test(description));
140
+ return TECH_STACK_PATTERNS.some((pattern) => hasAffirmedPattern(description, pattern));
141
+ }
142
+ const NEGATIVE_CONTEXT_RE = /\b(?:no|not|never|without|exclude|excluding|forbid|forbidden|remove|avoid|must not|should not|do not|does not|don't|doesn't|out[- ]of[- ]scope|unsupported|unrelated|false positive)\b/i;
143
+ const INCIDENTAL_CONTEXT_RE = /\b(?:example|for example|e\.g\.|quoted|regression|bug caused by|incorrectly inferred|must not infer)\b/i;
144
+ function hasAffirmedPattern(description, pattern) {
145
+ const withoutQuotedText = description
146
+ .replace(/```[\s\S]*?```/g, ' ')
147
+ .replace(/`[^`]+`/g, ' ')
148
+ .replace(/"[^"]+"|'[^']+'/g, ' ');
149
+ return withoutQuotedText.split(/(?:\r?\n|[.;!?]+)/).some((segment) => {
150
+ const match = new RegExp(pattern.source, pattern.flags.replace('g', '')).exec(segment);
151
+ if (!match) {
152
+ return false;
153
+ }
154
+ const prefix = segment.slice(0, match.index);
155
+ return !NEGATIVE_CONTEXT_RE.test(prefix) && !INCIDENTAL_CONTEXT_RE.test(prefix);
156
+ });
141
157
  }
142
158
  // === Serverless platform detection ===
143
159
  const SERVERLESS_PLATFORMS = ['vercel', 'netlify', 'lambda', 'cloudflare-workers', 'deno-deploy'];
@@ -185,12 +201,12 @@ export async function generatePostCreationSuggestions(projectPath, description,
185
201
  if (isServerlessKnowledge(knowledge)) {
186
202
  suggestions.push({
187
203
  tool: 'scan_project',
188
- reason: '[SERVERLESS WARNING] Module-level mutable state resets on cold starts. Avoid in-memory caches, singletons, or shared mutable variables for cross-request data — use Redis or a database instead.',
204
+ reason: '[SERVERLESS WARNING] Module-level mutable state may reset on cold starts. Confirm the required state lifetime and storage strategy before implementation.',
189
205
  });
190
206
  }
191
- // Item 4: Suggest templates when description matches known patterns
207
+ // Item 4: Suggest templates only when the user explicitly requests one.
192
208
  for (const { pattern, category } of TEMPLATE_PATTERNS) {
193
- if (pattern.test(description)) {
209
+ if (hasAffirmedPattern(description, pattern) && explicitlyRequestsTemplate(description)) {
194
210
  suggestions.push({
195
211
  tool: 'apply_template',
196
212
  reason: `Description matches "${category}" pattern. Consider using an ${category} template.`,
@@ -205,6 +221,9 @@ export async function generatePostCreationSuggestions(projectPath, description,
205
221
  });
206
222
  return suggestions;
207
223
  }
224
+ function explicitlyRequestsTemplate(description) {
225
+ return /\b(?:use|apply|start from|base (?:this|it) on)\s+(?:an?\s+)?(?:[\w-]+\s+)?template\b/i.test(description);
226
+ }
208
227
  /** Formats a structured post-creation suggestion for human-readable next steps. */
209
228
  export function formatPostCreationSuggestion(suggestion) {
210
229
  const templateSuffix = suggestion.templateCategory !== undefined ? ` (${suggestion.templateCategory})` : '';
@@ -7,14 +7,12 @@ import { formatSuccess, addNextSteps, toolResult, interactiveResult } from './re
7
7
  import { writeFile, mkdir, rm, readFile, stat as fsStat, rename, link as hardLink, } from 'node:fs/promises';
8
8
  import { createHash, randomUUID } from 'node:crypto';
9
9
  import { dirname as pathDirname, isAbsolute as pathIsAbsolute, join as pathJoin, relative as pathRelative, sep as pathSeparator, } from 'node:path';
10
- import { estimateSpec } from '../engine/estimator.js';
11
10
  import { checkSpecReadiness } from '../engine/readiness-checker.js';
12
11
  import { buildSpecContext, buildSplitResult } from './create-spec/spec-builder.js';
13
12
  import { validateConstitution } from './create-spec/constitution-validator.js';
14
13
  import { setupGitBranch, checkContradictions, fireSpecCreatedHook, generatePostCreationSuggestions, formatPostCreationSuggestion, runAutopilotAsync, getAsyncAnalysisPath, } from './create-spec/post-creation.js';
15
14
  import { notifyStoreChange } from '../engine/doc-generator/portal/regen-hook.js';
16
15
  import { compactObj } from '../engine/compact-obj.js';
17
- import { buildCreateSpecSummary } from '../engine/human-summary.js';
18
16
  import { runAutoPostCreatePipeline } from './create-spec/auto-pipeline.js';
19
17
  import { extractCriteria, generateLeanSpecContent, } from '../engine/spec-format/lean-spec-generator.js';
20
18
  import { generateLeanTechnicalContent, } from '../engine/spec-format/lean-technical-generator.js';
@@ -22,7 +20,7 @@ import { extractFilesFromSpecBody } from '../engine/spec-format/technical-md-pop
22
20
  import { buildUnifiedSpecContent } from '../engine/spec-format/unified-spec-builder.js';
23
21
  import { appendImplementationContractIfMissing } from '../engine/implementation-contract/index.js';
24
22
  import { validateEnglishOnlySpecText } from '../engine/spec-language/english-only.js';
25
- import { ApiKeyResolver, FallbackGenerator, OpusGenerator, } from '../engine/spec-generator/index.js';
23
+ import { FallbackGenerator } from '../engine/spec-generator/index.js';
26
24
  import { analyzeProjectForSpec, getEmptyAutopilotResult, } from './create-spec/autopilot-analyzer.js';
27
25
  import { AutopilotSummaryCollector } from '../engine/autopilot/summary-collector.js';
28
26
  import { trackCost } from '../engine/cost-tracking/operation-tracker.js';
@@ -33,10 +31,7 @@ import { resolveProjectIdOrAutoDetect } from './resolve-project-id.js';
33
31
  import { hashProjectPath } from '../storage/base-store.js';
34
32
  import { findSimilarSpecs } from '../engine/spec-searcher.js';
35
33
  import { scoreSpecQuality } from '../engine/spec-quality-scorer.js';
36
- import { enrichEstimationWithVelocity } from './create-spec/adapters/velocity-estimation.js';
37
- import { enrichEstimationWithCalibration } from './create-spec/adapters/calibration-estimation.js';
38
34
  import { runPriorDecisionsHint } from './create-spec/adapters/prior-decisions-hint.js';
39
- import { suggestOutOfScope } from '../engine/scope-boundaries/index.js';
40
35
  import { adviseSimilarSpecs } from '../engine/complexity-budget/index.js';
41
36
  import { issuePlannerToken } from '../engine/reviewer-tokens/issuer.js';
42
37
  import { generateInteractiveQuestions } from './create-spec/question-generator.js';
@@ -115,10 +110,9 @@ async function resolveTechnicalFiles(input) {
115
110
  .join('\n\n');
116
111
  const extracted = await extractFilesFromSpecBody(generatedSource, input.projectPath);
117
112
  if (extracted !== null && hasFileEntries(extracted)) {
118
- return mergeTechnicalFiles(extracted, input.autopilot.suggestedFiles);
119
- }
120
- if (input.fallbackReason !== undefined && hasFileEntries(input.autopilot.suggestedFiles)) {
121
- return input.autopilot.suggestedFiles;
113
+ return input.fallbackReason === undefined
114
+ ? mergeTechnicalFiles(extracted, input.autopilot.suggestedFiles)
115
+ : extracted;
122
116
  }
123
117
  return { create: [], modify: [], test: [] };
124
118
  }
@@ -141,8 +135,9 @@ async function groundTechnicalFiles(input) {
141
135
  const pathInUserInput = userInput.includes(file.path.toLowerCase());
142
136
  const exists = await pathExists(pathJoin(input.projectPath, file.path));
143
137
  const fromAutopilot = autopilotPaths.has(file.path);
144
- const derivedTest = section === 'test' && isDerivedFromGroundedSource(file.path, input.files.modify);
145
- if (pathInUserInput || exists || fromAutopilot || derivedTest) {
138
+ const derivedTest = section === 'test' && isDerivedFromGroundedSource(file.path, grounded.modify);
139
+ const directProjectEvidence = fromAutopilot && exists;
140
+ if (pathInUserInput || directProjectEvidence || derivedTest) {
146
141
  grounded[section].push(file);
147
142
  records.push({
148
143
  path: file.path,
@@ -150,8 +145,9 @@ async function groundTechnicalFiles(input) {
150
145
  source: pathInUserInput ? 'user_input' : 'project_evidence',
151
146
  evidence: [
152
147
  ...(pathInUserInput ? ['create_spec.description'] : []),
153
- ...(exists ? [`file:${file.path}`] : []),
154
- ...(fromAutopilot ? ['autopilot.suggestedFiles'] : []),
148
+ ...(directProjectEvidence
149
+ ? [`file:${file.path}`, 'autopilot.semantic-file-match']
150
+ : []),
155
151
  ...(derivedTest ? ['derived-test-path'] : []),
156
152
  ],
157
153
  confidence: pathInUserInput || exists ? 'high' : 'medium',
@@ -224,24 +220,9 @@ function buildAdvisoryCompat() {
224
220
  canonicalField: 'advisorySignals',
225
221
  };
226
222
  }
227
- /** SPEC-612: resolve outOfScope items (provided by user, or auto-suggested from description). */
228
- function resolveOutOfScope(description, provided) {
229
- if (provided !== undefined && provided.length > 0) {
230
- return { items: provided, message: null };
231
- }
232
- try {
233
- const auto = suggestOutOfScope(description).allSuggestions.slice(0, 3);
234
- if (auto.length > 0) {
235
- return {
236
- items: auto,
237
- message: `Auto-suggested outOfScope items based on description keywords: ${auto.join(', ')}`,
238
- };
239
- }
240
- }
241
- catch {
242
- /* best-effort */
243
- }
244
- return { items: [], message: null };
223
+ /** Persist only out-of-scope items supplied explicitly by the caller. */
224
+ function resolveOutOfScope(provided) {
225
+ return provided?.filter((item) => item.trim().length > 0) ?? [];
245
226
  }
246
227
  /** SPEC-614 AC4: suggest complexity category based on similar historical specs (best-effort). */
247
228
  async function runComplexityAdvice(projectId, currentSpecId, tags, targetCriteriaCount) {
@@ -867,8 +848,6 @@ export async function handleCreateSpec(inputParams, server) {
867
848
  }
868
849
  params = clarificationResult.params;
869
850
  }
870
- // Create spec directory and write lean files (SPEC-461)
871
- await measureStep('mkdir-specDir', () => mkdir(specDir, { recursive: true }));
872
851
  const filteredCriteria = filterGroundedCriteria(autopilot.suggestedCriteria);
873
852
  const technologyContract = await readTechnologySelectionContract(params.projectPath ?? '');
874
853
  const contractNote = technologyContract
@@ -889,10 +868,7 @@ export async function handleCreateSpec(inputParams, server) {
889
868
  .filter(Boolean)
890
869
  .join('\n')
891
870
  : '';
892
- const anthropicKey = await new ApiKeyResolver().resolveAnthropicKey(params.projectPath ?? '');
893
- const specGenerator = anthropicKey !== undefined
894
- ? new OpusGenerator({ apiKey: anthropicKey })
895
- : new FallbackGenerator();
871
+ const specGenerator = new FallbackGenerator();
896
872
  const generatedSpec = await measureStep('generateSpecBody', () => specGenerator.generate({
897
873
  title: spec.title,
898
874
  description: `${description}${contractNote}`,
@@ -913,18 +889,32 @@ export async function handleCreateSpec(inputParams, server) {
913
889
  const groundingCriteria = buildCriterionGroundingRecords({
914
890
  criteria: [...baseCriteria, ...filteredCriteria],
915
891
  userInput: description,
916
- projectEvidence: [
917
- ...autopilot.detectedPatterns.map((pattern) => `detected-pattern:${pattern}`),
918
- ...autopilot.suggestedFiles.modify.map((file) => `file:${file.path}`),
919
- ...autopilot.suggestedFiles.create.map((file) => `file:${file.path}`),
920
- ...autopilot.suggestedFiles.test.map((file) => `file:${file.path}`),
921
- ],
922
892
  generatedEvidence: [
923
893
  generatedSpec.generation.modelId ?? generatedSpec.generation.method,
924
894
  ],
925
895
  });
926
896
  const contractCriteria = getContractCriteria(groundingCriteria);
927
897
  const advisoryCriteria = getAdvisoryCriteria(groundingCriteria).map((record) => record.text);
898
+ if (contractCriteria.length === 0) {
899
+ return {
900
+ ok: false,
901
+ earlyReturn: {
902
+ content: [
903
+ {
904
+ type: 'text',
905
+ text: 'create_spec did not persist a spec because no acceptance criterion was grounded in the user request. ' +
906
+ 'Needs decision: provide at least one explicit, verifiable criterion as a checkbox or Given/When/Then outcome.',
907
+ },
908
+ ],
909
+ isError: true,
910
+ structuredContent: {
911
+ error: 'MISSING_GROUNDED_ACCEPTANCE_CRITERIA',
912
+ missingDecision: 'Provide at least one explicit, verifiable acceptance criterion.',
913
+ persisted: false,
914
+ },
915
+ },
916
+ };
917
+ }
928
918
  const actionableMetrics = calculateActionableSpecMetrics({
929
919
  criteria: [...baseCriteria, ...filteredCriteria],
930
920
  groundingRecords: groundingCriteria,
@@ -942,7 +932,7 @@ export async function handleCreateSpec(inputParams, server) {
942
932
  userInput: description,
943
933
  autopilot,
944
934
  }));
945
- const outOfScopeResolved = resolveOutOfScope(description, params.outOfScope);
935
+ const outOfScope = resolveOutOfScope(params.outOfScope);
946
936
  const scenarioTestPaths = groundedTechnical.files.test.map((file) => file.path);
947
937
  const leanSpec = generateLeanSpecContent({
948
938
  spec,
@@ -952,7 +942,7 @@ export async function handleCreateSpec(inputParams, server) {
952
942
  text: record.text,
953
943
  done: false,
954
944
  })),
955
- groundingCriteria,
945
+ groundingCriteria: contractCriteria,
956
946
  groundingTechnicalReferences: groundedTechnical.records,
957
947
  acFormat: params.acFormat,
958
948
  scenarioTestPaths,
@@ -972,15 +962,8 @@ export async function handleCreateSpec(inputParams, server) {
972
962
  description: generatedSpec.specBody,
973
963
  criteria: contractCriteria.map((record) => ({ text: record.text, done: false })),
974
964
  files: groundedTechnical.files,
975
- outOfScope: outOfScopeResolved.items,
976
- verificationCommands: [
977
- ...(scenarioTestPaths.length > 0
978
- ? [`pnpm vitest run ${scenarioTestPaths.join(' ')}`]
979
- : []),
980
- 'pnpm typecheck',
981
- 'pnpm lint',
982
- 'pnpm test',
983
- ],
965
+ outOfScope,
966
+ verificationCommands: [],
984
967
  });
985
968
  const genericOutputGate = checkGenericSpecOutput(unifiedSpec);
986
969
  if (!genericOutputGate.passed) {
@@ -1008,6 +991,7 @@ export async function handleCreateSpec(inputParams, server) {
1008
991
  };
1009
992
  }
1010
993
  try {
994
+ await measureStep('mkdir-specDir', () => mkdir(specDir, { recursive: true }));
1011
995
  // SPEC-713: measure file write — this is the critical persistence step.
1012
996
  await measureStep('writeFile-specPath', () => writeFile(specPath, unifiedSpec, 'utf-8'));
1013
997
  await measureStep('commit-idempotency-evidence', () => commitIdempotencyEvidence(resolvedPath, idempotencyKey, idempotencyClaim, spec, specPath));
@@ -1020,11 +1004,10 @@ export async function handleCreateSpec(inputParams, server) {
1020
1004
  await rm(specDir, { recursive: true, force: true });
1021
1005
  throw writeErr;
1022
1006
  }
1023
- // SPEC-612: Auto-suggest outOfScope when user did not provide any
1024
- if (outOfScopeResolved.items.length > 0) {
1025
- spec.outOfScope = outOfScopeResolved.items;
1007
+ // Persist only explicit out-of-scope input.
1008
+ if (outOfScope.length > 0) {
1009
+ spec.outOfScope = outOfScope;
1026
1010
  }
1027
- const outOfScopeSuggestionMsg = outOfScopeResolved.message;
1028
1011
  // Persist spec in storage
1029
1012
  // SPEC-713: measure specStore.createSpec — file lock + JSON rewrite
1030
1013
  await measureStep('specStore-createSpec', () => specStore.createSpec(projectId, spec));
@@ -1050,7 +1033,6 @@ export async function handleCreateSpec(inputParams, server) {
1050
1033
  ...groundedTechnical.advisoryFiles.map((file) => file.path),
1051
1034
  ],
1052
1035
  actionableMetrics,
1053
- outOfScopeSuggestionMsg,
1054
1036
  },
1055
1037
  };
1056
1038
  }); // end withTotalBudget critical path
@@ -1081,7 +1063,7 @@ export async function handleCreateSpec(inputParams, server) {
1081
1063
  // Destructure critical path results for use in post-creation enrichment
1082
1064
  const { spec, specDir: _specDir, specPath,
1083
1065
  // SPEC-1010 Bug A: no longer surfaced in the response payload (SSR back-migration).
1084
- technicalPath: _technicalPath, estimation, splitSuggestion, duplicate, projectId, agentTeamPlan, knowledge, clarificationSession, constitutionCheck, autopilot, filteredCriteria, advisoryCriteria, actionableMetrics, outOfScopeSuggestionMsg, } = criticalResult.value.data;
1066
+ technicalPath: _technicalPath, estimation, splitSuggestion, duplicate, projectId, agentTeamPlan, knowledge, clarificationSession, constitutionCheck, autopilot, filteredCriteria, advisoryCriteria, actionableMetrics, } = criticalResult.value.data;
1085
1067
  // -----------------------------------------------------------------------
1086
1068
  // Post-creation enrichment (outside 25s ceiling — best-effort, budgeted)
1087
1069
  // -----------------------------------------------------------------------
@@ -1098,21 +1080,6 @@ export async function handleCreateSpec(inputParams, server) {
1098
1080
  // SPEC-713: withBudget 2s — scans all specs for contradiction keywords
1099
1081
  const contradictionResult = await withBudget('checkContradictions', 2_000, () => measureStep('checkContradictions', () => checkContradictions(projectId, spec.id, description)));
1100
1082
  const contradictionHint = unwrapBudget(contradictionResult, undefined);
1101
- // SPEC-555: Enrich estimation with calendar days (best-effort, non-blocking)
1102
- // SPEC-713: withBudget 2s
1103
- const velocityResult = await withBudget('velocityEnrichment', 2_000, () => measureStep('velocityEnrichment', () => enrichEstimationWithVelocity(params.projectPath ?? '', estimation.devHours)));
1104
- const velocityEnrichment = unwrapBudget(velocityResult, {
1105
- calendarDays: null,
1106
- velocityNote: 'velocity data unavailable',
1107
- });
1108
- // SPEC-506: Apply persisted calibration multiplier (best-effort, non-blocking)
1109
- // SPEC-713: withBudget 2s
1110
- const calibrationResult = await withBudget('calibrationEnrichment', 2_000, () => measureStep('calibrationEnrichment', () => enrichEstimationWithCalibration(params.projectPath ?? '', estimation.devHours, spec.scope, spec.type)));
1111
- const calibrationEnrichment = unwrapBudget(calibrationResult, {
1112
- calibratedHours: estimation.devHours,
1113
- applied: false,
1114
- calibrationNote: null,
1115
- });
1116
1083
  // Build result (SPEC-461: lean — no progress, HTML, diagrams, scope filters)
1117
1084
  const result = {
1118
1085
  // SPEC-781: async analysis running in background (external project data)
@@ -1134,15 +1101,6 @@ export async function handleCreateSpec(inputParams, server) {
1134
1101
  // ## Technical; the file is never written and the field would point to
1135
1102
  // a non-existent path. Internal Spec record still carries it for
1136
1103
  // backwards-compat with stored data — that field is removed in PR-C.
1137
- estimation: {
1138
- devHours: calibrationEnrichment.calibratedHours,
1139
- reviewHours: estimation.reviewHours,
1140
- totalCostUsd: estimation.totalCostUsd,
1141
- recommendedModel: estimation.recommendedModel,
1142
- executionMode: estimation.tokenOptimization.mode,
1143
- calendarDays: velocityEnrichment.calendarDays,
1144
- velocityNote: velocityEnrichment.velocityNote,
1145
- },
1146
1104
  duplicateWarning: duplicate
1147
1105
  ? ti('spec.duplicateTitle', { title: spec.title, slug: spec.slug })
1148
1106
  : undefined,
@@ -1156,7 +1114,7 @@ export async function handleCreateSpec(inputParams, server) {
1156
1114
  clarificationAnswersUsed: Object.keys(clarificationSession.answers).length,
1157
1115
  }
1158
1116
  : {
1159
- clarificationNote: 'Spec created directly from description Planu inferred requirements from project context.',
1117
+ clarificationNote: 'Spec created directly from the supplied description; no clarification session was linked.',
1160
1118
  }),
1161
1119
  message: ti('tools.create_spec.success', { id: spec.id, title: spec.title }),
1162
1120
  ...(contradictionHint ? { contradictionHint } : {}),
@@ -1181,7 +1139,6 @@ export async function handleCreateSpec(inputParams, server) {
1181
1139
  }
1182
1140
  const splitResult = buildSplitResult(splitSuggestion, knowledge?.experienceLevel);
1183
1141
  if (splitResult) {
1184
- result.splitSuggestion = splitResult;
1185
1142
  advisorySignals.push(makeAdvisorySignal({
1186
1143
  key: 'split-suggestion',
1187
1144
  kind: 'complexity',
@@ -1191,7 +1148,6 @@ export async function handleCreateSpec(inputParams, server) {
1191
1148
  confidence: 0.5,
1192
1149
  surface: 'structuredContent',
1193
1150
  value: splitResult,
1194
- deprecatedAlias: 'splitSuggestion',
1195
1151
  }));
1196
1152
  }
1197
1153
  // Dispatch hook event (fire-and-forget)
@@ -1200,21 +1156,6 @@ export async function handleCreateSpec(inputParams, server) {
1200
1156
  runAutopilotAsync(spec.id, params.projectPath ?? '', description);
1201
1157
  // SPEC-713: Track warnings from budgeted post-creation steps
1202
1158
  const budgetWarnings = [];
1203
- // Auto-estimation (best-effort, fire-and-forget)
1204
- // SPEC-713: withBudget 2s — estimation is sync, specStore.updateSpec is a single JSON write
1205
- const autoEstimationResult = await withBudget('auto-estimation', 2_000, async () => {
1206
- const autoEstimation = estimateSpec(spec);
1207
- await measureStep('specStore-updateSpec', () => specStore.updateSpec(projectId, spec.id, { estimation: autoEstimation.estimation }));
1208
- return autoEstimation;
1209
- });
1210
- if (!autoEstimationResult.exceeded) {
1211
- const ae = autoEstimationResult.value;
1212
- result.autoEstimation = {
1213
- confidence: ae.confidence,
1214
- reasoning: ae.reasoning,
1215
- similarSpecs: ae.similarSpecs,
1216
- };
1217
- }
1218
1159
  // SPEC-461: No per-spec HTML reports — lean format
1219
1160
  // SPEC-713: Run post-creation checks in parallel to reduce total wall-clock time.
1220
1161
  // Each has an individual budget; results are merged into `result` after settlement.
@@ -1376,9 +1317,6 @@ export async function handleCreateSpec(inputParams, server) {
1376
1317
  `| Target | ${spec.target} |`,
1377
1318
  `| Status | ${spec.status} |`,
1378
1319
  `| Tags | ${spec.tags.join(', ')} |`,
1379
- `| Dev hours | ${String(estimation.devHours)}h |`,
1380
- `| Review hours | ${String(estimation.reviewHours)}h |`,
1381
- `| Cost | $${String(estimation.totalCostUsd)} |`,
1382
1320
  ];
1383
1321
  if (spec.gitBranch) {
1384
1322
  lines.push(`| Branch | \`${spec.gitBranch}\` |`);
@@ -1406,20 +1344,6 @@ export async function handleCreateSpec(inputParams, server) {
1406
1344
  : formatSuccess(ti('tools.create_spec.success', { id: spec.id, title: spec.title }), lines.join('\n'));
1407
1345
  // SPEC-469: Build autopilot summary from analyzer results
1408
1346
  const collector = new AutopilotSummaryCollector();
1409
- // SPEC-612: Record outOfScope auto-suggestion in autopilot summary
1410
- if (outOfScopeSuggestionMsg !== null) {
1411
- collector.pushOk('scope-boundaries', outOfScopeSuggestionMsg);
1412
- advisorySignals.push(makeAdvisorySignal({
1413
- key: 'out-of-scope-suggestions',
1414
- kind: 'out-of-scope',
1415
- message: outOfScopeSuggestionMsg,
1416
- source: 'heuristic',
1417
- evidence: ['scope-boundaries suggester'],
1418
- confidence: 0.45,
1419
- surface: 'structuredContent',
1420
- value: spec.outOfScope ?? [],
1421
- }));
1422
- }
1423
1347
  if (autopilot.detectedPatterns.length > 0) {
1424
1348
  collector.pushOk('pattern-detection', `Detected patterns: ${autopilot.detectedPatterns.join(', ')}`);
1425
1349
  advisorySignals.push(makeAdvisorySignal({
@@ -1459,17 +1383,13 @@ export async function handleCreateSpec(inputParams, server) {
1459
1383
  }));
1460
1384
  }
1461
1385
  if (filteredCriteria.length > 0) {
1462
- collector.pushOk('criteria-enrichment', `Added ${String(filteredCriteria.length)} acceptance criteria from project context`);
1386
+ collector.pushOk('criteria-enrichment', `Evaluated ${String(filteredCriteria.length)} proposed criteria; only directly grounded items entered the contract`);
1463
1387
  }
1464
1388
  if (pipelineResult.challengeSummary) {
1465
1389
  collector.pushOk('challenge', `Challenge analysis complete: ${pipelineResult.challengeSummary}`);
1466
1390
  }
1467
1391
  if (pipelineResult.readinessScore !== null) {
1468
- // SPEC-629: append Opus hint for difficulty >= 3 specs
1469
- const opusHint = spec.difficulty >= 3
1470
- ? ` — difficulty ${String(spec.difficulty)}: use Opus to add exact file paths + function names`
1471
- : '';
1472
- collector.pushOk('readiness', `Readiness score: ${String(pipelineResult.readinessScore)}/100${opusHint}`);
1392
+ collector.pushOk('readiness', `Readiness score: ${String(pipelineResult.readinessScore)}/100`);
1473
1393
  advisorySignals.push(makeAdvisorySignal({
1474
1394
  key: 'readiness-score',
1475
1395
  kind: 'readiness',
@@ -1481,24 +1401,7 @@ export async function handleCreateSpec(inputParams, server) {
1481
1401
  value: pipelineResult.readinessScore,
1482
1402
  }));
1483
1403
  }
1484
- else if (spec.difficulty >= 3) {
1485
- // SPEC-629: always surface the Opus hint for high-difficulty specs
1486
- collector.pushOk('recommend_model', `Difficulty ${String(spec.difficulty)} spec — use Opus to add exact file paths, function names and anticipated test breaks.`);
1487
- advisorySignals.push(makeAdvisorySignal({
1488
- key: 'model-recommendation',
1489
- kind: 'model',
1490
- message: 'High-difficulty spec may benefit from a stronger model for review.',
1491
- source: 'heuristic',
1492
- evidence: [`difficulty:${String(spec.difficulty)}`],
1493
- confidence: 0.4,
1494
- surface: 'structuredContent',
1495
- value: { recommendedTier: 'max' },
1496
- }));
1497
- }
1498
- if (calibrationEnrichment.calibrationNote) {
1499
- collector.pushOk('calibration', `📐 ${calibrationEnrichment.calibrationNote}`);
1500
- }
1501
- const humanSummary = buildCreateSpecSummary(spec.title, estimation.devHours);
1404
+ const humanSummary = `Plan created for "${spec.title}". Review the grounded criteria before approval.`;
1502
1405
  result.advisorySignals = advisorySignals;
1503
1406
  result.compat = buildAdvisoryCompat();
1504
1407
  const compactResult = compactObj(result);
@@ -1,6 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { generateSpecQuestions, formatQuestionsAsPrompt, parseAnswers, } from '../engine/elicitation/index.js';
3
- import { buildDefaultAnswers } from '../engine/elicitation/non-interactive-defaults.js';
4
3
  /** Build a clarification summary from parsed answers */
5
4
  function buildSummary(session, parsedAnswers) {
6
5
  const lines = [`## Clarification Summary for "${session.title}"`, ''];
@@ -34,10 +33,8 @@ export function handleElicitRequirements(input) {
34
33
  const { title, description, answers, mode } = input;
35
34
  // intakeRef is silently acknowledged — used by downstream agents to chain context
36
35
  const context = {
37
- hasDatabase: description.toLowerCase().includes('data') ||
38
- description.toLowerCase().includes('store') ||
39
- description.toLowerCase().includes('database'),
40
- isApi: description.toLowerCase().includes('api') || description.toLowerCase().includes('endpoint'),
36
+ hasDatabase: hasAffirmedTerm(description, /\b(?:data|store|database)\b/i),
37
+ isApi: hasAffirmedTerm(description, /\b(?:api|endpoint)\b/i),
41
38
  };
42
39
  const questions = generateSpecQuestions(title, description, context);
43
40
  const sessionId = input.sessionId ?? randomUUID();
@@ -47,17 +44,21 @@ export function handleElicitRequirements(input) {
47
44
  questions,
48
45
  answers: {},
49
46
  };
50
- // SPEC-724: Non-interactive mode synthesise defaults without AskUserQuestion round-trip
47
+ // Non-interactive mode cannot complete unanswered decisions on the user's behalf.
51
48
  if (mode === 'non-interactive' && (answers === undefined || answers.trim().length === 0)) {
52
- const defaultAnswers = buildDefaultAnswers(input);
53
- session.answers = defaultAnswers;
54
- session.completedAt = new Date().toISOString();
55
- const summary = buildSummary(session, defaultAnswers);
49
+ const missingDecision = 'Needs decision: provide explicit answers before requirements can be treated as confirmed.';
56
50
  return Promise.resolve({
57
- content: [{ type: 'text', text: summary }],
51
+ content: [{ type: 'text', text: missingDecision }],
58
52
  structuredContent: {
59
- summary,
60
53
  mode: 'non-interactive',
54
+ complete: false,
55
+ missingDecision,
56
+ pendingQuestions: questions.map((question) => ({
57
+ id: question.id,
58
+ question: question.question,
59
+ required: question.required,
60
+ ...(question.choices ? { candidates: question.choices } : {}),
61
+ })),
61
62
  },
62
63
  });
63
64
  }
@@ -81,4 +82,11 @@ export function handleElicitRequirements(input) {
81
82
  const summary = buildSummary(session, parsedAnswers);
82
83
  return Promise.resolve({ content: [{ type: 'text', text: summary }] });
83
84
  }
85
+ const NEGATION_RE = /\b(?:no|not|never|without|exclude|excluding|forbid|forbidden|avoid|must not|should not|do not|does not|don't|doesn't|out[- ]of[- ]scope)\b/i;
86
+ function hasAffirmedTerm(description, pattern) {
87
+ return description.split(/(?:\r?\n|[.;!?]+)/).some((segment) => {
88
+ const match = new RegExp(pattern.source, pattern.flags.replace('g', '')).exec(segment);
89
+ return match !== null && !NEGATION_RE.test(segment.slice(0, match.index));
90
+ });
91
+ }
84
92
  //# sourceMappingURL=elicit-requirements-handler.js.map