@cassiomc1/forgeloop 0.1.14 → 0.1.16
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/.forgeloop/forgeloop.gitignore +1 -0
- package/DOCS_INDEX.md +60 -0
- package/EXECUTION_STATE.md +9 -0
- package/LOOP_ENGINEERING.md +44 -6
- package/LOOP_SYSTEM_DESIGN.md +8 -0
- package/PROTOCOL_INTEGRATION.md +41 -0
- package/QUALITY_SCORECARD.md +2 -0
- package/README.md +183 -643
- package/TERMINOLOGY.md +4 -0
- package/THREAT_MODEL.md +14 -0
- package/docs/assets/forgeloop-flow.svg +1 -0
- package/docs/forgeloop-flow.mmd +51 -0
- package/package.json +16 -3
- package/schemas/check.schema.json +2 -0
- package/schemas/continuity.schema.json +57 -0
- package/schemas/execution.schema.json +63 -0
- package/scripts/CI_VALIDATORS.md +32 -0
- package/src/cli.js +351 -196
- package/src/commands/clear-continuity.js +9 -0
- package/src/commands/continuity.js +25 -0
- package/src/commands/doctor.js +17 -2
- package/src/commands/reconcile-continuity.js +23 -0
- package/src/commands/record-continuity.js +63 -0
- package/src/commands/run-check.js +83 -0
- package/src/commands/status.js +14 -1
- package/src/commands/update.js +12 -13
- package/src/commands/validate-protocol.js +37 -0
- package/src/core/artifacts.js +9 -0
- package/src/core/bundles.js +78 -0
- package/src/core/checks.js +16 -0
- package/src/core/command-resolution.js +295 -0
- package/src/core/command-tokenizer.js +122 -0
- package/src/core/completion-artifacts.js +229 -47
- package/src/core/completion.js +19 -1
- package/src/core/conformance.js +8 -2
- package/src/core/continuity-cli-options.js +58 -0
- package/src/core/continuity-conformance.js +46 -0
- package/src/core/continuity-observability.js +20 -0
- package/src/core/continuity-reconciliation.js +224 -0
- package/src/core/continuity.js +245 -0
- package/src/core/evidence-readiness.js +26 -1
- package/src/core/execution.js +185 -0
- package/src/core/inspect.js +9 -1
- package/src/core/installation-authority.js +178 -0
- package/src/core/json-safety.js +17 -13
- package/src/core/next-action-artifacts.js +118 -0
- package/src/core/next-action-continuity.js +65 -0
- package/src/core/next-action-model.js +127 -0
- package/src/core/next-action-phases.js +12 -0
- package/src/core/next-action.js +22 -249
- package/src/core/npm-classifier.js +343 -0
- package/src/core/package-manager-classifiers.js +37 -0
- package/src/core/preflight-consistency.js +221 -0
- package/src/core/preflight-loaders.js +112 -0
- package/src/core/preflight-model.js +83 -0
- package/src/core/preflight.js +34 -444
- package/src/core/protocol.js +7 -0
- package/src/core/schema-validation.js +16 -1
- package/src/core/templates.js +3 -0
- package/src/core/verification-capability.js +31 -495
- package/src/core/verification-constants.js +61 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { canonicalFingerprint } from "./artifacts.js";
|
|
2
|
+
import { assertContinuitySemantics, readContinuity } from "./continuity.js";
|
|
3
|
+
import { WORK_TRANSITIONS } from "./protocol.js";
|
|
4
|
+
|
|
5
|
+
const RECONCILIATION_CODE = "E_CONTINUITY_RECONCILIATION_REQUIRED";
|
|
6
|
+
|
|
7
|
+
function uniqueSorted(values) {
|
|
8
|
+
return [...new Set(values.filter(Boolean))].sort();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function pathCoveredByHint(changedPath, hint) {
|
|
12
|
+
const normalizedPath = String(changedPath).replaceAll("\\", "/").replace(/^\.\//, "");
|
|
13
|
+
const normalizedHint = String(hint).replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
14
|
+
return normalizedPath === normalizedHint || normalizedPath.startsWith(`${normalizedHint}/`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function compareChangedPaths(changedPaths, hints) {
|
|
18
|
+
if (!Array.isArray(changedPaths)) return "NOT_VERIFIED";
|
|
19
|
+
if (!Array.isArray(hints) || hints.length === 0) return changedPaths.length === 0 ? "MATCH" : "MISMATCH";
|
|
20
|
+
const matched = hints.filter((hint) => changedPaths.some((changedPath) => pathCoveredByHint(changedPath, hint))).length;
|
|
21
|
+
if (matched === hints.length) return "MATCH";
|
|
22
|
+
if (matched > 0) return "PARTIAL";
|
|
23
|
+
return "MISMATCH";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function compareRepository(saved, current) {
|
|
27
|
+
const savedUnavailable = !saved || (saved.branch === null && saved.head === null);
|
|
28
|
+
const currentUnavailable = !current || (current.branch === null && current.head === null);
|
|
29
|
+
if (savedUnavailable && currentUnavailable) return "NOT_VERIFIED";
|
|
30
|
+
if (!saved || !current) return "NOT_VERIFIED";
|
|
31
|
+
return saved.branch === current.branch && saved.head === current.head ? "MATCH" : "MISMATCH";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function phaseReachable(from, to) {
|
|
35
|
+
if (from === to) return true;
|
|
36
|
+
if (to === "BLOCKED" && from !== "COMPLETE") return true;
|
|
37
|
+
if (from === "BLOCKED") return false;
|
|
38
|
+
const queue = [from];
|
|
39
|
+
const visited = new Set(queue);
|
|
40
|
+
while (queue.length > 0) {
|
|
41
|
+
const phase = queue.shift();
|
|
42
|
+
for (const next of WORK_TRANSITIONS[phase] ?? []) {
|
|
43
|
+
if (next === to) return true;
|
|
44
|
+
if (!visited.has(next)) {
|
|
45
|
+
visited.add(next);
|
|
46
|
+
queue.push(next);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function baseResult(classification, overrides = {}) {
|
|
54
|
+
return {
|
|
55
|
+
classification,
|
|
56
|
+
taskMatches: overrides.taskMatches ?? null,
|
|
57
|
+
workStateMatches: overrides.workStateMatches ?? null,
|
|
58
|
+
contractMatches: overrides.contractMatches ?? null,
|
|
59
|
+
phaseMatches: overrides.phaseMatches ?? null,
|
|
60
|
+
repositoryComparison: overrides.repositoryComparison ?? "NOT_VERIFIED",
|
|
61
|
+
changedPathComparison: overrides.changedPathComparison ?? "NOT_VERIFIED",
|
|
62
|
+
reasonCodes: uniqueSorted(overrides.reasonCodes ?? []),
|
|
63
|
+
reasons: uniqueSorted(overrides.reasons ?? []),
|
|
64
|
+
authority: "OPERATIONAL_CONTEXT_ONLY",
|
|
65
|
+
evidenceAuthority: "NONE",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function classifyContinuity({
|
|
70
|
+
continuity,
|
|
71
|
+
state,
|
|
72
|
+
contractFingerprint,
|
|
73
|
+
repositoryFingerprint,
|
|
74
|
+
changedPaths,
|
|
75
|
+
} = {}) {
|
|
76
|
+
if (!continuity) {
|
|
77
|
+
return baseResult("ABSENT", { reasons: ["CONTINUITY_ABSENT"] });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let value;
|
|
81
|
+
try {
|
|
82
|
+
value = assertContinuitySemantics(continuity);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return baseResult("INVALID", {
|
|
85
|
+
reasonCodes: [error.code ?? "E_CONTINUITY_INVALID"],
|
|
86
|
+
reasons: [error.message],
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!state || typeof state !== "object") {
|
|
91
|
+
return baseResult("INCONSISTENT", {
|
|
92
|
+
reasonCodes: ["E_CONTINUITY_STATE_MISSING"],
|
|
93
|
+
reasons: ["CONTINUITY_STATE_MISSING"],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (state.phase === "COMPLETE") {
|
|
98
|
+
return baseResult("NOT_APPLICABLE", {
|
|
99
|
+
taskMatches: value.taskId === state.taskId,
|
|
100
|
+
phaseMatches: value.phase === state.phase,
|
|
101
|
+
reasons: ["CONTINUITY_NOT_APPLICABLE_AFTER_COMPLETE"],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const taskMatches = value.taskId === state.taskId;
|
|
106
|
+
const workStateMatches = value.workStateFingerprint === canonicalFingerprint(state);
|
|
107
|
+
const expectedContract = contractFingerprint ?? state.contractFingerprint ?? null;
|
|
108
|
+
const contractMatches = value.contractFingerprint === state.contractFingerprint
|
|
109
|
+
&& (expectedContract === null || value.contractFingerprint === expectedContract);
|
|
110
|
+
const phaseMatches = value.phase === state.phase;
|
|
111
|
+
const repositoryComparison = compareRepository(value.repositoryFingerprint, repositoryFingerprint);
|
|
112
|
+
const changedPathComparison = compareChangedPaths(changedPaths, value.changedAreas);
|
|
113
|
+
|
|
114
|
+
const inconsistentCodes = [];
|
|
115
|
+
const inconsistentReasons = [];
|
|
116
|
+
if (!taskMatches) {
|
|
117
|
+
inconsistentCodes.push("E_CONTINUITY_TASK_MISMATCH");
|
|
118
|
+
inconsistentReasons.push("CONTINUITY_TASK_MISMATCH");
|
|
119
|
+
}
|
|
120
|
+
if (!contractMatches) {
|
|
121
|
+
inconsistentCodes.push("E_CONTINUITY_CONTRACT_MISMATCH");
|
|
122
|
+
inconsistentReasons.push("CONTINUITY_CONTRACT_MISMATCH");
|
|
123
|
+
}
|
|
124
|
+
if (!phaseMatches && !phaseReachable(value.phase, state.phase)) {
|
|
125
|
+
inconsistentCodes.push("E_CONTINUITY_PHASE_MISMATCH");
|
|
126
|
+
inconsistentReasons.push("CONTINUITY_PHASE_MISMATCH");
|
|
127
|
+
}
|
|
128
|
+
if (inconsistentCodes.length > 0) {
|
|
129
|
+
return baseResult("INCONSISTENT", {
|
|
130
|
+
taskMatches,
|
|
131
|
+
workStateMatches,
|
|
132
|
+
contractMatches,
|
|
133
|
+
phaseMatches,
|
|
134
|
+
repositoryComparison,
|
|
135
|
+
changedPathComparison,
|
|
136
|
+
reasonCodes: inconsistentCodes,
|
|
137
|
+
reasons: inconsistentReasons,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const reconciliationReasons = [];
|
|
142
|
+
if (!workStateMatches) reconciliationReasons.push("CONTINUITY_WORK_STATE_CHANGED");
|
|
143
|
+
if (!phaseMatches) reconciliationReasons.push("CONTINUITY_PHASE_CHANGED");
|
|
144
|
+
if (repositoryComparison === "MISMATCH") reconciliationReasons.push("CONTINUITY_REPOSITORY_CHANGED");
|
|
145
|
+
if (["PARTIAL", "MISMATCH"].includes(changedPathComparison)) {
|
|
146
|
+
reconciliationReasons.push("CONTINUITY_CHANGED_PATHS_DIFFER");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (reconciliationReasons.length > 0) {
|
|
150
|
+
return baseResult("RECONCILIATION_REQUIRED", {
|
|
151
|
+
taskMatches,
|
|
152
|
+
workStateMatches,
|
|
153
|
+
contractMatches,
|
|
154
|
+
phaseMatches,
|
|
155
|
+
repositoryComparison,
|
|
156
|
+
changedPathComparison,
|
|
157
|
+
reasonCodes: [RECONCILIATION_CODE],
|
|
158
|
+
reasons: reconciliationReasons,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return baseResult("FRESH", {
|
|
163
|
+
taskMatches,
|
|
164
|
+
workStateMatches,
|
|
165
|
+
contractMatches,
|
|
166
|
+
phaseMatches,
|
|
167
|
+
repositoryComparison,
|
|
168
|
+
changedPathComparison,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function reconcileContinuity({ target, packageRoot } = {}) {
|
|
173
|
+
const [{ readWorkState }, { readContract }, repository] = await Promise.all([
|
|
174
|
+
import("./work-state.js"),
|
|
175
|
+
import("./contract.js"),
|
|
176
|
+
import("./repository.js"),
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
const state = await readWorkState(target, packageRoot);
|
|
180
|
+
let continuityArtifact;
|
|
181
|
+
try {
|
|
182
|
+
continuityArtifact = await readContinuity(target, packageRoot);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error.code === "ARTIFACT_MISSING") {
|
|
185
|
+
return { ...classifyContinuity({ continuity: null, state }), path: ".forgeloop/continuity.json", present: false };
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
...baseResult("INVALID", {
|
|
189
|
+
reasonCodes: [error.code ?? "E_CONTINUITY_INVALID"],
|
|
190
|
+
reasons: [error.message],
|
|
191
|
+
}),
|
|
192
|
+
path: ".forgeloop/continuity.json",
|
|
193
|
+
present: true,
|
|
194
|
+
error: error.message,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let contractFingerprint = null;
|
|
199
|
+
try {
|
|
200
|
+
const contract = await readContract(target, packageRoot);
|
|
201
|
+
contractFingerprint = contract.fingerprint;
|
|
202
|
+
} catch {
|
|
203
|
+
contractFingerprint = null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const [repositoryFingerprint, changedPaths] = await Promise.all([
|
|
207
|
+
repository.currentRepositoryFingerprint(target),
|
|
208
|
+
repository.currentChangedPaths(target),
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
...classifyContinuity({
|
|
213
|
+
continuity: continuityArtifact.value,
|
|
214
|
+
state,
|
|
215
|
+
contractFingerprint,
|
|
216
|
+
repositoryFingerprint,
|
|
217
|
+
changedPaths,
|
|
218
|
+
}),
|
|
219
|
+
path: continuityArtifact.path,
|
|
220
|
+
present: true,
|
|
221
|
+
fingerprint: continuityArtifact.fingerprint,
|
|
222
|
+
continuity: continuityArtifact.value,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { unlink } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ARTIFACT_PATHS,
|
|
6
|
+
canonicalFingerprint,
|
|
7
|
+
readJsonArtifact,
|
|
8
|
+
writeJsonArtifact,
|
|
9
|
+
} from "./artifacts.js";
|
|
10
|
+
import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
|
|
11
|
+
import { PROTOCOL_VERSION, WORK_PHASES } from "./protocol.js";
|
|
12
|
+
import { assertSecretFree } from "./receipt.js";
|
|
13
|
+
import { getPackageRoot } from "./templates.js";
|
|
14
|
+
|
|
15
|
+
export const CONTINUITY_PATH = ARTIFACT_PATHS.continuity;
|
|
16
|
+
export const CONTINUITY_SCHEMA_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
const LIMITS = Object.freeze({
|
|
19
|
+
taskId: 128,
|
|
20
|
+
id: 128,
|
|
21
|
+
summary: 1000,
|
|
22
|
+
path: 1024,
|
|
23
|
+
resumeNote: 2000,
|
|
24
|
+
remainingWork: 40,
|
|
25
|
+
knownIssues: 40,
|
|
26
|
+
changedAreas: 80,
|
|
27
|
+
inspectFirst: 40,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function continuityError(code, message, artifacts = [CONTINUITY_PATH]) {
|
|
31
|
+
const error = new Error(message);
|
|
32
|
+
error.name = "ContinuityError";
|
|
33
|
+
error.code = code;
|
|
34
|
+
error.artifacts = artifacts;
|
|
35
|
+
return error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nonEmptyString(value, label, maxLength) {
|
|
39
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
40
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} must be a non-empty string`);
|
|
41
|
+
}
|
|
42
|
+
if (value.length > maxLength) {
|
|
43
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} exceeds the ${maxLength}-character limit`);
|
|
44
|
+
}
|
|
45
|
+
if (/\p{Cc}/u.test(value)) {
|
|
46
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} contains control characters`);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fingerprint(value, label) {
|
|
52
|
+
if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
|
|
53
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} must be a lowercase SHA-256 fingerprint`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeProjectPath(value, label) {
|
|
59
|
+
nonEmptyString(value, label, LIMITS.path);
|
|
60
|
+
const portable = value.replaceAll("\\", "/");
|
|
61
|
+
if (portable.startsWith("/") || /^[A-Za-z]:\//.test(portable)) {
|
|
62
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} must remain a relative project path`);
|
|
63
|
+
}
|
|
64
|
+
const normalized = path.posix.normalize(portable);
|
|
65
|
+
if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
|
|
66
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} must not escape the task target`);
|
|
67
|
+
}
|
|
68
|
+
return normalized.replace(/^\.\//, "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeWorkItem(value, label) {
|
|
72
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
73
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} must be an object`);
|
|
74
|
+
}
|
|
75
|
+
const keys = Object.keys(value).sort();
|
|
76
|
+
if (keys.some((key) => !["id", "summary"].includes(key))) {
|
|
77
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} contains an unsupported field`);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
id: nonEmptyString(value.id, `${label}.id`, LIMITS.id),
|
|
81
|
+
summary: nonEmptyString(value.summary, `${label}.summary`, LIMITS.summary),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeWorkItems(value, label, maxItems) {
|
|
86
|
+
if (!Array.isArray(value)) throw continuityError("E_CONTINUITY_INVALID", `${label} must be an array`);
|
|
87
|
+
if (value.length > maxItems) throw continuityError("E_CONTINUITY_INVALID", `${label} exceeds the ${maxItems}-item limit`);
|
|
88
|
+
const items = value.map((item, index) => normalizeWorkItem(item, `${label}[${index}]`));
|
|
89
|
+
if (new Set(items.map((item) => item.id)).size !== items.length) {
|
|
90
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} must not contain duplicate ids`);
|
|
91
|
+
}
|
|
92
|
+
return items;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizePaths(value, label, maxItems) {
|
|
96
|
+
if (!Array.isArray(value)) throw continuityError("E_CONTINUITY_INVALID", `${label} must be an array`);
|
|
97
|
+
if (value.length > maxItems) throw continuityError("E_CONTINUITY_INVALID", `${label} exceeds the ${maxItems}-item limit`);
|
|
98
|
+
const paths = value.map((item, index) => normalizeProjectPath(item, `${label}[${index}]`));
|
|
99
|
+
if (new Set(paths).size !== paths.length) throw continuityError("E_CONTINUITY_INVALID", `${label} must not contain duplicate paths`);
|
|
100
|
+
return paths;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeRepositoryFingerprint(value) {
|
|
104
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
105
|
+
throw continuityError("E_CONTINUITY_INVALID", "repositoryFingerprint must be an object");
|
|
106
|
+
}
|
|
107
|
+
if (Object.keys(value).some((key) => !["branch", "head"].includes(key))) {
|
|
108
|
+
throw continuityError("E_CONTINUITY_INVALID", "repositoryFingerprint contains an unsupported field");
|
|
109
|
+
}
|
|
110
|
+
for (const key of ["branch", "head"]) {
|
|
111
|
+
if (value[key] !== null && typeof value[key] !== "string") {
|
|
112
|
+
throw continuityError("E_CONTINUITY_INVALID", `repositoryFingerprint.${key} must be a string or null`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { branch: value.branch ?? null, head: value.head ?? null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const CONTINUITY_FIELDS = new Set([
|
|
119
|
+
"schemaVersion",
|
|
120
|
+
"protocolVersion",
|
|
121
|
+
"taskId",
|
|
122
|
+
"workStateFingerprint",
|
|
123
|
+
"contractFingerprint",
|
|
124
|
+
"phase",
|
|
125
|
+
"verificationCycle",
|
|
126
|
+
"repositoryFingerprint",
|
|
127
|
+
"updatedAt",
|
|
128
|
+
"currentFocus",
|
|
129
|
+
"remainingWork",
|
|
130
|
+
"knownIssues",
|
|
131
|
+
"changedAreas",
|
|
132
|
+
"inspectFirst",
|
|
133
|
+
"resumeNote",
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
export function assertContinuitySemantics(input) {
|
|
137
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
138
|
+
throw continuityError("E_CONTINUITY_INVALID", "Continuity must be a JSON object");
|
|
139
|
+
}
|
|
140
|
+
const unknownField = Object.keys(input).find((key) => !CONTINUITY_FIELDS.has(key));
|
|
141
|
+
if (unknownField) {
|
|
142
|
+
throw continuityError("E_CONTINUITY_INVALID", `Continuity contains unsupported field: ${unknownField}`);
|
|
143
|
+
}
|
|
144
|
+
if (input.schemaVersion !== CONTINUITY_SCHEMA_VERSION || input.protocolVersion !== PROTOCOL_VERSION) {
|
|
145
|
+
throw continuityError("E_CONTINUITY_SCHEMA_UNSUPPORTED", "Unsupported continuity schema or protocol version");
|
|
146
|
+
}
|
|
147
|
+
if (!WORK_PHASES.includes(input.phase)) {
|
|
148
|
+
throw continuityError("E_CONTINUITY_INVALID", `Unknown continuity phase: ${input.phase}`);
|
|
149
|
+
}
|
|
150
|
+
if (input.verificationCycle !== undefined
|
|
151
|
+
&& (!Number.isInteger(input.verificationCycle) || input.verificationCycle < 1)) {
|
|
152
|
+
throw continuityError("E_CONTINUITY_INVALID", "verificationCycle must be a positive integer");
|
|
153
|
+
}
|
|
154
|
+
const normalized = {
|
|
155
|
+
schemaVersion: CONTINUITY_SCHEMA_VERSION,
|
|
156
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
157
|
+
taskId: nonEmptyString(input.taskId, "taskId", LIMITS.taskId),
|
|
158
|
+
workStateFingerprint: fingerprint(input.workStateFingerprint, "workStateFingerprint"),
|
|
159
|
+
contractFingerprint: fingerprint(input.contractFingerprint, "contractFingerprint"),
|
|
160
|
+
phase: input.phase,
|
|
161
|
+
...(input.verificationCycle !== undefined ? { verificationCycle: input.verificationCycle } : {}),
|
|
162
|
+
repositoryFingerprint: normalizeRepositoryFingerprint(input.repositoryFingerprint),
|
|
163
|
+
updatedAt: nonEmptyString(input.updatedAt, "updatedAt", 128),
|
|
164
|
+
...(input.currentFocus !== undefined ? { currentFocus: normalizeWorkItem(input.currentFocus, "currentFocus") } : {}),
|
|
165
|
+
remainingWork: normalizeWorkItems(input.remainingWork, "remainingWork", LIMITS.remainingWork),
|
|
166
|
+
knownIssues: normalizeWorkItems(input.knownIssues, "knownIssues", LIMITS.knownIssues),
|
|
167
|
+
changedAreas: normalizePaths(input.changedAreas, "changedAreas", LIMITS.changedAreas),
|
|
168
|
+
inspectFirst: normalizePaths(input.inspectFirst, "inspectFirst", LIMITS.inspectFirst),
|
|
169
|
+
...(input.resumeNote !== undefined
|
|
170
|
+
? { resumeNote: nonEmptyString(input.resumeNote, "resumeNote", LIMITS.resumeNote) }
|
|
171
|
+
: {}),
|
|
172
|
+
};
|
|
173
|
+
assertSecretFree(normalized);
|
|
174
|
+
return normalized;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function createContinuity(input) {
|
|
178
|
+
return assertContinuitySemantics({
|
|
179
|
+
schemaVersion: input.schemaVersion ?? CONTINUITY_SCHEMA_VERSION,
|
|
180
|
+
protocolVersion: input.protocolVersion ?? PROTOCOL_VERSION,
|
|
181
|
+
...structuredClone(input),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function readContinuity(target, packageRoot = getPackageRoot()) {
|
|
186
|
+
const artifact = await readJsonArtifact(target, CONTINUITY_PATH, "continuity", packageRoot);
|
|
187
|
+
return { ...artifact, value: assertContinuitySemantics(artifact.value) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function writeContinuity(target, operationalInput = {}, options = {}) {
|
|
191
|
+
const packageRoot = options.packageRoot ?? getPackageRoot();
|
|
192
|
+
let state = options.state;
|
|
193
|
+
if (!state) {
|
|
194
|
+
const { readWorkState } = await import("./work-state.js");
|
|
195
|
+
state = await readWorkState(target, packageRoot);
|
|
196
|
+
}
|
|
197
|
+
if (!state) throw continuityError("E_CONTINUITY_STATE_MISSING", "Cannot record continuity without work state", [ARTIFACT_PATHS.state]);
|
|
198
|
+
|
|
199
|
+
let contract = options.contract;
|
|
200
|
+
if (!contract) {
|
|
201
|
+
const { readContract } = await import("./contract.js");
|
|
202
|
+
contract = await readContract(target, packageRoot);
|
|
203
|
+
}
|
|
204
|
+
const currentContractFingerprint = contract?.fingerprint
|
|
205
|
+
?? (contract ? canonicalFingerprint(contract.value ?? contract) : null);
|
|
206
|
+
if (!currentContractFingerprint) {
|
|
207
|
+
throw continuityError("E_CONTINUITY_CONTRACT_MISMATCH", "Cannot bind continuity to the current contract", [ARTIFACT_PATHS.contract]);
|
|
208
|
+
}
|
|
209
|
+
if (state.contractFingerprint !== currentContractFingerprint) {
|
|
210
|
+
throw continuityError(
|
|
211
|
+
"E_CONTINUITY_CONTRACT_MISMATCH",
|
|
212
|
+
"Work state contract fingerprint does not match the current contract",
|
|
213
|
+
[ARTIFACT_PATHS.state, ARTIFACT_PATHS.contract],
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const value = createContinuity({
|
|
217
|
+
taskId: state.taskId,
|
|
218
|
+
workStateFingerprint: canonicalFingerprint(state),
|
|
219
|
+
contractFingerprint: state.contractFingerprint,
|
|
220
|
+
phase: state.phase,
|
|
221
|
+
...(state.verificationCycle !== undefined ? { verificationCycle: state.verificationCycle } : {}),
|
|
222
|
+
repositoryFingerprint: options.repositoryFingerprint ?? await (async () => {
|
|
223
|
+
const { currentRepositoryFingerprint } = await import("./repository.js");
|
|
224
|
+
return currentRepositoryFingerprint(target);
|
|
225
|
+
})(),
|
|
226
|
+
updatedAt: options.now ?? new Date().toISOString(),
|
|
227
|
+
...(operationalInput.currentFocus !== undefined ? { currentFocus: operationalInput.currentFocus } : {}),
|
|
228
|
+
remainingWork: operationalInput.remainingWork ?? [],
|
|
229
|
+
knownIssues: operationalInput.knownIssues ?? [],
|
|
230
|
+
changedAreas: operationalInput.changedAreas ?? [],
|
|
231
|
+
inspectFirst: operationalInput.inspectFirst ?? [],
|
|
232
|
+
...(operationalInput.resumeNote !== undefined ? { resumeNote: operationalInput.resumeNote } : {}),
|
|
233
|
+
});
|
|
234
|
+
return writeJsonArtifact(target, CONTINUITY_PATH, value, "continuity", packageRoot, {
|
|
235
|
+
dryRun: options.dryRun ?? false,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function clearContinuity(target) {
|
|
240
|
+
await assertSafePath(target, CONTINUITY_PATH);
|
|
241
|
+
const artifactPath = ensureWithin(target, CONTINUITY_PATH);
|
|
242
|
+
if (!(await fileExists(artifactPath))) return { removed: false, path: CONTINUITY_PATH };
|
|
243
|
+
await unlink(artifactPath);
|
|
244
|
+
return { removed: true, path: CONTINUITY_PATH };
|
|
245
|
+
}
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { sha256 } from "./manifest.js";
|
|
2
2
|
import { validateVerificationAuthority } from "./verification-capability.js";
|
|
3
3
|
|
|
4
|
+
export const E_COMMAND_PROVENANCE_UNATTESTED = "E_COMMAND_PROVENANCE_UNATTESTED";
|
|
5
|
+
|
|
6
|
+
export function validateCommandProvenance(check) {
|
|
7
|
+
if (check?.kind !== "command" || check.evidenceKind !== "OBSERVED") {
|
|
8
|
+
return { valid: true, error: null };
|
|
9
|
+
}
|
|
10
|
+
if (check.provenance !== "FORGELOOP_EXECUTED" || typeof check.executionRef !== "string" || check.executionRef.trim() === "") {
|
|
11
|
+
return {
|
|
12
|
+
valid: false,
|
|
13
|
+
error: {
|
|
14
|
+
code: E_COMMAND_PROVENANCE_UNATTESTED,
|
|
15
|
+
message: "Observed command evidence requires a ForgeLoop execution artifact",
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return { valid: true, error: null };
|
|
20
|
+
}
|
|
21
|
+
|
|
4
22
|
export const REQUIREMENT_TYPES = Object.freeze([
|
|
5
23
|
"PRODUCT",
|
|
6
24
|
"VERIFICATION",
|
|
@@ -256,6 +274,8 @@ function componentStatus(check, requirement, allChecks = [], options = {}) {
|
|
|
256
274
|
item?.requirementId === child.id || item?.requirement === child.text
|
|
257
275
|
)).at(-1);
|
|
258
276
|
if (matchingComp) {
|
|
277
|
+
const provenance = validateCommandProvenance(matchingComp);
|
|
278
|
+
if (!provenance.valid) return { ...matchingComp, status: "failed", reasonCode: provenance.error.code };
|
|
259
279
|
const auth = validateVerificationAuthority(matchingComp, options);
|
|
260
280
|
if (!auth.valid) return { ...matchingComp, status: "failed", reasonCode: auth.error.code };
|
|
261
281
|
return matchingComp;
|
|
@@ -264,6 +284,8 @@ function componentStatus(check, requirement, allChecks = [], options = {}) {
|
|
|
264
284
|
const childCandidates = allChecks.filter((candidate) => matchesRequirement(candidate, child));
|
|
265
285
|
const childCheck = latestAuthoritativeCheck(childCandidates);
|
|
266
286
|
if (childCheck) {
|
|
287
|
+
const provenance = validateCommandProvenance(childCheck);
|
|
288
|
+
if (!provenance.valid) return { ...childCheck, status: "failed", reasonCode: provenance.error.code };
|
|
267
289
|
const auth = validateVerificationAuthority(childCheck, options);
|
|
268
290
|
if (!auth.valid) return { ...childCheck, status: "failed", reasonCode: auth.error.code };
|
|
269
291
|
}
|
|
@@ -314,7 +336,10 @@ export function evaluateRequiredEvidence({
|
|
|
314
336
|
}
|
|
315
337
|
const candidates = checks.filter((check) => matchesRequirement(check, requirement));
|
|
316
338
|
const check = latestAuthoritativeCheck(candidates);
|
|
317
|
-
const
|
|
339
|
+
const provenance = check ? validateCommandProvenance(check) : { valid: true };
|
|
340
|
+
const auth = provenance.valid
|
|
341
|
+
? (check ? validateVerificationAuthority(check, authOptions) : { valid: true })
|
|
342
|
+
: provenance;
|
|
318
343
|
const compound = componentStatus(check, requirement, checks, authOptions);
|
|
319
344
|
if (!auth.valid) {
|
|
320
345
|
result.invalid.push({ ...requirement, reasonCode: auth.error.code });
|