triad-plus 1.6.0 → 1.8.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 +15 -0
- package/README.md +26 -0
- package/adapters/antigravity/.agents/agents/triad-evaluator/agent.md +5 -0
- package/adapters/claude-code/.claude/agents/triad-evaluator.md +5 -0
- package/adapters/copilot/.github/agents/triad-evaluator.agent.md +5 -0
- package/adapters/hermes/skills/triad/SKILL.md +5 -0
- package/adapters/opencode/.opencode/agents/triad-evaluator.md +6 -0
- package/bin/triad-plus.js +28 -3
- package/docs/architecture.md +7 -0
- package/docs/bmad-integration.md +110 -0
- package/docs/configuration.md +19 -0
- package/docs/evaluator-plus.md +9 -0
- package/docs/operating-guide.it.md +10 -0
- package/docs/operating-guide.md +10 -0
- package/docs/quality-contract.md +119 -0
- package/docs/verification.md +17 -0
- package/integrations/bmad/README.md +12 -0
- package/integrations/bmad/story-importer.mjs +475 -0
- package/package.json +2 -2
- package/runtime/lib/quality-baseline.mjs +205 -0
- package/runtime/triad-evaluator-validate.mjs +261 -0
- package/runtime/triad-verify.mjs +19 -5
- package/schemas/evaluator-plus-result.schema.json +16 -0
- package/schemas/quality-baseline.schema.json +42 -0
- package/schemas/verification-evidence.schema.json +1 -1
- package/skills/triad-loop-bootstrap/SKILL.md +8 -0
- package/skills/triad-loop-bootstrap/assets/loop-template/handoff-report.template.md +13 -0
- package/skills/triad-loop-bootstrap/assets/loop-template/quality-baseline.json +15 -0
- package/skills/triad-loop-bootstrap/assets/loop-template/run-state.yaml +7 -0
- package/skills/triad-loop-bootstrap/assets/loop-template/runtime/assignments/assignment.template.json +2 -0
- package/skills/triad-loop-bootstrap/assets/project.yaml +5 -0
- package/skills/triad-loop-evaluator/SKILL.md +12 -2
- package/skills/triad-loop-orchestrator/SKILL.md +51 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const SHA256 = /^[a-f0-9]{64}$/i;
|
|
6
|
+
const QUALITY_SCOPES = new Set(["product_quality", "delivery_closure"]);
|
|
7
|
+
|
|
8
|
+
function qualityError(code, message) {
|
|
9
|
+
const error = new Error(message);
|
|
10
|
+
error.code = code;
|
|
11
|
+
return error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function objectLike(value) {
|
|
15
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Return a JSON-compatible value with object keys sorted recursively. Arrays
|
|
20
|
+
* intentionally retain their declared order: source and criterion order is
|
|
21
|
+
* part of the authored contract, while object formatting is not.
|
|
22
|
+
*/
|
|
23
|
+
export function canonicalizeQualityValue(value) {
|
|
24
|
+
if (Array.isArray(value)) return value.map(canonicalizeQualityValue);
|
|
25
|
+
if (!objectLike(value)) return value;
|
|
26
|
+
return Object.fromEntries(
|
|
27
|
+
Object.keys(value)
|
|
28
|
+
.sort()
|
|
29
|
+
.map((key) => [key, canonicalizeQualityValue(value[key])])
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function qualityBaselinePayload(manifest) {
|
|
34
|
+
if (!objectLike(manifest)) return manifest;
|
|
35
|
+
const { fingerprint: _fingerprint, ...payload } = manifest;
|
|
36
|
+
return canonicalizeQualityValue(payload);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function qualityBaselineFingerprint(manifest) {
|
|
40
|
+
const canonical = JSON.stringify(qualityBaselinePayload(manifest));
|
|
41
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function projectRelativePath(root, value, label) {
|
|
45
|
+
if (typeof value !== "string" || !value.trim() || path.isAbsolute(value)) {
|
|
46
|
+
throw qualityError("quality_baseline_invalid", `${label} must be a non-empty project-relative path`);
|
|
47
|
+
}
|
|
48
|
+
const normalized = value.replaceAll("\\", "/");
|
|
49
|
+
const posix = path.posix.normalize(normalized);
|
|
50
|
+
if (posix === "." || posix === ".." || posix.startsWith("../") || normalized.split("/").includes("..")) {
|
|
51
|
+
throw qualityError("quality_baseline_invalid", `${label} escapes the project root`);
|
|
52
|
+
}
|
|
53
|
+
const resolved = path.resolve(root, normalized);
|
|
54
|
+
if (!resolved.startsWith(`${root}${path.sep}`)) {
|
|
55
|
+
throw qualityError("quality_baseline_invalid", `${label} escapes the project root`);
|
|
56
|
+
}
|
|
57
|
+
return resolved;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validateSha(value, label) {
|
|
61
|
+
if (typeof value !== "string" || !SHA256.test(value)) {
|
|
62
|
+
throw qualityError("quality_baseline_invalid", `${label} must be a SHA-256 hex digest`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rejectUnknownProperties(value, allowed, label) {
|
|
67
|
+
for (const key of Object.keys(value)) {
|
|
68
|
+
if (!allowed.has(key)) throw qualityError("quality_baseline_invalid", `${label} contains unknown property: ${key}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validate the manifest shape and its self-declared fingerprint without
|
|
74
|
+
* touching source files. This is useful to callers that only need to inspect
|
|
75
|
+
* the immutable contract before resolving it against a project.
|
|
76
|
+
*/
|
|
77
|
+
export function validateQualityBaselineManifest(manifest) {
|
|
78
|
+
if (!objectLike(manifest)) throw qualityError("quality_baseline_invalid", "quality baseline must be a JSON object");
|
|
79
|
+
rejectUnknownProperties(manifest, new Set(["schema_version", "id", "revision", "sources", "criteria", "fingerprint"]), "quality baseline manifest");
|
|
80
|
+
if (manifest.schema_version !== 1) throw qualityError("quality_baseline_invalid", "quality baseline schema_version must be 1");
|
|
81
|
+
if (typeof manifest.id !== "string" || !manifest.id.trim()) throw qualityError("quality_baseline_invalid", "quality baseline id must be non-empty");
|
|
82
|
+
if (!Number.isInteger(manifest.revision) || manifest.revision < 1) throw qualityError("quality_baseline_invalid", "quality baseline revision must be a positive integer");
|
|
83
|
+
if (!Array.isArray(manifest.sources) || manifest.sources.length === 0) throw qualityError("quality_baseline_invalid", "quality baseline must declare at least one source");
|
|
84
|
+
if (!Array.isArray(manifest.criteria)) throw qualityError("quality_baseline_invalid", "quality baseline criteria must be an array");
|
|
85
|
+
|
|
86
|
+
const sourceIds = new Set();
|
|
87
|
+
for (const source of manifest.sources) {
|
|
88
|
+
if (!objectLike(source)) throw qualityError("quality_baseline_invalid", "quality baseline sources must contain objects");
|
|
89
|
+
rejectUnknownProperties(source, new Set(["id", "role", "path", "sha256"]), "quality baseline source");
|
|
90
|
+
if (typeof source.id !== "string" || !source.id.trim()) throw qualityError("quality_baseline_invalid", "quality baseline source id must be non-empty");
|
|
91
|
+
if (sourceIds.has(source.id)) throw qualityError("quality_baseline_invalid", `duplicate quality baseline source id: ${source.id}`);
|
|
92
|
+
sourceIds.add(source.id);
|
|
93
|
+
if (typeof source.role !== "string" || !source.role.trim()) throw qualityError("quality_baseline_invalid", `quality baseline source role is missing: ${source.id}`);
|
|
94
|
+
if (typeof source.path !== "string" || !source.path.trim() || path.isAbsolute(source.path)) throw qualityError("quality_baseline_invalid", `quality baseline source path must be project-relative: ${source.id}`);
|
|
95
|
+
const normalizedSourcePath = path.posix.normalize(source.path.replaceAll("\\", "/"));
|
|
96
|
+
if (normalizedSourcePath === "." || normalizedSourcePath === ".." || normalizedSourcePath.startsWith("../") || source.path.replaceAll("\\", "/").split("/").includes("..")) throw qualityError("quality_baseline_invalid", `quality baseline source path escapes the project root: ${source.id}`);
|
|
97
|
+
validateSha(source.sha256, `quality baseline source ${source.id}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const criterionIds = new Set();
|
|
101
|
+
for (const criterion of manifest.criteria) {
|
|
102
|
+
if (!objectLike(criterion)) throw qualityError("quality_baseline_invalid", "quality baseline criteria must contain objects");
|
|
103
|
+
rejectUnknownProperties(criterion, new Set(["id", "scope", "requirement"]), "quality baseline criterion");
|
|
104
|
+
if (typeof criterion.id !== "string" || !criterion.id.trim()) throw qualityError("quality_baseline_invalid", "quality baseline criterion id must be non-empty");
|
|
105
|
+
if (criterionIds.has(criterion.id)) throw qualityError("quality_baseline_invalid", `duplicate quality baseline criterion id: ${criterion.id}`);
|
|
106
|
+
criterionIds.add(criterion.id);
|
|
107
|
+
if (!QUALITY_SCOPES.has(criterion.scope)) throw qualityError("quality_baseline_invalid", `invalid quality baseline criterion scope: ${criterion.id}`);
|
|
108
|
+
if (typeof criterion.requirement !== "string" || !criterion.requirement.trim()) throw qualityError("quality_baseline_invalid", `quality baseline criterion requirement must be non-empty: ${criterion.id}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
validateSha(manifest.fingerprint, "quality baseline fingerprint");
|
|
112
|
+
const calculatedFingerprint = qualityBaselineFingerprint(manifest);
|
|
113
|
+
if (manifest.fingerprint.toLowerCase() !== calculatedFingerprint) {
|
|
114
|
+
throw qualityError("quality_baseline_invalid", "quality baseline declared fingerprint does not match its canonical content");
|
|
115
|
+
}
|
|
116
|
+
return { manifest, fingerprint: calculatedFingerprint };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function verifySources(manifest, root) {
|
|
120
|
+
const verifiedSources = [];
|
|
121
|
+
for (const source of manifest.sources) {
|
|
122
|
+
const sourcePath = projectRelativePath(root, source.path, `quality baseline source ${source.id}`);
|
|
123
|
+
let resolvedSource;
|
|
124
|
+
try {
|
|
125
|
+
resolvedSource = await realpath(sourcePath);
|
|
126
|
+
} catch {
|
|
127
|
+
throw qualityError("quality_baseline_invalid", `quality baseline source is missing: ${source.path}`);
|
|
128
|
+
}
|
|
129
|
+
if (!resolvedSource.startsWith(`${root}${path.sep}`)) {
|
|
130
|
+
throw qualityError("quality_baseline_invalid", `quality baseline source escapes the project root: ${source.path}`);
|
|
131
|
+
}
|
|
132
|
+
let content;
|
|
133
|
+
try {
|
|
134
|
+
content = await readFile(resolvedSource);
|
|
135
|
+
} catch {
|
|
136
|
+
throw qualityError("quality_baseline_invalid", `quality baseline source cannot be read: ${source.path}`);
|
|
137
|
+
}
|
|
138
|
+
const actual = createHash("sha256").update(content).digest("hex");
|
|
139
|
+
if (actual !== source.sha256.toLowerCase()) {
|
|
140
|
+
throw qualityError("quality_baseline_drift", `quality baseline source hash mismatch: ${source.path}`);
|
|
141
|
+
}
|
|
142
|
+
verifiedSources.push({ id: source.id, path: source.path, sha256: actual });
|
|
143
|
+
}
|
|
144
|
+
return verifiedSources;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Load, validate, fingerprint, and resolve the immutable quality contract
|
|
149
|
+
* against source files under the project root.
|
|
150
|
+
*/
|
|
151
|
+
export async function loadQualityBaseline(manifestPath, { projectRoot = process.cwd(), expectedFingerprint = null } = {}) {
|
|
152
|
+
const root = await realpath(projectRoot);
|
|
153
|
+
const resolvedManifestPath = projectRelativePath(root, manifestPath, "quality baseline path");
|
|
154
|
+
let source;
|
|
155
|
+
try {
|
|
156
|
+
source = await readFile(resolvedManifestPath, "utf8");
|
|
157
|
+
} catch {
|
|
158
|
+
throw qualityError("quality_baseline_invalid", `quality baseline manifest is missing: ${manifestPath}`);
|
|
159
|
+
}
|
|
160
|
+
let manifest;
|
|
161
|
+
try {
|
|
162
|
+
manifest = JSON.parse(source);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw qualityError("quality_baseline_invalid", `quality baseline manifest is not valid JSON: ${error.message}`);
|
|
165
|
+
}
|
|
166
|
+
const validated = validateQualityBaselineManifest(manifest);
|
|
167
|
+
if (expectedFingerprint !== null && expectedFingerprint !== undefined) {
|
|
168
|
+
validateSha(expectedFingerprint, "expected quality baseline fingerprint");
|
|
169
|
+
if (expectedFingerprint.toLowerCase() !== validated.fingerprint) {
|
|
170
|
+
throw qualityError("quality_baseline_drift", "quality baseline fingerprint does not match the assignment binding");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const sources = await verifySources(manifest, root);
|
|
174
|
+
return {
|
|
175
|
+
manifest,
|
|
176
|
+
path: path.relative(root, resolvedManifestPath),
|
|
177
|
+
fingerprint: validated.fingerprint,
|
|
178
|
+
sources,
|
|
179
|
+
criteria: manifest.criteria,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Resolve the optional assignment binding while preserving legacy mode. */
|
|
184
|
+
export async function resolveQualityContract(assignment, projectRoot) {
|
|
185
|
+
const manifestPath = assignment?.quality_baseline_path;
|
|
186
|
+
const expectedFingerprint = assignment?.expected_quality_baseline_fingerprint;
|
|
187
|
+
const pathPresent = typeof manifestPath === "string" && manifestPath.trim();
|
|
188
|
+
const fingerprintPresent = typeof expectedFingerprint === "string" && expectedFingerprint.trim();
|
|
189
|
+
if (!pathPresent && !fingerprintPresent) return null;
|
|
190
|
+
if (!pathPresent || !fingerprintPresent) {
|
|
191
|
+
throw qualityError("quality_baseline_invalid", "quality contract requires quality_baseline_path and expected_quality_baseline_fingerprint together");
|
|
192
|
+
}
|
|
193
|
+
return loadQualityBaseline(manifestPath, { projectRoot, expectedFingerprint });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function qualityCriteriaByScope(manifestOrContract) {
|
|
197
|
+
const manifest = manifestOrContract?.manifest ?? manifestOrContract;
|
|
198
|
+
if (!objectLike(manifest) || !Array.isArray(manifest.criteria)) throw qualityError("quality_baseline_invalid", "quality baseline criteria are unavailable");
|
|
199
|
+
return {
|
|
200
|
+
product_quality: manifest.criteria.filter((criterion) => criterion.scope === "product_quality"),
|
|
201
|
+
delivery_closure: manifest.criteria.filter((criterion) => criterion.scope === "delivery_closure"),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export { QUALITY_SCOPES };
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { loadQualityBaseline, qualityCriteriaByScope, validateQualityBaselineManifest } from "./lib/quality-baseline.mjs";
|
|
2
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const VERDICTS = new Set(["PASS", "FAIL", "INDETERMINATE"]);
|
|
7
|
+
const SHA256 = /^[a-f0-9]{64}$/i;
|
|
8
|
+
const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
9
|
+
|
|
10
|
+
function evaluatorError(code, message) {
|
|
11
|
+
const error = new Error(message);
|
|
12
|
+
error.code = code;
|
|
13
|
+
return error;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function rejectUnknownProperties(value, allowed, label) {
|
|
17
|
+
for (const key of Object.keys(value)) {
|
|
18
|
+
if (!allowed.has(key)) throw evaluatorError("evaluator_result_invalid", `${label} contains unknown property: ${key}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function requireBaseResult(result) {
|
|
23
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) throw evaluatorError("evaluator_result_invalid", "Evaluator+ result must be an object");
|
|
24
|
+
rejectUnknownProperties(result, new Set(["schema_version", "feature_id", "candidate_fingerprint", "quality_baseline_fingerprint", "verdict", "summary", "evidence_refs", "criteria", "created_at"]), "Evaluator+ result");
|
|
25
|
+
if (result.schema_version !== 1) throw evaluatorError("evaluator_result_invalid", "Evaluator+ schema_version must be 1");
|
|
26
|
+
if (typeof result.feature_id !== "string" || !result.feature_id.trim()) throw evaluatorError("evaluator_result_invalid", "Evaluator+ feature_id must be non-empty");
|
|
27
|
+
if (typeof result.candidate_fingerprint !== "string" || !result.candidate_fingerprint.trim()) throw evaluatorError("evaluator_result_invalid", "Evaluator+ candidate_fingerprint must be non-empty");
|
|
28
|
+
if (result.quality_baseline_fingerprint !== undefined && (typeof result.quality_baseline_fingerprint !== "string" || !SHA256.test(result.quality_baseline_fingerprint))) throw evaluatorError("evaluator_result_invalid", "Evaluator+ quality_baseline_fingerprint must be a SHA-256 hex digest");
|
|
29
|
+
if (!VERDICTS.has(result.verdict)) throw evaluatorError("evaluator_result_invalid", "Evaluator+ verdict must be PASS, FAIL, or INDETERMINATE");
|
|
30
|
+
if (typeof result.summary !== "string" || !result.summary.trim()) throw evaluatorError("evaluator_result_invalid", "Evaluator+ summary must be non-empty");
|
|
31
|
+
if (!Array.isArray(result.evidence_refs) || result.evidence_refs.some((ref) => typeof ref !== "string")) throw evaluatorError("evaluator_result_invalid", "Evaluator+ evidence_refs must be an array of strings");
|
|
32
|
+
if (typeof result.created_at !== "string" || !DATE_TIME.test(result.created_at) || Number.isNaN(Date.parse(result.created_at))) throw evaluatorError("evaluator_result_invalid", "Evaluator+ created_at must be an RFC 3339 date-time");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function aggregateEvaluatorVerdict(criteria) {
|
|
36
|
+
if (!Array.isArray(criteria)) throw evaluatorError("evaluator_result_invalid", "Evaluator+ criteria must be an array");
|
|
37
|
+
if (criteria.some((criterion) => criterion.verdict === "FAIL")) return "FAIL";
|
|
38
|
+
if (criteria.some((criterion) => criterion.verdict === "INDETERMINATE")) return "INDETERMINATE";
|
|
39
|
+
return "PASS";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateCriterionShape(criterion) {
|
|
43
|
+
if (!criterion || typeof criterion !== "object" || Array.isArray(criterion)) throw evaluatorError("evaluator_result_invalid", "Evaluator+ criterion entries must be objects");
|
|
44
|
+
rejectUnknownProperties(criterion, new Set(["id", "scope", "verdict", "summary", "evidence_refs"]), "Evaluator+ criterion");
|
|
45
|
+
if (typeof criterion.id !== "string" || !criterion.id.trim()) throw evaluatorError("evaluator_result_invalid", "Evaluator+ criterion id must be non-empty");
|
|
46
|
+
if (criterion.scope !== "product_quality") throw evaluatorError("evaluator_result_invalid", `Evaluator+ criterion ${criterion.id} is not product_quality`);
|
|
47
|
+
if (!VERDICTS.has(criterion.verdict)) throw evaluatorError("evaluator_result_invalid", `Evaluator+ criterion ${criterion.id} has an invalid verdict`);
|
|
48
|
+
if (typeof criterion.summary !== "string" || !criterion.summary.trim()) throw evaluatorError("evaluator_result_invalid", `Evaluator+ criterion ${criterion.id} summary must be non-empty`);
|
|
49
|
+
if (!Array.isArray(criterion.evidence_refs) || criterion.evidence_refs.some((ref) => typeof ref !== "string")) throw evaluatorError("evaluator_result_invalid", `Evaluator+ criterion ${criterion.id} evidence_refs must be an array of strings`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate an Evaluator+ result. Without a quality baseline the v1.7 legacy
|
|
54
|
+
* result contract remains valid. With a baseline, every product-quality
|
|
55
|
+
* criterion must be covered exactly once and the aggregate is deterministic.
|
|
56
|
+
*/
|
|
57
|
+
export function validateEvaluatorResult(result, { qualityBaseline = null, expectedCandidateFingerprint = null } = {}) {
|
|
58
|
+
requireBaseResult(result);
|
|
59
|
+
if (!qualityBaseline) {
|
|
60
|
+
if (result.criteria !== undefined) {
|
|
61
|
+
if (!Array.isArray(result.criteria)) throw evaluatorError("evaluator_result_invalid", "Evaluator+ criteria must be an array");
|
|
62
|
+
for (const criterion of result.criteria) validateCriterionShape(criterion);
|
|
63
|
+
}
|
|
64
|
+
return { valid: true, legacy: true, verdict: result.verdict, result };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const baseline = qualityBaseline.manifest ?? qualityBaseline;
|
|
68
|
+
const validatedBaseline = validateQualityBaselineManifest(baseline);
|
|
69
|
+
const expectedBaselineFingerprint = validatedBaseline.fingerprint;
|
|
70
|
+
if (qualityBaseline.fingerprint !== undefined && qualityBaseline.fingerprint !== expectedBaselineFingerprint) {
|
|
71
|
+
throw evaluatorError("evaluator_quality_baseline_mismatch", "loaded Quality Baseline fingerprint does not match its canonical content");
|
|
72
|
+
}
|
|
73
|
+
if (result.quality_baseline_fingerprint !== expectedBaselineFingerprint) {
|
|
74
|
+
throw evaluatorError("evaluator_quality_baseline_mismatch", "Evaluator+ result quality baseline fingerprint does not match the approved contract");
|
|
75
|
+
}
|
|
76
|
+
if (typeof expectedCandidateFingerprint !== "string" || !expectedCandidateFingerprint.trim()) {
|
|
77
|
+
throw evaluatorError("evaluator_candidate_binding_missing", "Quality Contract Evaluator+ validation requires an expected candidate fingerprint");
|
|
78
|
+
}
|
|
79
|
+
if (result.candidate_fingerprint !== expectedCandidateFingerprint) {
|
|
80
|
+
throw evaluatorError("evaluator_candidate_fingerprint_mismatch", "Evaluator+ result candidate fingerprint does not match the approved candidate");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const { product_quality: expectedCriteria, delivery_closure: deliveryCriteria } = qualityCriteriaByScope(baseline);
|
|
84
|
+
if (!Array.isArray(result.criteria)) throw evaluatorError("evaluator_result_invalid", "Quality Contract Evaluator+ result must include criteria");
|
|
85
|
+
const expectedById = new Map(expectedCriteria.map((criterion) => [criterion.id, criterion]));
|
|
86
|
+
const deliveryIds = new Set(deliveryCriteria.map((criterion) => criterion.id));
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
for (const criterion of result.criteria) {
|
|
89
|
+
validateCriterionShape(criterion);
|
|
90
|
+
if (seen.has(criterion.id)) throw evaluatorError("evaluator_result_invalid", `duplicate Evaluator+ criterion: ${criterion.id}`);
|
|
91
|
+
seen.add(criterion.id);
|
|
92
|
+
if (deliveryIds.has(criterion.id)) throw evaluatorError("evaluator_result_invalid", `delivery_closure criterion was sent to Evaluator+: ${criterion.id}`);
|
|
93
|
+
if (!expectedById.has(criterion.id)) throw evaluatorError("evaluator_result_invalid", `unexpected product_quality criterion: ${criterion.id}`);
|
|
94
|
+
}
|
|
95
|
+
for (const criterion of expectedCriteria) {
|
|
96
|
+
if (!seen.has(criterion.id)) throw evaluatorError("evaluator_result_invalid", `missing product_quality criterion: ${criterion.id}`);
|
|
97
|
+
}
|
|
98
|
+
const aggregate = aggregateEvaluatorVerdict(result.criteria);
|
|
99
|
+
if (result.verdict !== aggregate) throw evaluatorError("evaluator_result_invalid", `Evaluator+ aggregate verdict must be ${aggregate}`);
|
|
100
|
+
return {
|
|
101
|
+
valid: true,
|
|
102
|
+
legacy: false,
|
|
103
|
+
verdict: aggregate,
|
|
104
|
+
quality_baseline_fingerprint: expectedBaselineFingerprint,
|
|
105
|
+
product_quality_criteria: result.criteria,
|
|
106
|
+
excluded_delivery_closure_ids: deliveryCriteria.map((criterion) => criterion.id),
|
|
107
|
+
result,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function validateClosureCriterion(criterion, expectedScope = "delivery_closure") {
|
|
112
|
+
if (!criterion || typeof criterion !== "object" || Array.isArray(criterion)) throw evaluatorError("delivery_closure_invalid", "delivery closure criteria must be objects");
|
|
113
|
+
const allowed = new Set(["id", "scope", "verdict", "summary", "evidence_refs"]);
|
|
114
|
+
for (const key of Object.keys(criterion)) if (!allowed.has(key)) throw evaluatorError("delivery_closure_invalid", `delivery closure criterion contains unknown property: ${key}`);
|
|
115
|
+
if (typeof criterion.id !== "string" || !criterion.id.trim()) throw evaluatorError("delivery_closure_invalid", "delivery closure criterion id must be non-empty");
|
|
116
|
+
if (criterion.scope !== expectedScope) throw evaluatorError("delivery_closure_invalid", `delivery closure criterion ${criterion.id} has the wrong scope`);
|
|
117
|
+
if (!VERDICTS.has(criterion.verdict)) throw evaluatorError("delivery_closure_invalid", `delivery closure criterion ${criterion.id} has an invalid verdict`);
|
|
118
|
+
if (typeof criterion.summary !== "string" || !criterion.summary.trim()) throw evaluatorError("delivery_closure_invalid", `delivery closure criterion ${criterion.id} summary must be non-empty`);
|
|
119
|
+
if (!Array.isArray(criterion.evidence_refs) || criterion.evidence_refs.some((ref) => typeof ref !== "string")) throw evaluatorError("delivery_closure_invalid", `delivery closure criterion ${criterion.id} evidence_refs must be an array of strings`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Validate the separate delivery-closure criterion record. */
|
|
123
|
+
export function validateDeliveryClosureCriteria(qualityBaseline, results) {
|
|
124
|
+
const baseline = qualityBaseline?.manifest ?? qualityBaseline;
|
|
125
|
+
const validatedBaseline = validateQualityBaselineManifest(baseline);
|
|
126
|
+
const expected = qualityCriteriaByScope(baseline).delivery_closure;
|
|
127
|
+
if (!Array.isArray(results)) throw evaluatorError("delivery_closure_invalid", "delivery closure results must be an array");
|
|
128
|
+
const expectedIds = new Set(expected.map((criterion) => criterion.id));
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
for (const result of results) {
|
|
131
|
+
validateClosureCriterion(result);
|
|
132
|
+
if (seen.has(result.id)) throw evaluatorError("delivery_closure_invalid", `duplicate delivery closure criterion: ${result.id}`);
|
|
133
|
+
seen.add(result.id);
|
|
134
|
+
if (!expectedIds.has(result.id)) throw evaluatorError("delivery_closure_invalid", `unexpected delivery closure criterion: ${result.id}`);
|
|
135
|
+
}
|
|
136
|
+
for (const criterion of expected) {
|
|
137
|
+
if (!seen.has(criterion.id)) throw evaluatorError("delivery_closure_invalid", `missing delivery closure criterion: ${criterion.id}`);
|
|
138
|
+
}
|
|
139
|
+
const verdict = aggregateEvaluatorVerdict(results);
|
|
140
|
+
if (qualityBaseline?.fingerprint !== undefined && qualityBaseline.fingerprint !== validatedBaseline.fingerprint) {
|
|
141
|
+
throw evaluatorError("delivery_closure_invalid", "loaded Quality Baseline fingerprint does not match its canonical content");
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
valid: true,
|
|
145
|
+
verdict,
|
|
146
|
+
can_deliver: verdict === "PASS",
|
|
147
|
+
quality_baseline_fingerprint: validatedBaseline.fingerprint,
|
|
148
|
+
criteria: results,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export { VERDICTS };
|
|
153
|
+
|
|
154
|
+
function cliError(code, message) {
|
|
155
|
+
const error = new Error(message);
|
|
156
|
+
error.code = code;
|
|
157
|
+
return error;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseArgs(argv) {
|
|
161
|
+
const args = { mode: null, project: process.cwd(), baseline: null, result: null, expectedCandidateFingerprint: null };
|
|
162
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
163
|
+
const flag = argv[index];
|
|
164
|
+
if (flag === "--help" || flag === "-h") {
|
|
165
|
+
process.stdout.write("Usage: node runtime/triad-evaluator-validate.mjs --mode <baseline|evaluator|delivery> --baseline <path> [--result <path>] [--project <root>] [--expected-candidate-fingerprint <sha256>]\n");
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const key = {
|
|
169
|
+
"--mode": "mode",
|
|
170
|
+
"--project": "project",
|
|
171
|
+
"--baseline": "baseline",
|
|
172
|
+
"--result": "result",
|
|
173
|
+
"--expected-candidate-fingerprint": "expectedCandidateFingerprint",
|
|
174
|
+
}[flag];
|
|
175
|
+
if (!key) throw cliError("quality_contract_cli_invalid", `unknown option: ${flag}`);
|
|
176
|
+
const value = argv[index + 1];
|
|
177
|
+
if (!value || value.startsWith("--")) throw cliError("quality_contract_cli_invalid", `${flag} requires a value`);
|
|
178
|
+
args[key] = value;
|
|
179
|
+
index += 1;
|
|
180
|
+
}
|
|
181
|
+
if (!args.mode || !["baseline", "evaluator", "delivery"].includes(args.mode)) throw cliError("quality_contract_cli_invalid", "--mode must be baseline, evaluator, or delivery");
|
|
182
|
+
if (args.mode !== "baseline" && !args.result) throw cliError("quality_contract_cli_invalid", "--result is required");
|
|
183
|
+
if ((args.mode === "baseline" || args.mode === "delivery") && !args.baseline) throw cliError("quality_contract_cli_invalid", `--baseline is required for ${args.mode} validation`);
|
|
184
|
+
return args;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function readJsonFile(root, value, label) {
|
|
188
|
+
const target = path.isAbsolute(value) ? value : path.resolve(root, value);
|
|
189
|
+
let source;
|
|
190
|
+
try {
|
|
191
|
+
source = await readFile(target, "utf8");
|
|
192
|
+
} catch (error) {
|
|
193
|
+
throw cliError("quality_contract_cli_invalid", `${label} cannot be read: ${error.message}`);
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(source);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
throw cliError("quality_contract_cli_invalid", `${label} is not valid JSON: ${error.message}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Explicit control-plane entry point. It reloads the baseline from disk on
|
|
204
|
+
* every invocation so Evaluator+ and delivery closure cannot rely on a stale
|
|
205
|
+
* manifest/source check performed earlier in the run.
|
|
206
|
+
*/
|
|
207
|
+
async function runCli() {
|
|
208
|
+
const args = parseArgs(process.argv.slice(2));
|
|
209
|
+
if (!args) return;
|
|
210
|
+
const projectRoot = await realpath(args.project);
|
|
211
|
+
const qualityBaseline = args.baseline
|
|
212
|
+
? await loadQualityBaseline(args.baseline, { projectRoot })
|
|
213
|
+
: null;
|
|
214
|
+
if (args.mode === "baseline") {
|
|
215
|
+
process.stdout.write(`${JSON.stringify({
|
|
216
|
+
valid: true,
|
|
217
|
+
mode: "baseline",
|
|
218
|
+
quality_baseline_fingerprint: qualityBaseline.fingerprint,
|
|
219
|
+
sources: qualityBaseline.sources,
|
|
220
|
+
})}\n`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (args.mode === "evaluator") {
|
|
224
|
+
const result = await readJsonFile(projectRoot, args.result, "Evaluator+ result");
|
|
225
|
+
if (qualityBaseline && (!args.expectedCandidateFingerprint || !SHA256.test(args.expectedCandidateFingerprint))) {
|
|
226
|
+
throw cliError("evaluator_candidate_binding_missing", "Quality Contract Evaluator+ validation requires --expected-candidate-fingerprint");
|
|
227
|
+
}
|
|
228
|
+
const validated = validateEvaluatorResult(result, {
|
|
229
|
+
qualityBaseline,
|
|
230
|
+
expectedCandidateFingerprint: qualityBaseline ? args.expectedCandidateFingerprint : null,
|
|
231
|
+
});
|
|
232
|
+
process.stdout.write(`${JSON.stringify({
|
|
233
|
+
valid: true,
|
|
234
|
+
mode: "evaluator",
|
|
235
|
+
legacy: validated.legacy,
|
|
236
|
+
verdict: validated.verdict,
|
|
237
|
+
quality_baseline_fingerprint: validated.quality_baseline_fingerprint ?? null,
|
|
238
|
+
candidate_fingerprint: result.candidate_fingerprint,
|
|
239
|
+
product_quality_criteria: validated.product_quality_criteria ?? null,
|
|
240
|
+
})}\n`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const results = await readJsonFile(projectRoot, args.result, "delivery closure results");
|
|
245
|
+
const validated = validateDeliveryClosureCriteria(qualityBaseline, results);
|
|
246
|
+
process.stdout.write(`${JSON.stringify({
|
|
247
|
+
valid: true,
|
|
248
|
+
mode: "delivery",
|
|
249
|
+
verdict: validated.verdict,
|
|
250
|
+
can_deliver: validated.can_deliver,
|
|
251
|
+
quality_baseline_fingerprint: validated.quality_baseline_fingerprint,
|
|
252
|
+
criteria: validated.criteria,
|
|
253
|
+
})}\n`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
257
|
+
runCli().catch((error) => {
|
|
258
|
+
process.stdout.write(`${JSON.stringify({ valid: false, error: { code: error.code ?? "quality_contract_cli_invalid", message: error.message } })}\n`);
|
|
259
|
+
process.exitCode = 2;
|
|
260
|
+
});
|
|
261
|
+
}
|
package/runtime/triad-verify.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { writeAtomicJson } from "./lib/evidence.mjs";
|
|
7
7
|
import { calculateCandidateFingerprint, collectCandidateChanges, worktreeBranch } from "./lib/fingerprint.mjs";
|
|
8
8
|
import { executeGates, gateSelectionEvidence, loadTrustedGates, resolveGateSelection } from "./lib/gates.mjs";
|
|
9
|
+
import { resolveQualityContract } from "./lib/quality-baseline.mjs";
|
|
9
10
|
import { evaluateScopeContract, parseScopeContract } from "./lib/scope-contract.mjs";
|
|
10
11
|
|
|
11
12
|
const argv = process.argv.slice(2);
|
|
@@ -24,6 +25,10 @@ function sha256(value) {
|
|
|
24
25
|
return createHash("sha256").update(value).digest("hex");
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
function evidenceFingerprint(value) {
|
|
29
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/i.test(value) ? value : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
async function sha256File(value) {
|
|
28
33
|
return sha256(await readFile(value));
|
|
29
34
|
}
|
|
@@ -65,7 +70,7 @@ async function resolveAssignment(projectRoot, trigger, explicitAssignment) {
|
|
|
65
70
|
return { assignmentPath, assignment: JSON.parse(source), assignmentHash: sha256(source) };
|
|
66
71
|
}
|
|
67
72
|
|
|
68
|
-
async function buildInvalidEvidence({ runId, trigger, assignment, reason, outputPath, failureCode = "verification_context_invalid", gateSelection = null }) {
|
|
73
|
+
async function buildInvalidEvidence({ runId, trigger, assignment, reason, outputPath, failureCode = "verification_context_invalid", gateSelection = null, qualityBaselineFingerprint = null }) {
|
|
69
74
|
const evidence = {
|
|
70
75
|
schema_version: 1,
|
|
71
76
|
run_id: runId,
|
|
@@ -77,6 +82,7 @@ async function buildInvalidEvidence({ runId, trigger, assignment, reason, output
|
|
|
77
82
|
baseline: {
|
|
78
83
|
prd_sha256: assignment?.expected_prd_sha256 ?? null,
|
|
79
84
|
card_sha256: assignment?.expected_card_sha256 ?? null,
|
|
85
|
+
quality_baseline_fingerprint: evidenceFingerprint(qualityBaselineFingerprint ?? assignment?.expected_quality_baseline_fingerprint),
|
|
80
86
|
git_head: null,
|
|
81
87
|
candidate_fingerprint: null,
|
|
82
88
|
},
|
|
@@ -147,6 +153,7 @@ async function main() {
|
|
|
147
153
|
let assignmentPath;
|
|
148
154
|
let outputPath;
|
|
149
155
|
let gateSelection = null;
|
|
156
|
+
let qualityBaseline = null;
|
|
150
157
|
try {
|
|
151
158
|
let assignmentHash;
|
|
152
159
|
({ assignmentPath, assignment, assignmentHash } = await resolveAssignment(projectRoot, trigger, option("--assignment")));
|
|
@@ -166,6 +173,10 @@ async function main() {
|
|
|
166
173
|
const cardPath = path.resolve(projectRoot, assignment.card_path);
|
|
167
174
|
await access(prdPath);
|
|
168
175
|
await access(cardPath);
|
|
176
|
+
// Resolve the optional immutable Quality Contract before any baseline or
|
|
177
|
+
// expensive-gate check so a bound source drift keeps its precise failure
|
|
178
|
+
// classification (including when the source is the PRD itself).
|
|
179
|
+
qualityBaseline = await resolveQualityContract(assignment, projectRoot);
|
|
169
180
|
if ((await sha256File(prdPath)) !== assignment.expected_prd_sha256) throw new Error("PRD baseline hash mismatch");
|
|
170
181
|
if ((await sha256File(cardPath)) !== assignment.expected_card_sha256) throw new Error("feature card hash mismatch");
|
|
171
182
|
const repositorySkills = await validateRepositorySkills(assignment.required_repository_skills, worktree);
|
|
@@ -203,6 +214,7 @@ async function main() {
|
|
|
203
214
|
baseline: {
|
|
204
215
|
prd_sha256: assignment.expected_prd_sha256,
|
|
205
216
|
card_sha256: assignment.expected_card_sha256,
|
|
217
|
+
quality_baseline_fingerprint: qualityBaseline?.fingerprint ?? null,
|
|
206
218
|
gates_sha256: null,
|
|
207
219
|
git_head: before.git_head,
|
|
208
220
|
candidate_fingerprint: before.value,
|
|
@@ -237,9 +249,10 @@ async function main() {
|
|
|
237
249
|
trigger,
|
|
238
250
|
assignment_ref: path.relative(projectRoot, assignmentPath),
|
|
239
251
|
baseline: {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
252
|
+
prd_sha256: assignment.expected_prd_sha256,
|
|
253
|
+
card_sha256: assignment.expected_card_sha256,
|
|
254
|
+
quality_baseline_fingerprint: qualityBaseline?.fingerprint ?? null,
|
|
255
|
+
gates_sha256: trusted.actualHash,
|
|
243
256
|
git_head: before.git_head,
|
|
244
257
|
candidate_fingerprint: before.value,
|
|
245
258
|
branch,
|
|
@@ -269,7 +282,8 @@ async function main() {
|
|
|
269
282
|
reason: error.message,
|
|
270
283
|
outputPath,
|
|
271
284
|
failureCode: error.code ?? "verification_context_invalid",
|
|
272
|
-
gateSelection
|
|
285
|
+
gateSelection,
|
|
286
|
+
qualityBaselineFingerprint: qualityBaseline?.fingerprint ?? null
|
|
273
287
|
});
|
|
274
288
|
process.stdout.write(`${JSON.stringify({ run_id: runId, status: evidence.status, evidence: outputPath ?? null })}\n`);
|
|
275
289
|
process.exitCode = 3;
|
|
@@ -8,9 +8,25 @@
|
|
|
8
8
|
"schema_version": { "const": 1 },
|
|
9
9
|
"feature_id": { "type": "string", "minLength": 1 },
|
|
10
10
|
"candidate_fingerprint": { "type": "string", "minLength": 1 },
|
|
11
|
+
"quality_baseline_fingerprint": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" },
|
|
11
12
|
"verdict": { "enum": ["PASS", "FAIL", "INDETERMINATE"] },
|
|
12
13
|
"summary": { "type": "string", "minLength": 1 },
|
|
13
14
|
"evidence_refs": { "type": "array", "items": { "type": "string" } },
|
|
15
|
+
"criteria": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"items": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"required": ["id", "scope", "verdict", "summary", "evidence_refs"],
|
|
20
|
+
"properties": {
|
|
21
|
+
"id": { "type": "string", "minLength": 1 },
|
|
22
|
+
"scope": { "const": "product_quality" },
|
|
23
|
+
"verdict": { "enum": ["PASS", "FAIL", "INDETERMINATE"] },
|
|
24
|
+
"summary": { "type": "string", "minLength": 1 },
|
|
25
|
+
"evidence_refs": { "type": "array", "items": { "type": "string" } }
|
|
26
|
+
},
|
|
27
|
+
"additionalProperties": false
|
|
28
|
+
}
|
|
29
|
+
},
|
|
14
30
|
"created_at": { "type": "string", "format": "date-time" }
|
|
15
31
|
},
|
|
16
32
|
"additionalProperties": false
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://triad.engineering/schemas/quality-baseline.schema.json",
|
|
4
|
+
"title": "Triad immutable Quality Baseline",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schema_version", "id", "revision", "sources", "criteria", "fingerprint"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schema_version": { "const": 1 },
|
|
10
|
+
"id": { "type": "string", "minLength": 1 },
|
|
11
|
+
"revision": { "type": "integer", "minimum": 1 },
|
|
12
|
+
"sources": {
|
|
13
|
+
"type": "array",
|
|
14
|
+
"minItems": 1,
|
|
15
|
+
"items": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"additionalProperties": false,
|
|
18
|
+
"required": ["id", "role", "path", "sha256"],
|
|
19
|
+
"properties": {
|
|
20
|
+
"id": { "type": "string", "minLength": 1 },
|
|
21
|
+
"role": { "type": "string", "minLength": 1 },
|
|
22
|
+
"path": { "type": "string", "minLength": 1, "pattern": "^(?![\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+" },
|
|
23
|
+
"sha256": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"criteria": {
|
|
28
|
+
"type": "array",
|
|
29
|
+
"items": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"additionalProperties": false,
|
|
32
|
+
"required": ["id", "scope", "requirement"],
|
|
33
|
+
"properties": {
|
|
34
|
+
"id": { "type": "string", "minLength": 1 },
|
|
35
|
+
"scope": { "enum": ["product_quality", "delivery_closure"] },
|
|
36
|
+
"requirement": { "type": "string", "minLength": 1 }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"fingerprint": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"assignment_id": { "type": ["string", "null"] },
|
|
13
13
|
"assignment_sha256": { "type": ["string", "null"] },
|
|
14
14
|
"trigger": { "type": "object", "required": ["event", "agent_id", "agent_type"], "properties": { "event": { "type": "string" }, "agent_id": { "type": ["string", "null"] }, "agent_type": { "type": ["string", "null"] } } },
|
|
15
|
-
"baseline": { "type": "object", "required": ["prd_sha256", "card_sha256", "git_head", "candidate_fingerprint"], "properties": { "prd_sha256": { "type": ["string", "null"] }, "card_sha256": { "type": ["string", "null"] }, "git_head": { "type": ["string", "null"] }, "candidate_fingerprint": { "type": ["string", "null"] }, "branch": { "type": ["string", "null"] } } },
|
|
15
|
+
"baseline": { "type": "object", "required": ["prd_sha256", "card_sha256", "git_head", "candidate_fingerprint"], "properties": { "prd_sha256": { "type": ["string", "null"] }, "card_sha256": { "type": ["string", "null"] }, "quality_baseline_fingerprint": { "type": ["string", "null"], "pattern": "^[a-fA-F0-9]{64}$" }, "git_head": { "type": ["string", "null"] }, "candidate_fingerprint": { "type": ["string", "null"] }, "branch": { "type": ["string", "null"] } } },
|
|
16
16
|
"scope": {
|
|
17
17
|
"type": "object",
|
|
18
18
|
"properties": {
|
|
@@ -20,6 +20,14 @@ conditions, runnable quality gates, practical-test need, and integration need.
|
|
|
20
20
|
snapshot, revision when available, and SHA-256.
|
|
21
21
|
4. Copy `assets/loop-template/` to `.loop/`, then create bounded feature cards
|
|
22
22
|
under `features/` and a complete `feature-plan.md`.
|
|
23
|
+
When an owner-approved Quality Bar or additional intent/architecture source
|
|
24
|
+
exists, copy `assets/loop-template/quality-baseline.json`, fill only the
|
|
25
|
+
declared source hashes and criteria, compute its canonical fingerprint, and
|
|
26
|
+
bind `project.quality_contract.baseline` plus `fingerprint`. Use
|
|
27
|
+
`criteria: []` when no machine-readable Quality Bar was supplied; never
|
|
28
|
+
invent criteria. The manifest is immutable for the run and a requirement
|
|
29
|
+
change creates a new revision/rebaseline event rather than editing it in
|
|
30
|
+
place.
|
|
23
31
|
5. Replace every gate placeholder. Gate executors in v1 are only
|
|
24
32
|
`control-plane`; remove a non-applicable gate with a recorded reason instead
|
|
25
33
|
of declaring manual or MCP execution.
|