@cassiomc1/forgeloop 1.1.0 → 1.2.1
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/ENG/design-code-eng.md +102 -2
- package/ENG/taste-frontend-eng.md +7 -3
- package/ENG/test-code-eng.md +45 -0
- package/LOOP_ENGINEERING.md +51 -0
- package/ORCHESTRATOR_INTEGRATION.md +4 -2
- package/README.md +10 -4
- package/THIRD_PARTY_NOTICES.md +24 -1
- package/docs/ARTIFACT_REFERENCE.md +2 -2
- package/docs/CLI_REFERENCE.md +85 -2
- package/docs/GETTING_STARTED.md +30 -4
- package/docs/RECIPES.md +35 -6
- package/docs/TROUBLESHOOTING.md +88 -6
- package/package.json +1 -1
- package/src/cli.js +33 -0
- package/src/commands/next.js +15 -1
- package/src/commands/progress.js +51 -0
- package/src/commands/record-decision-criterion.js +34 -0
- package/src/commands/record-diagnosis.js +49 -0
- package/src/core/cli-command-definitions.js +50 -1
- package/src/core/diagnosis-model.js +214 -0
- package/src/core/diagnosis.js +171 -0
- package/src/core/error-codes.js +65 -0
- package/src/core/events.js +25 -1
- package/src/core/next-action-model.js +31 -5
- package/src/core/next-action.js +109 -12
- package/src/core/phase.js +29 -0
- package/src/core/preflight-model.js +10 -2
- package/src/core/progress.js +143 -0
- package/src/core/protocol.js +8 -0
- package/src/core/settlement-model.js +85 -0
- package/src/core/settlement.js +78 -0
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readContract } from "./contract.js";
|
|
2
|
+
import { appendProtocolEvent, validateEventLedger } from "./events.js";
|
|
3
|
+
import {
|
|
4
|
+
assertDecisionCriterionDetails,
|
|
5
|
+
criterionForDecision,
|
|
6
|
+
decisionCriterionEvents,
|
|
7
|
+
decisionId,
|
|
8
|
+
normalizeDecisionCriterionInput,
|
|
9
|
+
normalizeDecisionText,
|
|
10
|
+
} from "./settlement-model.js";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
assertDecisionCriterionDetails,
|
|
14
|
+
criterionForDecision,
|
|
15
|
+
decisionCriterionEvents,
|
|
16
|
+
decisionId,
|
|
17
|
+
normalizeDecisionCriterionInput,
|
|
18
|
+
normalizeDecisionText,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function recordDecisionCriterion({
|
|
22
|
+
target,
|
|
23
|
+
packageRoot,
|
|
24
|
+
decision,
|
|
25
|
+
settledBy,
|
|
26
|
+
taskId = null,
|
|
27
|
+
contractPath = null,
|
|
28
|
+
eventsPath = null,
|
|
29
|
+
}) {
|
|
30
|
+
const normalized = normalizeDecisionCriterionInput({ decision, settledBy });
|
|
31
|
+
const contract = await readContract(target, packageRoot, { taskId, contractPath });
|
|
32
|
+
if (!contract || !contract.value) {
|
|
33
|
+
const error = new Error("Current contract not found");
|
|
34
|
+
error.code = "E_CONTRACT_MISSING";
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const unresolved = contract.value.unresolvedDecisions ?? [];
|
|
39
|
+
if (!unresolved.includes(normalized.decision)) {
|
|
40
|
+
const error = new Error(`Decision "${normalized.decision}" is not present in current unresolvedDecisions`);
|
|
41
|
+
error.code = "E_DECISION_NOT_UNRESOLVED";
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ledger = await validateEventLedger(target, packageRoot, { taskId: taskId ?? null, eventsPath });
|
|
46
|
+
if (!ledger.valid) {
|
|
47
|
+
const first = ledger.errors[0];
|
|
48
|
+
const error = new Error(first.message);
|
|
49
|
+
error.code = first.code;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const decId = decisionId(normalized.decision);
|
|
54
|
+
const details = {
|
|
55
|
+
decision: normalized.decision,
|
|
56
|
+
decisionId: decId,
|
|
57
|
+
settledBy: normalized.settledBy,
|
|
58
|
+
contractFingerprint: contract.fingerprint,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
assertDecisionCriterionDetails(details);
|
|
62
|
+
|
|
63
|
+
const event = await appendProtocolEvent(
|
|
64
|
+
target,
|
|
65
|
+
{
|
|
66
|
+
taskId: contract.value.taskId,
|
|
67
|
+
event: "DECISION_CRITERION_RECORDED",
|
|
68
|
+
details,
|
|
69
|
+
},
|
|
70
|
+
packageRoot,
|
|
71
|
+
{ taskId: taskId ?? null, eventsPath },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
event,
|
|
76
|
+
criterion: details,
|
|
77
|
+
};
|
|
78
|
+
}
|