@cassiomc1/forgeloop 0.1.1 → 0.1.3
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/.cursor/rules/project-loop.mdc +12 -1
- package/.github/copilot-instructions.md +11 -0
- package/AGENTS.md +13 -1
- package/AGENT_COMPATIBILITY.md +14 -1
- package/CLAUDE.md +11 -1
- package/ENG/accessibility-eng.md +5 -0
- package/ENG/clean-code-eng.md +1 -0
- package/ENG/design-code-eng.md +5 -0
- package/ENG/games-code-design-web-eng.md +6 -0
- package/ENG/perf-code-eng.md +3 -0
- package/ENG/premium-sites-studio-eng.md +9 -0
- package/ENG/sec-code-eng.md +5 -0
- package/ENG/test-code-eng.md +3 -0
- package/GUIDE_ROUTER.md +16 -0
- package/LOOP_ENGINEERING.md +163 -4
- package/ORCHESTRATOR_INTEGRATION.md +15 -0
- package/QUALITY_SCORECARD.md +16 -0
- package/README.md +64 -1
- package/THREAT_MODEL.md +6 -0
- package/conformance/README.md +47 -0
- package/conformance/backend-auth/EXPECTED_ROUTE.json +7 -0
- package/conformance/backend-auth/REQUEST.md +4 -0
- package/conformance/backend-auth/REQUIRED_EVIDENCE.json +3 -0
- package/conformance/backend-auth/REQUIRED_GATES.json +3 -0
- package/conformance/blind-premium-website/EXPECTED_ROUTE.json +7 -0
- package/conformance/blind-premium-website/REQUEST.md +6 -0
- package/conformance/blind-premium-website/REQUIRED_EVIDENCE.json +14 -0
- package/conformance/blind-premium-website/REQUIRED_GATES.json +3 -0
- package/conformance/complete-website/EXPECTED_ROUTE.json +7 -0
- package/conformance/complete-website/REQUEST.md +6 -0
- package/conformance/complete-website/REQUIRED_EVIDENCE.json +3 -0
- package/conformance/complete-website/REQUIRED_GATES.json +3 -0
- package/conformance/docs-only/EXPECTED_ROUTE.json +7 -0
- package/conformance/docs-only/REQUEST.md +4 -0
- package/conformance/docs-only/REQUIRED_EVIDENCE.json +3 -0
- package/conformance/docs-only/REQUIRED_GATES.json +3 -0
- package/conformance/runs/2026-08-11-codex-first-live.md +98 -0
- package/conformance/runs/2026-08-11-codex-second-live.md +87 -0
- package/conformance/simple-bug/EXPECTED_ROUTE.json +7 -0
- package/conformance/simple-bug/REQUEST.md +4 -0
- package/conformance/simple-bug/REQUIRED_EVIDENCE.json +3 -0
- package/conformance/simple-bug/REQUIRED_GATES.json +3 -0
- package/package.json +4 -3
- package/schemas/activation.schema.json +15 -0
- package/schemas/check.schema.json +23 -0
- package/schemas/config.schema.json +16 -0
- package/schemas/current-contract.schema.json +50 -0
- package/schemas/event.schema.json +20 -0
- package/schemas/evidence-coverage.schema.json +17 -0
- package/schemas/execution-receipt.schema.json +10 -0
- package/schemas/gate.schema.json +32 -0
- package/schemas/policy.schema.json +19 -0
- package/schemas/preflight.schema.json +23 -0
- package/schemas/routing-result.schema.json +1 -0
- package/schemas/source-registry.schema.json +29 -0
- package/schemas/task-bundle.schema.json +14 -0
- package/schemas/work-state.schema.json +7 -1
- package/src/cli.js +249 -9
- package/src/commands/activate.js +11 -0
- package/src/commands/advance.js +12 -0
- package/src/commands/audit.js +12 -0
- package/src/commands/bundle.js +10 -0
- package/src/commands/complete.js +18 -0
- package/src/commands/next.js +29 -0
- package/src/commands/policy.js +19 -0
- package/src/commands/preflight.js +12 -0
- package/src/commands/prepare-completion.js +18 -0
- package/src/commands/record-check.js +20 -0
- package/src/commands/report.js +10 -0
- package/src/commands/route.js +14 -2
- package/src/commands/validate-receipt.js +7 -6
- package/src/core/activation.js +16 -0
- package/src/core/artifacts.js +132 -0
- package/src/core/audit.js +88 -0
- package/src/core/bundles.js +120 -0
- package/src/core/checks.js +115 -0
- package/src/core/completion-artifacts.js +321 -0
- package/src/core/completion-relationships.js +141 -0
- package/src/core/completion.js +266 -0
- package/src/core/config.js +35 -0
- package/src/core/conformance.js +7 -0
- package/src/core/contract.js +141 -0
- package/src/core/coverage.js +89 -0
- package/src/core/events.js +158 -0
- package/src/core/execution-prerequisites.js +200 -0
- package/src/core/gate-artifact.js +54 -0
- package/src/core/gates.js +55 -0
- package/src/core/guide-metadata.js +62 -0
- package/src/core/next-action.js +709 -0
- package/src/core/phase.js +212 -0
- package/src/core/policies.js +69 -0
- package/src/core/preflight.js +409 -0
- package/src/core/profile.js +48 -0
- package/src/core/protocol.js +34 -0
- package/src/core/receipt.js +40 -1
- package/src/core/report.js +49 -0
- package/src/core/repository.js +18 -0
- package/src/core/route-artifact.js +33 -0
- package/src/core/router.js +16 -8
- package/src/core/schema-validation.js +11 -0
- package/src/core/sources.js +86 -0
- package/src/core/templates.js +11 -0
- package/src/core/work-state.js +41 -13
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { ARTIFACT_PATHS, readJsonArtifact } from "./artifacts.js";
|
|
4
|
+
import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
|
|
5
|
+
import { assertSourceProvenance } from "./sources.js";
|
|
6
|
+
|
|
7
|
+
const SOURCE_ID_PATTERN = /\b(?:USER|FILE|CMD|DECISION|OBS|INFER|UNKNOWN)-[A-Z0-9_-]+\b/g;
|
|
8
|
+
const DIRECTIVE_PATTERN = /forgeloop-source:\s*([A-Z0-9_-]+)\s+kind=([a-z-]+)/gi;
|
|
9
|
+
|
|
10
|
+
function issue(code, message, artifacts = []) {
|
|
11
|
+
return { code, message, artifacts };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function validateProfileSources(target, packageRoot) {
|
|
15
|
+
const profilePath = "PROJECT_PROFILE.md";
|
|
16
|
+
await assertSafePath(target, profilePath);
|
|
17
|
+
const absolutePath = ensureWithin(target, profilePath);
|
|
18
|
+
if (!(await fileExists(absolutePath))) {
|
|
19
|
+
return { status: "missing", refs: [], errors: [] };
|
|
20
|
+
}
|
|
21
|
+
const text = await readFile(absolutePath, "utf8");
|
|
22
|
+
const refs = [...new Set(text.match(SOURCE_ID_PATTERN) ?? [])].sort();
|
|
23
|
+
const directives = [...text.matchAll(DIRECTIVE_PATTERN)].map((match) => ({ id: match[1], kind: match[2] }));
|
|
24
|
+
if (refs.length === 0 && directives.length === 0) {
|
|
25
|
+
return { status: "not-referenced", refs: [], errors: [] };
|
|
26
|
+
}
|
|
27
|
+
const errors = [];
|
|
28
|
+
let registry;
|
|
29
|
+
try {
|
|
30
|
+
registry = (await readJsonArtifact(target, ARTIFACT_PATHS.sources, "source-registry", packageRoot)).value;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
errors.push(issue("E_PROFILE_SOURCE_MISSING", error.message, [ARTIFACT_PATHS.sources]));
|
|
33
|
+
return { status: "invalid", refs, errors };
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
assertSourceProvenance(registry, refs);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
errors.push(issue(error.code ?? "E_PROFILE_SOURCE_UNKNOWN", error.message, [ARTIFACT_PATHS.sources]));
|
|
39
|
+
}
|
|
40
|
+
for (const directive of directives) {
|
|
41
|
+
try {
|
|
42
|
+
assertSourceProvenance(registry, [directive.id], { expectedKind: directive.kind });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
errors.push(issue(error.code ?? "E_PROFILE_SOURCE_MISCLASSIFIED", error.message, [ARTIFACT_PATHS.sources]));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { status: errors.length === 0 ? "valid" : "invalid", refs, errors };
|
|
48
|
+
}
|
package/src/core/protocol.js
CHANGED
|
@@ -15,6 +15,40 @@ export const FAILURE_CLASSES = Object.freeze([
|
|
|
15
15
|
"STALE_STATE_FAILURE",
|
|
16
16
|
]);
|
|
17
17
|
|
|
18
|
+
export const FAILURE_CODES = Object.freeze([
|
|
19
|
+
"E_PREFLIGHT_NOT_READY",
|
|
20
|
+
"E_CONTRACT_MISSING",
|
|
21
|
+
"E_CONTRACT_INVALID",
|
|
22
|
+
"E_CONTRACT_UNRESOLVED_DECISION",
|
|
23
|
+
"E_CONTRACT_STALE",
|
|
24
|
+
"E_ROUTE_MISSING",
|
|
25
|
+
"E_ROUTE_INVALID",
|
|
26
|
+
"E_ROUTE_STALE",
|
|
27
|
+
"E_ROUTE_GUIDE_MISMATCH",
|
|
28
|
+
"E_ROUTE_REASON_MISSING",
|
|
29
|
+
"E_GATE_REQUIRED",
|
|
30
|
+
"E_GATE_UNVERIFIED",
|
|
31
|
+
"E_GATE_STALE",
|
|
32
|
+
"E_PHASE_TRANSITION_INVALID",
|
|
33
|
+
"E_PHASE_PREREQUISITE_MISSING",
|
|
34
|
+
"E_PHASE_CHRONOLOGY_INVALID",
|
|
35
|
+
"E_EVIDENCE_REQUIRED",
|
|
36
|
+
"E_EVIDENCE_KIND_INVALID",
|
|
37
|
+
"E_EVIDENCE_STALE",
|
|
38
|
+
"E_EVIDENCE_COVERAGE_PARTIAL",
|
|
39
|
+
"E_CHECK_STATUS_CONTRADICTION",
|
|
40
|
+
"E_PROFILE_SOURCE_MISSING",
|
|
41
|
+
"E_PROFILE_SOURCE_UNKNOWN",
|
|
42
|
+
"E_PROFILE_SOURCE_MISCLASSIFIED",
|
|
43
|
+
"E_RECEIPT_ROUTE_MISMATCH",
|
|
44
|
+
"E_RECEIPT_STATE_MISMATCH",
|
|
45
|
+
"E_RECEIPT_CONTRACT_MISMATCH",
|
|
46
|
+
"E_RECEIPT_PATH_MISMATCH",
|
|
47
|
+
"E_COMPLETION_REJECTED",
|
|
48
|
+
"E_PUBLICATION_CLAIM_UNVERIFIED",
|
|
49
|
+
"E_PRODUCTION_READINESS_UNVERIFIED",
|
|
50
|
+
]);
|
|
51
|
+
|
|
18
52
|
export const WORK_PHASES = Object.freeze([
|
|
19
53
|
"RECEIVED",
|
|
20
54
|
"DISCOVERING",
|
package/src/core/receipt.js
CHANGED
|
@@ -2,6 +2,8 @@ import { GUIDE_IDS, PROTOCOL_VERSION } from "./protocol.js";
|
|
|
2
2
|
import { assertSchema, readSchema } from "./schema-validation.js";
|
|
3
3
|
import { assertEvidenceList, evidenceMatches } from "./evidence.js";
|
|
4
4
|
import { assertJsonLimits } from "./json-safety.js";
|
|
5
|
+
import { assertCheck, assertCheckList } from "./checks.js";
|
|
6
|
+
import { assertCoverageList } from "./coverage.js";
|
|
5
7
|
|
|
6
8
|
const RECEIPT_SCHEMA_VERSION = 1;
|
|
7
9
|
const SECRET_WORDS = new Set(["token", "password", "secret", "credential"]);
|
|
@@ -59,6 +61,10 @@ export function assertReceiptSemantics(receipt) {
|
|
|
59
61
|
assertEvidenceList(evidence, "receipt.evidence");
|
|
60
62
|
|
|
61
63
|
for (const [index, check] of receipt.checks.entries()) {
|
|
64
|
+
if (check?.schemaVersion !== undefined || check?.id !== undefined || check?.evidenceKind !== undefined) {
|
|
65
|
+
assertCheck(check, `receipt.checks[${index}]`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
62
68
|
if (check?.status === "passed") {
|
|
63
69
|
const hasCommandOrResult = [check.command, check.result, check.name]
|
|
64
70
|
.some((value) => typeof value === "string" && value.trim().length > 0);
|
|
@@ -68,6 +74,10 @@ export function assertReceiptSemantics(receipt) {
|
|
|
68
74
|
}
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
if (receipt.evidenceCoverage !== undefined) {
|
|
78
|
+
assertCoverageList(receipt.evidenceCoverage, "receipt.evidenceCoverage");
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
if (receipt.status === "complete") {
|
|
72
82
|
const verificationEvidence = evidence.filter((item) => ["OBSERVED", "INFERRED"].includes(item.kind));
|
|
73
83
|
if (verificationEvidence.length === 0) {
|
|
@@ -82,10 +92,32 @@ export function assertReceiptSemantics(receipt) {
|
|
|
82
92
|
];
|
|
83
93
|
for (const [claimed, terms, field, label] of publicationEvidence) {
|
|
84
94
|
if (claimed && !evidenceMatches(evidence, terms)) {
|
|
85
|
-
|
|
95
|
+
const error = new Error(`publication.${field} requires ${label} evidence`);
|
|
96
|
+
error.code = "E_PUBLICATION_CLAIM_UNVERIFIED";
|
|
97
|
+
throw error;
|
|
86
98
|
}
|
|
87
99
|
}
|
|
88
100
|
|
|
101
|
+
if (["committed", "pushed", "published", "deployed"].includes(receipt.publicationStatus)) {
|
|
102
|
+
const requiredTerms = receipt.publicationStatus === "committed"
|
|
103
|
+
? ["commit", "committed"]
|
|
104
|
+
: receipt.publicationStatus === "pushed"
|
|
105
|
+
? ["git push", "pushed"]
|
|
106
|
+
: receipt.publicationStatus === "deployed"
|
|
107
|
+
? ["deploy", "deployed", "deployment"]
|
|
108
|
+
: ["publish", "published", "release"];
|
|
109
|
+
if (!evidenceMatches(evidence, requiredTerms)) {
|
|
110
|
+
const error = new Error(`publicationStatus ${receipt.publicationStatus} requires publication evidence`);
|
|
111
|
+
error.code = "E_PUBLICATION_CLAIM_UNVERIFIED";
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (receipt.productionReadiness === "ready" && !evidenceMatches(evidence, ["production readiness", "production validation", "deployment"])) {
|
|
116
|
+
const error = new Error("productionReadiness ready requires production evidence");
|
|
117
|
+
error.code = "E_PRODUCTION_READINESS_UNVERIFIED";
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
|
|
89
121
|
if (receipt.review.independent === true) {
|
|
90
122
|
const implementer = receipt.review.implementerId;
|
|
91
123
|
const reviewer = receipt.review.reviewerId;
|
|
@@ -111,11 +143,18 @@ export async function createReceipt(input, packageRoot) {
|
|
|
111
143
|
protocolVersion: PROTOCOL_VERSION,
|
|
112
144
|
taskId: input.taskId,
|
|
113
145
|
contractFingerprint: input.contractFingerprint,
|
|
146
|
+
...(input.routeFingerprint !== undefined ? { routeFingerprint: input.routeFingerprint } : {}),
|
|
147
|
+
...(input.stateFingerprint !== undefined ? { stateFingerprint: input.stateFingerprint } : {}),
|
|
114
148
|
status: input.status ?? "in-progress",
|
|
149
|
+
...(input.taskStatus !== undefined ? { taskStatus: input.taskStatus } : {}),
|
|
150
|
+
...(input.verificationStatus !== undefined ? { verificationStatus: input.verificationStatus } : {}),
|
|
151
|
+
...(input.publicationStatus !== undefined ? { publicationStatus: input.publicationStatus } : {}),
|
|
152
|
+
...(input.productionReadiness !== undefined ? { productionReadiness: input.productionReadiness } : {}),
|
|
115
153
|
selectedGuides: [...(input.selectedGuides ?? [])],
|
|
116
154
|
changedPaths: [...(input.changedPaths ?? [])],
|
|
117
155
|
checks: [...(input.checks ?? [])],
|
|
118
156
|
evidence: [...(input.evidence ?? [])],
|
|
157
|
+
...(input.evidenceCoverage !== undefined ? { evidenceCoverage: [...input.evidenceCoverage] } : {}),
|
|
119
158
|
review: input.review ?? { status: "not-run", independent: false },
|
|
120
159
|
limitations: [...(input.limitations ?? [])],
|
|
121
160
|
publication: input.publication ?? {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { evaluateAudit } from "./audit.js";
|
|
4
|
+
import { fileExists, ensureWithin } from "./filesystem.js";
|
|
5
|
+
|
|
6
|
+
async function profileStatus(target) {
|
|
7
|
+
const profilePath = ensureWithin(target, "PROJECT_PROFILE.md");
|
|
8
|
+
if (!(await fileExists(profilePath))) return "NOT_VERIFIED";
|
|
9
|
+
const text = await readFile(profilePath, "utf8");
|
|
10
|
+
return /^profile-status:\s*verified\s*$/m.test(text) ? "PASS" : "NOT_VERIFIED";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function section(id, label, status, details = null) {
|
|
14
|
+
return { id, label, status, ...(details ? { details } : {}) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function evaluateReport({ target, packageRoot, strict = false } = {}) {
|
|
18
|
+
const audit = await evaluateAudit({ target, packageRoot, strict });
|
|
19
|
+
const completion = audit.completion;
|
|
20
|
+
const preflight = completion.preflight;
|
|
21
|
+
const errorCodes = new Set(audit.errors.map((error) => error.code));
|
|
22
|
+
const coverage = completion.coverage ?? [];
|
|
23
|
+
const coverageStatus = coverage.length === 0
|
|
24
|
+
? "NOT_VERIFIED"
|
|
25
|
+
: coverage.every((item) => item.status === "COVERED") ? "PASS" : "PARTIAL";
|
|
26
|
+
const sections = [
|
|
27
|
+
section("installation", "Installation", audit.installation.status === "ready" ? "PASS" : "NOT_VERIFIED"),
|
|
28
|
+
section("profile", "Profile", await profileStatus(target)),
|
|
29
|
+
section("contract", "Contract", preflight.contract.status === "valid" ? "PASS" : "NOT_VERIFIED"),
|
|
30
|
+
section("routing", "Routing", preflight.routing.status === "valid" ? "PASS" : "NOT_VERIFIED"),
|
|
31
|
+
section("required-gates", "Required gates", preflight.requiredGates.length === preflight.satisfiedGates.length ? "PASS" : "BLOCKED"),
|
|
32
|
+
section("phase-chronology", "Phase chronology", completion.ledger.status === "valid" ? "PASS" : "INVALID"),
|
|
33
|
+
section("evidence-coverage", "Evidence coverage", coverageStatus),
|
|
34
|
+
section("receipt", "Receipt", errorCodes.has("E_RECEIPT_MISSING") || errorCodes.has("E_RECEIPT_INVALID") ? "NOT_VERIFIED" : "PASS"),
|
|
35
|
+
section("publication", "Publication state", audit.publicationStatus.toUpperCase().replaceAll("-", "_")),
|
|
36
|
+
section("production-readiness", "Production readiness", audit.productionReadiness.toUpperCase().replaceAll("-", "_")),
|
|
37
|
+
];
|
|
38
|
+
return {
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
protocolVersion: 1,
|
|
41
|
+
status: audit.status,
|
|
42
|
+
verdict: audit.status === "VALID" ? "VALID" : "INCOMPLETE",
|
|
43
|
+
sections,
|
|
44
|
+
publicationStatus: audit.publicationStatus,
|
|
45
|
+
productionReadiness: audit.productionReadiness,
|
|
46
|
+
errors: audit.errors,
|
|
47
|
+
warnings: audit.warnings,
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/core/repository.js
CHANGED
|
@@ -17,3 +17,21 @@ export async function currentRepositoryFingerprint(target) {
|
|
|
17
17
|
return { branch: null, head: null };
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
export async function currentChangedPaths(target) {
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await execFileAsync(
|
|
24
|
+
"git",
|
|
25
|
+
["-C", target, "status", "--porcelain=v1", "--untracked-files=all"],
|
|
26
|
+
{ windowsHide: true },
|
|
27
|
+
);
|
|
28
|
+
return stdout
|
|
29
|
+
.split(/\r?\n/)
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map((line) => line.slice(3).trim())
|
|
32
|
+
.filter((relativePath) => relativePath && !relativePath.startsWith(".forgeloop/"))
|
|
33
|
+
.sort();
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { assertRouteInvariants } from "./router.js";
|
|
2
|
+
import { ARTIFACT_PATHS, readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
|
|
3
|
+
import { readContract } from "./contract.js";
|
|
4
|
+
|
|
5
|
+
export async function persistRoute(target, route, packageRoot, options = {}) {
|
|
6
|
+
assertRouteInvariants(route);
|
|
7
|
+
let { contractFingerprint, ...writeOptions } = options;
|
|
8
|
+
if (contractFingerprint === undefined) {
|
|
9
|
+
try {
|
|
10
|
+
contractFingerprint = (await readContract(target, packageRoot)).fingerprint;
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error.code !== "ARTIFACT_MISSING") throw error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const value = contractFingerprint === undefined
|
|
16
|
+
? route
|
|
17
|
+
: { ...route, contractFingerprint };
|
|
18
|
+
assertRouteInvariants(value);
|
|
19
|
+
return writeJsonArtifact(
|
|
20
|
+
target,
|
|
21
|
+
ARTIFACT_PATHS.route,
|
|
22
|
+
value,
|
|
23
|
+
"routing-result",
|
|
24
|
+
packageRoot,
|
|
25
|
+
writeOptions,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readPersistedRoute(target, packageRoot) {
|
|
30
|
+
const artifact = await readJsonArtifact(target, ARTIFACT_PATHS.route, "routing-result", packageRoot);
|
|
31
|
+
assertRouteInvariants(artifact.value);
|
|
32
|
+
return artifact;
|
|
33
|
+
}
|
package/src/core/router.js
CHANGED
|
@@ -259,31 +259,39 @@ export function evaluateRoute(input = {}) {
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
export function assertRouteInvariants(result) {
|
|
262
|
+
const invariantError = (code, message) => {
|
|
263
|
+
const error = new RouteInputError(message);
|
|
264
|
+
error.code = code;
|
|
265
|
+
return error;
|
|
266
|
+
};
|
|
262
267
|
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
263
|
-
throw
|
|
268
|
+
throw invariantError("E_ROUTE_INVALID", "Route result must be an object");
|
|
264
269
|
}
|
|
265
270
|
if (!Array.isArray(result.guides) || new Set(result.guides).size !== result.guides.length) {
|
|
266
|
-
throw
|
|
271
|
+
throw invariantError("E_ROUTE_INVALID", "Route result contains duplicate guides");
|
|
272
|
+
}
|
|
273
|
+
if (result.contractFingerprint !== undefined && !/^[a-f0-9]{64}$/.test(result.contractFingerprint)) {
|
|
274
|
+
throw invariantError("E_ROUTE_INVALID", "Route contractFingerprint must be a lowercase SHA-256 fingerprint");
|
|
267
275
|
}
|
|
268
276
|
for (const guide of result.guides) {
|
|
269
|
-
if (!GUIDE_IDS.includes(guide)) throw
|
|
277
|
+
if (!GUIDE_IDS.includes(guide)) throw invariantError("E_ROUTE_INVALID", `Route result contains unknown guide: ${guide}`);
|
|
270
278
|
if (!Array.isArray(result.reasons?.[guide]) || result.reasons[guide].length === 0) {
|
|
271
|
-
throw
|
|
279
|
+
throw invariantError("E_ROUTE_REASON_MISSING", `Selected guide has no reason: ${guide}`);
|
|
272
280
|
}
|
|
273
281
|
}
|
|
274
282
|
for (const [guide, reasons] of Object.entries(result.excluded ?? {})) {
|
|
275
283
|
if (guide !== "documentation" && !GUIDE_IDS.includes(guide)) {
|
|
276
|
-
throw
|
|
284
|
+
throw invariantError("E_ROUTE_INVALID", `Route result contains unknown excluded guide: ${guide}`);
|
|
277
285
|
}
|
|
278
286
|
if (result.guides.includes(guide)) {
|
|
279
|
-
throw
|
|
287
|
+
throw invariantError("E_ROUTE_INVALID", `Guide cannot be selected and excluded: ${guide}`);
|
|
280
288
|
}
|
|
281
289
|
if (!Array.isArray(reasons) || reasons.length === 0) {
|
|
282
|
-
throw
|
|
290
|
+
throw invariantError("E_ROUTE_REASON_MISSING", `Excluded guide has no exclusion reason: ${guide}`);
|
|
283
291
|
}
|
|
284
292
|
}
|
|
285
293
|
if (result.primary !== null && !result.guides.includes(result.primary)) {
|
|
286
|
-
throw
|
|
294
|
+
throw invariantError("E_ROUTE_INVALID", "Primary guide must be null or selected");
|
|
287
295
|
}
|
|
288
296
|
return result;
|
|
289
297
|
}
|
|
@@ -13,6 +13,17 @@ export const SHIPPED_SCHEMA_NAMES = Object.freeze([
|
|
|
13
13
|
"task-brief",
|
|
14
14
|
"delegated-result",
|
|
15
15
|
"evidence",
|
|
16
|
+
"current-contract",
|
|
17
|
+
"gate",
|
|
18
|
+
"source-registry",
|
|
19
|
+
"config",
|
|
20
|
+
"preflight",
|
|
21
|
+
"check",
|
|
22
|
+
"evidence-coverage",
|
|
23
|
+
"event",
|
|
24
|
+
"activation",
|
|
25
|
+
"policy",
|
|
26
|
+
"task-bundle",
|
|
16
27
|
]);
|
|
17
28
|
|
|
18
29
|
export class SchemaValidationError extends Error {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { PROTOCOL_VERSION } from "./protocol.js";
|
|
2
|
+
|
|
3
|
+
export const SOURCE_REGISTRY_SCHEMA_VERSION = 1;
|
|
4
|
+
export const SOURCE_KINDS = Object.freeze([
|
|
5
|
+
"user-request",
|
|
6
|
+
"repository-fact",
|
|
7
|
+
"observation",
|
|
8
|
+
"command",
|
|
9
|
+
"agent-decision",
|
|
10
|
+
"inference",
|
|
11
|
+
"unknown",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export function createSourceRegistry(sources = {}) {
|
|
15
|
+
if (!sources || typeof sources !== "object" || Array.isArray(sources)) {
|
|
16
|
+
throw new Error("Source registry must be an object");
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
schemaVersion: SOURCE_REGISTRY_SCHEMA_VERSION,
|
|
20
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
21
|
+
sources: structuredClone(sources),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function assertSourceKind(kind, label = "source.kind") {
|
|
26
|
+
if (!SOURCE_KINDS.includes(kind)) throw new Error(`${label} must be one of ${SOURCE_KINDS.join(", ")}`);
|
|
27
|
+
return kind;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function assertSourceRefs(registry, refs = []) {
|
|
31
|
+
const ids = new Set(Object.keys(registry?.sources ?? {}));
|
|
32
|
+
for (const ref of refs) {
|
|
33
|
+
if (!ids.has(ref)) {
|
|
34
|
+
const error = new Error(`Unknown source ID: ${ref}`);
|
|
35
|
+
error.code = "E_PROFILE_SOURCE_UNKNOWN";
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function assertSourceRegistry(registry) {
|
|
43
|
+
if (!registry || typeof registry !== "object" || Array.isArray(registry)) {
|
|
44
|
+
const error = new Error("Source registry must be an object");
|
|
45
|
+
error.code = "E_PROFILE_SOURCE_INVALID";
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
if (registry.schemaVersion !== SOURCE_REGISTRY_SCHEMA_VERSION || registry.protocolVersion !== PROTOCOL_VERSION) {
|
|
49
|
+
const error = new Error("Source registry has an unsupported protocol version");
|
|
50
|
+
error.code = "E_PROFILE_SOURCE_INVALID";
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
for (const [id, source] of Object.entries(registry.sources ?? {})) {
|
|
54
|
+
if (!id || !source || typeof source !== "object" || Array.isArray(source)) {
|
|
55
|
+
const error = new Error(`Invalid source entry: ${id}`);
|
|
56
|
+
error.code = "E_PROFILE_SOURCE_INVALID";
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
assertSourceKind(source.kind, `sources.${id}.kind`);
|
|
60
|
+
if (typeof source.summary !== "string" || source.summary.trim() === "") {
|
|
61
|
+
const error = new Error(`Source summary is required: ${id}`);
|
|
62
|
+
error.code = "E_PROFILE_SOURCE_INVALID";
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return registry;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function assertSourceProvenance(registry, refs = [], { expectedKind } = {}) {
|
|
70
|
+
assertSourceRegistry(registry);
|
|
71
|
+
assertSourceRefs(registry, refs);
|
|
72
|
+
const expectedKinds = expectedKind === undefined
|
|
73
|
+
? null
|
|
74
|
+
: new Set(Array.isArray(expectedKind) ? expectedKind : [expectedKind]);
|
|
75
|
+
if (expectedKinds) {
|
|
76
|
+
for (const ref of refs) {
|
|
77
|
+
const source = registry.sources[ref];
|
|
78
|
+
if (!expectedKinds.has(source.kind)) {
|
|
79
|
+
const error = new Error(`Source ${ref} has kind ${source.kind}, expected ${[...expectedKinds].join(", ")}`);
|
|
80
|
+
error.code = "E_PROFILE_SOURCE_MISCLASSIFIED";
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
package/src/core/templates.js
CHANGED
|
@@ -44,6 +44,17 @@ export const TEMPLATE_PATHS = [
|
|
|
44
44
|
"schemas/task-brief.schema.json",
|
|
45
45
|
"schemas/delegated-result.schema.json",
|
|
46
46
|
"schemas/evidence.schema.json",
|
|
47
|
+
"schemas/current-contract.schema.json",
|
|
48
|
+
"schemas/gate.schema.json",
|
|
49
|
+
"schemas/source-registry.schema.json",
|
|
50
|
+
"schemas/config.schema.json",
|
|
51
|
+
"schemas/preflight.schema.json",
|
|
52
|
+
"schemas/check.schema.json",
|
|
53
|
+
"schemas/evidence-coverage.schema.json",
|
|
54
|
+
"schemas/event.schema.json",
|
|
55
|
+
"schemas/activation.schema.json",
|
|
56
|
+
"schemas/policy.schema.json",
|
|
57
|
+
"schemas/task-bundle.schema.json",
|
|
47
58
|
];
|
|
48
59
|
|
|
49
60
|
export function getPackageRoot() {
|
package/src/core/work-state.js
CHANGED
|
@@ -16,6 +16,8 @@ import { getPackageRoot } from "./templates.js";
|
|
|
16
16
|
import { currentRepositoryFingerprint } from "./repository.js";
|
|
17
17
|
import { createEvidence } from "./evidence.js";
|
|
18
18
|
import { assertJsonBytes } from "./json-safety.js";
|
|
19
|
+
import { assertCoverageList } from "./coverage.js";
|
|
20
|
+
import { canonicalFingerprint } from "./artifacts.js";
|
|
19
21
|
|
|
20
22
|
export const WORK_STATE_PATH = ".forgeloop/work-state.json";
|
|
21
23
|
|
|
@@ -27,20 +29,8 @@ export class WorkStateError extends Error {
|
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
function canonicalize(value) {
|
|
31
|
-
if (Array.isArray(value)) return value.map(canonicalize);
|
|
32
|
-
if (value && typeof value === "object") {
|
|
33
|
-
return Object.fromEntries(
|
|
34
|
-
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]),
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
return value;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
32
|
export function contractFingerprint(contract) {
|
|
41
|
-
return
|
|
42
|
-
.update(JSON.stringify(canonicalize(contract)))
|
|
43
|
-
.digest("hex");
|
|
33
|
+
return canonicalFingerprint(contract);
|
|
44
34
|
}
|
|
45
35
|
|
|
46
36
|
function assertFingerprint(value, label) {
|
|
@@ -101,6 +91,7 @@ export function assertWorkStateSemantics(state) {
|
|
|
101
91
|
throw new WorkStateError("Work state taskId is required");
|
|
102
92
|
}
|
|
103
93
|
assertFingerprint(state.contractFingerprint, "contractFingerprint");
|
|
94
|
+
if (state.routeFingerprint !== undefined) assertFingerprint(state.routeFingerprint, "routeFingerprint");
|
|
104
95
|
if (!state.repositoryFingerprint || typeof state.repositoryFingerprint !== "object" || Array.isArray(state.repositoryFingerprint)) {
|
|
105
96
|
throw new WorkStateError("repositoryFingerprint is required");
|
|
106
97
|
}
|
|
@@ -117,6 +108,34 @@ export function assertWorkStateSemantics(state) {
|
|
|
117
108
|
if (new Set(state.selectedGuides).size !== state.selectedGuides.length) {
|
|
118
109
|
throw new WorkStateError("selectedGuides must not contain duplicates");
|
|
119
110
|
}
|
|
111
|
+
for (const key of ["requiredGates", "satisfiedGates"]) {
|
|
112
|
+
if (state[key] !== undefined) {
|
|
113
|
+
assertStringArray(state[key], key);
|
|
114
|
+
if (new Set(state[key]).size !== state[key].length) {
|
|
115
|
+
throw new WorkStateError(`${key} must not contain duplicates`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (state.satisfiedGates?.some((gate) => !state.requiredGates?.includes(gate))) {
|
|
120
|
+
throw new WorkStateError("satisfiedGates must be a subset of requiredGates");
|
|
121
|
+
}
|
|
122
|
+
if (state.complianceMode !== undefined && !["advisory", "standard", "strict"].includes(state.complianceMode)) {
|
|
123
|
+
throw new WorkStateError(`Unknown compliance mode: ${state.complianceMode}`);
|
|
124
|
+
}
|
|
125
|
+
if (state.publicationStatus !== undefined
|
|
126
|
+
&& !["not-published", "local-only", "committed", "pushed", "published", "deployed"].includes(state.publicationStatus)) {
|
|
127
|
+
throw new WorkStateError(`Unknown publication status: ${state.publicationStatus}`);
|
|
128
|
+
}
|
|
129
|
+
if (state.evidenceCoverage !== undefined && !Array.isArray(state.evidenceCoverage)) {
|
|
130
|
+
throw new WorkStateError("evidenceCoverage must be an array");
|
|
131
|
+
}
|
|
132
|
+
if (state.evidenceCoverage !== undefined) {
|
|
133
|
+
try {
|
|
134
|
+
assertCoverageList(state.evidenceCoverage);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new WorkStateError(error.message);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
120
139
|
assertStringArray(state.completedSteps, "completedSteps");
|
|
121
140
|
assertStringArray(state.pendingSteps, "pendingSteps");
|
|
122
141
|
normalizeRequiredArtifacts(state.requiredArtifacts);
|
|
@@ -126,6 +145,9 @@ export function assertWorkStateSemantics(state) {
|
|
|
126
145
|
if (state.phase === "COMPLETE" && state.verificationEvidence.length === 0) {
|
|
127
146
|
throw new WorkStateError("COMPLETE requires verification evidence");
|
|
128
147
|
}
|
|
148
|
+
if (state.phase === "COMPLETE" && state.evidenceCoverage?.some((item) => item.status !== "COVERED")) {
|
|
149
|
+
throw new WorkStateError("COMPLETE requires covered evidence for every recorded criterion");
|
|
150
|
+
}
|
|
129
151
|
if (state.phase === "BLOCKED" && state.blockers.length === 0) {
|
|
130
152
|
throw new WorkStateError("BLOCKED requires a blocker");
|
|
131
153
|
}
|
|
@@ -151,9 +173,15 @@ export function createWorkState(input) {
|
|
|
151
173
|
protocolVersion: PROTOCOL_VERSION,
|
|
152
174
|
taskId: input.taskId,
|
|
153
175
|
contractFingerprint: input.contractFingerprint,
|
|
176
|
+
...(input.routeFingerprint !== undefined ? { routeFingerprint: input.routeFingerprint } : {}),
|
|
154
177
|
repositoryFingerprint: input.repositoryFingerprint ?? { branch: null, head: null },
|
|
155
178
|
phase: input.phase,
|
|
156
179
|
selectedGuides: [...(input.selectedGuides ?? [])],
|
|
180
|
+
...(input.requiredGates !== undefined ? { requiredGates: [...input.requiredGates] } : {}),
|
|
181
|
+
...(input.satisfiedGates !== undefined ? { satisfiedGates: [...input.satisfiedGates] } : {}),
|
|
182
|
+
...(input.complianceMode !== undefined ? { complianceMode: input.complianceMode } : {}),
|
|
183
|
+
...(input.evidenceCoverage !== undefined ? { evidenceCoverage: [...input.evidenceCoverage] } : {}),
|
|
184
|
+
...(input.publicationStatus !== undefined ? { publicationStatus: input.publicationStatus } : {}),
|
|
157
185
|
completedSteps: [...(input.completedSteps ?? [])],
|
|
158
186
|
pendingSteps: [...(input.pendingSteps ?? [])],
|
|
159
187
|
requiredArtifacts: normalizeRequiredArtifacts(input.requiredArtifacts),
|