@planu/cli 4.11.2 → 4.11.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +7 -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/renderer.js +17 -39
  5. package/dist/engine/lifecycle-hints.d.ts +1 -1
  6. package/dist/engine/lifecycle-hints.js +13 -29
  7. package/dist/engine/spec-format/lean-spec-generator.d.ts +2 -2
  8. package/dist/engine/spec-format/lean-spec-generator.js +9 -10
  9. package/dist/engine/spec-generator/fallback-generator.js +13 -20
  10. package/dist/engine/spec-grounding/contract.d.ts +5 -0
  11. package/dist/engine/spec-grounding/contract.js +10 -6
  12. package/dist/engine/spec-migrator/lean-migration.js +9 -16
  13. package/dist/engine/web-fetcher/stack-advisor.d.ts +2 -2
  14. package/dist/engine/web-fetcher/stack-advisor.js +78 -17
  15. package/dist/tools/challenge-spec/challenge-report.d.ts +13 -0
  16. package/dist/tools/challenge-spec/challenge-report.js +55 -0
  17. package/dist/tools/challenge-spec.js +9 -8
  18. package/dist/tools/clarify-requirements/multiple-choice.js +1 -1
  19. package/dist/tools/clarify-requirements/questions-context.js +11 -11
  20. package/dist/tools/clarify-requirements/questions.js +3 -23
  21. package/dist/tools/clarify-requirements.js +38 -0
  22. package/dist/tools/create-spec/autopilot-analyzer.d.ts +1 -1
  23. package/dist/tools/create-spec/autopilot-analyzer.js +29 -54
  24. package/dist/tools/create-spec/post-creation.js +23 -4
  25. package/dist/tools/create-spec.js +47 -144
  26. package/dist/tools/elicit-requirements-handler.js +20 -12
  27. package/dist/tools/register-spec-tools/core-spec-tools.js +11 -2
  28. package/dist/tools/suggest-stack.js +49 -13
  29. package/dist/tools/update-status/dod-gates.d.ts +8 -0
  30. package/dist/tools/update-status/dod-gates.js +68 -43
  31. package/dist/tools/update-status/index.js +109 -74
  32. package/dist/tools/update-status/qa-gate.js +26 -0
  33. package/dist/tools/update-status/transition-guard.d.ts +2 -2
  34. package/dist/tools/update-status/transition-guard.js +10 -7
  35. package/dist/tools/validate-lint.d.ts +1 -1
  36. package/dist/tools/validate-lint.js +39 -1
  37. package/dist/types/spec/core.d.ts +20 -0
  38. package/dist/types/spec/inputs.d.ts +1 -1
  39. package/dist/types/stack/index.d.ts +1 -1
  40. package/dist/types/stack/recommend.d.ts +10 -0
  41. package/package.json +9 -9
  42. package/planu-native.json +1 -1
  43. package/planu-plugin.json +1 -1
  44. package/dist/engine/elicitation/non-interactive-defaults.d.ts +0 -9
  45. package/dist/engine/elicitation/non-interactive-defaults.js +0 -28
  46. package/dist/tools/clarify-requirements/interview-mode.d.ts +0 -42
  47. package/dist/tools/clarify-requirements/interview-mode.js +0 -342
@@ -96,20 +96,24 @@ function getBaseRecommendations() {
96
96
  },
97
97
  };
98
98
  }
