@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,158 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
|
|
6
|
+
import { ARTIFACT_PATHS, canonicalFingerprint } from "./artifacts.js";
|
|
7
|
+
import { assertJsonBytes, assertJsonLimits } from "./json-safety.js";
|
|
8
|
+
import { assertSchema, readSchema } from "./schema-validation.js";
|
|
9
|
+
import { assertSecretFree } from "./receipt.js";
|
|
10
|
+
import { PROTOCOL_VERSION } from "./protocol.js";
|
|
11
|
+
|
|
12
|
+
const EVENT_SCHEMA_VERSION = 1;
|
|
13
|
+
export const LIFECYCLE_MILESTONES = Object.freeze([
|
|
14
|
+
"CONTRACT_VALIDATED",
|
|
15
|
+
"ROUTE_VALIDATED",
|
|
16
|
+
"PREFLIGHT_READY",
|
|
17
|
+
"EXECUTION_STARTED",
|
|
18
|
+
"VERIFICATION_STARTED",
|
|
19
|
+
"VERIFICATION_RECORDED",
|
|
20
|
+
"COMPLETION_VALIDATED",
|
|
21
|
+
]);
|
|
22
|
+
const REPEATABLE_MILESTONES = new Set(["VERIFICATION_RECORDED"]);
|
|
23
|
+
|
|
24
|
+
function eventHash(event) {
|
|
25
|
+
const { hash, ...body } = event;
|
|
26
|
+
return canonicalFingerprint(body);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function protocolError(code, message, artifacts = [ARTIFACT_PATHS.events]) {
|
|
30
|
+
const error = new Error(message);
|
|
31
|
+
error.code = code;
|
|
32
|
+
error.artifacts = artifacts;
|
|
33
|
+
return error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readEvents(target, packageRoot) {
|
|
37
|
+
await assertSafePath(target, ARTIFACT_PATHS.events);
|
|
38
|
+
const eventsPath = ensureWithin(target, ARTIFACT_PATHS.events);
|
|
39
|
+
if (!(await fileExists(eventsPath))) return [];
|
|
40
|
+
const text = await readFile(eventsPath, "utf8");
|
|
41
|
+
assertJsonBytes(text, ARTIFACT_PATHS.events);
|
|
42
|
+
const schema = await readSchema("event", packageRoot);
|
|
43
|
+
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "");
|
|
44
|
+
return lines.map((line, index) => {
|
|
45
|
+
let event;
|
|
46
|
+
try {
|
|
47
|
+
event = JSON.parse(line);
|
|
48
|
+
assertJsonLimits(event, `${ARTIFACT_PATHS.events}[${index}]`);
|
|
49
|
+
assertSchema(event, schema, `${ARTIFACT_PATHS.events}[${index}]`);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw protocolError("E_EVENT_INVALID", `${ARTIFACT_PATHS.events} line ${index + 1}: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
return event;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function appendProtocolEvent(target, input, packageRoot, options = {}) {
|
|
58
|
+
if (typeof input?.taskId !== "string" || !input.taskId) throw protocolError("E_EVENT_INVALID", "event taskId is required");
|
|
59
|
+
if (typeof input?.event !== "string" || !input.event) throw protocolError("E_EVENT_INVALID", "event type is required");
|
|
60
|
+
const events = await readEvents(target, packageRoot);
|
|
61
|
+
const previous = events.at(-1) ?? null;
|
|
62
|
+
const event = {
|
|
63
|
+
seq: events.length + 1,
|
|
64
|
+
schemaVersion: EVENT_SCHEMA_VERSION,
|
|
65
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
66
|
+
taskId: input.taskId,
|
|
67
|
+
event: input.event,
|
|
68
|
+
at: input.at ?? new Date().toISOString(),
|
|
69
|
+
...(input.fingerprint ? { fingerprint: input.fingerprint } : {}),
|
|
70
|
+
previousHash: previous?.hash ?? null,
|
|
71
|
+
...(input.details ? { details: structuredClone(input.details) } : {}),
|
|
72
|
+
};
|
|
73
|
+
assertSecretFree(event);
|
|
74
|
+
const schema = await readSchema("event", packageRoot);
|
|
75
|
+
assertSchema(event, schema, ARTIFACT_PATHS.events);
|
|
76
|
+
event.hash = eventHash(event);
|
|
77
|
+
const eventsPath = ensureWithin(target, ARTIFACT_PATHS.events);
|
|
78
|
+
if (!options.dryRun) {
|
|
79
|
+
await mkdir(path.dirname(eventsPath), { recursive: true });
|
|
80
|
+
await appendFile(eventsPath, `${JSON.stringify(event)}\n`, { encoding: "utf8" });
|
|
81
|
+
}
|
|
82
|
+
return event;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function validateEventLedger(target, packageRoot) {
|
|
86
|
+
let events;
|
|
87
|
+
try {
|
|
88
|
+
events = await readEvents(target, packageRoot);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return { valid: false, events: [], errors: [{ code: error.code ?? "E_EVENT_INVALID", message: error.message }] };
|
|
91
|
+
}
|
|
92
|
+
const errors = [];
|
|
93
|
+
let taskId = null;
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
let lastMilestone = -1;
|
|
96
|
+
const milestoneCounts = new Map();
|
|
97
|
+
for (const [index, event] of events.entries()) {
|
|
98
|
+
if (event.seq !== index + 1) {
|
|
99
|
+
errors.push({ code: "E_EVENT_INVALID", message: `event sequence must be ${index + 1}` });
|
|
100
|
+
}
|
|
101
|
+
if (taskId === null) taskId = event.taskId;
|
|
102
|
+
if (event.taskId !== taskId) errors.push({ code: "E_EVENT_INVALID", message: "event task IDs must remain stable" });
|
|
103
|
+
if (event.previousHash !== (index === 0 ? null : events[index - 1].hash)) {
|
|
104
|
+
errors.push({ code: "E_LEDGER_HASH_INVALID", message: `event ${event.seq} previousHash does not match` });
|
|
105
|
+
}
|
|
106
|
+
if (event.hash !== eventHash(event)) {
|
|
107
|
+
errors.push({ code: "E_LEDGER_HASH_INVALID", message: `event ${event.seq} hash does not match its content` });
|
|
108
|
+
}
|
|
109
|
+
const milestoneIndex = LIFECYCLE_MILESTONES.indexOf(event.event);
|
|
110
|
+
if (milestoneIndex >= 0) {
|
|
111
|
+
const count = (milestoneCounts.get(event.event) ?? 0) + 1;
|
|
112
|
+
milestoneCounts.set(event.event, count);
|
|
113
|
+
if (count > 1 && !REPEATABLE_MILESTONES.has(event.event)) {
|
|
114
|
+
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: `lifecycle milestone must not repeat: ${event.event}` });
|
|
115
|
+
}
|
|
116
|
+
if (milestoneIndex > lastMilestone + 1) {
|
|
117
|
+
errors.push({
|
|
118
|
+
code: "E_PHASE_CHRONOLOGY_INVALID",
|
|
119
|
+
message: `${event.event} is missing prerequisite milestone: ${LIFECYCLE_MILESTONES[lastMilestone + 1]}`,
|
|
120
|
+
});
|
|
121
|
+
} else if (milestoneIndex < lastMilestone) {
|
|
122
|
+
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: `${event.event} is out of lifecycle order` });
|
|
123
|
+
} else if (milestoneIndex === lastMilestone && !REPEATABLE_MILESTONES.has(event.event)) {
|
|
124
|
+
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: `lifecycle milestone must not repeat: ${event.event}` });
|
|
125
|
+
}
|
|
126
|
+
if (milestoneIndex > lastMilestone) lastMilestone = milestoneIndex;
|
|
127
|
+
}
|
|
128
|
+
seen.add(event.event);
|
|
129
|
+
if (event.event === "EXECUTION_STARTED" && !seen.has("ROUTE_VALIDATED")) {
|
|
130
|
+
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "execution started before route validation" });
|
|
131
|
+
}
|
|
132
|
+
if (event.event === "EXECUTION_STARTED" && !seen.has("CONTRACT_VALIDATED")) {
|
|
133
|
+
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "execution started before contract validation" });
|
|
134
|
+
}
|
|
135
|
+
if (event.event === "EXECUTION_STARTED" && !seen.has("PREFLIGHT_READY")) {
|
|
136
|
+
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "execution started before preflight readiness" });
|
|
137
|
+
}
|
|
138
|
+
if (event.event === "EXECUTION_STARTED") {
|
|
139
|
+
const preflight = events.slice(0, index).findLast((candidate) => candidate.event === "PREFLIGHT_READY");
|
|
140
|
+
const requiredGates = preflight?.details?.requiredGates ?? [];
|
|
141
|
+
const satisfiedGates = new Set(events.slice(0, index)
|
|
142
|
+
.filter((candidate) => candidate.event === "GATE_SATISFIED")
|
|
143
|
+
.map((candidate) => candidate.details?.gate));
|
|
144
|
+
for (const gate of requiredGates) {
|
|
145
|
+
if (!satisfiedGates.has(gate)) {
|
|
146
|
+
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: `execution started before gate satisfaction: ${gate}` });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (event.event === "VERIFICATION_RECORDED" && !seen.has("VERIFICATION_STARTED")) {
|
|
151
|
+
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "verification evidence recorded before verification started" });
|
|
152
|
+
}
|
|
153
|
+
if (event.event === "COMPLETION_VALIDATED" && !seen.has("VERIFICATION_RECORDED")) {
|
|
154
|
+
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "completion validated before verification evidence" });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { valid: errors.length === 0, events, errors };
|
|
158
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { ARTIFACT_PATHS, readJsonArtifact } from "./artifacts.js";
|
|
2
|
+
import { readContract } from "./contract.js";
|
|
3
|
+
import { validateEventLedger } from "./events.js";
|
|
4
|
+
import { evaluatePreflight, validatePersistedPreflight } from "./preflight.js";
|
|
5
|
+
import { readPersistedRoute } from "./route-artifact.js";
|
|
6
|
+
import { stateIdentityErrors } from "./completion-relationships.js";
|
|
7
|
+
import { classifyLoadedWorkState } from "./work-state.js";
|
|
8
|
+
|
|
9
|
+
const START_EXECUTION_EVENTS = Object.freeze([
|
|
10
|
+
"CONTRACT_VALIDATED",
|
|
11
|
+
"ROUTE_VALIDATED",
|
|
12
|
+
"PREFLIGHT_READY",
|
|
13
|
+
]);
|
|
14
|
+
const POST_EXECUTION_PHASES = new Set([
|
|
15
|
+
"EXECUTING",
|
|
16
|
+
"VERIFYING",
|
|
17
|
+
"DIAGNOSING",
|
|
18
|
+
"CORRECTING",
|
|
19
|
+
"REVIEWING",
|
|
20
|
+
"COMPLETE",
|
|
21
|
+
]);
|
|
22
|
+
export const PREFLIGHT_ROUTE_IDENTITY_ERROR_MESSAGE = "PREFLIGHT_READY event routing fingerprint does not match the current READY preflight and route";
|
|
23
|
+
export const PREFLIGHT_CONTRACT_IDENTITY_ERROR_MESSAGE = "PREFLIGHT_READY event contract fingerprint does not match the current READY preflight";
|
|
24
|
+
|
|
25
|
+
export function hasExecutionStarted(phase) {
|
|
26
|
+
return POST_EXECUTION_PHASES.has(phase);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function prerequisiteError(prerequisites) {
|
|
30
|
+
const first = prerequisites.errors[0];
|
|
31
|
+
if (!first) return null;
|
|
32
|
+
const error = new Error(first.message);
|
|
33
|
+
error.code = first.code;
|
|
34
|
+
error.artifacts = first.artifacts;
|
|
35
|
+
return error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function assertExecutionPrerequisites(input = {}) {
|
|
39
|
+
const prerequisites = await evaluateStartExecutionPrerequisites(input);
|
|
40
|
+
const error = prerequisiteError(prerequisites);
|
|
41
|
+
if (error) throw error;
|
|
42
|
+
return prerequisites;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function issue(code, message, artifacts = []) {
|
|
46
|
+
return { code, message, artifacts };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sameStringSet(left, right) {
|
|
50
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
51
|
+
return left.length === new Set(left).size
|
|
52
|
+
&& right.length === new Set(right).size
|
|
53
|
+
&& left.length === right.length
|
|
54
|
+
&& left.every((value) => right.includes(value));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function load(loadArtifact, code, message, artifacts, errors) {
|
|
58
|
+
try {
|
|
59
|
+
return await loadArtifact();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
errors.push(issue(code, `${message}: ${error.message}`, artifacts));
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function freshnessErrors(state, freshness) {
|
|
67
|
+
if (freshness.status !== "REVALIDATION_REQUIRED") return [];
|
|
68
|
+
return [issue(
|
|
69
|
+
"E_STATE_REVALIDATION_REQUIRED",
|
|
70
|
+
`EXECUTING requires a fresh work-state checkpoint: ${freshness.reasons.join(", ")}`,
|
|
71
|
+
[
|
|
72
|
+
ARTIFACT_PATHS.state,
|
|
73
|
+
ARTIFACT_PATHS.contract,
|
|
74
|
+
...(state.requiredArtifacts?.map((artifact) => artifact.path) ?? []),
|
|
75
|
+
],
|
|
76
|
+
)];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function prerequisiteLedgerErrors(ledger, taskId, preflight, route) {
|
|
80
|
+
const errors = (ledger.errors ?? []).map((error) => issue(
|
|
81
|
+
error.code ?? "E_PHASE_CHRONOLOGY_INVALID",
|
|
82
|
+
error.message,
|
|
83
|
+
[ARTIFACT_PATHS.events],
|
|
84
|
+
));
|
|
85
|
+
const events = ledger.events ?? [];
|
|
86
|
+
if (events.some((event) => event.taskId !== taskId)) {
|
|
87
|
+
errors.push(issue(
|
|
88
|
+
"E_PHASE_CHRONOLOGY_INVALID",
|
|
89
|
+
"EXECUTING requires protocol prerequisite events to belong to the current task",
|
|
90
|
+
[ARTIFACT_PATHS.events, ARTIFACT_PATHS.state, ARTIFACT_PATHS.contract],
|
|
91
|
+
));
|
|
92
|
+
return errors;
|
|
93
|
+
}
|
|
94
|
+
const currentEvents = events.filter((event) => event.taskId === taskId);
|
|
95
|
+
for (const requiredEvent of START_EXECUTION_EVENTS) {
|
|
96
|
+
if (!currentEvents.some((event) => event.event === requiredEvent)) {
|
|
97
|
+
errors.push(issue(
|
|
98
|
+
"E_PHASE_CHRONOLOGY_INVALID",
|
|
99
|
+
`EXECUTING requires a ${requiredEvent} protocol event`,
|
|
100
|
+
[ARTIFACT_PATHS.events],
|
|
101
|
+
));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const preflightEvent = currentEvents.find((event) => event.event === "PREFLIGHT_READY");
|
|
105
|
+
if (preflightEvent && (!sameStringSet(preflightEvent.details?.requiredGates, preflight.requiredGates)
|
|
106
|
+
|| !sameStringSet(preflightEvent.details?.satisfiedGates, preflight.satisfiedGates))) {
|
|
107
|
+
errors.push(issue(
|
|
108
|
+
"E_PHASE_CHRONOLOGY_INVALID",
|
|
109
|
+
"PREFLIGHT_READY event gate sets do not match the current READY preflight",
|
|
110
|
+
[ARTIFACT_PATHS.events, ARTIFACT_PATHS.preflight],
|
|
111
|
+
));
|
|
112
|
+
}
|
|
113
|
+
if (preflightEvent && preflightEvent.fingerprint !== preflight.fingerprints.contract) {
|
|
114
|
+
errors.push(issue(
|
|
115
|
+
"E_PHASE_CHRONOLOGY_INVALID",
|
|
116
|
+
PREFLIGHT_CONTRACT_IDENTITY_ERROR_MESSAGE,
|
|
117
|
+
[ARTIFACT_PATHS.events, ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.contract],
|
|
118
|
+
));
|
|
119
|
+
}
|
|
120
|
+
if (preflightEvent && (preflightEvent.details?.routingFingerprint !== preflight.fingerprints.routing
|
|
121
|
+
|| preflightEvent.details?.routingFingerprint !== route.fingerprint)) {
|
|
122
|
+
errors.push(issue(
|
|
123
|
+
"E_PHASE_CHRONOLOGY_INVALID",
|
|
124
|
+
PREFLIGHT_ROUTE_IDENTITY_ERROR_MESSAGE,
|
|
125
|
+
[ARTIFACT_PATHS.events, ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.route],
|
|
126
|
+
));
|
|
127
|
+
}
|
|
128
|
+
return errors;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function evaluateStartExecutionPrerequisites({ target, state, packageRoot } = {}) {
|
|
132
|
+
const errors = [];
|
|
133
|
+
const requiredArtifacts = [
|
|
134
|
+
ARTIFACT_PATHS.state,
|
|
135
|
+
ARTIFACT_PATHS.contract,
|
|
136
|
+
ARTIFACT_PATHS.route,
|
|
137
|
+
ARTIFACT_PATHS.preflight,
|
|
138
|
+
ARTIFACT_PATHS.events,
|
|
139
|
+
];
|
|
140
|
+
if (!state) {
|
|
141
|
+
return {
|
|
142
|
+
errors: [issue("E_PHASE_PREREQUISITE_MISSING", "EXECUTING requires a work state", [ARTIFACT_PATHS.state])],
|
|
143
|
+
requiredArtifacts,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const contract = await load(
|
|
148
|
+
() => readContract(target, packageRoot),
|
|
149
|
+
"E_PHASE_PREREQUISITE_MISSING",
|
|
150
|
+
`EXECUTING requires ${ARTIFACT_PATHS.contract}`,
|
|
151
|
+
[ARTIFACT_PATHS.contract],
|
|
152
|
+
errors,
|
|
153
|
+
);
|
|
154
|
+
const route = await load(
|
|
155
|
+
() => readPersistedRoute(target, packageRoot),
|
|
156
|
+
"E_PHASE_PREREQUISITE_MISSING",
|
|
157
|
+
`EXECUTING requires ${ARTIFACT_PATHS.route}`,
|
|
158
|
+
[ARTIFACT_PATHS.route],
|
|
159
|
+
errors,
|
|
160
|
+
);
|
|
161
|
+
if (!contract || !route) return { errors, requiredArtifacts, contract, route };
|
|
162
|
+
|
|
163
|
+
if (state.routeFingerprint !== route.fingerprint) {
|
|
164
|
+
errors.push(issue(
|
|
165
|
+
"E_ROUTE_STALE",
|
|
166
|
+
"EXECUTING requires work state and route to match the current contract",
|
|
167
|
+
[ARTIFACT_PATHS.state, ARTIFACT_PATHS.route, ARTIFACT_PATHS.contract],
|
|
168
|
+
));
|
|
169
|
+
}
|
|
170
|
+
errors.push(...stateIdentityErrors({ contract, route, state }));
|
|
171
|
+
|
|
172
|
+
const freshness = await classifyLoadedWorkState({
|
|
173
|
+
target,
|
|
174
|
+
state,
|
|
175
|
+
contractFile: ARTIFACT_PATHS.contract,
|
|
176
|
+
});
|
|
177
|
+
errors.push(...freshnessErrors(state, freshness));
|
|
178
|
+
|
|
179
|
+
const preflight = await evaluatePreflight({ target, packageRoot });
|
|
180
|
+
let persistedPreflight = null;
|
|
181
|
+
try {
|
|
182
|
+
persistedPreflight = await readJsonArtifact(target, ARTIFACT_PATHS.preflight, "preflight", packageRoot);
|
|
183
|
+
} catch {
|
|
184
|
+
// validatePersistedPreflight reports the stable, actionable preflight reason.
|
|
185
|
+
}
|
|
186
|
+
const persistedPreflightErrors = validatePersistedPreflight(persistedPreflight?.value, preflight);
|
|
187
|
+
if (!sameStringSet(state.requiredGates, preflight.requiredGates)
|
|
188
|
+
|| !sameStringSet(state.satisfiedGates, preflight.satisfiedGates)) {
|
|
189
|
+
errors.push(issue(
|
|
190
|
+
"E_PREFLIGHT_GATES_STALE",
|
|
191
|
+
"Work state gate sets do not match the current preflight evaluation",
|
|
192
|
+
[ARTIFACT_PATHS.state, ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.gates],
|
|
193
|
+
));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const ledger = await validateEventLedger(target, packageRoot);
|
|
197
|
+
errors.push(...prerequisiteLedgerErrors(ledger, contract.value.taskId, preflight, route));
|
|
198
|
+
errors.push(...persistedPreflightErrors);
|
|
199
|
+
return { errors, requiredArtifacts, contract, route, preflight, persistedPreflight, ledger };
|
|
200
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { assertSafePath, ensureWithin, fileExists, readBytes } from "./filesystem.js";
|
|
4
|
+
import { ARTIFACT_PATHS, readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
|
|
5
|
+
import { assertSchema, readSchema } from "./schema-validation.js";
|
|
6
|
+
import { sha256 } from "./manifest.js";
|
|
7
|
+
|
|
8
|
+
function gateName(value) {
|
|
9
|
+
if (typeof value !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(value)) {
|
|
10
|
+
const error = new Error(`Invalid gate name: ${value}`);
|
|
11
|
+
error.code = "E_GATE_INVALID";
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function gatePath(gate) {
|
|
18
|
+
return `${ARTIFACT_PATHS.gates ?? ".forgeloop/gates"}/${gateName(gate)}.json`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function persistGate(target, gate, packageRoot, options = {}) {
|
|
22
|
+
const relativePath = gatePath(gate.gate);
|
|
23
|
+
return writeJsonArtifact(target, relativePath, gate, "gate", packageRoot, options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readGate(target, gate, packageRoot) {
|
|
27
|
+
const relativePath = gatePath(gate);
|
|
28
|
+
return readJsonArtifact(target, relativePath, "gate", packageRoot);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function readGateIfPresent(target, gate, packageRoot) {
|
|
32
|
+
const relativePath = gatePath(gate);
|
|
33
|
+
await assertSafePath(target, relativePath);
|
|
34
|
+
const filePath = ensureWithin(target, relativePath);
|
|
35
|
+
if (!(await fileExists(filePath))) return null;
|
|
36
|
+
return readJsonArtifact(target, relativePath, "gate", packageRoot);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function validateGateArtifacts(target, gateValue, packageRoot) {
|
|
40
|
+
const schema = await readSchema("gate", packageRoot);
|
|
41
|
+
assertSchema(gateValue, schema, "gate");
|
|
42
|
+
const stale = [];
|
|
43
|
+
for (const artifact of gateValue.artifacts ?? []) {
|
|
44
|
+
await assertSafePath(target, artifact.path);
|
|
45
|
+
const artifactPath = ensureWithin(target, artifact.path);
|
|
46
|
+
if (!(await fileExists(artifactPath))) {
|
|
47
|
+
stale.push({ path: artifact.path, status: "missing" });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const digest = sha256(await readBytes(artifactPath));
|
|
51
|
+
if (digest !== artifact.sha256) stale.push({ path: artifact.path, status: "changed" });
|
|
52
|
+
}
|
|
53
|
+
return stale;
|
|
54
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { PROTOCOL_VERSION } from "./protocol.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const GATE_SCHEMA_VERSION = 1;
|
|
5
|
+
export const GATE_STATUSES = Object.freeze(["satisfied", "unverified", "blocked"]);
|
|
6
|
+
|
|
7
|
+
function stringArray(value, label) {
|
|
8
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) {
|
|
9
|
+
throw new Error(`${label} must be an array of non-empty strings`);
|
|
10
|
+
}
|
|
11
|
+
return [...value];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function artifactList(value) {
|
|
15
|
+
if (!Array.isArray(value)) throw new Error("Gate artifacts must be an array");
|
|
16
|
+
return value.map((artifact) => {
|
|
17
|
+
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) {
|
|
18
|
+
throw new Error("Gate artifact entries must be objects");
|
|
19
|
+
}
|
|
20
|
+
if (typeof artifact.path !== "string" || !artifact.path) throw new Error("Gate artifact path is required");
|
|
21
|
+
const portable = artifact.path.replaceAll("\\", "/");
|
|
22
|
+
const normalized = path.posix.normalize(portable);
|
|
23
|
+
if (portable.startsWith("/") || /^[A-Za-z]:\//.test(portable) || normalized === ".." || normalized.startsWith("../")) {
|
|
24
|
+
throw new Error(`Gate artifact path escapes the target: ${artifact.path}`);
|
|
25
|
+
}
|
|
26
|
+
if (typeof artifact.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(artifact.sha256)) {
|
|
27
|
+
throw new Error(`Gate artifact hash is invalid: ${artifact.path}`);
|
|
28
|
+
}
|
|
29
|
+
return { path: normalized.replace(/^\.\//, ""), sha256: artifact.sha256 };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createGate(input = {}) {
|
|
34
|
+
if (typeof input.taskId !== "string" || !input.taskId) throw new Error("Gate taskId is required");
|
|
35
|
+
if (typeof input.gate !== "string" || !input.gate) throw new Error("Gate name is required");
|
|
36
|
+
if (!GATE_STATUSES.includes(input.status)) throw new Error(`Unknown gate status: ${input.status}`);
|
|
37
|
+
const artifacts = artifactList(input.artifacts ?? []);
|
|
38
|
+
return {
|
|
39
|
+
schemaVersion: GATE_SCHEMA_VERSION,
|
|
40
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
41
|
+
taskId: input.taskId,
|
|
42
|
+
gate: input.gate,
|
|
43
|
+
status: input.status,
|
|
44
|
+
requiredBy: stringArray(input.requiredBy ?? [], "Gate requiredBy"),
|
|
45
|
+
artifacts,
|
|
46
|
+
decisions: stringArray(input.decisions ?? [], "Gate decisions"),
|
|
47
|
+
unknowns: stringArray(input.unknowns ?? [], "Gate unknowns"),
|
|
48
|
+
approvedAssumptions: stringArray(input.approvedAssumptions ?? [], "Gate approvedAssumptions"),
|
|
49
|
+
evidence: Array.isArray(input.evidence) ? input.evidence.map((item) => ({ ...item })) : [],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function requiredGatePaths(gates) {
|
|
54
|
+
return [...new Set(gates)].sort().map((gate) => `.forgeloop/gates/${gate}.json`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const GUIDE_FILES = Object.freeze({
|
|
5
|
+
premium: "ENG/premium-sites-studio-eng.md",
|
|
6
|
+
clean: "ENG/clean-code-eng.md",
|
|
7
|
+
test: "ENG/test-code-eng.md",
|
|
8
|
+
security: "ENG/sec-code-eng.md",
|
|
9
|
+
design: "ENG/design-code-eng.md",
|
|
10
|
+
performance: "ENG/perf-code-eng.md",
|
|
11
|
+
accessibility: "ENG/accessibility-eng.md",
|
|
12
|
+
games: "ENG/games-code-design-web-eng.md",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function parseList(frontmatter, key) {
|
|
16
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
17
|
+
const start = lines.findIndex((line) => line.trim() === `${key}:`);
|
|
18
|
+
if (start < 0) return [];
|
|
19
|
+
const values = [];
|
|
20
|
+
for (const line of lines.slice(start + 1)) {
|
|
21
|
+
if (!/^\s+-\s+/.test(line)) break;
|
|
22
|
+
values.push(line.replace(/^\s+-\s+/, "").trim());
|
|
23
|
+
}
|
|
24
|
+
return values.filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseFrontmatter(text, relativePath) {
|
|
28
|
+
const match = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
|
|
29
|
+
if (!match) throw new Error(`${relativePath} is missing YAML frontmatter`);
|
|
30
|
+
const frontmatter = match[1];
|
|
31
|
+
const guideId = frontmatter.match(/^guide-id:\s*([^\s]+)\s*$/m)?.[1] ?? null;
|
|
32
|
+
return {
|
|
33
|
+
guideId,
|
|
34
|
+
requiresGates: parseList(frontmatter, "requires-gates"),
|
|
35
|
+
completionEvidence: parseList(frontmatter, "completion-evidence"),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function readGuideMetadata(packageRoot) {
|
|
40
|
+
const metadata = {};
|
|
41
|
+
for (const [id, relativePath] of Object.entries(GUIDE_FILES)) {
|
|
42
|
+
const text = await readFile(path.join(packageRoot, relativePath), "utf8");
|
|
43
|
+
const parsed = parseFrontmatter(text, relativePath);
|
|
44
|
+
metadata[id] = {
|
|
45
|
+
guideId: parsed.guideId ?? id,
|
|
46
|
+
requiresGates: [...parsed.requiresGates],
|
|
47
|
+
completionEvidence: [...parsed.completionEvidence],
|
|
48
|
+
path: relativePath,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return metadata;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function requiredGatesForGuides(guides, packageRoot) {
|
|
55
|
+
const metadata = await readGuideMetadata(packageRoot);
|
|
56
|
+
return [...new Set(guides.flatMap((guide) => metadata[guide]?.requiresGates ?? []))].sort();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function completionEvidenceForGuides(guides, packageRoot) {
|
|
60
|
+
const metadata = await readGuideMetadata(packageRoot);
|
|
61
|
+
return [...new Set(guides.flatMap((guide) => metadata[guide]?.completionEvidence ?? []))].sort();
|
|
62
|
+
}
|