@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.
- package/CHANGELOG.md +16 -0
- package/dist/engine/constitution/sdd-rules-registry.d.ts +12 -0
- package/dist/engine/constitution/sdd-rules-registry.js +105 -0
- package/dist/engine/evidence-gates/artifact-reader.js +10 -5
- package/dist/engine/evidence-index/done-drift.d.ts +6 -0
- package/dist/engine/evidence-index/done-drift.js +44 -0
- package/dist/engine/evidence-index/index-builder.d.ts +10 -0
- package/dist/engine/evidence-index/index-builder.js +138 -0
- package/dist/engine/host-rules-templates/templates.js +3 -0
- package/dist/engine/spec-format/lean-spec-generator.js +17 -12
- package/dist/engine/spec-grounding/contract.d.ts +19 -0
- package/dist/engine/spec-grounding/contract.js +186 -0
- package/dist/engine/spec-metrics/actionable-metrics.d.ts +10 -0
- package/dist/engine/spec-metrics/actionable-metrics.js +69 -0
- package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +1 -0
- package/dist/engine/spec-migrator/planu-canonical-policy.js +7 -0
- package/dist/engine/spec-migrator/strict-planu-cleanup.d.ts +2 -2
- package/dist/engine/spec-migrator/strict-planu-cleanup.js +35 -20
- package/dist/engine/spec-quality/generic-output-gate.d.ts +3 -0
- package/dist/engine/spec-quality/generic-output-gate.js +105 -0
- package/dist/tools/create-spec.js +160 -30
- package/dist/tools/update-status/evidence-gate.js +23 -6
- package/dist/tools/update-status/transition-guard.js +49 -0
- package/dist/tools/validate.js +5 -3
- package/dist/types/actionable-spec-metrics.d.ts +8 -0
- package/dist/types/actionable-spec-metrics.js +2 -0
- package/dist/types/evidence-gates.d.ts +1 -1
- package/dist/types/evidence-index.d.ts +24 -0
- package/dist/types/evidence-index.js +2 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +5 -0
- package/dist/types/sdd-constitution.d.ts +27 -0
- package/dist/types/sdd-constitution.js +2 -0
- package/dist/types/spec-format.d.ts +12 -0
- package/dist/types/spec-grounding.d.ts +22 -0
- package/dist/types/spec-grounding.js +2 -0
- package/dist/types/spec-quality.d.ts +10 -0
- package/package.json +11 -11
- package/planu-native.json +29 -8
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
(
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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,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:
|
|
308
|
-
const specFiles = await glob(
|
|
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
|
|
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:
|
|
408
|
+
const { join: joinPath } = await import('node:path');
|
|
342
409
|
const cutoff = Date.now() - windowMs;
|
|
343
|
-
const specFiles = await glob(
|
|
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
|
|
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
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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:
|
|
588
|
-
filesToModify:
|
|
589
|
-
filesToTest:
|
|
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);
|