@planu/cli 4.3.25 → 4.4.0

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 (34) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/engine/constitution/sdd-rules-registry.d.ts +12 -0
  3. package/dist/engine/constitution/sdd-rules-registry.js +105 -0
  4. package/dist/engine/evidence-index/done-drift.d.ts +6 -0
  5. package/dist/engine/evidence-index/done-drift.js +44 -0
  6. package/dist/engine/evidence-index/index-builder.d.ts +10 -0
  7. package/dist/engine/evidence-index/index-builder.js +138 -0
  8. package/dist/engine/host-rules-templates/templates.js +3 -0
  9. package/dist/engine/spec-format/lean-spec-generator.js +17 -12
  10. package/dist/engine/spec-grounding/contract.d.ts +19 -0
  11. package/dist/engine/spec-grounding/contract.js +186 -0
  12. package/dist/engine/spec-metrics/actionable-metrics.d.ts +10 -0
  13. package/dist/engine/spec-metrics/actionable-metrics.js +69 -0
  14. package/dist/engine/spec-quality/generic-output-gate.d.ts +3 -0
  15. package/dist/engine/spec-quality/generic-output-gate.js +105 -0
  16. package/dist/tools/create-spec.js +160 -30
  17. package/dist/tools/update-status/evidence-gate.js +23 -6
  18. package/dist/tools/update-status/transition-guard.js +49 -0
  19. package/dist/types/actionable-spec-metrics.d.ts +8 -0
  20. package/dist/types/actionable-spec-metrics.js +2 -0
  21. package/dist/types/evidence-gates.d.ts +1 -1
  22. package/dist/types/evidence-index.d.ts +24 -0
  23. package/dist/types/evidence-index.js +2 -0
  24. package/dist/types/index.d.ts +5 -0
  25. package/dist/types/index.js +5 -0
  26. package/dist/types/sdd-constitution.d.ts +27 -0
  27. package/dist/types/sdd-constitution.js +2 -0
  28. package/dist/types/spec-format.d.ts +7 -0
  29. package/dist/types/spec-grounding.d.ts +22 -0
  30. package/dist/types/spec-grounding.js +2 -0
  31. package/dist/types/spec-quality.d.ts +10 -0
  32. package/package.json +9 -9
  33. package/planu-native.json +29 -8
  34. package/planu-plugin.json +35 -7
