@planu/cli 5.3.3 → 5.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/dist/engine/autopilot/action-registry.js +20 -2
- package/dist/engine/constitution/sdd-rules-registry.js +8 -4
- package/dist/engine/drift/violation-resolver.js +30 -8
- package/dist/engine/evidence-gates/evidence-skeletons.js +9 -4
- package/dist/engine/handoff-packager.js +10 -3
- package/dist/engine/implementation-contract/common.d.ts +13 -1
- package/dist/engine/implementation-contract/common.js +19 -3
- package/dist/engine/implementation-contract/evaluator.js +116 -35
- package/dist/engine/implementation-contract/renderer.js +62 -31
- package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
- package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
- package/dist/engine/spec-format/unified-spec-builder.js +32 -8
- package/dist/engine/spec-generator/fallback-generator.js +5 -3
- package/dist/engine/spec-quality/generic-output-gate.d.ts +7 -1
- package/dist/engine/spec-quality/generic-output-gate.js +199 -8
- package/dist/engine/spec-quality-scorer.js +9 -5
- package/dist/engine/workers/handlers/auto-drift.js +22 -8
- package/dist/tools/create-spec.js +5 -0
- package/dist/tools/update-status/evidence-gate.js +10 -17
- package/dist/tools/update-status/transition-guard.js +26 -14
- package/dist/types/spec-quality.d.ts +6 -1
- package/package.json +9 -9
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
|
@@ -1,6 +1,45 @@
|
|
|
1
|
-
|
|
1
|
+
/** Bounds the search for criteria to the frontmatter block only (between the two `---`
|
|
2
|
+
* delimiters), so criterion-shaped lines never leak in from the rendered body below it. */
|
|
3
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/;
|
|
4
|
+
/**
|
|
5
|
+
* SPEC-1406 (DEFECT 1, round 4 — detector was inert on real specs): matches a criterion's
|
|
6
|
+
* `- text: "..."` line regardless of nesting depth, so it covers BOTH shapes this generator
|
|
7
|
+
* renders (`lean-spec-generator.ts`): the top-level checkbox `criteria:` list AND the BDD
|
|
8
|
+
* `grounding: criteria:` list nested under `grounding:` (see
|
|
9
|
+
* `contract.ts:renderGroundingFrontmatter`, ` - text: "..."`). The previous version only
|
|
10
|
+
* looked inside a slice bounded by a single-line lookahead that matched line-end (`\s*$`
|
|
11
|
+
* under `/m`) after the FIRST criterion, so multi-criterion checkbox specs (e.g. SPEC-001,
|
|
12
|
+
* 6 criteria) only ever contributed their first entry — and it never ran at all against
|
|
13
|
+
* `scenarios:`-shaped (BDD) specs, which have no top-level `criteria:` key, so criteria was
|
|
14
|
+
* always `[]` there and `isInterpolatedTemplate`'s `criteria.length === 0` guard made every
|
|
15
|
+
* BDD spec's filler unflaggable by construction. Scanning the whole frontmatter block with
|
|
16
|
+
* this regex (plus SCENARIO_TITLE_RE below) fixes both defects in one pass — no truncating
|
|
17
|
+
* section boundary is needed once matching isn't anchored to a single top-level key.
|
|
18
|
+
*/
|
|
2
19
|
const CRITERION_TEXT_RE = /^\s+- text:\s*"?(.+?)"?\s*$/gm;
|
|
20
|
+
/** Matches a BDD scenario's `- title: "..."` line (`bdd-parser.ts:renderBddScenariosYaml`) —
|
|
21
|
+
* the criterion-equivalent for `scenarios:`-format specs, which have no `- text:` entries. */
|
|
22
|
+
const SCENARIO_TITLE_RE = /^\s+- title:\s*"?(.+?)"?\s*$/gm;
|
|
3
23
|
const FILE_LINE_RE = /^-\s+(.+?)\s+\((pending|done)\)\s*$/gm;
|
|
24
|
+
const H3_HEADING_RE = /^###[ \t]+(.+?)[ \t]*$/gm;
|
|
25
|
+
const BULLET_LINE_RE = /^[-*][ \t]+(.+)$/;
|
|
26
|
+
/** A bullet that, once a leading `Label:`, wrapping backticks, and a trailing `(status)`
|
|
27
|
+
* marker are stripped, is just a file path: contains a `/` and ends in a short extension.
|
|
28
|
+
* File/artifact lists (`Files`, `Modify`, `Create`, `Test`, `File-Level Work Plan`, or any
|
|
29
|
+
* future heading with this shape) are legitimately parallel — the interpolation detector
|
|
30
|
+
* must not run on them. Detected structurally so no heading name needs hardcoding. */
|
|
31
|
+
const BULLET_LABEL_PREFIX_RE = /^[\w-]+:\s*/;
|
|
32
|
+
const STATUS_SUFFIX_RE = /\s*\([\w .-]+\)$/;
|
|
33
|
+
const PATH_TOKEN_RE = /^[\w./-]+\/[\w.-]+\.[a-zA-Z0-9]{1,10}$/;
|
|
34
|
+
/** Minimum normalized length before a variable substring can count as "restating" a
|
|
35
|
+
* criterion — guards against trivial short-string collisions. */
|
|
36
|
+
const CRITERION_RESTATEMENT_MIN_LENGTH = 8;
|
|
37
|
+
/**
|
|
38
|
+
* SPEC-1406 (DEFECT 2 fix): the constant kind reused to mark structural-interpolation
|
|
39
|
+
* findings so callers can tell them apart from the other generic-output issue kinds and
|
|
40
|
+
* decide, per call site, whether that finding blocks or only warns.
|
|
41
|
+
*/
|
|
42
|
+
export const STRUCTURAL_INTERPOLATION_KIND = 'structural-interpolation';
|
|
4
43
|
const GENERIC_CRITERION_RULES = [
|
|
5
44
|
{
|
|
6
45
|
pattern: /\bimplementation (is )?complete\b/i,
|
|
@@ -50,24 +89,176 @@ const PLACEHOLDER_REFERENCE_RULES = [
|
|
|
50
89
|
},
|
|
51
90
|
];
|
|
52
91
|
export function checkGenericSpecOutput(content) {
|
|
92
|
+
const criteria = frontmatterCriteria(content);
|
|
53
93
|
const issues = [
|
|
54
|
-
...checkCriteria(
|
|
94
|
+
...checkCriteria(criteria),
|
|
55
95
|
...checkTechnicalReferences(content),
|
|
96
|
+
...checkInterpolatedSections(content, criteria),
|
|
56
97
|
];
|
|
57
98
|
return { passed: issues.length === 0, issues };
|
|
58
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* SPEC-1406 (DEFECT 2, round 2 — detector rebuilt around CRITERION RESTATEMENT): the
|
|
102
|
+
* previous prefix/suffix-length rule flagged `### File-Level Work Plan` (whose bullets
|
|
103
|
+
* legitimately share a `- Modify: \`...\` (pending)` frame around a file path) and missed
|
|
104
|
+
* the actual filler this spec exists to kill (`- AC1 failure: ...`, `- Expected behavior
|
|
105
|
+
* AC1: ...`), because the framing on those templates is interrupted by a digit
|
|
106
|
+
* (`AC1` vs `AC2`) that a plain character-by-character prefix scan treats as a mismatch.
|
|
107
|
+
* The real discriminator isn't frame length — it's whether the varying middle of each
|
|
108
|
+
* bullet is itself a restatement of a declared acceptance criterion. Digit runs are
|
|
109
|
+
* treated as mutually equal while locating the shared frame, then the extracted middle
|
|
110
|
+
* is compared against every criterion.
|
|
111
|
+
*/
|
|
112
|
+
function checkInterpolatedSections(content, criteria) {
|
|
113
|
+
const issues = [];
|
|
114
|
+
const headingMatches = [...content.matchAll(H3_HEADING_RE)];
|
|
115
|
+
for (let i = 0; i < headingMatches.length; i += 1) {
|
|
116
|
+
const heading = headingMatches[i];
|
|
117
|
+
if (!heading?.[1]) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const start = heading.index + heading[0].length;
|
|
121
|
+
const end = headingMatches[i + 1]?.index ?? content.length;
|
|
122
|
+
const bullets = content
|
|
123
|
+
.slice(start, end)
|
|
124
|
+
.split('\n')
|
|
125
|
+
.map((line) => BULLET_LINE_RE.exec(line.trim())?.[1])
|
|
126
|
+
.filter((line) => Boolean(line));
|
|
127
|
+
if (isFileListSection(bullets)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (isInterpolatedTemplate(bullets, criteria)) {
|
|
131
|
+
issues.push({
|
|
132
|
+
kind: STRUCTURAL_INTERPOLATION_KIND,
|
|
133
|
+
phrase: heading[1],
|
|
134
|
+
reason: `subsection "${heading[1]}" restates each criterion into the same template with no new information`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return issues;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* SPEC-1406 (DEFECT 1, round 3 — file-list false positive): a section whose bullets are
|
|
142
|
+
* mostly file paths (`### Modify`, `### Files`, `### Create`, `### Test`, `### File-Level
|
|
143
|
+
* Work Plan`, ...) is an allowlisted shape, not prose — parallel paths under a shared
|
|
144
|
+
* directory/extension carry no information loss, even when a path also happens to appear
|
|
145
|
+
* inside a criterion's `FILES:` marker. Detected structurally (allowlist, not a heading
|
|
146
|
+
* name denylist) so an unanticipated heading defaults to safe.
|
|
147
|
+
*/
|
|
148
|
+
function isFileListSection(bullets) {
|
|
149
|
+
if (bullets.length === 0) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
const pathLikeCount = bullets.filter(isPathLikeBullet).length;
|
|
153
|
+
return pathLikeCount > bullets.length / 2;
|
|
154
|
+
}
|
|
155
|
+
function isPathLikeBullet(bullet) {
|
|
156
|
+
const stripped = bullet
|
|
157
|
+
.trim()
|
|
158
|
+
.replace(BULLET_LABEL_PREFIX_RE, '')
|
|
159
|
+
.replace(STATUS_SUFFIX_RE, '')
|
|
160
|
+
.trim()
|
|
161
|
+
.replace(/^`|`$/g, '');
|
|
162
|
+
return PATH_TOKEN_RE.test(stripped);
|
|
163
|
+
}
|
|
164
|
+
function isInterpolatedTemplate(bullets, criteria) {
|
|
165
|
+
if (bullets.length < 2 || criteria.length === 0) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
const first = bullets[0] ?? '';
|
|
169
|
+
const prefix = commonPrefixLength(bullets);
|
|
170
|
+
const suffix = commonSuffixLength(bullets, prefix);
|
|
171
|
+
if (prefix + suffix >= first.length) {
|
|
172
|
+
// Digit-tolerant framing consumed the entire bullet: the bullets are identical
|
|
173
|
+
// except for an index digit (e.g. `AC1` vs `AC2`) with no other variable content.
|
|
174
|
+
// The digit itself isn't the restated criterion — the digit-stripped body is.
|
|
175
|
+
return bullets.every((bullet) => restatesCriterion(stripDigits(bullet), criteria));
|
|
176
|
+
}
|
|
177
|
+
return bullets.every((bullet) => {
|
|
178
|
+
const variable = bullet.slice(prefix, bullet.length - suffix);
|
|
179
|
+
return restatesCriterion(variable, criteria);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function stripDigits(value) {
|
|
183
|
+
return value.replace(/\d+/g, '');
|
|
184
|
+
}
|
|
185
|
+
function restatesCriterion(variable, criteria) {
|
|
186
|
+
const normalizedVariable = normalizeForRestatementMatch(variable);
|
|
187
|
+
if (normalizedVariable.length < CRITERION_RESTATEMENT_MIN_LENGTH) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
return criteria.some((criterion) => {
|
|
191
|
+
const normalizedCriterion = normalizeForRestatementMatch(criterion);
|
|
192
|
+
if (normalizedCriterion.length < CRITERION_RESTATEMENT_MIN_LENGTH) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
return (normalizedVariable.includes(normalizedCriterion) ||
|
|
196
|
+
normalizedCriterion.includes(normalizedVariable));
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function normalizeForRestatementMatch(value) {
|
|
200
|
+
return value
|
|
201
|
+
.replace(/[`*_"]/g, '')
|
|
202
|
+
.replace(/\s+/g, ' ')
|
|
203
|
+
.trim()
|
|
204
|
+
.toLowerCase();
|
|
205
|
+
}
|
|
206
|
+
/** Digit runs are treated as mutually equal so a fixed template interrupted only by an
|
|
207
|
+
* `AC1`/`AC2`-style index still resolves to its true shared frame. */
|
|
208
|
+
function charsMatch(a, b) {
|
|
209
|
+
if (a === undefined || b === undefined) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
return a === b || (isDigit(a) && isDigit(b));
|
|
213
|
+
}
|
|
214
|
+
function isDigit(ch) {
|
|
215
|
+
return ch >= '0' && ch <= '9';
|
|
216
|
+
}
|
|
217
|
+
function commonPrefixLength(values) {
|
|
218
|
+
const first = values[0] ?? '';
|
|
219
|
+
let length = first.length;
|
|
220
|
+
for (const value of values.slice(1)) {
|
|
221
|
+
let i = 0;
|
|
222
|
+
while (i < length && i < value.length && charsMatch(value[i], first[i])) {
|
|
223
|
+
i += 1;
|
|
224
|
+
}
|
|
225
|
+
length = Math.min(length, i);
|
|
226
|
+
}
|
|
227
|
+
return length;
|
|
228
|
+
}
|
|
229
|
+
function commonSuffixLength(values, limit) {
|
|
230
|
+
const first = values[0] ?? '';
|
|
231
|
+
let length = first.length - limit;
|
|
232
|
+
for (const value of values.slice(1)) {
|
|
233
|
+
let i = 0;
|
|
234
|
+
while (i < length &&
|
|
235
|
+
i < value.length - limit &&
|
|
236
|
+
charsMatch(value[value.length - 1 - i], first[first.length - 1 - i])) {
|
|
237
|
+
i += 1;
|
|
238
|
+
}
|
|
239
|
+
length = Math.min(length, i);
|
|
240
|
+
}
|
|
241
|
+
return Math.max(length, 0);
|
|
242
|
+
}
|
|
59
243
|
function frontmatterCriteria(content) {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
244
|
+
const frontmatter = FRONTMATTER_RE.exec(content)?.[1] ?? '';
|
|
245
|
+
return [
|
|
246
|
+
...collectMatches(frontmatter, CRITERION_TEXT_RE),
|
|
247
|
+
...collectMatches(frontmatter, SCENARIO_TITLE_RE),
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
function collectMatches(text, pattern) {
|
|
251
|
+
const re = new RegExp(pattern.source, pattern.flags);
|
|
252
|
+
const values = [];
|
|
253
|
+
let match = re.exec(text);
|
|
63
254
|
while (match) {
|
|
64
255
|
const phrase = match[1]?.replace(/\\"/g, '"').trim() ?? '';
|
|
65
256
|
if (phrase.length > 0) {
|
|
66
|
-
|
|
257
|
+
values.push(phrase);
|
|
67
258
|
}
|
|
68
|
-
match =
|
|
259
|
+
match = re.exec(text);
|
|
69
260
|
}
|
|
70
|
-
return
|
|
261
|
+
return values;
|
|
71
262
|
}
|
|
72
263
|
function checkCriteria(criteria) {
|
|
73
264
|
const issues = [];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// engine/spec-quality-scorer.ts — Spec quality scoring logic (SPEC-314)
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { readSpecTechnicalSection } from './spec-format/read-technical-section.js';
|
|
4
|
+
import { extractAcceptanceCriteriaTexts } from './spec-format/acceptance-criteria.js';
|
|
4
5
|
import { stripFrontmatter } from './frontmatter-parser.js';
|
|
5
6
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
6
7
|
const MAX_DIMENSION_SCORE = 25;
|
|
@@ -67,11 +68,14 @@ async function readSpecContent(spec) {
|
|
|
67
68
|
async function readTechnicalContent(spec) {
|
|
68
69
|
return readSpecTechnicalSection(spec);
|
|
69
70
|
}
|
|
71
|
+
// Delegates to the canonical parser (SPEC-1410) instead of matching checkbox
|
|
72
|
+
// lines only, which returned [] for every GIVEN/WHEN/THEN spec. Deliberately
|
|
73
|
+
// does NOT reuse `readiness-checker.extractCriteriaLines`: that helper returns
|
|
74
|
+
// every non-empty line in the section (including WHEN/THEN/FILES: lines),
|
|
75
|
+
// which would inflate the denominator that scoreTestability/scoreAmbiguity
|
|
76
|
+
// divide by.
|
|
70
77
|
function extractCriteriaLines(content) {
|
|
71
|
-
return content
|
|
72
|
-
.split('\n')
|
|
73
|
-
.filter((line) => /^- \[[ x]\]/.test(line.trim()))
|
|
74
|
-
.map((line) => line.trim());
|
|
78
|
+
return extractAcceptanceCriteriaTexts(content);
|
|
75
79
|
}
|
|
76
80
|
// ── Dimension scorers ────────────────────────────────────────────────────────
|
|
77
81
|
function scoreCompleteness(spec, content) {
|
|
@@ -107,7 +111,7 @@ function scoreCompleteness(spec, content) {
|
|
|
107
111
|
}
|
|
108
112
|
else {
|
|
109
113
|
issues.push('No acceptance criteria found');
|
|
110
|
-
recommendations.push('Add acceptance criteria in
|
|
114
|
+
recommendations.push('Add acceptance criteria in GIVEN/WHEN/THEN format');
|
|
111
115
|
}
|
|
112
116
|
// Scope set to non-default (5 pts) — cast to handle partial specs at runtime
|
|
113
117
|
const scope = spec.scope;
|
|
@@ -154,14 +154,6 @@ async function buildDriftFindings(projectDir, effectiveChanged, activeSpecs, pro
|
|
|
154
154
|
driftedFiles,
|
|
155
155
|
cachedAt: new Date().toISOString(),
|
|
156
156
|
});
|
|
157
|
-
// Wire drift:detected event so autopilot trigger-rules can react
|
|
158
|
-
emitAutopilotEvent({
|
|
159
|
-
name: 'drift:detected',
|
|
160
|
-
projectPath: projectDir,
|
|
161
|
-
projectId,
|
|
162
|
-
specId: spec.id,
|
|
163
|
-
payload: { driftScore, driftLevel: driftScore >= 0.7 ? 'high' : 'warning' },
|
|
164
|
-
});
|
|
165
157
|
findings.push({
|
|
166
158
|
severity: driftScore >= 0.7 ? 'high' : 'warning',
|
|
167
159
|
file: '',
|
|
@@ -171,6 +163,28 @@ async function buildDriftFindings(projectDir, effectiveChanged, activeSpecs, pro
|
|
|
171
163
|
});
|
|
172
164
|
}
|
|
173
165
|
}
|
|
166
|
+
if (driftScore >= threshold) {
|
|
167
|
+
// Wire drift:detected event so autopilot trigger-rules can react.
|
|
168
|
+
// Emitted once per project run — driftScore is project-wide, not per-spec,
|
|
169
|
+
// so emitting inside the loop would fire N identical events for N specs.
|
|
170
|
+
//
|
|
171
|
+
// MUST run after the cache-persistence loop above: emitAutopilotEvent
|
|
172
|
+
// dispatches handlers in a microtask (event-bus.ts), and the enabled
|
|
173
|
+
// rule drift-detected:resolve_drift_violations calls resolveDriftViolations,
|
|
174
|
+
// which loads the drift cache as its first action. Emitting before the
|
|
175
|
+
// cache entries are persisted lets that microtask race ahead and read an
|
|
176
|
+
// empty cache, silently resolving nothing.
|
|
177
|
+
emitAutopilotEvent({
|
|
178
|
+
name: 'drift:detected',
|
|
179
|
+
projectPath: projectDir,
|
|
180
|
+
projectId,
|
|
181
|
+
payload: {
|
|
182
|
+
driftScore,
|
|
183
|
+
driftLevel: driftScore >= 0.7 ? 'high' : 'warning',
|
|
184
|
+
specIds: activeSpecs.map((s) => s.id),
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
174
188
|
return findings;
|
|
175
189
|
}
|
|
176
190
|
// ---------------------------------------------------------------------------
|
|
@@ -1030,6 +1030,11 @@ async function prepareCreateSpecCandidate(initialParams, server) {
|
|
|
1030
1030
|
},
|
|
1031
1031
|
}));
|
|
1032
1032
|
spec.generation = generatedSpec.generation;
|
|
1033
|
+
// SPEC-1406 (DEFECT 4, round 2): FallbackGenerator no longer emits an unconditional
|
|
1034
|
+
// warning, so qualityWarnings only needs the non-empty guard now — the redundant
|
|
1035
|
+
// constant-string filter was removed from create-spec.ts, fallback-generator.ts and
|
|
1036
|
+
// opus-generator.ts (dead code, reverted) so the literal doesn't have to stay
|
|
1037
|
+
// byte-identical across three files.
|
|
1033
1038
|
spec.qualityWarnings =
|
|
1034
1039
|
generatedSpec.qualityWarnings.length > 0 ? generatedSpec.qualityWarnings : undefined;
|
|
1035
1040
|
const baseCriteria = extractCriteria(generatedSpec.specBody).map((criterion) => criterion.text);
|
|
@@ -68,7 +68,7 @@ import { captureValidationFreshnessLease } from '../../engine/validation/validat
|
|
|
68
68
|
import { computeDurableValidationBindings, toValidationReceiptBindings, } from '../../engine/validation/durable-validation.js';
|
|
69
69
|
import { checkReconciliationFreshness } from '../../engine/evidence-gates/reconciliation-freshness.js';
|
|
70
70
|
import { extractCanonicalFileOwnership } from '../../engine/handoff-packager.js';
|
|
71
|
-
import {
|
|
71
|
+
import { generateTaskPlanSkeleton, writeEvidenceSkeleton, } from '../../engine/evidence-gates/evidence-skeletons.js';
|
|
72
72
|
import { extractSection } from '../../engine/spec-format/markdown-sections.js';
|
|
73
73
|
const CANONICAL_SCOPE_PATH = /(?:src|tests|scripts|website)\/[\w/.@-]+\.\w+/g;
|
|
74
74
|
/** SPEC-1356: repo-relative paths mentioned in the spec's Problem/Technical prose also count
|
|
@@ -187,28 +187,21 @@ function isArtifactAbsent(artifacts, label) {
|
|
|
187
187
|
return !artifacts.invalidArtifacts.some((entry) => entry.startsWith(label));
|
|
188
188
|
}
|
|
189
189
|
/**
|
|
190
|
-
* SPEC-1356: self-heal missing (not malformed)
|
|
191
|
-
*
|
|
192
|
-
*
|
|
190
|
+
* SPEC-1356: self-heal missing (not malformed) task-plan handoff evidence by generating a
|
|
191
|
+
* schema-valid skeleton and persisting it, instead of blocking the transition on a manual
|
|
192
|
+
* authoring step. Malformed existing artifacts still fail the gate as before.
|
|
193
|
+
*
|
|
194
|
+
* SPEC-1406 (DEFECT 3): discovery evidence for `approved` is intentionally NOT self-healed —
|
|
195
|
+
* writing a skeleton here made the blocking discovery gate self-certify by fabricating the
|
|
196
|
+
* evidence it was supposed to require. `checkLifecycleEvidenceGate` (lifecycle-gate.ts) reports
|
|
197
|
+
* `discovery_missing` with the specific missing fields named when discovery is absent, and that
|
|
198
|
+
* is the intended, honest outcome.
|
|
193
199
|
*/
|
|
194
200
|
async function autofillMissingEvidenceSkeletons(args) {
|
|
195
201
|
if (args.spec.scope === 'trivial') {
|
|
196
202
|
return args.artifacts;
|
|
197
203
|
}
|
|
198
204
|
let artifacts = args.artifacts;
|
|
199
|
-
if (args.transition === 'approved' &&
|
|
200
|
-
!artifacts.discovery &&
|
|
201
|
-
isArtifactAbsent(artifacts, 'Discovery evidence')) {
|
|
202
|
-
const skeleton = generateDiscoverySkeleton({ spec: args.spec, body: args.body });
|
|
203
|
-
await writeEvidenceSkeleton({
|
|
204
|
-
projectId: args.projectId,
|
|
205
|
-
specId: args.specId,
|
|
206
|
-
filename: 'discovery.json',
|
|
207
|
-
artifact: skeleton,
|
|
208
|
-
});
|
|
209
|
-
console.warn('[planu:evidence-skeleton]', { specId: args.specId, filename: 'discovery.json' });
|
|
210
|
-
artifacts = { ...artifacts, discovery: skeleton };
|
|
211
|
-
}
|
|
212
205
|
if (args.transition === 'implementing' &&
|
|
213
206
|
!artifacts.taskPlan &&
|
|
214
207
|
isArtifactAbsent(artifacts, 'Task plan evidence')) {
|
|
@@ -7,7 +7,7 @@ import { scoreAmbiguityFromPath } from '../../engine/ambiguity-scorer.js';
|
|
|
7
7
|
import { checkSpecReadiness } from '../../engine/readiness-checker.js';
|
|
8
8
|
import { validateEnglishOnlySpecText } from '../../engine/spec-language/english-only.js';
|
|
9
9
|
import { checkGroundedSpecContract } from '../../engine/spec-grounding/contract.js';
|
|
10
|
-
import { checkGenericSpecOutput } from '../../engine/spec-quality/generic-output-gate.js';
|
|
10
|
+
import { checkGenericSpecOutput, STRUCTURAL_INTERPOLATION_KIND, } from '../../engine/spec-quality/generic-output-gate.js';
|
|
11
11
|
import { formatKeyValue } from '../output-formatter.js';
|
|
12
12
|
import { getResolvedChallengeEvidence, requiredResolvedChallenges, } from '../challenge-spec/challenge-report.js';
|
|
13
13
|
import { evaluateSpecDependencies } from '../../engine/dependency-evaluator.js';
|
|
@@ -319,14 +319,22 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
|
|
|
319
319
|
};
|
|
320
320
|
}
|
|
321
321
|
const genericOutputGate = checkGenericSpecOutput(body);
|
|
322
|
-
|
|
322
|
+
// SPEC-1406: structural-interpolation findings warn instead of block here — the
|
|
323
|
+
// `create_spec` call site is where this new detector is blocking; ~1100 already-persisted
|
|
324
|
+
// specs must stay transitionable at update_status even if their rendered body still
|
|
325
|
+
// contains an interpolated-list pattern.
|
|
326
|
+
const blockingGenericOutputIssues = genericOutputGate.issues.filter((issue) => issue.kind !== STRUCTURAL_INTERPOLATION_KIND);
|
|
327
|
+
const structuralInterpolationWarnings = genericOutputGate.issues
|
|
328
|
+
.filter((issue) => issue.kind === STRUCTURAL_INTERPOLATION_KIND)
|
|
329
|
+
.map((issue) => `${issue.phrase}: ${issue.reason}`);
|
|
330
|
+
if (blockingGenericOutputIssues.length > 0) {
|
|
323
331
|
return {
|
|
324
332
|
blockResult: {
|
|
325
333
|
content: [
|
|
326
334
|
{
|
|
327
335
|
type: 'text',
|
|
328
336
|
text: `Spec quality gate blocked transition to ${newStatus}. ` +
|
|
329
|
-
|
|
337
|
+
blockingGenericOutputIssues
|
|
330
338
|
.slice(0, 3)
|
|
331
339
|
.map((issue) => `${issue.phrase}: ${issue.reason}`)
|
|
332
340
|
.join('; '),
|
|
@@ -336,11 +344,15 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
|
|
|
336
344
|
structuredContent: {
|
|
337
345
|
error: 'GENERIC_SPEC_OUTPUT_BLOCKED',
|
|
338
346
|
sddConstitutionRuleId: 'sdd.no-generic-output',
|
|
339
|
-
issues:
|
|
347
|
+
issues: blockingGenericOutputIssues,
|
|
340
348
|
fixHint: 'Replace generic criteria or placeholder references with grounded, testable behavior before review or approval.',
|
|
341
349
|
},
|
|
342
350
|
},
|
|
343
|
-
|
|
351
|
+
// SPEC-1406 (AC3, round 2): the offending section must be named in the output on
|
|
352
|
+
// every return path, not only the success path — this blockResult already fired
|
|
353
|
+
// for a different reason, but any structural-interpolation warnings computed above
|
|
354
|
+
// are still real findings and must not be dropped.
|
|
355
|
+
qualityWarnings: structuralInterpolationWarnings,
|
|
344
356
|
};
|
|
345
357
|
}
|
|
346
358
|
let readiness;
|
|
@@ -350,7 +362,7 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
|
|
|
350
362
|
catch (error) {
|
|
351
363
|
/* reliability-optional: READINESS_EVALUATION_FAILED — transition fails closed below */
|
|
352
364
|
reportClassifiedDegradation('READINESS_EVALUATION_FAILED', error);
|
|
353
|
-
return readinessUnavailable('READINESS_EVALUATION_FAILED', error instanceof Error ? error.message : 'readiness evaluation failed');
|
|
365
|
+
return readinessUnavailable('READINESS_EVALUATION_FAILED', error instanceof Error ? error.message : 'readiness evaluation failed', structuralInterpolationWarnings);
|
|
354
366
|
}
|
|
355
367
|
const { score } = readiness;
|
|
356
368
|
const blockers = [...readiness.issues.blockers];
|
|
@@ -377,7 +389,7 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
|
|
|
377
389
|
fixHint: 'Add at least 3 testable acceptance criteria before moving to review.',
|
|
378
390
|
},
|
|
379
391
|
},
|
|
380
|
-
qualityWarnings:
|
|
392
|
+
qualityWarnings: structuralInterpolationWarnings,
|
|
381
393
|
};
|
|
382
394
|
}
|
|
383
395
|
const question = {
|
|
@@ -412,11 +424,11 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
|
|
|
412
424
|
interactiveQuestions: [question],
|
|
413
425
|
},
|
|
414
426
|
},
|
|
415
|
-
qualityWarnings:
|
|
427
|
+
qualityWarnings: structuralInterpolationWarnings,
|
|
416
428
|
};
|
|
417
429
|
}
|
|
418
430
|
if (readiness.ready) {
|
|
419
|
-
return { blockResult: null, qualityWarnings:
|
|
431
|
+
return { blockResult: null, qualityWarnings: structuralInterpolationWarnings };
|
|
420
432
|
}
|
|
421
433
|
const diagnostic = score < 70
|
|
422
434
|
? `score ${String(score)}/100 is below the 70 threshold`
|
|
@@ -442,13 +454,13 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
|
|
|
442
454
|
fixHint: 'Complete the spec before moving from draft to review.',
|
|
443
455
|
},
|
|
444
456
|
},
|
|
445
|
-
qualityWarnings:
|
|
457
|
+
qualityWarnings: structuralInterpolationWarnings,
|
|
446
458
|
};
|
|
447
459
|
}
|
|
448
460
|
// Score < 70
|
|
449
461
|
if (forceApprove) {
|
|
450
462
|
// Caller wants to force through — proceed with warnings attached
|
|
451
|
-
return { blockResult: null, qualityWarnings: issues };
|
|
463
|
+
return { blockResult: null, qualityWarnings: [...issues, ...structuralInterpolationWarnings] };
|
|
452
464
|
}
|
|
453
465
|
const question = {
|
|
454
466
|
header: 'Readiness gate',
|
|
@@ -486,7 +498,7 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
|
|
|
486
498
|
interactiveQuestions: [question],
|
|
487
499
|
},
|
|
488
500
|
},
|
|
489
|
-
qualityWarnings:
|
|
501
|
+
qualityWarnings: structuralInterpolationWarnings,
|
|
490
502
|
};
|
|
491
503
|
}
|
|
492
504
|
function gateUnavailableResult(code, message) {
|
|
@@ -496,8 +508,8 @@ function gateUnavailableResult(code, message) {
|
|
|
496
508
|
structuredContent: { error: code, message, retryable: true },
|
|
497
509
|
};
|
|
498
510
|
}
|
|
499
|
-
function readinessUnavailable(code, message) {
|
|
500
|
-
return { blockResult: gateUnavailableResult(code, message), qualityWarnings
|
|
511
|
+
function readinessUnavailable(code, message, qualityWarnings = []) {
|
|
512
|
+
return { blockResult: gateUnavailableResult(code, message), qualityWarnings };
|
|
501
513
|
}
|
|
502
514
|
/**
|
|
503
515
|
* SPEC-964: Challenge gate — block 'review' if challenge_spec was never run
|
|
@@ -35,7 +35,12 @@ export interface SpecQualityReport {
|
|
|
35
35
|
risk: QualityDimensionDetail;
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
-
export type GenericSpecOutputIssueKind = 'generic-criterion' | 'unsupported-verification' | 'placeholder-reference' | 'generic-technical-reference'
|
|
38
|
+
export type GenericSpecOutputIssueKind = 'generic-criterion' | 'unsupported-verification' | 'placeholder-reference' | 'generic-technical-reference'
|
|
39
|
+
/** SPEC-1406: dedicated kind for structural-interpolation findings (checkInterpolatedSections).
|
|
40
|
+
* Kept distinct from 'generic-technical-reference' — severity routing in transition-guard.ts
|
|
41
|
+
* and sdd-rules-registry.ts filters BY KIND, so aliasing the two would silently downgrade
|
|
42
|
+
* whichever kind collides the day something else emits the shared value. */
|
|
43
|
+
| 'structural-interpolation';
|
|
39
44
|
export interface GenericSpecOutputIssue {
|
|
40
45
|
kind: GenericSpecOutputIssueKind;
|
|
41
46
|
phrase: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planu/cli",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.4",
|
|
4
4
|
"description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -35,14 +35,14 @@
|
|
|
35
35
|
"packageName": "@planu/core"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@planu/core-darwin-arm64": "5.3.
|
|
39
|
-
"@planu/core-darwin-x64": "5.3.
|
|
40
|
-
"@planu/core-linux-arm64-gnu": "5.3.
|
|
41
|
-
"@planu/core-linux-arm64-musl": "5.3.
|
|
42
|
-
"@planu/core-linux-x64-gnu": "5.3.
|
|
43
|
-
"@planu/core-linux-x64-musl": "5.3.
|
|
44
|
-
"@planu/core-win32-arm64-msvc": "5.3.
|
|
45
|
-
"@planu/core-win32-x64-msvc": "5.3.
|
|
38
|
+
"@planu/core-darwin-arm64": "5.3.4",
|
|
39
|
+
"@planu/core-darwin-x64": "5.3.4",
|
|
40
|
+
"@planu/core-linux-arm64-gnu": "5.3.4",
|
|
41
|
+
"@planu/core-linux-arm64-musl": "5.3.4",
|
|
42
|
+
"@planu/core-linux-x64-gnu": "5.3.4",
|
|
43
|
+
"@planu/core-linux-x64-musl": "5.3.4",
|
|
44
|
+
"@planu/core-win32-arm64-msvc": "5.3.4",
|
|
45
|
+
"@planu/core-win32-x64-msvc": "5.3.4"
|
|
46
46
|
},
|
|
47
47
|
"engines": {
|
|
48
48
|
"node": ">=24.0.0"
|
package/planu-native.json
CHANGED
package/planu-plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dev.planu.cli",
|
|
3
3
|
"displayName": "Planu — Spec Driven Development",
|
|
4
4
|
"description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
|
|
5
|
-
"version": "5.3.
|
|
5
|
+
"version": "5.3.4",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": ["npx", "@planu/cli@latest"],
|
|
8
8
|
"packageName": "@planu/cli",
|