@planu/cli 4.3.25 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -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-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-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/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 +7 -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 +9 -9
- package/planu-native.json +29 -8
- package/planu-plugin.json +35 -7
package/CHANGELOG.md
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EffectiveSddConstitutionRule, SddConstitutionOverride, SddConstitutionRule, SddConstitutionViolation } from '../../types/index.js';
|
|
2
|
+
import type { Spec } from '../../types/spec/core.js';
|
|
3
|
+
export declare const DEFAULT_SDD_CONSTITUTION_RULES: readonly SddConstitutionRule[];
|
|
4
|
+
export declare function loadSddConstitutionRules(overrides?: readonly SddConstitutionOverride[]): EffectiveSddConstitutionRule[];
|
|
5
|
+
export declare function validateSddConstitutionOverrides(overrides: readonly SddConstitutionOverride[]): string[];
|
|
6
|
+
export declare function evaluateSddConstitutionRules(args: {
|
|
7
|
+
spec: Spec;
|
|
8
|
+
content: string;
|
|
9
|
+
overrides?: readonly SddConstitutionOverride[];
|
|
10
|
+
}): SddConstitutionViolation[];
|
|
11
|
+
export declare function renderSddConstitutionRulesForHost(): string;
|
|
12
|
+
//# sourceMappingURL=sdd-rules-registry.d.ts.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { checkGenericSpecOutput } from '../spec-quality/generic-output-gate.js';
|
|
2
|
+
import { checkGroundedSpecContract } from '../spec-grounding/contract.js';
|
|
3
|
+
export const DEFAULT_SDD_CONSTITUTION_RULES = [
|
|
4
|
+
{
|
|
5
|
+
id: 'sdd.grounded-contract',
|
|
6
|
+
title: 'Grounded Spec Contract',
|
|
7
|
+
level: 'blocking',
|
|
8
|
+
category: 'grounding',
|
|
9
|
+
description: 'Contract criteria and technical references must be grounded in user input, project evidence, or explicit review evidence.',
|
|
10
|
+
nextAction: 'Add grounding metadata, remove ungrounded criteria from the contract, or move assumptions to advisory output.',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: 'sdd.no-generic-output',
|
|
14
|
+
title: 'No Generic Spec Output',
|
|
15
|
+
level: 'blocking',
|
|
16
|
+
category: 'quality',
|
|
17
|
+
description: 'Specs must not persist self-certifying criteria, placeholder ownership, unsupported verification claims, or example-only contract language.',
|
|
18
|
+
nextAction: 'Replace generic output with grounded behavior, exact evidence, or a discovery question.',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'sdd.no-self-approval',
|
|
22
|
+
title: 'No Self Approval',
|
|
23
|
+
level: 'blocking',
|
|
24
|
+
category: 'approval',
|
|
25
|
+
description: 'The planner, spec author, or implementation agent must not approve their own spec or completion evidence.',
|
|
26
|
+
nextAction: 'Attach independent reviewer evidence before approval or done.',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'sdd.done-evidence-required',
|
|
30
|
+
title: 'Done Requires Evidence',
|
|
31
|
+
level: 'blocking',
|
|
32
|
+
category: 'evidence',
|
|
33
|
+
description: 'Done requires traceability, validation evidence, reviewer evidence, and no unexplained drift from the approved contract.',
|
|
34
|
+
nextAction: 'Add traceability rows, validation output, reviewer evidence, or reconcile approved scope changes.',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'sdd.actionable-metrics-only',
|
|
38
|
+
title: 'Actionable Metrics Only',
|
|
39
|
+
level: 'advisory',
|
|
40
|
+
category: 'metrics',
|
|
41
|
+
description: 'User-facing metrics must expose inputs and a concrete next action; vanity scores stay hidden.',
|
|
42
|
+
nextAction: 'Show only metrics that identify a missing criterion, evidence gap, ambiguity, or drift risk.',
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
export function loadSddConstitutionRules(overrides = []) {
|
|
46
|
+
const overrideByRule = new Map(overrides.map((override) => [override.ruleId, override]));
|
|
47
|
+
return DEFAULT_SDD_CONSTITUTION_RULES.map((rule) => {
|
|
48
|
+
const override = overrideByRule.get(rule.id);
|
|
49
|
+
if (!override) {
|
|
50
|
+
return { ...rule, enabled: true };
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
...rule,
|
|
54
|
+
enabled: override.enabled ?? true,
|
|
55
|
+
level: override.level ?? rule.level,
|
|
56
|
+
overrideRationale: override.rationale,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export function validateSddConstitutionOverrides(overrides) {
|
|
61
|
+
const known = new Set(DEFAULT_SDD_CONSTITUTION_RULES.map((rule) => rule.id));
|
|
62
|
+
return overrides.flatMap((override) => {
|
|
63
|
+
const issues = [];
|
|
64
|
+
if (!known.has(override.ruleId)) {
|
|
65
|
+
issues.push(`Unknown SDD constitution rule override: ${override.ruleId}`);
|
|
66
|
+
}
|
|
67
|
+
if (override.rationale.trim().length < 20) {
|
|
68
|
+
issues.push(`Override ${override.ruleId} requires a rationale of at least 20 characters.`);
|
|
69
|
+
}
|
|
70
|
+
return issues;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export function evaluateSddConstitutionRules(args) {
|
|
74
|
+
const rules = loadSddConstitutionRules(args.overrides).filter((rule) => rule.enabled);
|
|
75
|
+
const violations = [];
|
|
76
|
+
if (rules.some((rule) => rule.id === 'sdd.grounded-contract')) {
|
|
77
|
+
const grounding = checkGroundedSpecContract(args.spec, args.content);
|
|
78
|
+
if (!grounding.passed) {
|
|
79
|
+
violations.push(...grounding.issues.map((issue) => violation('sdd.grounded-contract', issue, [issue], rules)));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (rules.some((rule) => rule.id === 'sdd.no-generic-output')) {
|
|
83
|
+
const generic = checkGenericSpecOutput(args.content);
|
|
84
|
+
violations.push(...generic.issues.map((issue) => violation('sdd.no-generic-output', issue.reason, [issue.phrase], rules)));
|
|
85
|
+
}
|
|
86
|
+
return violations;
|
|
87
|
+
}
|
|
88
|
+
export function renderSddConstitutionRulesForHost() {
|
|
89
|
+
const enabled = loadSddConstitutionRules();
|
|
90
|
+
return [
|
|
91
|
+
'### Constitutional SDD rules',
|
|
92
|
+
...enabled.map((rule) => `- ${rule.id} (${rule.level}): ${rule.description} Next action: ${rule.nextAction}`),
|
|
93
|
+
].join('\n');
|
|
94
|
+
}
|
|
95
|
+
function violation(ruleId, message, evidence, rules) {
|
|
96
|
+
const rule = rules.find((candidate) => candidate.id === ruleId);
|
|
97
|
+
return {
|
|
98
|
+
ruleId,
|
|
99
|
+
level: rule?.level ?? 'blocking',
|
|
100
|
+
message,
|
|
101
|
+
evidence,
|
|
102
|
+
nextAction: rule?.nextAction ?? 'Fix the violated SDD constitution rule.',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=sdd-rules-registry.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { EvidenceGateIssue, SpecEvidenceIndex } from '../../types/index.js';
|
|
2
|
+
export declare function checkDoneDriftContract(args: {
|
|
3
|
+
index: SpecEvidenceIndex;
|
|
4
|
+
groundedTechnicalPaths: readonly string[];
|
|
5
|
+
}): EvidenceGateIssue[];
|
|
6
|
+
//# sourceMappingURL=done-drift.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function checkDoneDriftContract(args) {
|
|
2
|
+
const issues = [];
|
|
3
|
+
const uncovered = args.index.criteria.filter((entry) => {
|
|
4
|
+
const hasChangedFile = entry.evidence.some((record) => record.kind === 'changed-file' &&
|
|
5
|
+
(record.status === 'valid' || record.status === 'missing'));
|
|
6
|
+
const hasValidation = entry.evidence.some((record) => record.kind === 'validation' && record.status === 'valid');
|
|
7
|
+
return !hasChangedFile || !hasValidation;
|
|
8
|
+
});
|
|
9
|
+
if (uncovered.length > 0) {
|
|
10
|
+
issues.push({
|
|
11
|
+
code: 'done_drift_uncovered_criteria',
|
|
12
|
+
message: `Done drift check found criteria without mapped changed files or validation evidence: ${uncovered.map((entry) => entry.criterion).join('; ')}`,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
const staleOrInvalid = args.index.criteria.flatMap((entry) => entry.evidence
|
|
16
|
+
.filter((record) => record.status === 'stale' || record.status === 'invalid')
|
|
17
|
+
.map((record) => `${entry.criterion}: ${record.value} (${record.status})`));
|
|
18
|
+
if (staleOrInvalid.length > 0) {
|
|
19
|
+
issues.push({
|
|
20
|
+
code: 'done_drift_stale_evidence',
|
|
21
|
+
message: `Done drift check found stale or invalid evidence: ${staleOrInvalid.join('; ')}`,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const grounded = new Set(args.groundedTechnicalPaths);
|
|
25
|
+
if (grounded.size > 0) {
|
|
26
|
+
const unapproved = args.index.criteria.flatMap((entry) => {
|
|
27
|
+
const hasScopeAmendment = entry.evidence.some((record) => record.kind === 'reviewer' && /scope amendment|approved scope/i.test(record.value));
|
|
28
|
+
if (hasScopeAmendment) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
return entry.evidence
|
|
32
|
+
.filter((record) => record.kind === 'changed-file' && !grounded.has(record.value))
|
|
33
|
+
.map((record) => `${entry.criterion}: ${record.value}`);
|
|
34
|
+
});
|
|
35
|
+
if (unapproved.length > 0) {
|
|
36
|
+
issues.push({
|
|
37
|
+
code: 'done_drift_unapproved_scope',
|
|
38
|
+
message: `Done drift check found changed files outside grounded spec scope: ${unapproved.join('; ')}`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return issues;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=done-drift.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EvidenceArtifacts, SpecEvidenceIndex } from '../../types/index.js';
|
|
2
|
+
export declare function criterionKey(criterion: string): string;
|
|
3
|
+
export declare function buildSpecEvidenceIndex(args: {
|
|
4
|
+
specId: string;
|
|
5
|
+
criteria: string[];
|
|
6
|
+
artifacts: EvidenceArtifacts;
|
|
7
|
+
projectPath?: string;
|
|
8
|
+
now?: string;
|
|
9
|
+
}): Promise<SpecEvidenceIndex>;
|
|
10
|
+
//# sourceMappingURL=index-builder.d.ts.map
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, join, normalize } from 'node:path';
|
|
4
|
+
export function criterionKey(criterion) {
|
|
5
|
+
const normalized = normalizeCriterion(criterion);
|
|
6
|
+
return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
|
7
|
+
}
|
|
8
|
+
export async function buildSpecEvidenceIndex(args) {
|
|
9
|
+
const diagnostics = [...args.artifacts.invalidArtifacts];
|
|
10
|
+
const matrixRows = args.artifacts.traceabilityMatrix?.rows ?? [];
|
|
11
|
+
const contractValidations = args.artifacts.contractValidations;
|
|
12
|
+
const entries = await Promise.all(args.criteria.map(async (criterion) => {
|
|
13
|
+
const rows = matrixRows.filter((row) => normalizeCriterion(row.acceptanceCriterion) === normalizeCriterion(criterion));
|
|
14
|
+
const evidence = await collectEvidence({
|
|
15
|
+
rows,
|
|
16
|
+
contractValidations,
|
|
17
|
+
projectPath: args.projectPath,
|
|
18
|
+
diagnostics,
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
criterion,
|
|
22
|
+
criterionKey: criterionKey(criterion),
|
|
23
|
+
evidence,
|
|
24
|
+
};
|
|
25
|
+
}));
|
|
26
|
+
return {
|
|
27
|
+
specId: args.specId,
|
|
28
|
+
generatedAt: args.now ?? new Date().toISOString(),
|
|
29
|
+
criteria: entries,
|
|
30
|
+
diagnostics,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function collectEvidence(args) {
|
|
34
|
+
const records = [];
|
|
35
|
+
for (const row of args.rows) {
|
|
36
|
+
if (row.scenario) {
|
|
37
|
+
records.push(record('scenario', row.scenario, 'valid', 'traceability-matrix'));
|
|
38
|
+
}
|
|
39
|
+
for (const test of row.testEvidence ?? []) {
|
|
40
|
+
records.push(await pathRecord('test', test, 'traceability-matrix', args));
|
|
41
|
+
}
|
|
42
|
+
for (const contract of row.contractEvidence ?? []) {
|
|
43
|
+
records.push(await pathRecord('contract', contract, 'traceability-matrix', args));
|
|
44
|
+
}
|
|
45
|
+
if (row.manualEvidence) {
|
|
46
|
+
records.push(record('manual', row.manualEvidence, 'valid', 'traceability-matrix'));
|
|
47
|
+
}
|
|
48
|
+
for (const changedFile of row.changedFiles) {
|
|
49
|
+
records.push(await pathRecord('changed-file', changedFile, 'traceability-matrix', args));
|
|
50
|
+
}
|
|
51
|
+
if (row.validationEvidence) {
|
|
52
|
+
records.push(validationRecord(row.validationEvidence));
|
|
53
|
+
}
|
|
54
|
+
if (row.reviewerEvidence) {
|
|
55
|
+
records.push(record('reviewer', row.reviewerEvidence, 'valid', 'traceability-matrix'));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const validation of args.contractValidations) {
|
|
59
|
+
if (validation.reportPath) {
|
|
60
|
+
records.push(await pathRecord('contract', validation.reportPath, `contract-validation:${validation.kind}`, args));
|
|
61
|
+
}
|
|
62
|
+
if (validation.summary) {
|
|
63
|
+
records.push({
|
|
64
|
+
...record('contract', validation.summary, validation.passed ? 'valid' : 'invalid', `contract-validation:${validation.kind}`),
|
|
65
|
+
passed: validation.passed,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return records;
|
|
70
|
+
}
|
|
71
|
+
function validationRecord(value) {
|
|
72
|
+
const parsed = parseValidationEvidence(value);
|
|
73
|
+
return {
|
|
74
|
+
kind: 'validation',
|
|
75
|
+
value,
|
|
76
|
+
status: parsed.passed === false ? 'invalid' : 'valid',
|
|
77
|
+
source: 'traceability-matrix',
|
|
78
|
+
...parsed,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function parseValidationEvidence(value) {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(value);
|
|
84
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
85
|
+
return { command: value };
|
|
86
|
+
}
|
|
87
|
+
const parsedRecord = parsed;
|
|
88
|
+
return {
|
|
89
|
+
command: typeof parsedRecord.command === 'string' ? parsedRecord.command : value,
|
|
90
|
+
passed: typeof parsedRecord.passed === 'boolean' ? parsedRecord.passed : undefined,
|
|
91
|
+
timestamp: typeof parsedRecord.timestamp === 'string' ? parsedRecord.timestamp : undefined,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return { command: value };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function pathRecord(kind, value, source, args) {
|
|
99
|
+
const safe = resolveSafeProjectPath(value, args.projectPath);
|
|
100
|
+
if (!safe.ok) {
|
|
101
|
+
args.diagnostics.push(`${value}: ${safe.reason}`);
|
|
102
|
+
return record(kind, value, 'invalid', source, safe.reason);
|
|
103
|
+
}
|
|
104
|
+
if (!args.projectPath) {
|
|
105
|
+
return record(kind, value, 'missing', source, 'projectPath unavailable');
|
|
106
|
+
}
|
|
107
|
+
const exists = await stat(safe.path)
|
|
108
|
+
.then(() => true)
|
|
109
|
+
.catch(() => false);
|
|
110
|
+
return record(kind, value, exists ? 'valid' : 'stale', source, exists ? undefined : 'file missing');
|
|
111
|
+
}
|
|
112
|
+
function resolveSafeProjectPath(value, projectPath) {
|
|
113
|
+
if (value.trim().length === 0) {
|
|
114
|
+
return { ok: false, reason: 'empty evidence path' };
|
|
115
|
+
}
|
|
116
|
+
if (isAbsolute(value)) {
|
|
117
|
+
return { ok: false, reason: 'absolute evidence paths are not allowed' };
|
|
118
|
+
}
|
|
119
|
+
const normalized = normalize(value);
|
|
120
|
+
if (normalized.startsWith('..') || normalized.includes('/../')) {
|
|
121
|
+
return { ok: false, reason: 'path traversal is not allowed' };
|
|
122
|
+
}
|
|
123
|
+
if (!projectPath) {
|
|
124
|
+
return { ok: true, path: normalized };
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, path: join(projectPath, normalized) };
|
|
127
|
+
}
|
|
128
|
+
function record(kind, value, status, source, reason) {
|
|
129
|
+
return { kind, value, status, source, ...(reason ? { reason } : {}) };
|
|
130
|
+
}
|
|
131
|
+
function normalizeCriterion(value) {
|
|
132
|
+
return value
|
|
133
|
+
.replace(/^-\s*\[[ x]\]\s*/i, '')
|
|
134
|
+
.replace(/\s+/g, ' ')
|
|
135
|
+
.trim()
|
|
136
|
+
.toLowerCase();
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=index-builder.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// engine/host-rules-templates/templates.ts — SPEC-708: Per-host rules + question directives.
|
|
2
2
|
// Each LLM host has its own native interactive-input mechanism. We adapt the rule
|
|
3
3
|
// text + the runtime directive emitted by withInteractiveQuestions() per host.
|
|
4
|
+
import { renderSddConstitutionRulesForHost } from '../constitution/sdd-rules-registry.js';
|
|
4
5
|
/** Native interactive-input mechanism for a given host (used in rule wording). */
|
|
5
6
|
const HOST_QUESTIONS_RULE = {
|
|
6
7
|
'claude-code': 'In Claude Code: use `AskUserQuestion`. Never present questions as plain text.',
|
|
@@ -62,6 +63,8 @@ function buildSharedRules() {
|
|
|
62
63
|
'- The implementation reviewer must be different from the implementation agent.',
|
|
63
64
|
'- User pressure, shortcut requests, or force-approval language do not bypass reviewer evidence; stop and produce the missing review first.',
|
|
64
65
|
'',
|
|
66
|
+
renderSddConstitutionRulesForHost(),
|
|
67
|
+
'',
|
|
65
68
|
'### External integrations',
|
|
66
69
|
'| Trigger | Automatic action |',
|
|
67
70
|
'|---------|-----------------|',
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { parseBddScenarios, renderBddScenariosYaml, convertCheckboxToBdd } from './bdd-parser.js';
|
|
4
4
|
import { deriveModelBudget } from './model-budget-deriver.js';
|
|
5
5
|
import { renderHistoryYaml } from '../spec-versioning/render-history.js';
|
|
6
|
+
import { renderGroundingFrontmatter, renderTechnicalReferenceGroundingFrontmatter, } from '../spec-grounding/contract.js';
|
|
6
7
|
/** Strip redundant "SPEC-XXX — " or "SPEC-XXX: " prefix from spec titles.
|
|
7
8
|
* Titles must not repeat the ID since it is already stored in the `id` field. */
|
|
8
9
|
function sanitizeTitle(title, specId) {
|
|
@@ -47,13 +48,17 @@ export function generateLeanSpecContent(input) {
|
|
|
47
48
|
...(spec.qualityWarnings && spec.qualityWarnings.length > 0
|
|
48
49
|
? [`qualityWarnings: ${JSON.stringify(spec.qualityWarnings)}`]
|
|
49
50
|
: []),
|
|
51
|
+
...(input.groundingCriteria ? renderGroundingFrontmatter(input.groundingCriteria) : []),
|
|
52
|
+
...(input.groundingTechnicalReferences
|
|
53
|
+
? renderTechnicalReferenceGroundingFrontmatter(input.groundingTechnicalReferences)
|
|
54
|
+
: []),
|
|
50
55
|
];
|
|
51
56
|
let acLines;
|
|
52
57
|
if (acFormat === 'bdd') {
|
|
53
|
-
acLines = buildBddLines(description, extraCriteria);
|
|
58
|
+
acLines = buildBddLines(description, extraCriteria, input.criteriaOverride);
|
|
54
59
|
}
|
|
55
60
|
else {
|
|
56
|
-
acLines = buildCheckboxLines(description, extraCriteria);
|
|
61
|
+
acLines = buildCheckboxLines(description, extraCriteria, input.criteriaOverride);
|
|
57
62
|
}
|
|
58
63
|
// SPEC-717: render empty version history for new specs
|
|
59
64
|
const historyYaml = renderHistoryYaml([]);
|
|
@@ -72,25 +77,25 @@ export function generateLeanSpecContent(input) {
|
|
|
72
77
|
return lines.join('\n');
|
|
73
78
|
}
|
|
74
79
|
/** Build checkbox-format criteria lines. */
|
|
75
|
-
function buildCheckboxLines(description, extraCriteria) {
|
|
76
|
-
const criteria =
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
function buildCheckboxLines(description, extraCriteria, criteriaOverride) {
|
|
81
|
+
const criteria = criteriaOverride ?? [
|
|
82
|
+
...extractCriteria(description),
|
|
83
|
+
...extraCriteria.map((text) => ({ text, done: false })),
|
|
84
|
+
];
|
|
80
85
|
return [
|
|
81
86
|
'criteria:',
|
|
82
87
|
...criteria.map((c) => ` - text: "${escapeYaml(c.text)}"\n done: ${String(c.done)}`),
|
|
83
88
|
];
|
|
84
89
|
}
|
|
85
90
|
/** Build BDD scenario lines. */
|
|
86
|
-
function buildBddLines(description, extraCriteria) {
|
|
91
|
+
function buildBddLines(description, extraCriteria, criteriaOverride) {
|
|
87
92
|
let scenarios = parseBddScenarios(description);
|
|
88
93
|
// Fallback: convert checkbox criteria to BDD scenarios
|
|
89
94
|
if (scenarios.length === 0) {
|
|
90
|
-
const checkboxCriteria =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
const checkboxCriteria = criteriaOverride ?? [
|
|
96
|
+
...extractCriteria(description),
|
|
97
|
+
...extraCriteria.map((text) => ({ text, done: false })),
|
|
98
|
+
];
|
|
94
99
|
scenarios = convertCheckboxToBdd(checkboxCriteria);
|
|
95
100
|
}
|
|
96
101
|
else {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Spec } from '../../types/index.js';
|
|
2
|
+
import type { CriterionGroundingRecord, GroundingGateResult, TechnicalReferenceGroundingRecord } from '../../types/spec-grounding.js';
|
|
3
|
+
export declare function normalizeCriterionText(text: string): string;
|
|
4
|
+
export declare function isGenericCriterion(text: string): boolean;
|
|
5
|
+
export declare function filterGroundedCriteria(criteria: string[]): string[];
|
|
6
|
+
export declare function buildCriterionGroundingRecords(args: {
|
|
7
|
+
criteria: string[];
|
|
8
|
+
userInput: string;
|
|
9
|
+
projectEvidence?: string[];
|
|
10
|
+
generatedEvidence?: string[];
|
|
11
|
+
}): CriterionGroundingRecord[];
|
|
12
|
+
export declare function getContractCriteria(records: CriterionGroundingRecord[]): CriterionGroundingRecord[];
|
|
13
|
+
export declare function getAdvisoryCriteria(records: CriterionGroundingRecord[]): CriterionGroundingRecord[];
|
|
14
|
+
export declare function renderGroundingFrontmatter(records: CriterionGroundingRecord[]): string[];
|
|
15
|
+
export declare function renderTechnicalReferenceGroundingFrontmatter(records: TechnicalReferenceGroundingRecord[]): string[];
|
|
16
|
+
export declare function checkGroundedSpecContract(spec: Spec, content: string): GroundingGateResult;
|
|
17
|
+
export declare function parseCriterionGroundingRecords(frontmatter: string): CriterionGroundingRecord[];
|
|
18
|
+
export declare function parseTechnicalReferenceGroundingRecords(frontmatter: string): TechnicalReferenceGroundingRecord[];
|
|
19
|
+
//# sourceMappingURL=contract.d.ts.map
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
const GENERIC_CRITERION_PATTERNS = [
|
|
2
|
+
/\bimplementation complete\b/i,
|
|
3
|
+
/\bcomplete and tested\b/i,
|
|
4
|
+
/\bensure (adequate )?coverage\b/i,
|
|
5
|
+
/\ball edge cases\b/i,
|
|
6
|
+
/\bgeneric validation\b/i,
|
|
7
|
+
/\bautopilot could not infer testable behavior\b/i,
|
|
8
|
+
/\bdefine acceptance criteria\b/i,
|
|
9
|
+
];
|
|
10
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/;
|
|
11
|
+
export function normalizeCriterionText(text) {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/^[-*]\s*\[[ xX]\]\s*/, '')
|
|
14
|
+
.replace(/\s+/g, ' ')
|
|
15
|
+
.trim()
|
|
16
|
+
.toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
export function isGenericCriterion(text) {
|
|
19
|
+
return GENERIC_CRITERION_PATTERNS.some((pattern) => pattern.test(text));
|
|
20
|
+
}
|
|
21
|
+
export function filterGroundedCriteria(criteria) {
|
|
22
|
+
return criteria.filter((criterion) => criterion.trim().length > 0 && !isGenericCriterion(criterion));
|
|
23
|
+
}
|
|
24
|
+
export function buildCriterionGroundingRecords(args) {
|
|
25
|
+
const userInput = normalizeCriterionText(args.userInput);
|
|
26
|
+
const projectEvidence = args.projectEvidence ?? [];
|
|
27
|
+
const generatedEvidence = args.generatedEvidence ?? [];
|
|
28
|
+
return args.criteria.map((criterion) => {
|
|
29
|
+
const normalized = normalizeCriterionText(criterion);
|
|
30
|
+
if (isGenericCriterion(criterion)) {
|
|
31
|
+
return {
|
|
32
|
+
text: criterion,
|
|
33
|
+
source: 'ungrounded_advisory',
|
|
34
|
+
evidence: [],
|
|
35
|
+
confidence: 'low',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (normalized.length > 0 && userInput.includes(normalized)) {
|
|
39
|
+
return {
|
|
40
|
+
text: criterion,
|
|
41
|
+
source: 'user_input',
|
|
42
|
+
evidence: ['create_spec.description'],
|
|
43
|
+
confidence: 'high',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (projectEvidence.length > 0) {
|
|
47
|
+
return {
|
|
48
|
+
text: criterion,
|
|
49
|
+
source: 'project_evidence',
|
|
50
|
+
evidence: projectEvidence.slice(0, 8),
|
|
51
|
+
confidence: 'medium',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
text: criterion,
|
|
56
|
+
source: 'documented_assumption',
|
|
57
|
+
evidence: generatedEvidence.length > 0 ? generatedEvidence.slice(0, 4) : ['generated-spec-body'],
|
|
58
|
+
confidence: 'medium',
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export function getContractCriteria(records) {
|
|
63
|
+
return records.filter((record) => record.source === 'user_input' || record.source === 'project_evidence');
|
|
64
|
+
}
|
|
65
|
+
export function getAdvisoryCriteria(records) {
|
|
66
|
+
return records.filter((record) => record.source === 'documented_assumption' || record.source === 'ungrounded_advisory');
|
|
67
|
+
}
|
|
68
|
+
export function renderGroundingFrontmatter(records) {
|
|
69
|
+
if (records.length === 0) {
|
|
70
|
+
return ['grounding_required: true', 'grounding:', ' criteria: []'];
|
|
71
|
+
}
|
|
72
|
+
return [
|
|
73
|
+
'grounding_required: true',
|
|
74
|
+
'grounding:',
|
|
75
|
+
' criteria:',
|
|
76
|
+
...records.flatMap((record) => [
|
|
77
|
+
` - text: "${escapeYaml(record.text)}"`,
|
|
78
|
+
` source: ${record.source}`,
|
|
79
|
+
` evidence: [${record.evidence.map((item) => `"${escapeYaml(item)}"`).join(', ')}]`,
|
|
80
|
+
` confidence: ${record.confidence}`,
|
|
81
|
+
]),
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
export function renderTechnicalReferenceGroundingFrontmatter(records) {
|
|
85
|
+
if (records.length === 0) {
|
|
86
|
+
return [' technicalReferences: []'];
|
|
87
|
+
}
|
|
88
|
+
return [
|
|
89
|
+
' technicalReferences:',
|
|
90
|
+
...records.flatMap((record) => [
|
|
91
|
+
` - path: "${escapeYaml(record.path)}"`,
|
|
92
|
+
` section: ${record.section}`,
|
|
93
|
+
` source: ${record.source}`,
|
|
94
|
+
` evidence: [${record.evidence.map((item) => `"${escapeYaml(item)}"`).join(', ')}]`,
|
|
95
|
+
` confidence: ${record.confidence}`,
|
|
96
|
+
]),
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
export function checkGroundedSpecContract(spec, content) {
|
|
100
|
+
const frontmatter = FRONTMATTER_RE.exec(content)?.[1] ?? '';
|
|
101
|
+
const required = /^grounding_required:\s*true\s*$/m.test(frontmatter);
|
|
102
|
+
const records = parseCriterionGroundingRecords(frontmatter);
|
|
103
|
+
if (!required) {
|
|
104
|
+
return { passed: true, required: false, issues: [], records };
|
|
105
|
+
}
|
|
106
|
+
const issues = [];
|
|
107
|
+
if (records.length === 0 && spec.scope !== 'trivial') {
|
|
108
|
+
issues.push(`Spec ${spec.id} requires grounding metadata but has no grounded criteria records.`);
|
|
109
|
+
}
|
|
110
|
+
for (const record of records) {
|
|
111
|
+
if (record.source === 'ungrounded_advisory') {
|
|
112
|
+
issues.push(`Criterion is advisory-only and cannot be approved as contract: ${record.text}`);
|
|
113
|
+
}
|
|
114
|
+
if (record.evidence.length === 0 && record.source !== 'ungrounded_advisory') {
|
|
115
|
+
issues.push(`Criterion has no grounding evidence: ${record.text}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const technicalRecords = parseTechnicalReferenceGroundingRecords(frontmatter);
|
|
119
|
+
for (const record of technicalRecords) {
|
|
120
|
+
if (record.source === 'ungrounded_advisory') {
|
|
121
|
+
issues.push(`Technical reference is advisory-only and cannot be contract: ${record.path}`);
|
|
122
|
+
}
|
|
123
|
+
if (record.evidence.length === 0 && record.source !== 'ungrounded_advisory') {
|
|
124
|
+
issues.push(`Technical reference has no grounding evidence: ${record.path}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
passed: issues.length === 0,
|
|
129
|
+
required: true,
|
|
130
|
+
issues,
|
|
131
|
+
records,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export function parseCriterionGroundingRecords(frontmatter) {
|
|
135
|
+
const records = [];
|
|
136
|
+
const chunks = frontmatter.split(/\n\s{4}- text:\s*/).slice(1);
|
|
137
|
+
for (const chunk of chunks) {
|
|
138
|
+
const text = /^"((?:\\"|[^"])*)"/.exec(chunk)?.[1]?.replace(/\\"/g, '"') ?? '';
|
|
139
|
+
const source = /^\s{6}source:\s*(\S+)/m.exec(chunk)?.[1];
|
|
140
|
+
const confidence = /^\s{6}confidence:\s*(\S+)/m.exec(chunk)?.[1];
|
|
141
|
+
const evidenceRaw = /^\s{6}evidence:\s*\[(.*)\]/m.exec(chunk)?.[1] ?? '';
|
|
142
|
+
if (!text || !source || !confidence) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
records.push({
|
|
146
|
+
text,
|
|
147
|
+
source,
|
|
148
|
+
evidence: parseYamlInlineStringArray(evidenceRaw),
|
|
149
|
+
confidence,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return records;
|
|
153
|
+
}
|
|
154
|
+
export function parseTechnicalReferenceGroundingRecords(frontmatter) {
|
|
155
|
+
const records = [];
|
|
156
|
+
const chunks = frontmatter.split(/\n\s{4}- path:\s*/).slice(1);
|
|
157
|
+
for (const chunk of chunks) {
|
|
158
|
+
const path = /^"((?:\\"|[^"])*)"/.exec(chunk)?.[1]?.replace(/\\"/g, '"') ?? '';
|
|
159
|
+
const section = /^\s{6}section:\s*(\S+)/m.exec(chunk)?.[1];
|
|
160
|
+
const source = /^\s{6}source:\s*(\S+)/m.exec(chunk)?.[1];
|
|
161
|
+
const confidence = /^\s{6}confidence:\s*(\S+)/m.exec(chunk)?.[1];
|
|
162
|
+
const evidenceRaw = /^\s{6}evidence:\s*\[(.*)\]/m.exec(chunk)?.[1] ?? '';
|
|
163
|
+
if (!path || !section || !source || !confidence) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
records.push({
|
|
167
|
+
path,
|
|
168
|
+
section,
|
|
169
|
+
source,
|
|
170
|
+
evidence: parseYamlInlineStringArray(evidenceRaw),
|
|
171
|
+
confidence,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return records;
|
|
175
|
+
}
|
|
176
|
+
function parseYamlInlineStringArray(raw) {
|
|
177
|
+
if (raw.trim().length === 0) {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
const matches = raw.matchAll(/"((?:\\"|[^"])*)"/g);
|
|
181
|
+
return [...matches].map((match) => match[1]?.replace(/\\"/g, '"') ?? '').filter(Boolean);
|
|
182
|
+
}
|
|
183
|
+
function escapeYaml(value) {
|
|
184
|
+
return value.replace(/"/g, '\\"');
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=contract.js.map
|
|
@@ -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
|