@cassiomc1/forgeloop 1.1.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 +124 -0
- package/ENG/premium-sites-studio-eng.md +28 -0
- package/ENG/taste-frontend-eng.md +3 -2
- package/ENG/test-code-eng.md +45 -0
- package/LOOP_ENGINEERING.md +74 -0
- package/LOOP_SYSTEM_DESIGN.md +9 -5
- package/ORCHESTRATOR_INTEGRATION.md +41 -6
- package/PROTOCOL_INTEGRATION.md +13 -0
- package/README.md +40 -6
- package/TERMINOLOGY.md +10 -0
- package/THIRD_PARTY_NOTICES.md +58 -1
- package/THREAT_MODEL.md +12 -1
- package/docs/ARTIFACT_REFERENCE.md +152 -2
- package/docs/CLI_REFERENCE.md +346 -30
- package/docs/CROSS_HARNESS_CONTINUITY.md +1 -0
- package/docs/DOCUMENTATION_GUIDE.md +41 -4
- package/docs/GETTING_STARTED.md +39 -8
- package/docs/RECIPES.md +66 -7
- package/docs/TROUBLESHOOTING.md +279 -6
- 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 +102 -1
- package/src/commands/baseline.js +120 -0
- package/src/commands/init.js +304 -6
- package/src/commands/next.js +15 -1
- 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/progress.js +51 -0
- package/src/commands/reconcile-closure.js +49 -0
- package/src/commands/record-decision-criterion.js +34 -0
- package/src/commands/record-diagnosis.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 +163 -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/diagnosis-model.js +214 -0
- package/src/core/diagnosis.js +171 -0
- package/src/core/error-codes.js +292 -0
- package/src/core/events.js +47 -1
- 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 +40 -5
- package/src/core/next-action.js +234 -91
- package/src/core/phase.js +29 -0
- 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-model.js +10 -2
- package/src/core/preflight.js +65 -1
- package/src/core/progress.js +143 -0
- package/src/core/protocol.js +8 -0
- package/src/core/reconcile-closure.js +173 -0
- package/src/core/schema-validation.js +6 -0
- package/src/core/settlement-model.js +85 -0
- package/src/core/settlement.js +78 -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
|
}
|
|
@@ -34,11 +34,19 @@ export function sameStringSet(left, right) {
|
|
|
34
34
|
export function validatePersistedPreflight(persisted, current) {
|
|
35
35
|
const errors = [];
|
|
36
36
|
if (persisted?.status !== "READY") {
|
|
37
|
-
|
|
37
|
+
if (Array.isArray(persisted?.errors) && persisted.errors.length > 0) {
|
|
38
|
+
errors.push(...persisted.errors);
|
|
39
|
+
} else {
|
|
40
|
+
errors.push(issue("E_PREFLIGHT_NOT_READY", "A persisted READY preflight is required", [ARTIFACT_PATHS.preflight]));
|
|
41
|
+
}
|
|
38
42
|
return errors;
|
|
39
43
|
}
|
|
40
44
|
if (current?.status !== "READY") {
|
|
41
|
-
|
|
45
|
+
if (Array.isArray(current?.errors) && current.errors.length > 0) {
|
|
46
|
+
errors.push(...current.errors);
|
|
47
|
+
} else {
|
|
48
|
+
errors.push(issue("E_PREFLIGHT_NOT_READY", "The current preflight evaluation is not READY", [ARTIFACT_PATHS.preflight]));
|
|
49
|
+
}
|
|
42
50
|
}
|
|
43
51
|
if (persisted.taskId !== current?.taskId) {
|
|
44
52
|
errors.push(issue(
|
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,143 @@
|
|
|
1
|
+
import { diagnosisEventsForTask } from "./diagnosis-model.js";
|
|
2
|
+
|
|
3
|
+
export const PROGRESS_STATUS = Object.freeze({
|
|
4
|
+
ADVANCING: "ADVANCING",
|
|
5
|
+
WATCH: "WATCH",
|
|
6
|
+
STALLED: "STALLED",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const PROGRESS_SIGNAL = Object.freeze({
|
|
10
|
+
NO_DIAGNOSTIC_INFORMATION_GAIN: "NO_DIAGNOSTIC_INFORMATION_GAIN",
|
|
11
|
+
REPEATED_FAILED_REQUIREMENT: "REPEATED_FAILED_REQUIREMENT",
|
|
12
|
+
REPEATED_FAILURE_WITH_SAME_DIAGNOSIS: "REPEATED_FAILURE_WITH_SAME_DIAGNOSIS",
|
|
13
|
+
HIGH_CORRECTION_CYCLE_COUNT: "HIGH_CORRECTION_CYCLE_COUNT",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export function diagnosisRequirements(diagnosis, checksById) {
|
|
17
|
+
if (!diagnosis) return [];
|
|
18
|
+
return [...new Set(
|
|
19
|
+
(diagnosis.evidenceRefs ?? [])
|
|
20
|
+
.map((id) => checksById.get(id)?.requirement)
|
|
21
|
+
.filter(Boolean),
|
|
22
|
+
)].sort();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function evaluateProgress({ state, events = [] } = {}) {
|
|
26
|
+
const signals = [];
|
|
27
|
+
let status = PROGRESS_STATUS.ADVANCING;
|
|
28
|
+
|
|
29
|
+
if (!state) {
|
|
30
|
+
return { status, signals };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const taskEvents = Array.isArray(events) ? events.filter((e) => !state.taskId || e.taskId === state.taskId) : [];
|
|
34
|
+
const diagEvents = diagnosisEventsForTask(taskEvents, state.taskId);
|
|
35
|
+
const latestDiag = diagEvents.at(-1)?.details ?? null;
|
|
36
|
+
|
|
37
|
+
// Build checksById index for resolving requirement from check IDs
|
|
38
|
+
const checksById = new Map();
|
|
39
|
+
for (const check of state.checks ?? []) {
|
|
40
|
+
if (check.id) checksById.set(check.id, check);
|
|
41
|
+
if (check.checkId) checksById.set(check.checkId, check);
|
|
42
|
+
}
|
|
43
|
+
for (const event of taskEvents) {
|
|
44
|
+
if (event.event === "VERIFICATION_RECORDED" && event.details) {
|
|
45
|
+
if (event.details.id) checksById.set(event.details.id, event.details);
|
|
46
|
+
if (event.details.checkId) checksById.set(event.details.checkId, event.details);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 1. Check if latest diagnosis has NO information gain (global stall)
|
|
51
|
+
if (latestDiag && latestDiag.informationGain === "NONE") {
|
|
52
|
+
status = PROGRESS_STATUS.STALLED;
|
|
53
|
+
signals.push({
|
|
54
|
+
code: PROGRESS_SIGNAL.NO_DIAGNOSTIC_INFORMATION_GAIN,
|
|
55
|
+
severity: "BLOCKING_FOR_RETRY",
|
|
56
|
+
message: "Latest diagnosis repeats the prior hypothesis with the same evidence.",
|
|
57
|
+
verificationCycles: [latestDiag.verificationCycle],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. Track failed requirements across distinct verification cycles
|
|
62
|
+
const reqCycles = new Map();
|
|
63
|
+
const checks = state.checks ?? [];
|
|
64
|
+
for (const check of checks) {
|
|
65
|
+
if (check.status === "failed" || check.status === "blocked") {
|
|
66
|
+
const cycle = check.details?.verificationCycle ?? state.verificationCycle ?? 1;
|
|
67
|
+
const req = check.requirement ?? "default";
|
|
68
|
+
if (!reqCycles.has(req)) reqCycles.set(req, new Set());
|
|
69
|
+
reqCycles.get(req).add(cycle);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Also extract from VERIFICATION_RECORDED events
|
|
74
|
+
for (const event of taskEvents) {
|
|
75
|
+
if (event.event === "VERIFICATION_RECORDED") {
|
|
76
|
+
const details = event.details ?? {};
|
|
77
|
+
if (details.status === "failed" || details.status === "blocked") {
|
|
78
|
+
const cycle = details.verificationCycle ?? 1;
|
|
79
|
+
const req = details.requirement ?? "default";
|
|
80
|
+
if (!reqCycles.has(req)) reqCycles.set(req, new Set());
|
|
81
|
+
reqCycles.get(req).add(cycle);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const latestDiagReqs = diagnosisRequirements(latestDiag, checksById);
|
|
87
|
+
|
|
88
|
+
for (const [req, cyclesSet] of [...reqCycles.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
89
|
+
if (cyclesSet.size >= 3) {
|
|
90
|
+
const sortedCycles = [...cyclesSet].sort((a, b) => a - b);
|
|
91
|
+
const isLatestStalledForThisReq = latestDiag && latestDiag.informationGain === "NONE" && latestDiagReqs.includes(req);
|
|
92
|
+
if (isLatestStalledForThisReq) {
|
|
93
|
+
if (!signals.some((s) => s.code === PROGRESS_SIGNAL.REPEATED_FAILURE_WITH_SAME_DIAGNOSIS && s.requirement === req)) {
|
|
94
|
+
signals.push({
|
|
95
|
+
code: PROGRESS_SIGNAL.REPEATED_FAILURE_WITH_SAME_DIAGNOSIS,
|
|
96
|
+
severity: "BLOCKING_FOR_RETRY",
|
|
97
|
+
message: `Requirement "${req}" failed in ${cyclesSet.size} cycles and the latest diagnoses contain no new information.`,
|
|
98
|
+
verificationCycles: sortedCycles,
|
|
99
|
+
requirement: req,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
status = PROGRESS_STATUS.STALLED;
|
|
103
|
+
} else {
|
|
104
|
+
if (!signals.some((s) => s.code === PROGRESS_SIGNAL.REPEATED_FAILED_REQUIREMENT && s.requirement === req)) {
|
|
105
|
+
signals.push({
|
|
106
|
+
code: PROGRESS_SIGNAL.REPEATED_FAILED_REQUIREMENT,
|
|
107
|
+
severity: "ADVISORY",
|
|
108
|
+
message: `Requirement "${req}" failed across ${cyclesSet.size} distinct verification cycles.`,
|
|
109
|
+
verificationCycles: sortedCycles,
|
|
110
|
+
requirement: req,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (status !== PROGRESS_STATUS.STALLED) {
|
|
114
|
+
status = PROGRESS_STATUS.WATCH;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. High cycle count
|
|
121
|
+
const currentCycle = state.verificationCycle ?? 1;
|
|
122
|
+
if (currentCycle >= 4) {
|
|
123
|
+
signals.push({
|
|
124
|
+
code: PROGRESS_SIGNAL.HIGH_CORRECTION_CYCLE_COUNT,
|
|
125
|
+
severity: "ADVISORY",
|
|
126
|
+
message: `Task has reached verification cycle ${currentCycle}.`,
|
|
127
|
+
verificationCycles: Array.from({ length: currentCycle }, (_, i) => i + 1),
|
|
128
|
+
});
|
|
129
|
+
if (status !== PROGRESS_STATUS.STALLED) {
|
|
130
|
+
status = PROGRESS_STATUS.WATCH;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Sort signals deterministically
|
|
135
|
+
signals.sort((left, right) =>
|
|
136
|
+
left.code.localeCompare(right.code) || (left.requirement || "").localeCompare(right.requirement || "")
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
status,
|
|
141
|
+
signals,
|
|
142
|
+
};
|
|
143
|
+
}
|
package/src/core/protocol.js
CHANGED
|
@@ -93,6 +93,14 @@ export const FAILURE_CODES = Object.freeze([
|
|
|
93
93
|
"E_CONTINUITY_CONTRACT_MISMATCH",
|
|
94
94
|
"E_CONTINUITY_PHASE_MISMATCH",
|
|
95
95
|
"E_CONTINUITY_RECONCILIATION_REQUIRED",
|
|
96
|
+
"E_DIAGNOSIS_REQUIRED",
|
|
97
|
+
"E_DIAGNOSIS_INVALID",
|
|
98
|
+
"E_DIAGNOSIS_EVIDENCE_INVALID",
|
|
99
|
+
"E_DIAGNOSIS_CYCLE_MISMATCH",
|
|
100
|
+
"E_DIAGNOSIS_NO_NEW_INFORMATION",
|
|
101
|
+
"E_PROGRESS_STALLED",
|
|
102
|
+
"E_DECISION_CRITERION_INVALID",
|
|
103
|
+
"E_DECISION_NOT_UNRESOLVED",
|
|
96
104
|
]);
|
|
97
105
|
|
|
98
106
|
export const WORK_PHASES = Object.freeze([
|
|
@@ -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);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function normalizeDecisionText(value) {
|
|
4
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
5
|
+
const error = new Error("Decision text must be a non-empty string");
|
|
6
|
+
error.code = "E_DECISION_CRITERION_INVALID";
|
|
7
|
+
throw error;
|
|
8
|
+
}
|
|
9
|
+
return value.trim().replace(/\s+/gu, " ").toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function decisionId(decisionText) {
|
|
13
|
+
const normalized = normalizeDecisionText(decisionText);
|
|
14
|
+
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
15
|
+
return `decision-${hash}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeDecisionCriterionInput(input) {
|
|
19
|
+
if (!input || typeof input !== "object") {
|
|
20
|
+
const error = new Error("Decision criterion input must be an object");
|
|
21
|
+
error.code = "E_DECISION_CRITERION_INVALID";
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
if (typeof input.decision !== "string" || !input.decision.trim()) {
|
|
25
|
+
const error = new Error("decision must be a non-empty string");
|
|
26
|
+
error.code = "E_DECISION_CRITERION_INVALID";
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
if (typeof input.settledBy !== "string" || !input.settledBy.trim()) {
|
|
30
|
+
const error = new Error("settledBy must be a non-empty string");
|
|
31
|
+
error.code = "E_DECISION_CRITERION_INVALID";
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
decision: input.decision.trim(),
|
|
36
|
+
settledBy: input.settledBy.trim(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function assertDecisionCriterionDetails(details) {
|
|
41
|
+
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
|
42
|
+
const error = new Error("Decision criterion details must be an object");
|
|
43
|
+
error.code = "E_DECISION_CRITERION_INVALID";
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
if (typeof details.decision !== "string" || !details.decision.trim()) {
|
|
47
|
+
const error = new Error("Decision criterion decision must be a non-empty string");
|
|
48
|
+
error.code = "E_DECISION_CRITERION_INVALID";
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
const expectedId = decisionId(details.decision);
|
|
52
|
+
if (details.decisionId !== expectedId) {
|
|
53
|
+
const error = new Error(`Decision criterion decisionId "${details.decisionId}" does not match computed "${expectedId}"`);
|
|
54
|
+
error.code = "E_DECISION_CRITERION_INVALID";
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
if (typeof details.settledBy !== "string" || !details.settledBy.trim()) {
|
|
58
|
+
const error = new Error("Decision criterion settledBy must be a non-empty string");
|
|
59
|
+
error.code = "E_DECISION_CRITERION_INVALID";
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
if (typeof details.contractFingerprint !== "string" || !/^[a-f0-9]{64}$/.test(details.contractFingerprint)) {
|
|
63
|
+
const error = new Error("Decision criterion contractFingerprint must be a 64-char hex string");
|
|
64
|
+
error.code = "E_DECISION_CRITERION_INVALID";
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
return details;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function decisionCriterionEvents(events, taskId) {
|
|
71
|
+
if (!Array.isArray(events)) return [];
|
|
72
|
+
return events.filter((e) => e.event === "DECISION_CRITERION_RECORDED" && (!taskId || e.taskId === taskId));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function criterionForDecision(events, taskId, decisionText, contractFingerprint) {
|
|
76
|
+
const decId = decisionId(decisionText);
|
|
77
|
+
const taskEvents = decisionCriterionEvents(events, taskId);
|
|
78
|
+
for (let i = taskEvents.length - 1; i >= 0; i--) {
|
|
79
|
+
const details = taskEvents[i].details;
|
|
80
|
+
if (details?.decisionId === decId && (!contractFingerprint || details?.contractFingerprint === contractFingerprint)) {
|
|
81
|
+
return details;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|