99
- function applyRequirementOverrides(rec, reqStr, constStr) {
99
+ function applyRequirementOverrides(rec, requirements, constraints) {
100
100
  let result = rec;
101
- if (reqStr.includes('python') || reqStr.includes('ml') || reqStr.includes('ai')) {
101
+ if (hasAffirmedTerm(requirements, /\bpython\b/i) ||
102
+ hasAffirmedTerm(requirements, /\bml\b/i) ||
103
+ hasAffirmedTerm(requirements, /\bai\b/i)) {
104
+ const requestsApi = hasAffirmedTerm(requirements, /\bapi\b/i);
102
105
  result = {
103
106
  ...result,
104
107
  language: 'python',
105
- framework: reqStr.includes('api') ? 'fastapi' : 'django',
106
- version: reqStr.includes('api') ? '0.100+' : '5+',
107
- ecosystem: reqStr.includes('api')
108
+ framework: requestsApi ? 'fastapi' : 'django',
109
+ version: requestsApi ? '0.100+' : '5+',
110
+ ecosystem: requestsApi
108
111
  ? ['fastapi', 'pydantic', 'sqlalchemy', 'alembic']
109
112
  : ['django', 'django-rest-framework', 'celery', 'pytest'],
110
113
  };
111
114
  }
112
- if (reqStr.includes('go') || reqStr.includes('golang') || reqStr.includes('performance')) {
115
+ if (hasAffirmedTerm(requirements, /\bgo(?:lang)?\b/i) ||
116
+ hasAffirmedTerm(requirements, /\bperformance\b/i)) {
113
117
  result = {
114
118
  ...result,
115
119
  language: 'go',
@@ -118,7 +122,7 @@ function applyRequirementOverrides(rec, reqStr, constStr) {
118
122
  ecosystem: ['gin', 'gorm', 'wire', 'testify'],
119
123
  };
120
124
  }
121
- if (reqStr.includes('supabase')) {
125
+ if (hasAffirmedTerm(requirements, /\bsupabase\b/i)) {
122
126
  result = {
123
127
  ...result,
124
128
  database: 'supabase',
@@ -126,22 +130,21 @@ function applyRequirementOverrides(rec, reqStr, constStr) {
126
130
  auth: 'supabase-auth',
127
131
  };
128
132
  }
129
- if (reqStr.includes('firebase')) {
133
+ if (hasAffirmedTerm(requirements, /\bfirebase\b/i)) {
130
134
  result = { ...result, database: 'firebase', auth: 'firebase-auth' };
131
135
  }
132
- if (constStr.includes('no-vendor-lock') || constStr.includes('self-host')) {
136
+ if (constraints.some((constraint) => constraint.toLowerCase().includes('no-vendor-lock')) ||
137
+ hasAffirmedTerm(constraints, /\bself[- ]host(?:ed|ing)?\b/i)) {
133
138
  result = { ...result, deployment: 'docker + railway', database: 'postgresql' };
134
139
  }
135
140
  return result;
136
141
  }
137
142
  export function recommendStack(projectType, requirements, constraints) {
138
- const reqStr = requirements.join(' ').toLowerCase();
139
- const constStr = constraints.join(' ').toLowerCase();
140
143
  const recommendations = getBaseRecommendations();
141
144
  const base =
142
145
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- 'web' key always exists in getBaseRecommendations()
143
146
  recommendations[projectType] ?? recommendations.web;
144
- return applyRequirementOverrides(base, reqStr, constStr);
147
+ return applyRequirementOverrides(base, requirements, constraints);
145
148
  }
146
149
  export function recommendAlternativeStack(projectType, _requirements) {
147
150
  const alternatives = {
@@ -208,23 +211,23 @@ export function buildWhyNotOthers(_projectType, chosenFramework) {
208
211
  const allAlternatives = [
209
212
  {
210
213
  name: 'Ruby on Rails',
211
- reason: 'Great for prototyping but TypeScript ecosystem is larger and has better type safety.',
214
+ reason: 'Optional candidate; compare it against the confirmed requirements before selection.',
212
215
  },
213
216
  {
214
217
  name: 'PHP/Laravel',
215
- reason: 'Solid framework but less modern tooling and TypeScript interop.',
218
+ reason: 'Optional candidate; compare it against the confirmed requirements before selection.',
216
219
  },
217
220
  {
218
221
  name: 'Java/Spring',
219
- reason: 'Enterprise-grade but heavier setup and verbose for smaller projects.',
222
+ reason: 'Optional candidate; compare it against the confirmed requirements before selection.',
220
223
  },
221
224
  {
222
225
  name: 'C#/.NET',
223
- reason: 'Excellent for enterprise but tighter coupling to Microsoft ecosystem.',
226
+ reason: 'Optional candidate; compare it against the confirmed requirements before selection.',
224
227
  },
225
228
  {
226
229
  name: 'Elixir/Phoenix',
227
- reason: 'Great for real-time but smaller ecosystem and talent pool.',
230
+ reason: 'Optional candidate; compare it against the confirmed requirements before selection.',
228
231
  },
229
232
  ];
230
233
  return allAlternatives
@@ -307,6 +310,8 @@ export async function buildSuggestStackResult(projectType, requirements, constra
307
310
  // Offline: keep static versions, mark confidence as low
308
311
  confidence = 'low';
309
312
  }
313
+ const detectedEvidence = collectDetectedEvidence(requirements, constraints);
314
+ const confirmedChoices = collectConfirmedChoices(requirements);
310
315
  return {
311
316
  primary,
312
317
  alternative,
@@ -314,6 +319,62 @@ export async function buildSuggestStackResult(projectType, requirements, constra
314
319
  mcpCompatibility: buildMcpCompatibility(primary),
315
320
  lastChecked: new Date().toISOString(),
316
321
  confidence,
322
+ selectionStatus: confirmedChoices.length > 0 ? 'partially-confirmed' : 'unconfirmed',
323
+ detectedEvidence,
324
+ confirmedChoices,
325
+ candidateNotice: 'Recommendations are optional candidates. No candidate is selected or persisted without user confirmation.',
317
326
  };
318
327
  }
328
+ 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;
329
+ const NEGATIVE_SUFFIX_RE = /\b(?:forbidden|excluded|not allowed|out[- ]of[- ]scope)\b/i;
330
+ function hasAffirmedTerm(values, pattern) {
331
+ return values.some((value) => {
332
+ const withoutQuotedText = value
333
+ .replace(/```[\s\S]*?```/g, ' ')
334
+ .replace(/`[^`]+`/g, ' ')
335
+ .replace(/"[^"]+"|'[^']+'/g, ' ');
336
+ return withoutQuotedText.split(/(?:\r?\n|[.,;!?]+)/).some((segment) => {
337
+ const match = new RegExp(pattern.source, pattern.flags.replace('g', '')).exec(segment);
338
+ if (!match) {
339
+ return false;
340
+ }
341
+ const prefix = segment.slice(0, match.index);
342
+ const suffix = segment.slice(match.index + match[0].length);
343
+ return !NEGATION_RE.test(prefix) && !NEGATIVE_SUFFIX_RE.test(suffix);
344
+ });
345
+ });
346
+ }
347
+ function collectDetectedEvidence(requirements, constraints) {
348
+ const definitions = [
349
+ ['language:python', /\bpython\b/i],
350
+ ['language:go', /\bgo(?:lang)?\b/i],
351
+ ['database:supabase', /\bsupabase\b/i],
352
+ ['database:firebase', /\bfirebase\b/i],
353
+ ['capability:api', /\bapi\b/i],
354
+ ['capability:ml', /\b(?:ml|ai)\b/i],
355
+ ];
356
+ const evidence = definitions
357
+ .filter(([, pattern]) => hasAffirmedTerm(requirements, pattern))
358
+ .map(([name]) => `requirement:${name}`);
359
+ if (constraints.some((constraint) => constraint.toLowerCase().includes('no-vendor-lock'))) {
360
+ evidence.push('constraint:no-vendor-lock');
361
+ }
362
+ if (hasAffirmedTerm(constraints, /\bself[- ]host(?:ed|ing)?\b/i)) {
363
+ evidence.push('constraint:self-host');
364
+ }
365
+ return evidence;
366
+ }
367
+ function collectConfirmedChoices(requirements) {
368
+ const confirmationText = requirements.join(' ');
369
+ const choices = [
370
+ ['language', 'python', /\b(?:must use|use|selected|confirmed|requires?)\s+python\b/i],
371
+ ['language', 'go', /\b(?:must use|use|selected|confirmed|requires?)\s+go(?:lang)?\b/i],
372
+ ['database', 'supabase', /\b(?:must use|use|selected|confirmed|requires?)\s+supabase\b/i],
373
+ ['database', 'firebase', /\b(?:must use|use|selected|confirmed|requires?)\s+firebase\b/i],
374
+ ];
375
+ return choices
376
+ .filter(([, , pattern]) => pattern.test(confirmationText))
377
+ .filter(([, value]) => hasAffirmedTerm(requirements, new RegExp(`\\b${value}\\b`, 'i')))
378
+ .map(([field, value]) => ({ field, value, source: 'user_confirmation' }));
379
+ }
319
380
  //# sourceMappingURL=stack-advisor.js.map
@@ -0,0 +1,13 @@
1
+ import type { ChallengeReport, ChallengeResolutionEvidence, FailureScenario } from '../../types/index.js';
2
+ export declare const MINIMUM_RESOLVED_CHALLENGES = 3;
3
+ /** Count only evidence tied to findings persisted by the same challenge report. */
4
+ export declare function getResolvedChallengeEvidence(report: ChallengeReport | undefined): ChallengeResolutionEvidence[];
5
+ /** Build a report whose resolution state is derived exclusively from explicit evidence. */
6
+ export declare function buildChallengeReport(input: {
7
+ scenarios: FailureScenario[];
8
+ focusAreas: string[];
9
+ overallRisk: ChallengeReport['overallRisk'];
10
+ previousReport?: ChallengeReport;
11
+ runAt?: string;
12
+ }): ChallengeReport;
13
+ //# sourceMappingURL=challenge-report.d.ts.map
@@ -0,0 +1,55 @@
1
+ export const MINIMUM_RESOLVED_CHALLENGES = 3;
2
+ function isExplicitResolutionEvidence(value) {
3
+ if (typeof value !== 'object' || value === null) {
4
+ return false;
5
+ }
6
+ const candidate = value;
7
+ return (typeof candidate.scenario === 'string' &&
8
+ candidate.scenario.trim().length > 0 &&
9
+ (candidate.resolution === 'mitigated' || candidate.resolution === 'accepted') &&
10
+ typeof candidate.evidence === 'string' &&
11
+ candidate.evidence.trim().length > 0 &&
12
+ typeof candidate.resolvedAt === 'string' &&
13
+ candidate.resolvedAt.trim().length > 0);
14
+ }
15
+ function uniqueMatchingEvidence(report, scenarioNames) {
16
+ const evidence = Array.isArray(report?.resolutionEvidence) ? report.resolutionEvidence : [];
17
+ const matched = new Map();
18
+ for (const item of evidence) {
19
+ if (isExplicitResolutionEvidence(item) && scenarioNames.has(item.scenario)) {
20
+ matched.set(item.scenario, item);
21
+ }
22
+ }
23
+ return [...matched.values()];
24
+ }
25
+ /** Count only evidence tied to findings persisted by the same challenge report. */
26
+ export function getResolvedChallengeEvidence(report) {
27
+ if (!Array.isArray(report?.findings)) {
28
+ return [];
29
+ }
30
+ const scenarioNames = new Set(report.findings
31
+ .map((finding) => finding.scenario)
32
+ .filter((scenario) => typeof scenario === 'string' && scenario.trim().length > 0));
33
+ return uniqueMatchingEvidence(report, scenarioNames);
34
+ }
35
+ /** Build a report whose resolution state is derived exclusively from explicit evidence. */
36
+ export function buildChallengeReport(input) {
37
+ const scenarioNames = new Set(input.scenarios.map((scenario) => scenario.scenario));
38
+ const resolutionEvidence = uniqueMatchingEvidence(input.previousReport, scenarioNames);
39
+ const resolvedScenarios = new Set(resolutionEvidence.map((item) => item.scenario));
40
+ const addressedCount = resolvedScenarios.size;
41
+ return {
42
+ runAt: input.runAt ?? new Date().toISOString(),
43
+ totalScenarios: input.scenarios.length,
44
+ addressedCount,
45
+ focusAreas: input.focusAreas,
46
+ overallRisk: input.overallRisk,
47
+ passed: addressedCount >= MINIMUM_RESOLVED_CHALLENGES,
48
+ findings: input.scenarios.map((scenario) => ({
49
+ scenario: scenario.scenario,
50
+ resolved: resolvedScenarios.has(scenario.scenario),
51
+ })),
52
+ resolutionEvidence,
53
+ };
54
+ }
55
+ //# sourceMappingURL=challenge-report.js.map
@@ -18,6 +18,7 @@ import { calculateTokenBudget, injectBudgetIntoPrompt } from '../engine/token-bu
18
18
  import { analyzeMinimalImplementation, loadMinimalImplementationPolicy, } from '../engine/minimality/index.js';
19
19
  import { detectChallengeCapabilities, } from './challenge-spec/scenarios-utils.js';
20
20
  import { collectCapabilityScenarios } from './challenge-spec/scenario-collector.js';
21
+ import { buildChallengeReport } from './challenge-spec/challenge-report.js';
21
22
  const ALL_FOCUS_AREAS = [
22
23
  'failures',
23
24
  'concurrency',
@@ -193,6 +194,12 @@ export async function handleChallengeSpec(args, server) {
193
194
  scalabilityAssessment,
194
195
  overallRisk,
195
196
  };
197
+ const challengeReport = buildChallengeReport({
198
+ scenarios: failureScenariosScored,
199
+ focusAreas,
200
+ overallRisk,
201
+ previousReport: spec.challengeReport,
202
+ });
196
203
  const rawHumanSummary = buildChallengeSpecSummary(prioritized, overallRisk);
197
204
  // SPEC-620: Inject token budget tag into the LLM-facing summary prompt
198
205
  const specDevHours = spec.estimation.devHours;
@@ -232,18 +239,12 @@ export async function handleChallengeSpec(args, server) {
232
239
  checkedAt: new Date().toISOString(),
233
240
  }
234
241
  : null,
242
+ challengeReport,
235
243
  };
236
244
  // SPEC-964: Persist challenge report so update_status can gate on it
237
245
  try {
238
246
  await updateSpec(projectId, specId, {
239
- challengeReport: {
240
- runAt: new Date().toISOString(),
241
- totalScenarios: actionableFailureScenarios.length,
242
- addressedCount: actionableFailureScenarios.length,
243
- focusAreas: focusAreas,
244
- overallRisk: overallRisk,
245
- passed: true,
246
- },
247
+ challengeReport,
247
248
  });
248
249
  }
249
250
  catch {
@@ -277,7 +277,7 @@ export function attachOptions(question) {
277
277
  * The returned string is suitable for passing directly to create_spec.
278
278
  */
279
279
  export function buildSuggestedDescription(topic, answers, questions) {
280
- const parts = [`Feature: ${topic}.`];
280
+ const parts = [`Topic: ${topic}.`];
281
281
  for (const q of questions) {
282
282
  const answer = answers[q.id];
283
283
  if (answer === undefined || answer.trim() === '') {
@@ -6,13 +6,12 @@ import { buildSuggestedAnswers } from './questions.js';
6
6
  */
7
7
  export function generateQuestions(topic, _context, knowledge, maxQuestions) {
8
8
  const questions = [];
9
- const lower = topic.toLowerCase();
10
9
  let idx = 0;
11
10
  const nextId = () => `q-${++idx}`;
12
11
  // === Core question: What do you want? (always relevant) ===
13
12
  questions.push({
14
13
  id: nextId(),
15
- question: `What is the main goal of "${topic}"? What should the user be able to do?`,
14
+ question: `What observable behavior or result should "${topic}" produce?`,
16
15
  category: 'scope',
17
16
  importance: 'critical',
18
17
  });
@@ -26,16 +25,13 @@ export function generateQuestions(topic, _context, knowledge, maxQuestions) {
26
25
  // === Acceptance: What does done look like? ===
27
26
  questions.push({
28
27
  id: nextId(),
29
- question: `What does "done" look like? Describe the ideal user flow.`,
28
+ question: `Which explicit observations or checks will prove that "${topic}" is done?`,
30
29
  category: 'acceptance',
31
30
  importance: 'critical',
32
31
  });
33
32
  // === Context-specific (only if topic mentions specific domains) ===
34
33
  // Auth related — only ask which method, nothing else
35
- if (lower.includes('auth') ||
36
- lower.includes('login') ||
37
- lower.includes('register') ||
38
- lower.includes('password')) {
34
+ if (hasAffirmedTerm(topic, /\b(?:auth|authentication|login|register|password)\b/i)) {
39
35
  questions.push({
40
36
  id: nextId(),
41
37
  question: `What login methods? (email/password, Google, Apple, magic link, etc.)`,
@@ -44,10 +40,7 @@ export function generateQuestions(topic, _context, knowledge, maxQuestions) {
44
40
  });
45
41
  }
46
42
  // UI related — only ask if there are mockups
47
- if (lower.includes('ui') ||
48
- lower.includes('page') ||
49
- lower.includes('screen') ||
50
- lower.includes('dashboard')) {
43
+ if (hasAffirmedTerm(topic, /\b(?:ui|page|screen|dashboard)\b/i)) {
51
44
  questions.push({
52
45
  id: nextId(),
53
46
  question: `Are there mockups or a reference design to follow?`,
@@ -71,4 +64,11 @@ export function generateQuestions(topic, _context, knowledge, maxQuestions) {
71
64
  suggestedAnswers: buildSuggestedAnswers(q, knowledge),
72
65
  }));
73
66
  }
67
+ const NEGATION_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)\b/i;
68
+ function hasAffirmedTerm(text, pattern) {
69
+ return text.split(/(?:\r?\n|[.;!?]+)/).some((segment) => {
70
+ const match = new RegExp(pattern.source, pattern.flags.replace('g', '')).exec(segment);
71
+ return match !== null && !NEGATION_RE.test(segment.slice(0, match.index));
72
+ });
73
+ }
74
74
  //# sourceMappingURL=questions-context.js.map
@@ -7,28 +7,22 @@ export function generateMetaQuestions() {
7
7
  return [
8
8
  {
9
9
  id: 'meta-1',
10
- question: 'Is this a brand new feature or an improvement to something that already exists?',
10
+ question: 'Which change type best describes the request?',
11
11
  category: 'scope',
12
12
  importance: 'critical',
13
13
  suggestedAnswers: ['New feature', 'Improvement to existing feature', 'Bug fix', 'Refactor'],
14
14
  },
15
15
  {
16
16
  id: 'meta-2',
17
- question: 'Who is the primary user that benefits from this? (e.g., end user, admin, developer)',
17
+ question: 'What observable outcome must this work produce?',
18
18
  category: 'scope',
19
19
  importance: 'critical',
20
- suggestedAnswers: ['End user', 'Admin', 'Developer/internal', 'All users'],
21
20
  },
22
21
  {
23
22
  id: 'meta-3',
24
- question: 'What is the minimum viable scope what would make this "done" at a basic level?',
23
+ question: 'Which boundaries are explicitly in scope and out of scope?',
25
24
  category: 'scope',
26
25
  importance: 'critical',
27
- suggestedAnswers: [
28
- 'Just the core happy path',
29
- 'Happy path + error handling',
30
- 'Full feature with edge cases',
31
- ],
32
26
  },
33
27
  ];
34
28
  }
@@ -97,20 +91,6 @@ export function buildSuggestedAnswers(question, knowledge) {
97
91
  ];
98
92
  }
99
93
  }
100
- // Performance questions
101
- if (lower.includes('performance') ||
102
- lower.includes('response time') ||
103
- lower.includes('throughput')) {
104
- return ['< 200ms response', '< 1s response', 'No strict requirement', 'Depends on load'];
105
- }
106
- // Auth questions
107
- if (lower.includes('authentication') || lower.includes('auth')) {
108
- return ['JWT tokens', 'Session-based', 'OAuth 2.0', 'No authentication needed', 'API key'];
109
- }
110
- // Timeline questions
111
- if (lower.includes('deadline') || lower.includes('timeline')) {
112
- return ['No strict deadline', 'Within 1 week', 'Within 1 sprint (2 weeks)', 'Specific date'];
113
- }
114
94
  return [];
115
95
  }
116
96
  //# sourceMappingURL=questions.js.map
@@ -6,6 +6,12 @@ import { isShortTopic, generateMetaQuestions, detectContradictions, buildSuggest
6
6
  import { generateQuestions } from './clarify-requirements/questions-context.js';
7
7
  import { detectStack, shouldSkipQuestion, attachOptions, buildSuggestedDescription, } from './clarify-requirements/multiple-choice.js';
8
8
  import { compactJson, compactResult, compactError } from './output-formatter.js';
9
+ function buildConfirmedChoices(questions, answers) {
10
+ return questions.flatMap((question) => {
11
+ const value = answers[question.id]?.trim();
12
+ return value ? [{ questionId: question.id, value, source: 'user_confirmation' }] : [];
13
+ });
14
+ }
9
15
  /** Resume an existing clarification session with new answers. */
10
16
  async function handleResumeSession(projectId, sessionId, newAnswers, knowledge) {
11
17
  const existing = await knowledgeStore.getClarification(projectId, sessionId).catch(() => null);
@@ -50,11 +56,14 @@ async function handleResumeSession(projectId, sessionId, newAnswers, knowledge)
50
56
  category: q.category,
51
57
  importance: q.importance,
52
58
  suggestedAnswers: q.suggestedAnswers,
59
+ ...(q.suggestedAnswers.length > 0 ? { suggestedAnswersClassification: 'candidate' } : {}),
53
60
  options: q.options,
61
+ ...(q.options ? { optionsClassification: 'candidate' } : {}),
54
62
  skipped: q.skipped,
55
63
  answered: mergedAnswers[q.id] !== undefined,
56
64
  })),
57
65
  specReady,
66
+ confirmedChoices: buildConfirmedChoices(enriched, mergedAnswers),
58
67
  ...(suggestedDescription !== undefined ? { suggestedDescription } : {}),
59
68
  answeredCount: Object.keys(mergedAnswers).length,
60
69
  ...(contradictions.length > 0
@@ -126,6 +135,29 @@ export async function handleClarifyRequirements(params, server) {
126
135
  const skipped = shouldSkipQuestion(q, stack);
127
136
  return skipped ? { ...withOptions, skipped: true } : withOptions;
128
137
  });
138
+ const detectedEvidence = [
139
+ ...(stack.language
140
+ ? [{ field: 'language', value: stack.language, source: 'project_manifest' }]
141
+ : knowledge?.language && knowledge.language !== 'unknown'
142
+ ? [{ field: 'language', value: knowledge.language, source: 'project_knowledge' }]
143
+ : []),
144
+ ...(stack.framework
145
+ ? [{ field: 'framework', value: stack.framework, source: 'project_manifest' }]
146
+ : knowledge?.framework
147
+ ? [
148
+ {
149
+ field: 'framework',
150
+ value: knowledge.framework,
151
+ source: 'project_knowledge',
152
+ },
153
+ ]
154
+ : []),
155
+ ...(stack.database
156
+ ? [{ field: 'database', value: stack.database, source: 'project_manifest' }]
157
+ : knowledge?.database && knowledge.database !== 'unknown'
158
+ ? [{ field: 'database', value: knowledge.database, source: 'project_knowledge' }]
159
+ : []),
160
+ ];
129
161
  // Determine if spec is ready (all non-skipped critical questions have non-empty answers)
130
162
  const criticalIds = questions
131
163
  .filter((q) => q.importance === 'critical' && q.skipped !== true)
@@ -170,9 +202,15 @@ export async function handleClarifyRequirements(params, server) {
170
202
  suggestedAnswer: q.suggestedAnswer,
171
203
  /* v8 ignore next 1 */
172
204
  suggestedAnswers: q.suggestedAnswers ?? [],
205
+ ...(q.suggestedAnswers && q.suggestedAnswers.length > 0
206
+ ? { suggestedAnswersClassification: 'candidate' }
207
+ : {}),
173
208
  options: q.options,
209
+ ...(q.options ? { optionsClassification: 'candidate' } : {}),
174
210
  skipped: q.skipped,
175
211
  })),
212
+ detectedEvidence,
213
+ confirmedChoices: buildConfirmedChoices(questions, currentAnswers),
176
214
  specReady,
177
215
  ...(suggestedDescription !== undefined ? { suggestedDescription } : {}),
178
216
  instructions: specReady
@@ -20,7 +20,7 @@ export declare function getEmptyAutopilotResult(): AutopilotAnalysis;
20
20
  * Analyze the project to enrich a spec with real file paths and patterns.
21
21
  * Runs automatically during create_spec — no user interaction needed.
22
22
  */
23
- export declare function analyzeProjectForSpec(projectPath: string, description: string, title: string, knowledge: ProjectKnowledge | null): Promise<AutopilotAnalysis>;
23
+ export declare function analyzeProjectForSpec(projectPath: string, description: string, title: string, _knowledge: ProjectKnowledge | null): Promise<AutopilotAnalysis>;
24
24
  /**
25
25
  * SPEC-619 AC1: Detect broad-scope descriptions that mention 2+ major subsystems.
26
26
  * When true, create_spec should ask clarifying questions before proceeding.
@@ -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;