@@ -0,0 +1,69 @@
1
+ export function calculateActionableSpecMetrics(args) {
2
+ return [
3
+ groundingCoverage(args.criteria, args.groundingRecords ?? []),
4
+ verificationCoverage(args.evidenceIndex),
5
+ ambiguityCount(args.ambiguityFindings ?? []),
6
+ driftRisk(args.driftIssues ?? []),
7
+ ].filter((metric) => metric !== null);
8
+ }
9
+ export function shouldExposeMetric(metric) {
10
+ return metric.inputs.length > 0 && metric.nextAction.trim().length > 0;
11
+ }
12
+ function groundingCoverage(criteria, records) {
13
+ const missing = criteria.filter((criterion) => !records.some((record) => normalize(record.text) === normalize(criterion) &&
14
+ record.source !== 'ungrounded_advisory' &&
15
+ record.evidence.length > 0));
16
+ if (criteria.length === 0 || missing.length === 0) {
17
+ return null;
18
+ }
19
+ return {
20
+ name: 'groundingCoverage',
21
+ value: Math.round(((criteria.length - missing.length) / criteria.length) * 100),
22
+ inputs: missing,
23
+ nextAction: 'Add grounding evidence or move ungrounded criteria to discovery/advisory output.',
24
+ };
25
+ }
26
+ function verificationCoverage(index) {
27
+ if (!index) {
28
+ return null;
29
+ }
30
+ const missing = index.criteria.filter((entry) => !entry.evidence.some((record) => record.kind === 'validation' && record.status === 'valid'));
31
+ if (index.criteria.length === 0 || missing.length === 0) {
32
+ return null;
33
+ }
34
+ return {
35
+ name: 'verificationCoverage',
36
+ value: Math.round(((index.criteria.length - missing.length) / index.criteria.length) * 100),
37
+ inputs: missing.map((entry) => entry.criterion),
38
+ nextAction: 'Attach validation evidence mapped to each uncovered criterion.',
39
+ };
40
+ }
41
+ function ambiguityCount(findings) {
42
+ if (findings.length === 0) {
43
+ return null;
44
+ }
45
+ return {
46
+ name: 'ambiguityCount',
47
+ value: findings.length,
48
+ inputs: [...findings],
49
+ nextAction: 'Resolve each ambiguity or mark it explicitly out of scope before implementation.',
50
+ };
51
+ }
52
+ function driftRisk(issues) {
53
+ const driftIssues = issues.filter((issue) => issue.code.startsWith('done_drift_'));
54
+ if (driftIssues.length === 0) {
55
+ return null;
56
+ }
57
+ return {
58
+ name: 'driftRisk',
59
+ value: driftIssues.some((issue) => issue.code === 'done_drift_unapproved_scope')
60
+ ? 'high'
61
+ : 'medium',
62
+ inputs: driftIssues.map((issue) => issue.message),
63
+ nextAction: 'Reconcile scope, replace stale evidence, or map validation to the affected criteria.',
64
+ };
65
+ }
66
+ function normalize(value) {
67
+ return value.replace(/\s+/g, ' ').trim().toLowerCase();
68
+ }
69
+ //# sourceMappingURL=actionable-metrics.js.map
@@ -0,0 +1,3 @@
1
+ import type { GenericSpecOutputGateResult } from '../../types/index.js';
2
+ export declare function checkGenericSpecOutput(content: string): GenericSpecOutputGateResult;
3
+ //# sourceMappingURL=generic-output-gate.d.ts.map
@@ -0,0 +1,105 @@
1
+ const CRITERIA_SECTION_RE = /^criteria:\n([\s\S]*?)(?=^[a-zA-Z_][\w-]*:|\n---|\s*$)/m;
2
+ const CRITERION_TEXT_RE = /^\s+- text:\s*"?(.+?)"?\s*$/gm;
3
+ const FILE_LINE_RE = /^-\s+(.+?)\s+\((pending|done)\)\s*$/gm;
4
+ const GENERIC_CRITERION_RULES = [
5
+ {
6
+ pattern: /\bimplementation (is )?complete\b/i,
7
+ reason: 'self-certifying completion is not a testable behavior',
8
+ },
9
+ {
10
+ pattern: /\bcomplete and tested\b/i,
11
+ reason: 'completion and testing must be proven by evidence, not criteria text',
12
+ },
13
+ {
14
+ pattern: /\ball edge cases\b/i,
15
+ reason: 'edge cases must be named explicitly',
16
+ },
17
+ {
18
+ pattern: /\bgeneric validation\b/i,
19
+ reason: 'validation must identify the behavior and expected result',
20
+ },
21
+ {
22
+ pattern: /\bensure (adequate )?coverage\b/i,
23
+ reason: 'coverage claims must map to concrete validation evidence',
24
+ },
25
+ {
26
+ pattern: /\bautopilot could not infer testable behavior\b/i,
27
+ reason: 'fallback uncertainty cannot become an acceptance criterion',
28
+ },
29
+ {
30
+ pattern: /\bdefine acceptance criteria\b/i,
31
+ reason: 'a request to define criteria is not itself a criterion',
32
+ },
33
+ {
34
+ pattern: /\b(example only|for example|sample)\b/i,
35
+ reason: 'example-only references cannot become contract criteria',
36
+ },
37
+ {
38
+ pattern: /\b(add|create|implement)\s+(api\s+)?(endpoint|route|migration)s?\b/i,
39
+ reason: 'broad implementation references must name grounded behavior and evidence',
40
+ },
41
+ ];
42
+ const PLACEHOLDER_REFERENCE_RULES = [
43
+ {
44
+ pattern: /\b(to be determined|tbd|placeholder|not specified)\b/i,
45
+ reason: 'placeholder references are not grounded implementation ownership',
46
+ },
47
+ {
48
+ pattern: /(^|\/)(example|sample|mock|dummy)[\w.-]*\.[a-z]+$/i,
49
+ reason: 'example file names must not be persisted as technical ownership',
50
+ },
51
+ ];
52
+ export function checkGenericSpecOutput(content) {
53
+ const issues = [
54
+ ...checkCriteria(frontmatterCriteria(content)),
55
+ ...checkTechnicalReferences(content),
56
+ ];
57
+ return { passed: issues.length === 0, issues };
58
+ }
59
+ function frontmatterCriteria(content) {
60
+ const criteriaSection = CRITERIA_SECTION_RE.exec(content)?.[1] ?? '';
61
+ const criteria = [];
62
+ let match = CRITERION_TEXT_RE.exec(criteriaSection);
63
+ while (match) {
64
+ const phrase = match[1]?.replace(/\\"/g, '"').trim() ?? '';
65
+ if (phrase.length > 0) {
66
+ criteria.push(phrase);
67
+ }
68
+ match = CRITERION_TEXT_RE.exec(criteriaSection);
69
+ }
70
+ return criteria;
71
+ }
72
+ function checkCriteria(criteria) {
73
+ const issues = [];
74
+ for (const criterion of criteria) {
75
+ for (const rule of GENERIC_CRITERION_RULES) {
76
+ if (rule.pattern.test(criterion)) {
77
+ issues.push({
78
+ kind: rule.reason.includes('coverage') ? 'unsupported-verification' : 'generic-criterion',
79
+ phrase: criterion,
80
+ reason: rule.reason,
81
+ });
82
+ }
83
+ }
84
+ }
85
+ return issues;
86
+ }
87
+ function checkTechnicalReferences(content) {
88
+ const issues = [];
89
+ let match = FILE_LINE_RE.exec(content);
90
+ while (match) {
91
+ const phrase = match[1]?.replace(/`/g, '').trim() ?? '';
92
+ for (const rule of PLACEHOLDER_REFERENCE_RULES) {
93
+ if (rule.pattern.test(phrase)) {
94
+ issues.push({
95
+ kind: 'placeholder-reference',
96
+ phrase,
97
+ reason: rule.reason,
98
+ });
99
+ }
100
+ }
101
+ match = FILE_LINE_RE.exec(content);
102
+ }
103
+ return issues;
104
+ }
105
+ //# sourceMappingURL=generic-output-gate.js.map
@@ -4,8 +4,9 @@ import { ti } from '../i18n/index.js';
4
4
  import { knowledgeStore, specStore } from '../storage/index.js';
5
5
  import { readTechnologySelectionContract } from '../storage/technology-selection-store.js';
6
6
  import { formatSuccess, addNextSteps, toolResult, interactiveResult } from './response-helpers.js';
7
- import { writeFile, mkdir, rm, readFile, stat } from 'node:fs/promises';
7
+ import { writeFile, mkdir, rm, readFile, stat as fsStat } from 'node:fs/promises';
8
8
  import { createHash } from 'node:crypto';
9
+ import { join as pathJoin } from 'node:path';
9
10
  import { estimateSpec } from '../engine/estimator.js';
10
11
  import { checkSpecReadiness } from '../engine/readiness-checker.js';
11
12
  import { buildSpecContext, buildSplitResult } from './create-spec/spec-builder.js';
@@ -15,7 +16,7 @@ import { notifyStoreChange } from '../engine/doc-generator/portal/regen-hook.js'
15
16
  import { compactObj } from '../engine/compact-obj.js';
16
17
  import { buildCreateSpecSummary } from '../engine/human-summary.js';
17
18
  import { runAutoPostCreatePipeline } from './create-spec/auto-pipeline.js';
18
- import { generateLeanSpecContent } from '../engine/spec-format/lean-spec-generator.js';
19
+ import { extractCriteria, generateLeanSpecContent, } from '../engine/spec-format/lean-spec-generator.js';
19
20
  import { generateLeanTechnicalContent, } from '../engine/spec-format/lean-technical-generator.js';
20
21
  import { extractFilesFromSpecBody } from '../engine/spec-format/technical-md-populator.js';
21
22
  import { buildUnifiedSpecContent } from '../engine/spec-format/unified-spec-builder.js';
@@ -38,6 +39,9 @@ import { suggestOutOfScope } from '../engine/scope-boundaries/index.js';
38
39
  import { adviseSimilarSpecs } from '../engine/complexity-budget/index.js';
39
40
  import { issuePlannerToken } from '../engine/reviewer-tokens/issuer.js';
40
41
  import { generateInteractiveQuestions } from './create-spec/question-generator.js';
42
+ import { buildCriterionGroundingRecords, filterGroundedCriteria, getAdvisoryCriteria, getContractCriteria, } from '../engine/spec-grounding/contract.js';
43
+ import { checkGenericSpecOutput } from '../engine/spec-quality/generic-output-gate.js';
44
+ import { calculateActionableSpecMetrics, shouldExposeMetric, } from '../engine/spec-metrics/actionable-metrics.js';
41
45
  /** SPEC-584: Persist a clarification token when interactive questions are emitted. Best-effort. */
42
46
  async function persistClarificationToken(earlyReturn, projectId, toolName) {
43
47
  try {
@@ -121,6 +125,69 @@ async function resolveTechnicalFiles(input) {
121
125
  }
122
126
  return { create: [], modify: [], test: [] };
123
127
  }
128
+ async function groundTechnicalFiles(input) {
129
+ const userInput = input.userInput.toLowerCase();
130
+ const autopilotPaths = new Set([
131
+ ...input.autopilot.suggestedFiles.modify.map((file) => file.path),
132
+ ...input.autopilot.suggestedFiles.create.map((file) => file.path),
133
+ ...input.autopilot.suggestedFiles.test.map((file) => file.path),
134
+ ]);
135
+ const grounded = {
136
+ create: [],
137
+ modify: [],
138
+ test: [],
139
+ };
140
+ const records = [];
141
+ const advisoryFiles = [];
142
+ for (const section of ['create', 'modify', 'test']) {
143
+ for (const file of input.files[section]) {
144
+ const pathInUserInput = userInput.includes(file.path.toLowerCase());
145
+ const exists = await pathExists(pathJoin(input.projectPath, file.path));
146
+ const fromAutopilot = autopilotPaths.has(file.path);
147
+ const derivedTest = section === 'test' && isDerivedFromGroundedSource(file.path, input.files.modify);
148
+ if (pathInUserInput || exists || fromAutopilot || derivedTest) {
149
+ grounded[section].push(file);
150
+ records.push({
151
+ path: file.path,
152
+ section,
153
+ source: pathInUserInput ? 'user_input' : 'project_evidence',
154
+ evidence: [
155
+ ...(pathInUserInput ? ['create_spec.description'] : []),
156
+ ...(exists ? [`file:${file.path}`] : []),
157
+ ...(fromAutopilot ? ['autopilot.suggestedFiles'] : []),
158
+ ...(derivedTest ? ['derived-test-path'] : []),
159
+ ],
160
+ confidence: pathInUserInput || exists ? 'high' : 'medium',
161
+ });
162
+ }
163
+ else {
164
+ advisoryFiles.push(file);
165
+ }
166
+ }
167
+ }
168
+ return { files: grounded, records, advisoryFiles };
169
+ }
170
+ function isDerivedFromGroundedSource(testPath, modifyFiles) {
171
+ return modifyFiles.some((file) => {
172
+ const withoutSrc = file.path.startsWith('src/') ? file.path.slice(4) : file.path;
173
+ const ext = withoutSrc.replace(/^.*(\.[^.]+)$/, '$1');
174
+ const base = withoutSrc.slice(0, -ext.length);
175
+ return testPath === `tests/${base}.test${ext}` || testPath === `tests/${withoutSrc}`;
176
+ });
177
+ }
178
+ async function pathExists(path) {
179
+ try {
180
+ const fs = await import('node:fs/promises');
181
+ if (typeof fs.stat !== 'function') {
182
+ return false;
183
+ }
184
+ await fs.stat(path);
185
+ return true;
186
+ }
187
+ catch {
188
+ return false;
189
+ }
190
+ }
124
191
  const HIGH_RISK_WARNING = 'High-risk spec — consider: ' +
125
192
  '1) Is the approach technically feasible within the project constraints? ' +
126
193
  '2) What edge cases (network failure, concurrent writes, empty state) could break this? ' +
@@ -304,10 +371,10 @@ async function findByIdempotencyKey(projectPath, key, projectId, windowMs = 10 *
304
371
  // Fallback: scan frontmatter in spec.md files (catches specs written before store update)
305
372
  try {
306
373
  const { glob } = await import('glob');
307
- const { join: pathJoin } = await import('node:path');
308
- const specFiles = await glob(pathJoin(projectPath, 'planu/specs/*/spec.md'));
374
+ const { join: joinPath } = await import('node:path');
375
+ const specFiles = await glob(joinPath(projectPath, 'planu/specs/*/spec.md'));
309
376
  for (const specFile of specFiles) {
310
- const fileStat = await stat(specFile).catch(() => null);
377
+ const fileStat = await fsStat(specFile).catch(() => null);
311
378
  if (fileStat === null || fileStat.mtimeMs < cutoff) {
312
379
  continue;
313
380
  }
@@ -338,12 +405,12 @@ async function findByIdempotencyKey(projectPath, key, projectId, windowMs = 10 *
338
405
  async function findRecentlyWrittenSpecs(projectPath, windowMs) {
339
406
  try {
340
407
  const { glob } = await import('glob');
341
- const { join: pathJoin } = await import('node:path');
408
+ const { join: joinPath } = await import('node:path');
342
409
  const cutoff = Date.now() - windowMs;
343
- const specFiles = await glob(pathJoin(projectPath, 'planu/specs/*/spec.md'));
410
+ const specFiles = await glob(joinPath(projectPath, 'planu/specs/*/spec.md'));
344
411
  const results = [];
345
412
  for (const specFile of specFiles) {
346
- const fileStat = await stat(specFile).catch(() => null);
413
+ const fileStat = await fsStat(specFile).catch(() => null);
347
414
  if (fileStat === null || fileStat.mtimeMs < cutoff) {
348
415
  continue;
349
416
  }
@@ -528,7 +595,7 @@ export async function handleCreateSpec(inputParams, server) {
528
595
  }
529
596
  // Create spec directory and write lean files (SPEC-461)
530
597
  await measureStep('mkdir-specDir', () => mkdir(specDir, { recursive: true }));
531
- const filteredCriteria = autopilot.suggestedCriteria;
598
+ const filteredCriteria = filterGroundedCriteria(autopilot.suggestedCriteria);
532
599
  const technologyContract = await readTechnologySelectionContract(params.projectPath ?? '');
533
600
  const contractNote = technologyContract
534
601
  ? [
@@ -568,13 +635,24 @@ export async function handleCreateSpec(inputParams, server) {
568
635
  spec.generatedWithModel = generatedSpec.generatedWithModel;
569
636
  spec.generatedAt = generatedSpec.generatedAt;
570
637
  spec.qualityWarnings = generatedSpec.qualityWarnings;
571
- const leanSpec = generateLeanSpecContent({
572
- spec,
573
- description: generatedSpec.specBody,
574
- estimation,
575
- extraCriteria: filteredCriteria,
576
- acFormat: params.acFormat,
638
+ const baseCriteria = extractCriteria(generatedSpec.specBody).map((criterion) => criterion.text);
639
+ const groundingCriteria = buildCriterionGroundingRecords({
640
+ criteria: [...baseCriteria, ...filteredCriteria],
641
+ userInput: description,
642
+ projectEvidence: [
643
+ ...autopilot.detectedPatterns.map((pattern) => `detected-pattern:${pattern}`),
644
+ ...autopilot.suggestedFiles.modify.map((file) => `file:${file.path}`),
645
+ ...autopilot.suggestedFiles.create.map((file) => `file:${file.path}`),
646
+ ...autopilot.suggestedFiles.test.map((file) => `file:${file.path}`),
647
+ ],
648
+ generatedEvidence: [generatedSpec.generatedWithModel],
577
649
  });
650
+ const contractCriteria = getContractCriteria(groundingCriteria);
651
+ const advisoryCriteria = getAdvisoryCriteria(groundingCriteria).map((record) => record.text);
652
+ const actionableMetrics = calculateActionableSpecMetrics({
653
+ criteria: [...baseCriteria, ...filteredCriteria],
654
+ groundingRecords: groundingCriteria,
655
+ }).filter(shouldExposeMetric);
578
656
  const technicalFiles = await measureStep('resolveTechnicalFiles', () => resolveTechnicalFiles({
579
657
  generatedSpecBody: generatedSpec.specBody,
580
658
  generatedTechnicalSection: generatedSpec.technicalSection,
@@ -582,16 +660,59 @@ export async function handleCreateSpec(inputParams, server) {
582
660
  autopilot,
583
661
  fallbackReason: generatedSpec.fallbackReason,
584
662
  }));
663
+ const groundedTechnical = await measureStep('groundTechnicalFiles', () => groundTechnicalFiles({
664
+ files: technicalFiles,
665
+ projectPath: params.projectPath ?? '',
666
+ userInput: description,
667
+ autopilot,
668
+ }));
669
+ const leanSpec = generateLeanSpecContent({
670
+ spec,
671
+ description: generatedSpec.specBody,
672
+ estimation,
673
+ criteriaOverride: contractCriteria.map((record) => ({
674
+ text: record.text,
675
+ done: false,
676
+ })),
677
+ groundingCriteria,
678
+ groundingTechnicalReferences: groundedTechnical.records,
679
+ acFormat: params.acFormat,
680
+ });
585
681
  const leanTechnical = generateLeanTechnicalContent({
586
682
  specId: spec.id,
587
- filesToCreate: technicalFiles.create,
588
- filesToModify: technicalFiles.modify,
589
- filesToTest: technicalFiles.test,
683
+ filesToCreate: groundedTechnical.files.create,
684
+ filesToModify: groundedTechnical.files.modify,
685
+ filesToTest: groundedTechnical.files.test,
590
686
  });
591
687
  // SPEC-709: write unified spec.md from origin — no separate technical.md.
592
688
  // The legacy two-file output is preserved by appending the technical body
593
689
  // as a `## Technical` section inside spec.md.
594
690
  const unifiedSpec = buildUnifiedSpecContent(leanSpec, leanTechnical);
691
+ const genericOutputGate = checkGenericSpecOutput(unifiedSpec);
692
+ if (!genericOutputGate.passed) {
693
+ return {
694
+ ok: false,
695
+ earlyReturn: {
696
+ content: [
697
+ {
698
+ type: 'text',
699
+ text: 'Spec quality gate blocked generic output before persistence. ' +
700
+ genericOutputGate.issues
701
+ .slice(0, 3)
702
+ .map((issue) => `${issue.phrase}: ${issue.reason}`)
703
+ .join('; '),
704
+ },
705
+ ],
706
+ isError: true,
707
+ structuredContent: {
708
+ error: 'GENERIC_SPEC_OUTPUT_BLOCKED',
709
+ sddConstitutionRuleId: 'sdd.no-generic-output',
710
+ issues: genericOutputGate.issues,
711
+ fixHint: 'Replace generic criteria or placeholder references with grounded, testable behavior.',
712
+ },
713
+ },
714
+ };
715
+ }
595
716
  try {
596
717
  // SPEC-713: measure file write — this is the critical persistence step
597
718
  await measureStep('writeFile-specPath', () => writeFile(specPath, unifiedSpec, 'utf-8'));
@@ -629,6 +750,11 @@ export async function handleCreateSpec(inputParams, server) {
629
750
  constitutionCheck,
630
751
  autopilot,
631
752
  filteredCriteria,
753
+ advisoryCriteria: [
754
+ ...advisoryCriteria,
755
+ ...groundedTechnical.advisoryFiles.map((file) => file.path),
756
+ ],
757
+ actionableMetrics,
632
758
  outOfScopeSuggestionMsg,
633
759
  },
634
760
  };
@@ -659,7 +785,7 @@ export async function handleCreateSpec(inputParams, server) {
659
785
  // Destructure critical path results for use in post-creation enrichment
660
786
  const { spec, specDir: _specDir, specPath,
661
787
  // SPEC-1010 Bug A: no longer surfaced in the response payload (SSR back-migration).
662
- technicalPath: _technicalPath, estimation, splitSuggestion, duplicate, projectId, agentTeamPlan, knowledge, clarificationSession, constitutionCheck, autopilot, filteredCriteria, outOfScopeSuggestionMsg, } = criticalResult.value.data;
788
+ technicalPath: _technicalPath, estimation, splitSuggestion, duplicate, projectId, agentTeamPlan, knowledge, clarificationSession, constitutionCheck, autopilot, filteredCriteria, advisoryCriteria, actionableMetrics, outOfScopeSuggestionMsg, } = criticalResult.value.data;
663
789
  // -----------------------------------------------------------------------
664
790
  // Post-creation enrichment (outside 25s ceiling — best-effort, budgeted)
665
791
  // -----------------------------------------------------------------------
@@ -742,6 +868,21 @@ export async function handleCreateSpec(inputParams, server) {
742
868
  ...(gitSetupResult ? { gitAutoSetup: gitSetupResult.data } : {}),
743
869
  };
744
870
  const advisorySignals = [];
871
+ if (actionableMetrics.length > 0) {
872
+ result.actionableMetrics = actionableMetrics;
873
+ }
874
+ if (advisoryCriteria.length > 0) {
875
+ advisorySignals.push(makeAdvisorySignal({
876
+ key: 'ungrounded-contract-items',
877
+ kind: 'quality',
878
+ message: `${String(advisoryCriteria.length)} ungrounded contract item(s) kept advisory-only.`,
879
+ source: 'validator',
880
+ evidence: advisoryCriteria.slice(0, 10),
881
+ confidence: 0.8,
882
+ surface: 'structuredContent',
883
+ value: advisoryCriteria,
884
+ }));
885
+ }
745
886
  const splitResult = buildSplitResult(splitSuggestion, knowledge?.experienceLevel);
746
887
  if (splitResult) {
747
888
  result.splitSuggestion = splitResult;
@@ -810,17 +951,6 @@ export async function handleCreateSpec(inputParams, server) {
810
951
  const qualityValue = unwrapBudget(qualityResult, null, budgetWarnings);
811
952
  if (qualityValue !== null) {
812
953
  result.qualityScore = qualityValue;
813
- advisorySignals.push(makeAdvisorySignal({
814
- key: 'quality-score',
815
- kind: 'quality',
816
- message: `Quality score ${String(qualityValue.total)}/100 (${qualityValue.grade}).`,
817
- source: 'validator',
818
- evidence: ['spec-quality-scorer'],
819
- confidence: 0.7,
820
- surface: 'structuredContent',
821
- value: qualityValue,
822
- deprecatedAlias: 'qualityScore',
823
- }));
824
954
  }
825
955
  // SPEC-485: Simplicity autopilot — detect over-engineering signals (best-effort, sync)
826
956
  const simplicityResult = runSimplicityCheck([params.description, ...filteredCriteria].join('\n'), estimation.devHours);
@@ -4,10 +4,13 @@ import { extractCriteria, extractListItems, extractSection, } from '../../engine
4
4
  import { stripFrontmatter } from '../../engine/frontmatter-parser.js';
5
5
  import { readEvidenceArtifacts } from '../../engine/evidence-gates/artifact-reader.js';
6
6
  import { checkLifecycleEvidenceGate } from '../../engine/evidence-gates/lifecycle-gate.js';
7
+ import { buildSpecEvidenceIndex } from '../../engine/evidence-index/index-builder.js';
8
+ import { checkDoneDriftContract } from '../../engine/evidence-index/done-drift.js';
9
+ import { parseTechnicalReferenceGroundingRecords } from '../../engine/spec-grounding/contract.js';
7
10
  /** SPEC-1054: BDD/SDD lifecycle evidence gate. */
8
11
  export async function checkLifecycleEvidenceTransitionGate(args) {
9
- const [criteria, artifacts] = await Promise.all([
10
- extractLifecycleGateCriteria(args.spec, args.projectPath),
12
+ const [criteriaResult, artifacts] = await Promise.all([
13
+ extractLifecycleGateCriteriaAndBody(args.spec, args.projectPath),
11
14
  readEvidenceArtifacts({
12
15
  spec: args.spec,
13
16
  projectId: args.projectId,
@@ -15,13 +18,26 @@ export async function checkLifecycleEvidenceTransitionGate(args) {
15
18
  projectPath: args.projectPath,
16
19
  }),
17
20
  ]);
21
+ const { criteria, body } = criteriaResult;
18
22
  const result = checkLifecycleEvidenceGate({
19
23
  transition: args.transition,
20
24
  spec: args.spec,
21
25
  criteria,
22
26
  artifacts,
23
27
  });
24
- if (result.passed) {
28
+ if (args.transition === 'done') {
29
+ const index = await buildSpecEvidenceIndex({
30
+ specId: args.specId,
31
+ criteria,
32
+ artifacts,
33
+ projectPath: args.projectPath,
34
+ });
35
+ const groundedTechnicalPaths = body
36
+ ? parseTechnicalReferenceGroundingRecords(body).map((record) => record.path)
37
+ : [];
38
+ result.issues.push(...checkDoneDriftContract({ index, groundedTechnicalPaths }));
39
+ }
40
+ if (result.issues.length === 0) {
25
41
  return null;
26
42
  }
27
43
  return evidenceGateError({
@@ -34,7 +50,7 @@ export async function checkLifecycleEvidenceTransitionGate(args) {
34
50
  requiredContractKinds: result.requiredContractKinds,
35
51
  });
36
52
  }
37
- async function extractLifecycleGateCriteria(spec, projectPath) {
53
+ async function extractLifecycleGateCriteriaAndBody(spec, projectPath) {
38
54
  const specPath = isAbsolute(spec.specPath) || !projectPath ? spec.specPath : join(projectPath, spec.specPath);
39
55
  try {
40
56
  const raw = await readFile(specPath, 'utf-8');
@@ -43,14 +59,14 @@ async function extractLifecycleGateCriteria(spec, projectPath) {
43
59
  if (acceptanceCriteria) {
44
60
  const items = extractListItems(acceptanceCriteria);
45
61
  if (items.length > 0) {
46
- return [...new Set(items)];
62
+ return { criteria: [...new Set(items)], body: raw };
47
63
  }
48
64
  }
49
65
  }
50
66
  catch {
51
67
  // Fall back to the broader validator extractor when the canonical file is unavailable.
52
68
  }
53
- return extractCriteria(spec);
69
+ return { criteria: await extractCriteria(spec), body: null };
54
70
  }
55
71
  function evidenceGateError(args) {
56
72
  return {
@@ -72,6 +88,7 @@ function evidenceGateError(args) {
72
88
  structuredContent: {
73
89
  error: 'lifecycle_evidence_gate_failed',
74
90
  code: 422,
91
+ sddConstitutionRuleId: args.transition === 'done' ? 'sdd.done-evidence-required' : 'sdd.grounded-contract',
75
92
  context: {
76
93
  specId: args.specId,
77
94
  transition: args.transition,
@@ -6,6 +6,8 @@ import { dispatchFeedbackEvent } from '../learn.js';
6
6
  import { scoreAmbiguityFromPath } from '../../engine/ambiguity-scorer.js';
7
7
  import { checkReadinessInternal } from '../../engine/readiness-checker.js';
8
8
  import { validateEnglishOnlySpecText } from '../../engine/spec-language/english-only.js';
9
+ import { checkGroundedSpecContract } from '../../engine/spec-grounding/contract.js';
10
+ import { checkGenericSpecOutput } from '../../engine/spec-quality/generic-output-gate.js';
9
11
  /**
10
12
  * Valid state transitions for spec lifecycle.
11
13
  * draft -> review -> approved -> implementing -> done
@@ -224,6 +226,53 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
224
226
  qualityWarnings: [],
225
227
  };
226
228
  }
229
+ const groundingResult = checkGroundedSpecContract(spec, body);
230
+ if (!groundingResult.passed) {
231
+ return {
232
+ blockResult: {
233
+ content: [
234
+ {
235
+ type: 'text',
236
+ text: `Grounded spec contract blocked transition to ${newStatus}. ` +
237
+ groundingResult.issues.slice(0, 3).join('; '),
238
+ },
239
+ ],
240
+ isError: true,
241
+ structuredContent: {
242
+ error: 'GROUNDED_SPEC_CONTRACT_BLOCKED',
243
+ sddConstitutionRuleId: 'sdd.grounded-contract',
244
+ issues: groundingResult.issues,
245
+ fixHint: 'Add grounding metadata for every contract criterion or move ungrounded criteria to advisory/discovery before review or approval.',
246
+ },
247
+ },
248
+ qualityWarnings: [],
249
+ };
250
+ }
251
+ const genericOutputGate = checkGenericSpecOutput(body);
252
+ if (!genericOutputGate.passed) {
253
+ return {
254
+ blockResult: {
255
+ content: [
256
+ {
257
+ type: 'text',
258
+ text: `Spec quality gate blocked transition to ${newStatus}. ` +
259
+ genericOutputGate.issues
260
+ .slice(0, 3)
261
+ .map((issue) => `${issue.phrase}: ${issue.reason}`)
262
+ .join('; '),
263
+ },
264
+ ],
265
+ isError: true,
266
+ structuredContent: {
267
+ error: 'GENERIC_SPEC_OUTPUT_BLOCKED',
268
+ sddConstitutionRuleId: 'sdd.no-generic-output',
269
+ issues: genericOutputGate.issues,
270
+ fixHint: 'Replace generic criteria or placeholder references with grounded, testable behavior before review or approval.',
271
+ },
272
+ },
273
+ qualityWarnings: [],
274
+ };
275
+ }
227
276
  let score;
228
277
  let warnings;
229
278
  let criteriaCount;
@@ -0,0 +1,8 @@
1
+ export type ActionableSpecMetricName = 'groundingCoverage' | 'verificationCoverage' | 'ambiguityCount' | 'driftRisk';
2
+ export interface ActionableSpecMetric {
3
+ name: ActionableSpecMetricName;
4
+ value: number | 'none' | 'low' | 'medium' | 'high';
5
+ inputs: string[];
6
+ nextAction: string;
7
+ }
8
+ //# sourceMappingURL=actionable-spec-metrics.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=actionable-spec-metrics.js.map
@@ -54,7 +54,7 @@ export interface EvidenceArtifacts {
54
54
  invalidArtifacts: string[];
55
55
  }
56
56
  export interface EvidenceGateIssue {
57
- code: 'discovery_missing' | 'discovery_unresolved_questions' | 'task_plan_missing' | 'task_plan_uncovered_criteria' | 'traceability_missing' | 'traceability_uncovered_criteria' | 'traceability_incomplete_rows' | 'contract_validation_missing' | 'contract_validation_failed' | 'evidence_artifact_invalid';
57
+ code: 'discovery_missing' | 'discovery_unresolved_questions' | 'task_plan_missing' | 'task_plan_uncovered_criteria' | 'traceability_missing' | 'traceability_uncovered_criteria' | 'traceability_incomplete_rows' | 'contract_validation_missing' | 'contract_validation_failed' | 'done_drift_uncovered_criteria' | 'done_drift_stale_evidence' | 'done_drift_unapproved_scope' | 'evidence_artifact_invalid';
58
58
  message: string;
59
59
  }
60
60
  export interface EvidenceGateResult {
@@ -0,0 +1,24 @@
1
+ export type EvidenceRecordKind = 'scenario' | 'test' | 'contract' | 'manual' | 'changed-file' | 'validation' | 'reviewer';
2
+ export type EvidenceRecordStatus = 'valid' | 'stale' | 'invalid' | 'missing';
3
+ export interface EvidenceRecord {
4
+ kind: EvidenceRecordKind;
5
+ value: string;
6
+ status: EvidenceRecordStatus;
7
+ source: string;
8
+ command?: string;
9
+ passed?: boolean;
10
+ timestamp?: string;
11
+ reason?: string;
12
+ }
13
+ export interface CriterionEvidenceIndexEntry {
14
+ criterion: string;
15
+ criterionKey: string;
16
+ evidence: EvidenceRecord[];
17
+ }
18
+ export interface SpecEvidenceIndex {
19
+ specId: string;
20
+ generatedAt: string;
21
+ criteria: CriterionEvidenceIndexEntry[];
22
+ diagnostics: string[];
23
+ }
24
+ //# sourceMappingURL=evidence-index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=evidence-index.js.map