@cassiomc1/forgeloop 1.5.0 → 1.6.0
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/DOCS_INDEX.md +13 -8
- package/EXECUTION_STATE.md +20 -0
- package/LOOP_ENGINEERING.md +81 -0
- package/LOOP_SYSTEM_DESIGN.md +32 -0
- package/PROTOCOL_INTEGRATION.md +46 -0
- package/QUALITY_SCORECARD.md +2 -0
- package/README.md +31 -9
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/THREAT_MODEL.md +39 -0
- package/docs/ARTIFACT_REFERENCE.md +140 -0
- package/docs/CLI_REFERENCE.md +294 -3
- package/docs/DIAGNOSTIC_MODEL.md +181 -0
- package/docs/DOCUMENTATION_GUIDE.md +22 -13
- package/docs/EXECUTION_TRACE.md +76 -0
- package/docs/MCP.md +33 -0
- package/docs/RECIPES.md +67 -0
- package/docs/TROUBLESHOOTING.md +106 -2
- package/docs/assets/diagrams/forgeloop-engineering-flow.html +13797 -0
- package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +37 -0
- package/docs/assets/diagrams/forgeloop-engineering-flow.svg +5002 -0
- package/docs/diagrams/README.md +55 -0
- package/docs/diagrams/forgeloop-engineering-flow.workflow.json +122 -0
- package/docs/diagrams/manifest.json +42 -0
- package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +20 -0
- package/package.json +8 -6
- package/schemas/action.schema.json +100 -0
- package/schemas/approval.schema.json +51 -0
- package/schemas/capability-policy.schema.json +41 -0
- package/schemas/diagnostic-case.schema.json +85 -0
- package/schemas/execution-receipt.schema.json +16 -0
- package/schemas/hypothesis-disposition.schema.json +16 -0
- package/schemas/intervention.schema.json +27 -0
- package/schemas/policy-lock.schema.json +1 -0
- package/schemas/policy-snapshot.schema.json +2 -0
- package/schemas/trajectory-evaluation.schema.json +64 -0
- package/schemas/trajectory-scenario.schema.json +42 -0
- package/src/cli.js +94 -0
- package/src/commands/action-authorize.js +41 -0
- package/src/commands/action-propose.js +10 -0
- package/src/commands/action-reconcile.js +10 -0
- package/src/commands/action-record.js +47 -0
- package/src/commands/action-show.js +10 -0
- package/src/commands/action-verify.js +10 -0
- package/src/commands/advance.js +7 -2
- package/src/commands/approval-request.js +64 -0
- package/src/commands/approval-resolve.js +10 -0
- package/src/commands/baseline.js +3 -3
- package/src/commands/eval.js +6 -0
- package/src/commands/history.js +18 -0
- package/src/commands/init.js +2 -2
- package/src/commands/inspect.js +49 -0
- package/src/commands/metrics.js +7 -0
- package/src/commands/next.js +8 -2
- package/src/commands/policy-discover.js +2 -2
- package/src/commands/record-diagnosis.js +37 -1
- package/src/commands/record-hypothesis-disposition.js +45 -0
- package/src/commands/record-intervention.js +35 -0
- package/src/commands/reflect.js +38 -0
- package/src/commands/report.js +9 -1
- package/src/commands/run-action.js +18 -0
- package/src/commands/trace.js +34 -0
- package/src/commands/validate-protocol.js +21 -13
- package/src/core/action-authorization.js +106 -0
- package/src/core/action-constants.js +86 -0
- package/src/core/action-execution.js +105 -0
- package/src/core/action-ledger-projection.js +302 -0
- package/src/core/action-model.js +581 -0
- package/src/core/action-readiness.js +141 -0
- package/src/core/action-reconciliation-policy.js +49 -0
- package/src/core/action-reconciliation.js +66 -0
- package/src/core/action-verification.js +111 -0
- package/src/core/actions.js +462 -0
- package/src/core/approvals.js +405 -0
- package/src/core/artifact-registry.js +48 -0
- package/src/core/audit.js +25 -0
- package/src/core/bundles.js +15 -0
- package/src/core/capability-policy.js +226 -0
- package/src/core/cli-command-definitions.js +210 -1
- package/src/core/command-executors.js +171 -15
- package/src/core/command-runtime.js +12 -1
- package/src/core/completion-artifacts.js +37 -12
- package/src/core/completion-recovery-rebind.js +194 -0
- package/src/core/completion.js +70 -0
- package/src/core/continuity-reconciliation.js +24 -5
- package/src/core/diagnostic-model.js +396 -0
- package/src/core/diagnostic-projection.js +51 -0
- package/src/core/diagnostic-record.js +360 -0
- package/src/core/error-codes.js +343 -0
- package/src/core/events.js +41 -1
- package/src/core/execution-prerequisites.js +4 -1
- package/src/core/execution.js +26 -188
- package/src/core/failure-signature.js +70 -0
- package/src/core/failure-surface.js +57 -0
- package/src/core/history.js +110 -0
- package/src/core/hypothesis-projection.js +85 -0
- package/src/core/information-gain-projection.js +283 -0
- package/src/core/information-gain.js +138 -0
- package/src/core/inspect.js +105 -7
- package/src/core/integration-invocation-policy.js +47 -0
- package/src/core/integration-resources.js +51 -0
- package/src/core/next-action-model.js +35 -1
- package/src/core/next-action.js +459 -3
- package/src/core/phase.js +40 -21
- package/src/core/policy-engine.js +113 -6
- package/src/core/preflight-consistency.js +31 -5
- package/src/core/preflight.js +19 -2
- package/src/core/prepared-execution.js +227 -0
- package/src/core/progress.js +41 -4
- package/src/core/protocol-info.js +48 -0
- package/src/core/protocol.js +14 -0
- package/src/core/receipt.js +1 -0
- package/src/core/reconcile-closure.js +15 -12
- package/src/core/reflection.js +305 -0
- package/src/core/resumability.js +57 -3
- package/src/core/schema-validation.js +8 -0
- package/src/core/strategy-analysis.js +97 -0
- package/src/core/task-paths.js +28 -0
- package/src/core/task-snapshot.js +53 -0
- package/src/core/templates.js +8 -0
- package/src/core/trace.js +548 -0
- package/src/core/trajectory-evaluation.js +71 -0
- package/src/core/trajectory-metrics.js +80 -0
- package/src/core/transaction.js +8 -0
- package/src/core/work-state.js +10 -5
- package/docs/assets/forgeloop-flow.svg +0 -1
- package/docs/forgeloop-flow.mmd +0 -51
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { getActiveTaskTransaction, withTaskTransaction } from "./transaction.js";
|
|
5
|
+
import { appendProtocolEvent } from "./events.js";
|
|
6
|
+
import { canonicalFingerprint } from "./artifacts.js";
|
|
7
|
+
import {
|
|
8
|
+
assertApprovalIdFormat,
|
|
9
|
+
assertApprovalEventDetails,
|
|
10
|
+
validateApprovalArtifact,
|
|
11
|
+
} from "./action-model.js";
|
|
12
|
+
import {
|
|
13
|
+
E_ACTION_AUTHORITY_REQUIRED,
|
|
14
|
+
E_ACTION_NOT_FOUND,
|
|
15
|
+
E_APPROVAL_ALREADY_RESOLVED,
|
|
16
|
+
E_APPROVAL_INVALID,
|
|
17
|
+
E_APPROVAL_STALE,
|
|
18
|
+
} from "./error-codes.js";
|
|
19
|
+
import { assertSafePath, ensureWithin } from "./filesystem.js";
|
|
20
|
+
import { isTrustedHostAuthorityContext } from "./capability-policy.js";
|
|
21
|
+
import { taskApprovalPath, taskDirectory, TASK_ARTIFACT_FILES } from "./task-paths.js";
|
|
22
|
+
import { readAction } from "./actions.js";
|
|
23
|
+
import { readWorkState } from "./work-state.js";
|
|
24
|
+
|
|
25
|
+
function approvalError(code, message) {
|
|
26
|
+
const error = new Error(message);
|
|
27
|
+
error.code = code;
|
|
28
|
+
return error;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readApprovalFile(target, taskId, approvalId) {
|
|
32
|
+
const relPath = taskApprovalPath(taskId, approvalId);
|
|
33
|
+
await assertSafePath(target, relPath);
|
|
34
|
+
let text;
|
|
35
|
+
try {
|
|
36
|
+
text = await readFile(ensureWithin(target, relPath), "utf8");
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error?.code === "ENOENT") return null;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(text);
|
|
43
|
+
} catch {
|
|
44
|
+
throw approvalError(E_APPROVAL_INVALID, `approval artifact is not valid JSON: ${relPath}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function writeApprovalFile(target, taskId, approval) {
|
|
49
|
+
const relPath = taskApprovalPath(taskId, approval.approvalId);
|
|
50
|
+
await assertSafePath(target, relPath);
|
|
51
|
+
const serialized = `${JSON.stringify(approval, null, 2)}\n`;
|
|
52
|
+
const activeTransaction = getActiveTaskTransaction();
|
|
53
|
+
if (activeTransaction) {
|
|
54
|
+
await activeTransaction.stageText(relPath, serialized);
|
|
55
|
+
} else {
|
|
56
|
+
const absolute = ensureWithin(target, relPath);
|
|
57
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
58
|
+
await writeFile(absolute, serialized, "utf8");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function listApprovalFiles(target, taskId) {
|
|
63
|
+
const relDir = `${taskDirectory(taskId)}/${TASK_ARTIFACT_FILES.approvals}`;
|
|
64
|
+
await assertSafePath(target, relDir);
|
|
65
|
+
const absoluteDir = ensureWithin(target, relDir);
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = await readdir(absoluteDir);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error?.code === "ENOENT") return [];
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
const approvals = [];
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (!entry.endsWith(".json")) continue;
|
|
76
|
+
const parsed = await readApprovalFile(target, taskId, entry.replace(/\.json$/, ""));
|
|
77
|
+
if (parsed) approvals.push(parsed);
|
|
78
|
+
}
|
|
79
|
+
approvals.sort((left, right) => String(left.requestedAt).localeCompare(String(right.requestedAt)));
|
|
80
|
+
return approvals;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function approvalBindingFields(approval) {
|
|
84
|
+
return {
|
|
85
|
+
taskId: approval.taskId,
|
|
86
|
+
actionId: approval.actionId,
|
|
87
|
+
actionFingerprint: approval.actionFingerprint,
|
|
88
|
+
contractFingerprint: approval.contractFingerprint,
|
|
89
|
+
taskRevision: approval.taskRevision,
|
|
90
|
+
capability: approval.capability,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function approvalFingerprintPayload(approval) {
|
|
95
|
+
return {
|
|
96
|
+
...approvalBindingFields(approval),
|
|
97
|
+
status: approval.status,
|
|
98
|
+
decision: approval.decision ?? null,
|
|
99
|
+
resolvedAt: approval.resolvedAt ?? null,
|
|
100
|
+
authorityKind: approval.authorityKind ?? null,
|
|
101
|
+
hostGrantRef: approval.hostGrantRef ?? null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Canonical approval fingerprint. Binds the full resolved approval content —
|
|
107
|
+
* including its resolution authority — so any post-authorization mutation of
|
|
108
|
+
* the approval artifact is detectable during ledger replay/audit.
|
|
109
|
+
*/
|
|
110
|
+
export function approvalFingerprint(approval) {
|
|
111
|
+
return canonicalFingerprint(approvalFingerprintPayload(approval));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function assertApprovalFresh(approval, expectedBinding) {
|
|
115
|
+
assertApprovalBinding(approval, expectedBinding);
|
|
116
|
+
if (approval.status !== "APPROVED") {
|
|
117
|
+
throw approvalError(E_APPROVAL_INVALID, `approval ${approval.approvalId} is not APPROVED`);
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validate only the immutable binding between an approval and the current
|
|
124
|
+
* action/task revision. Pending approvals use this check before they become
|
|
125
|
+
* the active guidance target; approval status is intentionally evaluated by
|
|
126
|
+
* the current capability policy, not by historical approval state.
|
|
127
|
+
*/
|
|
128
|
+
export function assertApprovalBinding(approval, expectedBinding) {
|
|
129
|
+
if (!expectedBinding || typeof expectedBinding !== "object") {
|
|
130
|
+
throw approvalError(E_APPROVAL_INVALID, "expected binding must be an object");
|
|
131
|
+
}
|
|
132
|
+
for (const [key, value] of Object.entries(approvalBindingFields(approval))) {
|
|
133
|
+
if (expectedBinding[key] !== value) {
|
|
134
|
+
throw approvalError(
|
|
135
|
+
E_APPROVAL_STALE,
|
|
136
|
+
`approval ${approval.approvalId} is stale: bound ${key} does not match the current task/action state`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function assertRequestInput(input) {
|
|
144
|
+
if (!input || typeof input !== "object") {
|
|
145
|
+
throw approvalError(E_APPROVAL_INVALID, "approval input must be an object");
|
|
146
|
+
}
|
|
147
|
+
assertApprovalIdFormat(input.approvalId);
|
|
148
|
+
for (const key of ["actionId", "actionFingerprint", "contractFingerprint"]) {
|
|
149
|
+
if (typeof input[key] !== "string" || !input[key]) {
|
|
150
|
+
throw approvalError(E_APPROVAL_INVALID, `approval input.${key} must be a non-empty string`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!/^[a-f0-9]{64}$/.test(input.actionFingerprint)) {
|
|
154
|
+
throw approvalError(
|
|
155
|
+
E_APPROVAL_INVALID,
|
|
156
|
+
"approval input.actionFingerprint must be a lowercase sha256 hex digest",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (!/^[a-f0-9]{64}$/.test(input.contractFingerprint)) {
|
|
160
|
+
throw approvalError(
|
|
161
|
+
E_APPROVAL_INVALID,
|
|
162
|
+
"approval input.contractFingerprint must be a lowercase sha256 hex digest",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (!Number.isInteger(input.taskRevision) || input.taskRevision < 0) {
|
|
166
|
+
throw approvalError(E_APPROVAL_INVALID, "approval input.taskRevision must be a non-negative integer");
|
|
167
|
+
}
|
|
168
|
+
if (input.reason !== undefined && input.reason !== null && (typeof input.reason !== "string" || input.reason.length > 512)) {
|
|
169
|
+
throw approvalError(E_APPROVAL_INVALID, "approval input.reason must be a string of at most 512 characters");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function requestApproval(target, { packageRoot, taskId, input }) {
|
|
174
|
+
assertRequestInput(input);
|
|
175
|
+
return withTaskTransaction(
|
|
176
|
+
{ target, taskId, operation: "request-approval" },
|
|
177
|
+
async () => {
|
|
178
|
+
const existing = await readApprovalFile(target, taskId, input.approvalId);
|
|
179
|
+
if (existing) {
|
|
180
|
+
validateApprovalArtifact(existing);
|
|
181
|
+
const existingBinding = approvalBindingFields(existing);
|
|
182
|
+
const nextBinding = { ...approvalBindingFields({ ...input }), taskId };
|
|
183
|
+
for (const key of Object.keys(existingBinding)) {
|
|
184
|
+
if (existingBinding[key] !== nextBinding[key]) {
|
|
185
|
+
throw approvalError(
|
|
186
|
+
E_APPROVAL_INVALID,
|
|
187
|
+
`approval ${input.approvalId} already exists with a different immutable binding`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { created: false, idempotent: true, approval: existing };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const now = new Date().toISOString();
|
|
195
|
+
const approval = {
|
|
196
|
+
schemaVersion: 1,
|
|
197
|
+
taskId,
|
|
198
|
+
approvalId: input.approvalId,
|
|
199
|
+
actionId: input.actionId,
|
|
200
|
+
actionFingerprint: input.actionFingerprint,
|
|
201
|
+
contractFingerprint: input.contractFingerprint,
|
|
202
|
+
taskRevision: input.taskRevision,
|
|
203
|
+
capability: input.capability,
|
|
204
|
+
status: "PENDING",
|
|
205
|
+
requestedAt: now,
|
|
206
|
+
reason: input.reason ?? null,
|
|
207
|
+
};
|
|
208
|
+
validateApprovalArtifact(approval);
|
|
209
|
+
|
|
210
|
+
await writeApprovalFile(target, taskId, approval);
|
|
211
|
+
await appendProtocolEvent(target, {
|
|
212
|
+
taskId,
|
|
213
|
+
event: "APPROVAL_REQUESTED",
|
|
214
|
+
fingerprint: approval.contractFingerprint,
|
|
215
|
+
details: {
|
|
216
|
+
approvalId: approval.approvalId,
|
|
217
|
+
actionId: approval.actionId,
|
|
218
|
+
actionFingerprint: approval.actionFingerprint,
|
|
219
|
+
contractFingerprint: approval.contractFingerprint,
|
|
220
|
+
taskRevision: approval.taskRevision,
|
|
221
|
+
capability: approval.capability,
|
|
222
|
+
},
|
|
223
|
+
}, packageRoot, { taskId });
|
|
224
|
+
|
|
225
|
+
return { created: true, idempotent: false, approval };
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function resolveApproval(target, {
|
|
231
|
+
packageRoot,
|
|
232
|
+
taskId,
|
|
233
|
+
approvalId,
|
|
234
|
+
decision,
|
|
235
|
+
authorityKind,
|
|
236
|
+
hostGrantRef,
|
|
237
|
+
authorityContext,
|
|
238
|
+
reason,
|
|
239
|
+
}) {
|
|
240
|
+
assertApprovalIdFormat(approvalId);
|
|
241
|
+
if (!["APPROVED", "REJECTED"].includes(decision)) {
|
|
242
|
+
throw approvalError(E_APPROVAL_INVALID, "decision must be APPROVED or REJECTED");
|
|
243
|
+
}
|
|
244
|
+
if (!["CALLER_ACKNOWLEDGED", "HOST_ATTESTED"].includes(authorityKind)) {
|
|
245
|
+
throw approvalError(E_APPROVAL_INVALID, "authorityKind must be CALLER_ACKNOWLEDGED or HOST_ATTESTED");
|
|
246
|
+
}
|
|
247
|
+
if (authorityKind === "HOST_ATTESTED") {
|
|
248
|
+
if (!isTrustedHostAuthorityContext(authorityContext)) {
|
|
249
|
+
throw approvalError(
|
|
250
|
+
E_ACTION_AUTHORITY_REQUIRED,
|
|
251
|
+
"HOST_ATTESTED approval resolution requires a trusted host-boundary authority context",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (typeof hostGrantRef !== "string" || !hostGrantRef || hostGrantRef.length > 256) {
|
|
255
|
+
throw approvalError(
|
|
256
|
+
E_APPROVAL_INVALID,
|
|
257
|
+
"HOST_ATTESTED resolution requires a bounded non-empty hostGrantRef supplied by the host boundary",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (typeof authorityContext?.grantRef === "string" && authorityContext.grantRef !== hostGrantRef) {
|
|
261
|
+
throw approvalError(E_ACTION_AUTHORITY_REQUIRED, "hostGrantRef does not match the trusted host authority context");
|
|
262
|
+
}
|
|
263
|
+
} else if (hostGrantRef !== undefined && hostGrantRef !== null) {
|
|
264
|
+
throw approvalError(
|
|
265
|
+
E_APPROVAL_INVALID,
|
|
266
|
+
"CALLER_ACKNOWLEDGED resolutions cannot carry a hostGrantRef",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (reason !== undefined && reason !== null && (typeof reason !== "string" || reason.length > 512)) {
|
|
270
|
+
throw approvalError(E_APPROVAL_INVALID, "reason must be a string of at most 512 characters");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return withTaskTransaction(
|
|
274
|
+
{ target, taskId, operation: "resolve-approval" },
|
|
275
|
+
async () => {
|
|
276
|
+
const current = await readApprovalFile(target, taskId, approvalId);
|
|
277
|
+
if (!current) {
|
|
278
|
+
throw approvalError(E_ACTION_NOT_FOUND, `approval ${approvalId} does not exist for task ${taskId}`);
|
|
279
|
+
}
|
|
280
|
+
validateApprovalArtifact(current);
|
|
281
|
+
if (current.status !== "PENDING") {
|
|
282
|
+
throw approvalError(
|
|
283
|
+
E_APPROVAL_ALREADY_RESOLVED,
|
|
284
|
+
`approval ${approvalId} is already ${current.status}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const resolved = {
|
|
289
|
+
...current,
|
|
290
|
+
status: decision,
|
|
291
|
+
decision,
|
|
292
|
+
resolvedAt: new Date().toISOString(),
|
|
293
|
+
authorityKind,
|
|
294
|
+
hostGrantRef: authorityKind === "HOST_ATTESTED" ? hostGrantRef : null,
|
|
295
|
+
reason: reason ?? current.reason ?? null,
|
|
296
|
+
};
|
|
297
|
+
validateApprovalArtifact(resolved);
|
|
298
|
+
await writeApprovalFile(target, taskId, resolved);
|
|
299
|
+
|
|
300
|
+
const details = {
|
|
301
|
+
approvalId: resolved.approvalId,
|
|
302
|
+
actionId: resolved.actionId,
|
|
303
|
+
actionFingerprint: resolved.actionFingerprint,
|
|
304
|
+
decision,
|
|
305
|
+
authorityKind,
|
|
306
|
+
};
|
|
307
|
+
if (authorityKind === "HOST_ATTESTED") details.hostGrantRef = hostGrantRef;
|
|
308
|
+
assertApprovalEventDetails({ event: "APPROVAL_RESOLVED", details });
|
|
309
|
+
await appendProtocolEvent(target, {
|
|
310
|
+
taskId,
|
|
311
|
+
event: "APPROVAL_RESOLVED",
|
|
312
|
+
fingerprint: resolved.contractFingerprint,
|
|
313
|
+
details,
|
|
314
|
+
}, packageRoot, { taskId });
|
|
315
|
+
|
|
316
|
+
return resolved;
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function readApproval(target, { packageRoot, taskId, approvalId }) {
|
|
322
|
+
const approval = await readApprovalFile(target, taskId, approvalId);
|
|
323
|
+
if (!approval) {
|
|
324
|
+
throw approvalError(E_ACTION_NOT_FOUND, `approval ${approvalId} does not exist for task ${taskId}`);
|
|
325
|
+
}
|
|
326
|
+
return validateApprovalArtifact(approval);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function listApprovals(target, { packageRoot, taskId }) {
|
|
330
|
+
const approvals = await listApprovalFiles(target, taskId);
|
|
331
|
+
return approvals.map((approval) => validateApprovalArtifact(approval));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function validateApprovalForAction(target, {
|
|
335
|
+
packageRoot,
|
|
336
|
+
taskId,
|
|
337
|
+
action,
|
|
338
|
+
actionId,
|
|
339
|
+
approvalId,
|
|
340
|
+
requireApproved = true,
|
|
341
|
+
}) {
|
|
342
|
+
const currentAction = action ?? await readAction(target, { packageRoot, taskId, actionId });
|
|
343
|
+
if (currentAction.taskId !== taskId) {
|
|
344
|
+
throw approvalError(E_APPROVAL_STALE, `action ${currentAction.actionId} belongs to a different task`);
|
|
345
|
+
}
|
|
346
|
+
const state = await readWorkState(target, { packageRoot, taskId });
|
|
347
|
+
if (!state) {
|
|
348
|
+
throw approvalError(E_APPROVAL_INVALID, `task ${taskId} has no canonical work state`);
|
|
349
|
+
}
|
|
350
|
+
const approval = await readApproval(target, { packageRoot, taskId, approvalId });
|
|
351
|
+
assertApprovalBinding(approval, {
|
|
352
|
+
taskId,
|
|
353
|
+
actionId: currentAction.actionId,
|
|
354
|
+
actionFingerprint: currentAction.actionFingerprint,
|
|
355
|
+
contractFingerprint: state.contractFingerprint,
|
|
356
|
+
taskRevision: state.revision ?? 0,
|
|
357
|
+
capability: currentAction.capability,
|
|
358
|
+
});
|
|
359
|
+
if (requireApproved && approval.status !== "APPROVED") {
|
|
360
|
+
throw approvalError(E_APPROVAL_INVALID, `approval ${approval.approvalId} is not APPROVED`);
|
|
361
|
+
}
|
|
362
|
+
return approval;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Validate that a resolved approval still hashes to the fingerprint bound at
|
|
367
|
+
* action authorization. Any post-authorization mutation of the approval
|
|
368
|
+
* artifact fails closed (INV-FINAL-APPROVAL-01).
|
|
369
|
+
*/
|
|
370
|
+
export async function validateBoundApprovalFingerprint(target, {
|
|
371
|
+
packageRoot,
|
|
372
|
+
taskId,
|
|
373
|
+
approvalId,
|
|
374
|
+
expectedFingerprint,
|
|
375
|
+
}) {
|
|
376
|
+
if (
|
|
377
|
+
typeof expectedFingerprint !== "string"
|
|
378
|
+
|| !/^[a-f0-9]{64}$/.test(expectedFingerprint)
|
|
379
|
+
) {
|
|
380
|
+
throw approvalError(
|
|
381
|
+
E_APPROVAL_INVALID,
|
|
382
|
+
"expected approval fingerprint must be a lowercase sha256 hex digest",
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const approval = await readApproval(target, {
|
|
387
|
+
packageRoot,
|
|
388
|
+
taskId,
|
|
389
|
+
approvalId,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const actualFingerprint = approvalFingerprint(approval);
|
|
393
|
+
|
|
394
|
+
if (actualFingerprint !== expectedFingerprint) {
|
|
395
|
+
throw approvalError(
|
|
396
|
+
E_APPROVAL_INVALID,
|
|
397
|
+
`approval ${approvalId} no longer matches the fingerprint bound at action authorization`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
approval,
|
|
403
|
+
fingerprint: actualFingerprint,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
@@ -235,4 +235,52 @@ export const ARTIFACT_REGISTRY = Object.freeze({
|
|
|
235
235
|
isPersisted: true,
|
|
236
236
|
description: "Durable task recovery state recording claim release and explicit resume requirements.",
|
|
237
237
|
}),
|
|
238
|
+
actions: Object.freeze({
|
|
239
|
+
key: "actions",
|
|
240
|
+
scope: "TASK",
|
|
241
|
+
path: `${TASK_STATE_ROOT}/<task-key>/actions/action-<id>.json`,
|
|
242
|
+
schema: "action",
|
|
243
|
+
owner: "PROTOCOL_MANAGED",
|
|
244
|
+
mutability: "STATE_MACHINE_TRANSITIONS",
|
|
245
|
+
trustRole: "EXTERNAL_ACTION_PROVENANCE",
|
|
246
|
+
isPublic: true,
|
|
247
|
+
isPersisted: true,
|
|
248
|
+
description: "Durable external action artifacts recording intent, policy, authority, execution, ambiguity, and reconciliation state.",
|
|
249
|
+
}),
|
|
250
|
+
approvals: Object.freeze({
|
|
251
|
+
key: "approvals",
|
|
252
|
+
scope: "TASK",
|
|
253
|
+
path: `${TASK_STATE_ROOT}/<task-key>/approvals/approval-<id>.json`,
|
|
254
|
+
schema: "approval",
|
|
255
|
+
owner: "PROTOCOL_MANAGED",
|
|
256
|
+
mutability: "APPEND_DECISION_ONCE",
|
|
257
|
+
trustRole: "ACTION_APPROVAL_ATTESTATION",
|
|
258
|
+
isPublic: true,
|
|
259
|
+
isPersisted: true,
|
|
260
|
+
description: "Crash-safe approval requests cryptographically bound to action fingerprint, contract fingerprint, task revision, and capability.",
|
|
261
|
+
}),
|
|
262
|
+
capabilityPolicy: Object.freeze({
|
|
263
|
+
key: "capabilityPolicy",
|
|
264
|
+
scope: "PROJECT",
|
|
265
|
+
path: PROJECT_ARTIFACT_PATHS.capabilityPolicy,
|
|
266
|
+
schema: "capability-policy",
|
|
267
|
+
owner: "OPERATOR_OR_AGENT",
|
|
268
|
+
mutability: "MUTABLE_CONFIGURATION",
|
|
269
|
+
trustRole: "CAPABILITY_POLICY_SPECIFICATION",
|
|
270
|
+
isPublic: true,
|
|
271
|
+
isPersisted: true,
|
|
272
|
+
description: "Project-local capability policy mapping canonical capabilities to ALLOW, DENY, REQUIRE_AUTHORITY, or REQUIRE_APPROVAL decisions; this is policy specification, never host authority.",
|
|
273
|
+
}),
|
|
274
|
+
evaluations: Object.freeze({
|
|
275
|
+
key: "evaluations",
|
|
276
|
+
scope: "TASK",
|
|
277
|
+
path: `${TASK_STATE_ROOT}/<task-key>/evaluations/eval-<id>.json`,
|
|
278
|
+
schema: "trajectory-evaluation",
|
|
279
|
+
owner: "PROTOCOL_COMPILED",
|
|
280
|
+
mutability: "IMMUTABLE_ONCE_WRITTEN",
|
|
281
|
+
trustRole: "TRAJECTORY_EVALUATION",
|
|
282
|
+
isPublic: true,
|
|
283
|
+
isPersisted: true,
|
|
284
|
+
description: "Immutable trajectory evaluation results compiled from the canonical trace against a local reference scenario.",
|
|
285
|
+
}),
|
|
238
286
|
});
|
package/src/core/audit.js
CHANGED
|
@@ -7,6 +7,7 @@ import { currentChangedPaths } from "./repository.js";
|
|
|
7
7
|
import { validateReadyProtocolConsistency } from "./preflight.js";
|
|
8
8
|
import { taskArtifactPath } from "./task-paths.js";
|
|
9
9
|
import { findTaskById } from "./task-discovery.js";
|
|
10
|
+
import { validateActionLedgerConsistency } from "./actions.js";
|
|
10
11
|
|
|
11
12
|
function sortErrors(errors) {
|
|
12
13
|
return [...errors].sort((left, right) => left.code.localeCompare(right.code)
|
|
@@ -118,6 +119,30 @@ export async function evaluateAudit({
|
|
|
118
119
|
}))
|
|
119
120
|
: [];
|
|
120
121
|
const errors = sortErrors([...completion.errors, ...readyConsistencyErrors, ...ownershipErrors]);
|
|
122
|
+
if (taskId) {
|
|
123
|
+
for (const actionError of await validateActionLedgerConsistency(target, { packageRoot, taskId })) {
|
|
124
|
+
errors.push({ ...actionError, artifacts: [taskArtifactPath(taskId, "actions"), taskArtifactPath(taskId, "events")] });
|
|
125
|
+
}
|
|
126
|
+
// Surface specific untrusted/ambiguous required actions with reasons.
|
|
127
|
+
const { evaluateRequiredActionReadiness } = await import("./action-readiness.js");
|
|
128
|
+
const readiness = await evaluateRequiredActionReadiness({ target, packageRoot, taskId });
|
|
129
|
+
for (const item of readiness.actions) {
|
|
130
|
+
if (item.status === "SATISFIED" || item.status === "PENDING") continue;
|
|
131
|
+
const code = item.status === "AMBIGUOUS"
|
|
132
|
+
? "E_ACTION_RECONCILIATION_REQUIRED"
|
|
133
|
+
: item.status === "UNTRUSTED"
|
|
134
|
+
? "E_ACTION_VERIFICATION_REQUIRED"
|
|
135
|
+
: "E_ACTION_STATE_MISMATCH";
|
|
136
|
+
errors.push({
|
|
137
|
+
code,
|
|
138
|
+
message: `Required action ${item.actionId} is not trusted-satisfied (${item.status}): ${item.reasons[0] ?? ""}`,
|
|
139
|
+
artifacts: [taskArtifactPath(taskId, "actions"), taskArtifactPath(taskId, "events")],
|
|
140
|
+
actionId: item.actionId,
|
|
141
|
+
readiness: item.status,
|
|
142
|
+
reasons: item.reasons,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
121
146
|
const changedPaths = await compareChangedPaths(target, packageRoot, { taskId, receiptPath });
|
|
122
147
|
if (changedPaths.status === "MISMATCH") {
|
|
123
148
|
errors.push({
|
package/src/core/bundles.js
CHANGED
|
@@ -10,6 +10,7 @@ import { assertContinuitySemantics } from "./continuity.js";
|
|
|
10
10
|
import { taskArtifactPath, taskDirectory } from "./task-paths.js";
|
|
11
11
|
import { resolveTaskClaimState } from "./task-claim-state.js";
|
|
12
12
|
import { E_TASK_CLAIM_OWNERSHIP_INCONSISTENT } from "./error-codes.js";
|
|
13
|
+
import { listActions } from "./actions.js";
|
|
13
14
|
|
|
14
15
|
export const BUNDLE_SCHEMA_VERSION = 1;
|
|
15
16
|
const BUNDLE_ROOT = ".forgeloop/tasks";
|
|
@@ -129,6 +130,13 @@ export async function exportTaskBundle(target, taskId, packageRoot) {
|
|
|
129
130
|
if (copied && !artifacts.includes(destinationName)) artifacts.push(destinationName);
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
const actionArtifacts = await listActions(target, { packageRoot, taskId });
|
|
134
|
+
for (const action of actionArtifacts) {
|
|
135
|
+
const destination = `${directory}/actions/${action.actionId}.json`;
|
|
136
|
+
await writeJsonArtifact(target, destination, action, "action", packageRoot);
|
|
137
|
+
artifacts.push(`actions/${action.actionId}.json`);
|
|
138
|
+
}
|
|
139
|
+
|
|
132
140
|
const executionRefs = [...new Set([
|
|
133
141
|
...(stateSource.value.checks ?? []),
|
|
134
142
|
...(receiptSource?.value?.checks ?? []),
|
|
@@ -195,6 +203,7 @@ export async function readTaskBundle(target, taskId, packageRoot) {
|
|
|
195
203
|
"continuity.json": ["continuity", "continuity"],
|
|
196
204
|
"task.json": ["descriptor", "task-descriptor"],
|
|
197
205
|
"recovery.json": ["recovery", "task-recovery"],
|
|
206
|
+
...Object.fromEntries(manifest.value.artifacts.filter((artifact) => artifact.startsWith("actions/")).map((artifact) => [artifact, ["action", "action"]])),
|
|
198
207
|
};
|
|
199
208
|
const executions = {};
|
|
200
209
|
for (const artifact of manifest.value.artifacts) {
|
|
@@ -203,6 +212,12 @@ export async function readTaskBundle(target, taskId, packageRoot) {
|
|
|
203
212
|
executions[execution.value.executionId] = execution.value;
|
|
204
213
|
continue;
|
|
205
214
|
}
|
|
215
|
+
if (artifact.startsWith("actions/") && artifact.endsWith(".json")) {
|
|
216
|
+
const action = await readJsonArtifact(target, `${directory}/${artifact}`, "action", packageRoot);
|
|
217
|
+
loaded.actions ??= {};
|
|
218
|
+
loaded.actions[action.value.actionId] = action.value;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
206
221
|
const mapping = mappings[artifact];
|
|
207
222
|
if (!mapping) continue;
|
|
208
223
|
const loadedArtifact = await readJsonArtifact(target, `${directory}/${artifact}`, mapping[1], packageRoot);
|