@cassiomc1/forgeloop 1.2.1 → 1.2.2
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 +1 -1
- package/.github/copilot-instructions.md +1 -1
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/DOCS_INDEX.md +3 -0
- package/ENG/design-code-eng.md +59 -0
- package/ENG/premium-sites-studio-eng.md +28 -0
- package/LOOP_ENGINEERING.md +23 -0
- package/LOOP_SYSTEM_DESIGN.md +9 -5
- package/ORCHESTRATOR_INTEGRATION.md +37 -4
- package/PROTOCOL_INTEGRATION.md +13 -0
- package/README.md +34 -2
- package/TERMINOLOGY.md +10 -0
- package/THIRD_PARTY_NOTICES.md +34 -0
- package/THREAT_MODEL.md +12 -1
- package/docs/ARTIFACT_REFERENCE.md +150 -0
- package/docs/CLI_REFERENCE.md +263 -30
- package/docs/CROSS_HARNESS_CONTINUITY.md +1 -0
- package/docs/DOCUMENTATION_GUIDE.md +41 -4
- package/docs/GETTING_STARTED.md +9 -4
- package/docs/RECIPES.md +31 -1
- package/docs/TROUBLESHOOTING.md +191 -0
- package/package.json +1 -1
- package/schemas/policy-baseline.schema.json +26 -0
- package/schemas/policy-discovery.schema.json +45 -0
- package/schemas/policy-lock.schema.json +16 -0
- package/schemas/policy-rules.schema.json +48 -0
- package/schemas/policy-snapshot.schema.json +16 -0
- package/src/cli.js +69 -1
- package/src/commands/baseline.js +120 -0
- package/src/commands/init.js +304 -6
- package/src/commands/policy-diff.js +51 -0
- package/src/commands/policy-discover.js +42 -0
- package/src/commands/policy-status.js +33 -0
- package/src/commands/profile-interview.js +50 -0
- package/src/commands/reconcile-closure.js +49 -0
- package/src/commands/rule-verify.js +36 -0
- package/src/commands/validate-receipt.js +38 -3
- package/src/core/artifact-registry.js +60 -0
- package/src/core/audit.js +24 -0
- package/src/core/cli-command-definitions.js +114 -7
- package/src/core/cli-metadata.js +1 -1
- package/src/core/completion-artifacts.js +29 -3
- package/src/core/completion.js +101 -10
- package/src/core/error-codes.js +227 -0
- package/src/core/events.js +22 -0
- package/src/core/execution-prerequisites.js +38 -20
- package/src/core/execution.js +20 -3
- package/src/core/native-adapters.js +14 -4
- package/src/core/next-action-model.js +9 -0
- package/src/core/next-action.js +128 -82
- package/src/core/policy-adapters.js +276 -0
- package/src/core/policy-baseline.js +144 -0
- package/src/core/policy-diff.js +133 -0
- package/src/core/policy-discovery.js +225 -0
- package/src/core/policy-engine.js +533 -0
- package/src/core/policy-mutation.js +139 -0
- package/src/core/preflight-consistency.js +23 -15
- package/src/core/preflight.js +65 -1
- package/src/core/reconcile-closure.js +173 -0
- package/src/core/schema-validation.js +6 -0
- package/src/core/task-context.js +11 -0
- package/src/core/task-discovery.js +67 -1
- package/src/core/task-paths.js +9 -0
- package/src/core/templates.js +5 -0
|
@@ -86,64 +86,72 @@ export async function validateReadyProtocolConsistency({
|
|
|
86
86
|
persisted,
|
|
87
87
|
current = null,
|
|
88
88
|
evaluateCurrentPreflight,
|
|
89
|
+
taskId = null,
|
|
89
90
|
} = {}) {
|
|
90
91
|
if (persisted?.status !== "READY") return [];
|
|
91
|
-
const result = current ?? await evaluateCurrentPreflight({ target, packageRoot });
|
|
92
|
+
const result = current ?? await evaluateCurrentPreflight({ target, packageRoot, taskId });
|
|
92
93
|
const errors = [...validatePersistedPreflight(persisted, result)];
|
|
93
94
|
|
|
95
|
+
const stateRel = taskId ? taskArtifactPath(taskId, "state") : ARTIFACT_PATHS.state;
|
|
96
|
+
const eventsRel = taskId ? taskArtifactPath(taskId, "events") : ARTIFACT_PATHS.events;
|
|
97
|
+
const preflightRel = taskId ? taskArtifactPath(taskId, "preflight") : ARTIFACT_PATHS.preflight;
|
|
98
|
+
const contractRel = taskId ? taskArtifactPath(taskId, "contract") : ARTIFACT_PATHS.contract;
|
|
99
|
+
const routeRel = taskId ? taskArtifactPath(taskId, "route") : ARTIFACT_PATHS.route;
|
|
100
|
+
const gatesRel = taskId ? taskArtifactPath(taskId, "gates") : ARTIFACT_PATHS.gates;
|
|
101
|
+
|
|
94
102
|
let state = null;
|
|
95
103
|
try {
|
|
96
|
-
state = await readWorkState(target, packageRoot);
|
|
104
|
+
state = await readWorkState(target, { packageRoot, taskId });
|
|
97
105
|
} catch (error) {
|
|
98
|
-
errors.push(issue("E_STATE_INVALID", error.message, [
|
|
106
|
+
errors.push(issue("E_STATE_INVALID", error.message, [stateRel]));
|
|
99
107
|
}
|
|
100
108
|
if (!state) {
|
|
101
109
|
errors.push(issue(
|
|
102
110
|
"E_STATE_MISSING_AFTER_PREFLIGHT_READY",
|
|
103
111
|
"A persisted READY preflight must have a resumable work-state checkpoint",
|
|
104
|
-
[
|
|
112
|
+
[preflightRel, stateRel],
|
|
105
113
|
));
|
|
106
114
|
} else {
|
|
107
115
|
if (state.taskId !== persisted.taskId) {
|
|
108
|
-
errors.push(issue("E_STATE_TASK_MISMATCH", "The resumable checkpoint does not belong to the READY preflight task", [
|
|
116
|
+
errors.push(issue("E_STATE_TASK_MISMATCH", "The resumable checkpoint does not belong to the READY preflight task", [stateRel, preflightRel]));
|
|
109
117
|
}
|
|
110
118
|
if (state.contractFingerprint !== result.fingerprints.contract) {
|
|
111
|
-
errors.push(issue("E_CONTRACT_STALE", "The resumable checkpoint does not match the READY contract fingerprint", [
|
|
119
|
+
errors.push(issue("E_CONTRACT_STALE", "The resumable checkpoint does not match the READY contract fingerprint", [stateRel, contractRel]));
|
|
112
120
|
}
|
|
113
121
|
if (state.routeFingerprint !== result.fingerprints.routing) {
|
|
114
|
-
errors.push(issue("E_ROUTE_STALE", "The resumable checkpoint does not match the READY routing fingerprint", [
|
|
122
|
+
errors.push(issue("E_ROUTE_STALE", "The resumable checkpoint does not match the READY routing fingerprint", [stateRel, routeRel]));
|
|
115
123
|
}
|
|
116
124
|
if (JSON.stringify(state.selectedGuides) !== JSON.stringify(result.routing.guides)) {
|
|
117
|
-
errors.push(issue("E_ROUTE_GUIDE_MISMATCH", "The resumable checkpoint guides do not match the READY routing result", [
|
|
125
|
+
errors.push(issue("E_ROUTE_GUIDE_MISMATCH", "The resumable checkpoint guides do not match the READY routing result", [stateRel, routeRel]));
|
|
118
126
|
}
|
|
119
127
|
if (!sameStringSet(state.requiredGates ?? [], persisted.requiredGates)
|
|
120
128
|
|| !sameStringSet(state.satisfiedGates ?? [], persisted.satisfiedGates)) {
|
|
121
|
-
errors.push(issue("E_PREFLIGHT_GATES_STALE", "The resumable checkpoint gate sets do not match the READY preflight", [
|
|
129
|
+
errors.push(issue("E_PREFLIGHT_GATES_STALE", "The resumable checkpoint gate sets do not match the READY preflight", [stateRel, preflightRel, gatesRel]));
|
|
122
130
|
}
|
|
123
131
|
}
|
|
124
132
|
|
|
125
|
-
const ledger = await validateEventLedger(target, packageRoot);
|
|
133
|
+
const ledger = await validateEventLedger(target, packageRoot, { taskId });
|
|
126
134
|
if (!ledger.valid) {
|
|
127
|
-
errors.push(...ledger.errors.map((error) => issue(error.code ?? "E_EVENT_INVALID", error.message, [
|
|
135
|
+
errors.push(...ledger.errors.map((error) => issue(error.code ?? "E_EVENT_INVALID", error.message, [eventsRel])));
|
|
128
136
|
}
|
|
129
137
|
const events = ledger.events ?? [];
|
|
130
138
|
for (const requiredEvent of ["CONTRACT_VALIDATED", "ROUTE_VALIDATED"]) {
|
|
131
139
|
if (!events.some((event) => event.event === requiredEvent && event.taskId === persisted.taskId)) {
|
|
132
|
-
errors.push(issue("E_PREFLIGHT_EVENT_MISSING", `READY preflight is missing lifecycle event: ${requiredEvent}`, [
|
|
140
|
+
errors.push(issue("E_PREFLIGHT_EVENT_MISSING", `READY preflight is missing lifecycle event: ${requiredEvent}`, [eventsRel, preflightRel]));
|
|
133
141
|
}
|
|
134
142
|
}
|
|
135
143
|
for (const gate of persisted.satisfiedGates ?? []) {
|
|
136
144
|
if (!events.some((event) => event.event === "GATE_SATISFIED"
|
|
137
145
|
&& event.taskId === persisted.taskId
|
|
138
146
|
&& event.details?.gate === gate)) {
|
|
139
|
-
errors.push(issue("E_PREFLIGHT_GATE_EVENT_MISSING", `READY preflight is missing lifecycle gate event: ${gate}`, [
|
|
147
|
+
errors.push(issue("E_PREFLIGHT_GATE_EVENT_MISSING", `READY preflight is missing lifecycle gate event: ${gate}`, [eventsRel, `${gatesRel}/${gate}.json`]));
|
|
140
148
|
}
|
|
141
149
|
}
|
|
142
150
|
const readyEvents = events.filter((event) => event.event === "PREFLIGHT_READY" && event.taskId === persisted.taskId);
|
|
143
151
|
if (readyEvents.length === 0) {
|
|
144
|
-
errors.push(issue("E_PREFLIGHT_READY_EVENT_MISSING", "Persisted READY preflight is missing the matching PREFLIGHT_READY lifecycle event", [
|
|
152
|
+
errors.push(issue("E_PREFLIGHT_READY_EVENT_MISSING", "Persisted READY preflight is missing the matching PREFLIGHT_READY lifecycle event", [preflightRel, eventsRel]));
|
|
145
153
|
} else if (!readyEvents.some((event) => sameReadyPreflightEvent(event, result))) {
|
|
146
|
-
errors.push(issue("E_PREFLIGHT_READY_EVENT_MISMATCH", "PREFLIGHT_READY lifecycle details do not match the persisted READY preflight", [
|
|
154
|
+
errors.push(issue("E_PREFLIGHT_READY_EVENT_MISMATCH", "PREFLIGHT_READY lifecycle details do not match the persisted READY preflight", [preflightRel, eventsRel]));
|
|
147
155
|
}
|
|
148
156
|
return sortIssues(errors);
|
|
149
157
|
}
|
package/src/core/preflight.js
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
import { PROFILE_PATH } from "./target-layout.js";
|
|
28
28
|
import { readPersistedRoute } from "./route-artifact.js";
|
|
29
29
|
import { validateEventLedger } from "./events.js";
|
|
30
|
-
import { taskArtifactPath } from "./task-paths.js";
|
|
30
|
+
import { PROJECT_ARTIFACT_PATHS, taskArtifactPath } from "./task-paths.js";
|
|
31
31
|
|
|
32
32
|
const PREVIEW_DECISION_LIMIT = 10;
|
|
33
33
|
const PREVIEW_DECISION_MAX_LENGTH = 240;
|
|
@@ -91,6 +91,24 @@ export async function evaluatePreflight({ target, packageRoot, strict = false, t
|
|
|
91
91
|
errors.push(issue("E_CONTRACT_STALE", "work-state references a different contract", [contractRelPath, stateRelPath]));
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
const { detectPolicyCapability } = await import("./policy-engine.js");
|
|
95
|
+
const policyCapability = await detectPolicyCapability(target, packageRoot);
|
|
96
|
+
if (policyCapability === "INVALID") {
|
|
97
|
+
errors.push(issue(
|
|
98
|
+
"E_POLICY_INVALID",
|
|
99
|
+
"Executable policy artifacts are present but invalid.",
|
|
100
|
+
[
|
|
101
|
+
PROJECT_ARTIFACT_PATHS.policyRules,
|
|
102
|
+
PROJECT_ARTIFACT_PATHS.policyBaseline,
|
|
103
|
+
PROJECT_ARTIFACT_PATHS.policyDiscovery,
|
|
104
|
+
PROJECT_ARTIFACT_PATHS.policyLock,
|
|
105
|
+
],
|
|
106
|
+
{
|
|
107
|
+
next: "Repair the invalid policy artifact and rerun forgeloop preflight.",
|
|
108
|
+
},
|
|
109
|
+
));
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
const sortedErrors = sortIssues(errors);
|
|
95
113
|
const effectiveTaskId = taskId ?? contract?.value?.taskId ?? state?.taskId ?? "unknown";
|
|
96
114
|
return {
|
|
@@ -178,6 +196,52 @@ export async function runPreflight({
|
|
|
178
196
|
}
|
|
179
197
|
|
|
180
198
|
if (result.taskId !== "unknown") {
|
|
199
|
+
const {
|
|
200
|
+
detectPolicyCapability,
|
|
201
|
+
loadEffectiveRules,
|
|
202
|
+
readBaseline,
|
|
203
|
+
computePolicyLockData,
|
|
204
|
+
readTaskPolicySnapshot,
|
|
205
|
+
writeTaskPolicySnapshot,
|
|
206
|
+
} = await import("./policy-engine.js");
|
|
207
|
+
|
|
208
|
+
const policyCapability = await detectPolicyCapability(target, packageRoot);
|
|
209
|
+
if (policyCapability === "INVALID") {
|
|
210
|
+
throw preflightError(
|
|
211
|
+
"E_POLICY_INVALID",
|
|
212
|
+
"Executable policy artifacts are present but invalid.",
|
|
213
|
+
[
|
|
214
|
+
PROJECT_ARTIFACT_PATHS.policyRules,
|
|
215
|
+
PROJECT_ARTIFACT_PATHS.policyBaseline,
|
|
216
|
+
PROJECT_ARTIFACT_PATHS.policyDiscovery,
|
|
217
|
+
PROJECT_ARTIFACT_PATHS.policyLock,
|
|
218
|
+
],
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (policyCapability === "AVAILABLE") {
|
|
223
|
+
try {
|
|
224
|
+
const existingSnapshot = await readTaskPolicySnapshot(target, result.taskId, packageRoot);
|
|
225
|
+
if (!existingSnapshot) {
|
|
226
|
+
const rules = await loadEffectiveRules(target, packageRoot);
|
|
227
|
+
const baseline = await readBaseline(target, packageRoot);
|
|
228
|
+
const lock = computePolicyLockData(rules, baseline);
|
|
229
|
+
const snapshot = {
|
|
230
|
+
schemaVersion: 1,
|
|
231
|
+
policyDigest: lock.digest,
|
|
232
|
+
rules,
|
|
233
|
+
baseline: baseline ?? { schemaVersion: 1, entries: [] },
|
|
234
|
+
baselineDigest: lock.baselineDigest,
|
|
235
|
+
capturedAt: new Date().toISOString(),
|
|
236
|
+
};
|
|
237
|
+
await writeTaskPolicySnapshot(target, result.taskId, snapshot, packageRoot);
|
|
238
|
+
}
|
|
239
|
+
} catch (error) {
|
|
240
|
+
const snapRel = taskArtifactPath(result.taskId, "policySnapshot");
|
|
241
|
+
throw preflightError("E_POLICY_SNAPSHOT_WRITE_FAILED", `Failed to persist task policy snapshot: ${error.message}`, [snapRel]);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
181
245
|
await appendActivationEvents(target, packageRoot, ledger, result, { eventsPath, taskId });
|
|
182
246
|
const afterEvents = await validateEventLedger(target, packageRoot, { eventsPath, taskId });
|
|
183
247
|
if (!afterEvents.valid) {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { readContract } from "./contract.js";
|
|
2
|
+
import { appendProtocolEvent, validateEventLedger } from "./events.js";
|
|
3
|
+
import { runCommandExecution } from "./execution.js";
|
|
4
|
+
import { currentRepositoryFingerprint } from "./repository.js";
|
|
5
|
+
import { taskArtifactPath } from "./task-paths.js";
|
|
6
|
+
import { classifyLoadedWorkState, readWorkState, writeWorkState } from "./work-state.js";
|
|
7
|
+
|
|
8
|
+
export const RECONCILE_EVENT = "CHECKPOINT_RECONCILED";
|
|
9
|
+
|
|
10
|
+
const RECONCILABLE_DRIFT = new Set(["REPOSITORY_CHANGED"]);
|
|
11
|
+
|
|
12
|
+
function reconcileError(code, message, artifacts = []) {
|
|
13
|
+
const error = new Error(message);
|
|
14
|
+
error.code = code;
|
|
15
|
+
error.artifacts = artifacts;
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Canonical recovery for an EXECUTING task whose objective is already
|
|
21
|
+
* satisfied in the current repository but whose work-state checkpoint is
|
|
22
|
+
* stale because the repository fingerprint moved.
|
|
23
|
+
*
|
|
24
|
+
* The command refreshes the checkpoint repository fingerprint only after:
|
|
25
|
+
* - the task is EXECUTING,
|
|
26
|
+
* - classification requires revalidation and the only drift is
|
|
27
|
+
* REPOSITORY_CHANGED,
|
|
28
|
+
* - the append-only event ledger is valid,
|
|
29
|
+
* - a contract-bound verification check (exact verification item id and
|
|
30
|
+
* requirement text, type VERIFICATION) executes successfully in the
|
|
31
|
+
* current repository as evidence that the objective is present.
|
|
32
|
+
*
|
|
33
|
+
* Closure itself proceeds through the canonical pipeline (advance to
|
|
34
|
+
* VERIFYING, prepare-completion, record-check, complete); claims are
|
|
35
|
+
* released only by canonical COMPLETE.
|
|
36
|
+
*/
|
|
37
|
+
export async function runReconcileClosure({
|
|
38
|
+
target,
|
|
39
|
+
packageRoot,
|
|
40
|
+
taskId,
|
|
41
|
+
checkId,
|
|
42
|
+
requirement,
|
|
43
|
+
argv,
|
|
44
|
+
details,
|
|
45
|
+
authorityContext,
|
|
46
|
+
runtimeContext,
|
|
47
|
+
} = {}) {
|
|
48
|
+
if (typeof taskId !== "string" || !taskId.trim()) {
|
|
49
|
+
throw reconcileError("E_TASK_REQUIRED", "reconcile-closure requires --task", []);
|
|
50
|
+
}
|
|
51
|
+
if (typeof checkId !== "string" || !checkId.trim()) {
|
|
52
|
+
throw reconcileError("E_RECONCILE_REQUIREMENT_UNKNOWN", "reconcile-closure requires --id", []);
|
|
53
|
+
}
|
|
54
|
+
if (typeof requirement !== "string" || !requirement.trim()) {
|
|
55
|
+
throw reconcileError("E_RECONCILE_REQUIREMENT_UNKNOWN", "reconcile-closure requires --requirement", []);
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
58
|
+
throw reconcileError("E_RECONCILE_EVIDENCE_FAILED", "reconcile-closure requires -- followed by the evidence command argv", []);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const stateRel = taskArtifactPath(taskId, "state");
|
|
62
|
+
const contractRel = taskArtifactPath(taskId, "contract");
|
|
63
|
+
const eventsRel = taskArtifactPath(taskId, "events");
|
|
64
|
+
|
|
65
|
+
const state = await readWorkState(target, { packageRoot, taskId });
|
|
66
|
+
if (!state) {
|
|
67
|
+
throw reconcileError("E_RECONCILE_PHASE_INVALID", "Cannot reconcile without work state", [stateRel]);
|
|
68
|
+
}
|
|
69
|
+
if (state.phase !== "EXECUTING") {
|
|
70
|
+
throw reconcileError(
|
|
71
|
+
"E_RECONCILE_PHASE_INVALID",
|
|
72
|
+
`reconcile-closure supports EXECUTING tasks whose objective is already satisfied; found ${state.phase}`,
|
|
73
|
+
[stateRel],
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const freshness = await classifyLoadedWorkState({ target, state, contractFile: contractRel });
|
|
78
|
+
if (freshness.status !== "REVALIDATION_REQUIRED" || !freshness.reasons.includes("REPOSITORY_CHANGED")) {
|
|
79
|
+
throw reconcileError(
|
|
80
|
+
"E_RECONCILE_NOT_STALE",
|
|
81
|
+
`work-state checkpoint is ${freshness.status === "FRESH" ? "fresh" : "not revalidation-required"}; no reconciliation required`,
|
|
82
|
+
[stateRel, contractRel],
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const unsupported = freshness.reasons.filter((reason) => !RECONCILABLE_DRIFT.has(reason));
|
|
86
|
+
if (unsupported.length > 0) {
|
|
87
|
+
throw reconcileError(
|
|
88
|
+
"E_RECONCILE_UNSUPPORTED_DRIFT",
|
|
89
|
+
`reconcile-closure only reconciles repository fingerprint drift; unresolved drift: ${unsupported.join(", ")}`,
|
|
90
|
+
[stateRel, contractRel],
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ledger = await validateEventLedger(target, packageRoot, { taskId });
|
|
95
|
+
if (!ledger.valid) {
|
|
96
|
+
const first = ledger.errors[0];
|
|
97
|
+
throw reconcileError(
|
|
98
|
+
"E_RECONCILE_LEDGER_INVALID",
|
|
99
|
+
`append-only event ledger must be valid before reconciliation: ${first?.message ?? "invalid ledger"}`,
|
|
100
|
+
[eventsRel],
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const contract = await readContract(target, packageRoot, { taskId });
|
|
105
|
+
const verificationItem = (contract.value.verification ?? []).find(
|
|
106
|
+
(item) => item.type === "VERIFICATION" && item.id === checkId && item.text === requirement,
|
|
107
|
+
);
|
|
108
|
+
if (!verificationItem) {
|
|
109
|
+
throw reconcileError(
|
|
110
|
+
"E_RECONCILE_REQUIREMENT_UNKNOWN",
|
|
111
|
+
`no contract verification item matches id "${checkId}" with the exact requirement text`,
|
|
112
|
+
[contractRel],
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const execution = await runCommandExecution({
|
|
117
|
+
target,
|
|
118
|
+
packageRoot,
|
|
119
|
+
taskId,
|
|
120
|
+
checkId,
|
|
121
|
+
requirement,
|
|
122
|
+
verificationCycle: state.verificationCycle ?? 1,
|
|
123
|
+
argv,
|
|
124
|
+
details,
|
|
125
|
+
authorityContext,
|
|
126
|
+
runtimeContext,
|
|
127
|
+
});
|
|
128
|
+
if (execution.execution.status !== "passed") {
|
|
129
|
+
throw reconcileError(
|
|
130
|
+
"E_RECONCILE_EVIDENCE_FAILED",
|
|
131
|
+
`objective-satisfaction evidence failed (exit ${execution.execution.exitCode ?? "not-started"}): ${execution.result}`,
|
|
132
|
+
[execution.path],
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const repository = await currentRepositoryFingerprint(target);
|
|
137
|
+
const previous = state.repositoryFingerprint;
|
|
138
|
+
|
|
139
|
+
await appendProtocolEvent(target, {
|
|
140
|
+
taskId,
|
|
141
|
+
event: RECONCILE_EVENT,
|
|
142
|
+
details: {
|
|
143
|
+
previousBranch: previous?.branch ?? null,
|
|
144
|
+
previousHead: previous?.head ?? null,
|
|
145
|
+
currentBranch: repository.branch,
|
|
146
|
+
currentHead: repository.head,
|
|
147
|
+
checkId,
|
|
148
|
+
requirement,
|
|
149
|
+
command: argv.join(" "),
|
|
150
|
+
exitCode: execution.execution.exitCode ?? 0,
|
|
151
|
+
executionId: execution.execution.executionId,
|
|
152
|
+
},
|
|
153
|
+
}, packageRoot, { taskId });
|
|
154
|
+
|
|
155
|
+
await writeWorkState(target, {
|
|
156
|
+
...state,
|
|
157
|
+
repositoryFingerprint: repository,
|
|
158
|
+
lastUpdated: new Date().toISOString(),
|
|
159
|
+
}, { packageRoot, taskId });
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
taskId,
|
|
163
|
+
phase: state.phase,
|
|
164
|
+
reconciled: true,
|
|
165
|
+
previousRepositoryFingerprint: previous,
|
|
166
|
+
repositoryFingerprint: repository,
|
|
167
|
+
checkId,
|
|
168
|
+
requirement,
|
|
169
|
+
executionId: execution.execution.executionId,
|
|
170
|
+
executionPath: execution.path,
|
|
171
|
+
event: RECONCILE_EVENT,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -24,6 +24,11 @@ export const SHIPPED_SCHEMA_NAMES = Object.freeze([
|
|
|
24
24
|
"event",
|
|
25
25
|
"activation",
|
|
26
26
|
"policy",
|
|
27
|
+
"policy-rules",
|
|
28
|
+
"policy-discovery",
|
|
29
|
+
"policy-baseline",
|
|
30
|
+
"policy-lock",
|
|
31
|
+
"policy-snapshot",
|
|
27
32
|
"task-bundle",
|
|
28
33
|
"execution",
|
|
29
34
|
"authority",
|
|
@@ -40,6 +45,7 @@ export class SchemaValidationError extends Error {
|
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
function typeMatches(value, type) {
|
|
48
|
+
if (Array.isArray(type)) return type.some((t) => typeMatches(value, t));
|
|
43
49
|
if (type === "object") return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
44
50
|
if (type === "array") return Array.isArray(value);
|
|
45
51
|
if (type === "integer") return Number.isInteger(value);
|
package/src/core/task-context.js
CHANGED
|
@@ -71,6 +71,17 @@ export async function resolveTaskContext(target, {
|
|
|
71
71
|
const tasks = await discoverTasks(target, packageRoot);
|
|
72
72
|
const healthyTasks = tasks.filter((t) => t.healthy !== false);
|
|
73
73
|
|
|
74
|
+
// Fail closed: modern task namespaces exist but are all corrupt/unhealthy.
|
|
75
|
+
// Never fall back to legacy singleton state over corrupt modern state.
|
|
76
|
+
if (tasks.length > 0 && healthyTasks.length === 0) {
|
|
77
|
+
const firstInvalid = tasks.find((t) => t.healthy === false);
|
|
78
|
+
const error = new Error(
|
|
79
|
+
firstInvalid?.error?.message ?? "Task namespaces exist but none are valid",
|
|
80
|
+
);
|
|
81
|
+
error.code = firstInvalid?.error?.code ?? "E_TASK_DESCRIPTOR_INVALID";
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
if (healthyTasks.length === 1) {
|
|
75
86
|
const single = healthyTasks[0];
|
|
76
87
|
return createTaskContext({
|
|
@@ -1,12 +1,59 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { ensureWithin, fileExists } from "./filesystem.js";
|
|
3
3
|
import { getPackageRoot } from "./templates.js";
|
|
4
|
-
import { TASK_STATE_ROOT, taskArtifactPath } from "./task-paths.js";
|
|
4
|
+
import { TASK_STATE_ROOT, TASK_ARTIFACT_FILES, taskArtifactPath } from "./task-paths.js";
|
|
5
5
|
import { readTaskDescriptor } from "./task-descriptor.js";
|
|
6
6
|
import { readJsonArtifact } from "./artifacts.js";
|
|
7
7
|
import { readLockInfo } from "./task-lock.js";
|
|
8
8
|
import { taskStorageKey } from "./task-identity.js";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Explicitly recognized legacy-incidental artifacts that may legitimately
|
|
12
|
+
* exist inside a 64-hex task-state directory WITHOUT a task.json descriptor.
|
|
13
|
+
* A legacy preflight writes a task-scoped policy snapshot for a task that has
|
|
14
|
+
* no modern namespace; that directory is not a task namespace. Any other
|
|
15
|
+
* content makes the directory a corrupt modern task namespace that must fail
|
|
16
|
+
* closed.
|
|
17
|
+
*/
|
|
18
|
+
const LEGACY_INCIDENTAL_ARTIFACTS = new Set([
|
|
19
|
+
TASK_ARTIFACT_FILES.policySnapshot,
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Classifies a 64-hex task-state directory whose task.json is missing.
|
|
24
|
+
*
|
|
25
|
+
* - directory contains only explicitly recognized legacy-incidental
|
|
26
|
+
* artifacts (policy-snapshot.json) -> LEGACY_INCIDENTAL (ignored)
|
|
27
|
+
* - anything else, including an empty directory -> CORRUPT_TASK_NAMESPACE
|
|
28
|
+
* (no positive evidence of legitimate legacy spillover)
|
|
29
|
+
*/
|
|
30
|
+
async function classifyDescriptorlessTaskDirectory(target, taskKey) {
|
|
31
|
+
const directory = ensureWithin(target, `${TASK_STATE_ROOT}/${taskKey}`);
|
|
32
|
+
let entries = [];
|
|
33
|
+
try {
|
|
34
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
35
|
+
} catch {
|
|
36
|
+
return {
|
|
37
|
+
kind: "CORRUPT_TASK_NAMESPACE",
|
|
38
|
+
error: {
|
|
39
|
+
code: "E_TASK_DESCRIPTOR_INVALID",
|
|
40
|
+
message: `Task namespace ${taskKey} is missing task.json`,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const names = new Set(entries.map((entry) => entry.name));
|
|
45
|
+
if (names.size > 0 && [...names].every((name) => LEGACY_INCIDENTAL_ARTIFACTS.has(name))) {
|
|
46
|
+
return { kind: "LEGACY_INCIDENTAL" };
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
kind: "CORRUPT_TASK_NAMESPACE",
|
|
50
|
+
error: {
|
|
51
|
+
code: "E_TASK_DESCRIPTOR_INVALID",
|
|
52
|
+
message: `Task namespace ${taskKey} contains task artifacts but task.json is missing`,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
10
57
|
export async function discoverTasks(target, packageRoot = getPackageRoot()) {
|
|
11
58
|
const rootPath = ensureWithin(target, TASK_STATE_ROOT);
|
|
12
59
|
if (!(await fileExists(rootPath))) {
|
|
@@ -92,6 +139,25 @@ export async function discoverTasks(target, packageRoot = getPackageRoot()) {
|
|
|
92
139
|
directory: `${TASK_STATE_ROOT}/${descriptor.taskKey}`,
|
|
93
140
|
});
|
|
94
141
|
} catch (err) {
|
|
142
|
+
// A directory without a task.json descriptor is not automatically a
|
|
143
|
+
// task namespace: classify by contents so explicitly recognized legacy
|
|
144
|
+
// spillover (policy-snapshot.json) stays compatible while any modern
|
|
145
|
+
// task artifact without a descriptor fails closed instead of silently
|
|
146
|
+
// reopening the legacy singleton fallback.
|
|
147
|
+
if (err.code === "E_TASK_NOT_FOUND" || err.code === "ARTIFACT_MISSING") {
|
|
148
|
+
const classification = await classifyDescriptorlessTaskDirectory(target, entry.name);
|
|
149
|
+
if (classification.kind === "LEGACY_INCIDENTAL") {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
tasks.push({
|
|
153
|
+
taskId: null,
|
|
154
|
+
taskKey: entry.name,
|
|
155
|
+
directory: `${TASK_STATE_ROOT}/${entry.name}`,
|
|
156
|
+
healthy: false,
|
|
157
|
+
error: classification.error,
|
|
158
|
+
});
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
95
161
|
// P1-2: Surface corrupt task namespaces instead of silently hiding them
|
|
96
162
|
tasks.push({
|
|
97
163
|
taskId: null,
|
package/src/core/task-paths.js
CHANGED
|
@@ -15,14 +15,22 @@ export const TASK_ARTIFACT_FILES = Object.freeze({
|
|
|
15
15
|
gates: "gates",
|
|
16
16
|
executions: "executions",
|
|
17
17
|
lock: ".lock",
|
|
18
|
+
policySnapshot: "policy-snapshot.json",
|
|
18
19
|
});
|
|
19
20
|
|
|
21
|
+
export const POLICY_ROOT = ".forgeloop/policy";
|
|
22
|
+
|
|
20
23
|
export const PROJECT_ARTIFACT_PATHS = Object.freeze({
|
|
21
24
|
config: ".forgeloop/config.json",
|
|
22
25
|
sources: ".forgeloop/sources.json",
|
|
23
26
|
manifest: ".forgeloop/.manifest.json",
|
|
24
27
|
kit: ".forgeloop/kit",
|
|
25
28
|
gitignore: ".forgeloop/.gitignore",
|
|
29
|
+
policyDir: ".forgeloop/policy",
|
|
30
|
+
policyRules: ".forgeloop/policy/rules.json",
|
|
31
|
+
policyBaseline: ".forgeloop/policy/baseline.json",
|
|
32
|
+
policyLock: ".forgeloop/policy/policy.lock",
|
|
33
|
+
policyDiscovery: ".forgeloop/policy/discovery.json",
|
|
26
34
|
});
|
|
27
35
|
|
|
28
36
|
export const LEGACY_TASK_ARTIFACT_PATHS = Object.freeze({
|
|
@@ -92,5 +100,6 @@ export function buildTaskArtifactPaths(taskId) {
|
|
|
92
100
|
gates: `${dir}/${TASK_ARTIFACT_FILES.gates}`,
|
|
93
101
|
executions: `${dir}/${TASK_ARTIFACT_FILES.executions}`,
|
|
94
102
|
lock: `${dir}/${TASK_ARTIFACT_FILES.lock}`,
|
|
103
|
+
policySnapshot: `${dir}/${TASK_ARTIFACT_FILES.policySnapshot}`,
|
|
95
104
|
});
|
|
96
105
|
}
|
package/src/core/templates.js
CHANGED
|
@@ -58,6 +58,11 @@ export const TEMPLATE_PATHS = [
|
|
|
58
58
|
"schemas/event.schema.json",
|
|
59
59
|
"schemas/activation.schema.json",
|
|
60
60
|
"schemas/policy.schema.json",
|
|
61
|
+
"schemas/policy-rules.schema.json",
|
|
62
|
+
"schemas/policy-discovery.schema.json",
|
|
63
|
+
"schemas/policy-baseline.schema.json",
|
|
64
|
+
"schemas/policy-lock.schema.json",
|
|
65
|
+
"schemas/policy-snapshot.schema.json",
|
|
61
66
|
"schemas/task-bundle.schema.json",
|
|
62
67
|
"schemas/authority.schema.json",
|
|
63
68
|
"schemas/task-descriptor.schema.json",
|