@planu/cli 4.3.25 → 4.4.1

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 (40) hide show
  1. package/CHANGELOG.md +16 -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-gates/artifact-reader.js +10 -5
  5. package/dist/engine/evidence-index/done-drift.d.ts +6 -0
  6. package/dist/engine/evidence-index/done-drift.js +44 -0
  7. package/dist/engine/evidence-index/index-builder.d.ts +10 -0
  8. package/dist/engine/evidence-index/index-builder.js +138 -0
  9. package/dist/engine/host-rules-templates/templates.js +3 -0
  10. package/dist/engine/spec-format/lean-spec-generator.js +17 -12
  11. package/dist/engine/spec-grounding/contract.d.ts +19 -0
  12. package/dist/engine/spec-grounding/contract.js +186 -0
  13. package/dist/engine/spec-metrics/actionable-metrics.d.ts +10 -0
  14. package/dist/engine/spec-metrics/actionable-metrics.js +69 -0
  15. package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +1 -0
  16. package/dist/engine/spec-migrator/planu-canonical-policy.js +7 -0
  17. package/dist/engine/spec-migrator/strict-planu-cleanup.d.ts +2 -2
  18. package/dist/engine/spec-migrator/strict-planu-cleanup.js +35 -20
  19. package/dist/engine/spec-quality/generic-output-gate.d.ts +3 -0
  20. package/dist/engine/spec-quality/generic-output-gate.js +105 -0
  21. package/dist/tools/create-spec.js +160 -30
  22. package/dist/tools/update-status/evidence-gate.js +23 -6
  23. package/dist/tools/update-status/transition-guard.js +49 -0
  24. package/dist/tools/validate.js +5 -3
  25. package/dist/types/actionable-spec-metrics.d.ts +8 -0
  26. package/dist/types/actionable-spec-metrics.js +2 -0
  27. package/dist/types/evidence-gates.d.ts +1 -1
  28. package/dist/types/evidence-index.d.ts +24 -0
  29. package/dist/types/evidence-index.js +2 -0
  30. package/dist/types/index.d.ts +5 -0
  31. package/dist/types/index.js +5 -0
  32. package/dist/types/sdd-constitution.d.ts +27 -0
  33. package/dist/types/sdd-constitution.js +2 -0
  34. package/dist/types/spec-format.d.ts +12 -0
  35. package/dist/types/spec-grounding.d.ts +22 -0
  36. package/dist/types/spec-grounding.js +2 -0
  37. package/dist/types/spec-quality.d.ts +10 -0
  38. package/package.json +11 -11
  39. package/planu-native.json +29 -8
  40. package/planu-plugin.json +35 -7
@@ -0,0 +1,10 @@
1
+ import type { ActionableSpecMetric, CriterionGroundingRecord, EvidenceGateIssue, SpecEvidenceIndex } from '../../types/index.js';
2
+ export declare function calculateActionableSpecMetrics(args: {
3
+ criteria: readonly string[];
4
+ groundingRecords?: readonly CriterionGroundingRecord[];
5
+ evidenceIndex?: SpecEvidenceIndex;
6
+ ambiguityFindings?: readonly string[];
7
+ driftIssues?: readonly EvidenceGateIssue[];
8
+ }): ActionableSpecMetric[];
9
+ export declare function shouldExposeMetric(metric: ActionableSpecMetric): boolean;
10
+ //# sourceMappingURL=actionable-metrics.d.ts.map
@@ -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
@@ -3,6 +3,7 @@ export declare const PLANU_CANONICAL_POLICY: PlanuCanonicalPathPolicy;
3
3
  export declare function isCanonicalPlanuRootFile(name: string): boolean;
4
4
  export declare function isCanonicalPlanuRootDir(name: string): boolean;
5
5
  export declare function isCanonicalSpecFile(name: string): boolean;
6
+ export declare function isCanonicalSpecDir(name: string): boolean;
6
7
  export declare function mustMergeBeforeDeleteSpecFile(name: string): boolean;
7
8
  export declare function isCanonicalReleaseFile(relativeToPlanu: string): boolean;
8
9
  export declare function canonicalContractText(): string;
@@ -4,6 +4,7 @@ export const PLANU_CANONICAL_POLICY = {
4
4
  canonicalRootFiles: ['conventions.json', 'context.md', 'session-context.md', 'session.json'],
5
5
  canonicalRootDirs: ['releases', 'specs'],
6
6
  canonicalSpecFiles: ['spec.md'],
7
+ canonicalSpecDirs: ['evidence'],
7
8
  forbiddenHostAssetRootDirs: ['agents', 'skills', 'rules', 'hooks'],
8
9
  generatedRuntimePatterns: [
9
10
  'planu/index.html',
@@ -38,6 +39,7 @@ export const PLANU_CANONICAL_POLICY = {
38
39
  const ROOT_FILE_SET = new Set(PLANU_CANONICAL_POLICY.canonicalRootFiles);
39
40
  const ROOT_DIR_SET = new Set(PLANU_CANONICAL_POLICY.canonicalRootDirs);
40
41
  const SPEC_FILE_SET = new Set(PLANU_CANONICAL_POLICY.canonicalSpecFiles);
42
+ const SPEC_DIR_SET = new Set(PLANU_CANONICAL_POLICY.canonicalSpecDirs);
41
43
  const LEGACY_MERGE_SET = new Set(PLANU_CANONICAL_POLICY.legacyMergeBeforeDeleteFiles);
42
44
  export function isCanonicalPlanuRootFile(name) {
43
45
  return ROOT_FILE_SET.has(name);
@@ -48,6 +50,9 @@ export function isCanonicalPlanuRootDir(name) {
48
50
  export function isCanonicalSpecFile(name) {
49
51
  return SPEC_FILE_SET.has(name);
50
52
  }
53
+ export function isCanonicalSpecDir(name) {
54
+ return SPEC_DIR_SET.has(name);
55
+ }
51
56
  export function mustMergeBeforeDeleteSpecFile(name) {
52
57
  return LEGACY_MERGE_SET.has(name);
53
58
  }
@@ -66,6 +71,8 @@ export function canonicalContractText() {
66
71
  ' specs/',
67
72
  ' SPEC-XXX-slug/',
68
73
  ' spec.md',
74
+ ' evidence/',
75
+ ' *.json',
69
76
  '',
70
77
  'Host adapters are written outside planu/:',
71
78
  ' Claude Code: .claude/agents, .claude/skills, .claude/rules',
@@ -1,6 +1,6 @@
1
- import type { StrictPlanuCleanupResult, StrictPlanuValidationResult } from '../../types/index.js';
1
+ import type { StrictPlanuCleanupResult, StrictPlanuValidationOptions, StrictPlanuValidationResult } from '../../types/index.js';
2
2
  import { PLANU_CANONICAL_POLICY } from './planu-canonical-policy.js';
3
3
  export declare function runStrictPlanuCleanup(projectPath: string): Promise<StrictPlanuCleanupResult>;
4
- export declare function validateStrictPlanuLayout(projectPath: string): Promise<StrictPlanuValidationResult>;
4
+ export declare function validateStrictPlanuLayout(projectPath: string, options?: StrictPlanuValidationOptions): Promise<StrictPlanuValidationResult>;
5
5
  export { PLANU_CANONICAL_POLICY };
6
6
  //# sourceMappingURL=strict-planu-cleanup.d.ts.map
@@ -4,10 +4,10 @@ import { readdir, readFile, rm, stat } from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
5
5
  import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
- import { join, relative } from 'node:path';
7
+ import { dirname, isAbsolute, join, relative } from 'node:path';
8
8
  import { atomicWriteFile } from '../safety/atomic-write-file.js';
9
9
  import { safeUnlink } from './git-aware-fs.js';
10
- import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isCanonicalReleaseFile, isCanonicalSpecFile, mustMergeBeforeDeleteSpecFile, } from './planu-canonical-policy.js';
10
+ import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isCanonicalReleaseFile, isCanonicalSpecDir, isCanonicalSpecFile, mustMergeBeforeDeleteSpecFile, } from './planu-canonical-policy.js';
11
11
  const execFileAsync = promisify(execFile);
12
12
  async function pathIsDirectory(path) {
13
13
  try {
@@ -125,7 +125,9 @@ async function walkSpecDirectory(projectPath, specDir, result) {
125
125
  }
126
126
  continue;
127
127
  }
128
- if (entry === 'reference' || !isCanonicalSpecFile(entry)) {
128
+ const isDir = await pathIsDirectory(full);
129
+ if (entry === 'reference' ||
130
+ (isDir ? !isCanonicalSpecDir(entry) : !isCanonicalSpecFile(entry))) {
129
131
  await removePath(projectPath, full);
130
132
  result.deleted.push(relative(projectPath, full));
131
133
  }
@@ -173,33 +175,46 @@ export async function runStrictPlanuCleanup(projectPath) {
173
175
  result.gitignoreUpdated = await updateGitignore(projectPath);
174
176
  return result;
175
177
  }
176
- export async function validateStrictPlanuLayout(projectPath) {
178
+ function resolveSpecDirsForValidation(projectPath, specPath) {
179
+ if (!specPath?.trim()) {
180
+ return null;
181
+ }
182
+ const resolved = isAbsolute(specPath) ? specPath : join(projectPath, specPath);
183
+ return [dirname(resolved)];
184
+ }
185
+ export async function validateStrictPlanuLayout(projectPath, options = {}) {
177
186
  const offenders = [];
178
187
  const planuDir = join(projectPath, 'planu');
179
- const entries = await readdir(planuDir).catch(() => []);
180
- for (const entry of entries) {
181
- const full = join(planuDir, entry);
182
- const isDir = await pathIsDirectory(full);
183
- if ((isDir && !isCanonicalPlanuRootDir(entry)) ||
184
- (!isDir && !isCanonicalPlanuRootFile(entry))) {
185
- offenders.push(relative(projectPath, full));
188
+ if (options.includeRoot !== false) {
189
+ const entries = await readdir(planuDir).catch(() => []);
190
+ for (const entry of entries) {
191
+ const full = join(planuDir, entry);
192
+ const isDir = await pathIsDirectory(full);
193
+ if ((isDir && !isCanonicalPlanuRootDir(entry)) ||
194
+ (!isDir && !isCanonicalPlanuRootFile(entry))) {
195
+ offenders.push(relative(projectPath, full));
196
+ }
186
197
  }
187
- }
188
- for (const entry of await readdir(join(planuDir, 'releases')).catch(() => [])) {
189
- const rel = `releases/${entry}`;
190
- if (!isCanonicalReleaseFile(rel)) {
191
- offenders.push(`planu/${rel}`);
198
+ for (const entry of await readdir(join(planuDir, 'releases')).catch(() => [])) {
199
+ const rel = `releases/${entry}`;
200
+ if (!isCanonicalReleaseFile(rel)) {
201
+ offenders.push(`planu/${rel}`);
202
+ }
192
203
  }
193
204
  }
194
- for (const specDir of await readdir(join(planuDir, 'specs')).catch(() => [])) {
195
- const full = join(planuDir, 'specs', specDir);
205
+ const scopedSpecDirs = resolveSpecDirsForValidation(projectPath, options.specPath) ??
206
+ (await readdir(join(planuDir, 'specs')).catch(() => [])).map((specDir) => join(planuDir, 'specs', specDir));
207
+ for (const full of scopedSpecDirs) {
208
+ const specDir = relative(join(planuDir, 'specs'), full);
196
209
  if (!(await pathIsDirectory(full)) || specDir === 'data') {
197
210
  offenders.push(relative(projectPath, full));
198
211
  continue;
199
212
  }
200
213
  for (const entry of await readdir(full).catch(() => [])) {
201
- if (!isCanonicalSpecFile(entry)) {
202
- offenders.push(relative(projectPath, join(full, entry)));
214
+ const entryPath = join(full, entry);
215
+ const isDir = await pathIsDirectory(entryPath);
216
+ if (isDir ? !isCanonicalSpecDir(entry) : !isCanonicalSpecFile(entry)) {
217
+ offenders.push(relative(projectPath, entryPath));
203
218
  }
204
219
  }
205
220
  }
@@ -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);