@cassiomc1/forgeloop 1.3.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/.github/copilot-instructions.md +1 -0
- package/AGENTS.md +1 -0
- package/CLAUDE.md +1 -0
- package/DOCS_INDEX.md +20 -8
- package/EXECUTION_STATE.md +60 -0
- package/LOOP_ENGINEERING.md +135 -5
- package/LOOP_SYSTEM_DESIGN.md +54 -1
- package/PROTOCOL_INTEGRATION.md +87 -0
- package/QUALITY_SCORECARD.md +2 -0
- package/README.md +69 -9
- package/TERMINOLOGY.md +15 -0
- package/THIRD_PARTY_NOTICES.md +30 -0
- package/THREAT_MODEL.md +59 -1
- package/docs/ARTIFACT_REFERENCE.md +183 -0
- package/docs/CLI_REFERENCE.md +391 -6
- package/docs/CROSS_HARNESS_CONTINUITY.md +23 -0
- package/docs/DIAGNOSTIC_MODEL.md +181 -0
- package/docs/DOCUMENTATION_GUIDE.md +36 -13
- package/docs/EXECUTION_TRACE.md +76 -0
- package/docs/GETTING_STARTED.md +1 -0
- package/docs/MCP.md +159 -0
- package/docs/RECIPES.md +149 -0
- package/docs/RELEASE_CHECKLIST_1_4.md +38 -0
- package/docs/RELEASE_CHECKLIST_1_5_MCP.md +78 -0
- package/docs/TROUBLESHOOTING.md +217 -3
- package/docs/UNIVERSAL_INTEGRATION.md +48 -0
- 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 +21 -8
- 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/task-recovery.schema.json +61 -0
- package/schemas/trajectory-evaluation.schema.json +64 -0
- package/schemas/trajectory-scenario.schema.json +42 -0
- package/src/cli.js +267 -347
- 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/audit.js +5 -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 +55 -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/progress.js +6 -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/status.js +17 -0
- package/src/commands/task-create.js +39 -1
- package/src/commands/task-list.js +14 -1
- package/src/commands/task-lock-status.js +2 -2
- package/src/commands/task-recover.js +202 -0
- package/src/commands/task-repair-legacy-recovery.js +417 -0
- package/src/commands/task-resume.js +172 -0
- package/src/commands/task-scope.js +23 -4
- package/src/commands/task-show.js +18 -4
- package/src/commands/trace.js +34 -0
- package/src/commands/validate-protocol.js +40 -15
- 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 +60 -0
- package/src/core/audit.js +45 -4
- package/src/core/bundles.js +30 -0
- package/src/core/capability-policy.js +226 -0
- package/src/core/cli-command-definitions.js +260 -5
- package/src/core/command-executors.js +543 -0
- package/src/core/command-input.js +107 -0
- package/src/core/command-runtime.js +117 -0
- package/src/core/completion-artifacts.js +39 -15
- package/src/core/completion-ownership.js +88 -0
- 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 +461 -1
- package/src/core/events.js +171 -2
- 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/filesystem.js +55 -6
- 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 +132 -7
- package/src/core/integration-invocation-policy.js +217 -0
- package/src/core/integration-limits.js +20 -0
- package/src/core/integration-resources.js +178 -0
- package/src/core/next-action-model.js +94 -0
- package/src/core/next-action.js +490 -3
- package/src/core/phase.js +42 -22
- 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/project-root.js +21 -0
- package/src/core/protocol-info.js +61 -0
- package/src/core/protocol.js +14 -0
- package/src/core/receipt.js +1 -0
- package/src/core/reconcile-closure.js +35 -10
- package/src/core/recovery-history.js +116 -0
- package/src/core/reflection.js +305 -0
- package/src/core/resumability.js +57 -3
- package/src/core/schema-validation.js +9 -0
- package/src/core/strategy-analysis.js +97 -0
- package/src/core/task-claim-state.js +272 -0
- package/src/core/task-command.js +5 -1
- package/src/core/task-conflict-inspection.js +321 -0
- package/src/core/task-context.js +32 -29
- package/src/core/task-discovery.js +14 -1
- package/src/core/task-lock.js +216 -22
- package/src/core/task-paths.js +31 -2
- package/src/core/task-recovery-migration.js +192 -0
- package/src/core/task-recovery.js +205 -0
- package/src/core/task-scope.js +33 -1
- package/src/core/task-snapshot.js +53 -0
- package/src/core/templates.js +9 -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 +36 -2
- package/src/core/work-state.js +10 -5
- package/src/integration.js +47 -0
- 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
|
+
}
|
|
@@ -223,4 +223,64 @@ export const ARTIFACT_REGISTRY = Object.freeze({
|
|
|
223
223
|
isPersisted: true,
|
|
224
224
|
description: "Task-scoped policy snapshot binding task activation to effective policy digest.",
|
|
225
225
|
}),
|
|
226
|
+
recovery: Object.freeze({
|
|
227
|
+
key: "recovery",
|
|
228
|
+
scope: "TASK",
|
|
229
|
+
path: `${TASK_STATE_ROOT}/<task-key>/${TASK_ARTIFACT_FILES.recovery}`,
|
|
230
|
+
schema: "task-recovery",
|
|
231
|
+
owner: "PROTOCOL_GENERATED",
|
|
232
|
+
mutability: "RECOVERY_STATE_TRANSITIONS",
|
|
233
|
+
trustRole: "TASK_RECOVERY_STATE",
|
|
234
|
+
isPublic: true,
|
|
235
|
+
isPersisted: true,
|
|
236
|
+
description: "Durable task recovery state recording claim release and explicit resume requirements.",
|
|
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
|
+
}),
|
|
226
286
|
});
|
package/src/core/audit.js
CHANGED
|
@@ -6,7 +6,8 @@ import { readJsonArtifact } from "./artifacts.js";
|
|
|
6
6
|
import { currentChangedPaths } from "./repository.js";
|
|
7
7
|
import { validateReadyProtocolConsistency } from "./preflight.js";
|
|
8
8
|
import { taskArtifactPath } from "./task-paths.js";
|
|
9
|
-
import {
|
|
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)
|
|
@@ -25,8 +26,8 @@ async function compareChangedPaths(target, packageRoot, options = {}) {
|
|
|
25
26
|
let writeClaims = [];
|
|
26
27
|
if (options.taskId) {
|
|
27
28
|
try {
|
|
28
|
-
const
|
|
29
|
-
writeClaims =
|
|
29
|
+
const task = await findTaskById(target, options.taskId, packageRoot);
|
|
30
|
+
writeClaims = task?.writeClaims ?? [];
|
|
30
31
|
} catch {
|
|
31
32
|
// ignore
|
|
32
33
|
}
|
|
@@ -110,7 +111,38 @@ export async function evaluateAudit({
|
|
|
110
111
|
} catch {
|
|
111
112
|
// Completion already reports missing or invalid preflight artifacts.
|
|
112
113
|
}
|
|
113
|
-
const
|
|
114
|
+
const taskInfo = taskId ? await findTaskById(target, taskId, packageRoot) : null;
|
|
115
|
+
const ownershipErrors = taskInfo?.ownershipValid === false
|
|
116
|
+
? (taskInfo.ownershipErrors ?? taskInfo.errors ?? []).map((error) => ({
|
|
117
|
+
...error,
|
|
118
|
+
artifacts: error.artifacts ?? [taskArtifactPath(taskId, "recovery"), taskArtifactPath(taskId, "events")],
|
|
119
|
+
}))
|
|
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
|
+
}
|
|
114
146
|
const changedPaths = await compareChangedPaths(target, packageRoot, { taskId, receiptPath });
|
|
115
147
|
if (changedPaths.status === "MISMATCH") {
|
|
116
148
|
errors.push({
|
|
@@ -167,6 +199,15 @@ export async function evaluateAudit({
|
|
|
167
199
|
},
|
|
168
200
|
policy: policyStatus,
|
|
169
201
|
completion,
|
|
202
|
+
recovery: taskInfo?.recovery ?? null,
|
|
203
|
+
claims: taskInfo ? {
|
|
204
|
+
state: taskInfo.claimState,
|
|
205
|
+
historical: taskInfo.historicalWriteClaims,
|
|
206
|
+
effective: taskInfo.effectiveWriteClaims,
|
|
207
|
+
mutationAllowed: taskInfo.mutationAllowed,
|
|
208
|
+
ownershipValid: taskInfo.ownershipValid,
|
|
209
|
+
ownershipErrors: taskInfo.ownershipErrors ?? taskInfo.errors ?? [],
|
|
210
|
+
} : null,
|
|
170
211
|
changedPaths,
|
|
171
212
|
publicationStatus: completion.publicationStatus,
|
|
172
213
|
productionReadiness: completion.productionReadiness,
|
package/src/core/bundles.js
CHANGED
|
@@ -8,6 +8,9 @@ import { validateChecksExecutionProvenance } from "./completion-artifacts.js";
|
|
|
8
8
|
import { readExecutionArtifact } from "./execution.js";
|
|
9
9
|
import { assertContinuitySemantics } from "./continuity.js";
|
|
10
10
|
import { taskArtifactPath, taskDirectory } from "./task-paths.js";
|
|
11
|
+
import { resolveTaskClaimState } from "./task-claim-state.js";
|
|
12
|
+
import { E_TASK_CLAIM_OWNERSHIP_INCONSISTENT } from "./error-codes.js";
|
|
13
|
+
import { listActions } from "./actions.js";
|
|
11
14
|
|
|
12
15
|
export const BUNDLE_SCHEMA_VERSION = 1;
|
|
13
16
|
const BUNDLE_ROOT = ".forgeloop/tasks";
|
|
@@ -53,6 +56,17 @@ export async function exportTaskBundle(target, taskId, packageRoot) {
|
|
|
53
56
|
const directory = bundleDirectory(taskId);
|
|
54
57
|
const artifacts = [];
|
|
55
58
|
|
|
59
|
+
if (await fileExists(ensureWithin(target, taskArtifactPath(taskId, "descriptor")))) {
|
|
60
|
+
const claimProjection = await resolveTaskClaimState(target, { taskId, packageRoot });
|
|
61
|
+
if (!claimProjection.valid) {
|
|
62
|
+
const error = new Error(`Task ${taskId} claim ownership is inconsistent and cannot be exported safely`);
|
|
63
|
+
error.code = E_TASK_CLAIM_OWNERSHIP_INCONSISTENT;
|
|
64
|
+
error.reasonCodes = claimProjection.reasonCodes;
|
|
65
|
+
error.errors = claimProjection.ownershipErrors;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
const stateSource = await tryReadJson(target, taskArtifactPath(taskId, "state"), ARTIFACT_PATHS.state, "work-state", packageRoot);
|
|
57
71
|
let receiptSource = null;
|
|
58
72
|
try {
|
|
@@ -102,6 +116,7 @@ export async function exportTaskBundle(target, taskId, packageRoot) {
|
|
|
102
116
|
[ARTIFACT_PATHS.sources, null, "sources.json", "source-registry"],
|
|
103
117
|
[ARTIFACT_PATHS.config, null, "config.json", "config"],
|
|
104
118
|
[taskArtifactPath(taskId, "continuity"), ARTIFACT_PATHS.continuity, "continuity.json", "continuity"],
|
|
119
|
+
[taskArtifactPath(taskId, "recovery"), null, "recovery.json", "task-recovery"],
|
|
105
120
|
];
|
|
106
121
|
for (const [taskRel, legacyRel, destinationName, schemaName] of optional) {
|
|
107
122
|
let copied = null;
|
|
@@ -115,6 +130,13 @@ export async function exportTaskBundle(target, taskId, packageRoot) {
|
|
|
115
130
|
if (copied && !artifacts.includes(destinationName)) artifacts.push(destinationName);
|
|
116
131
|
}
|
|
117
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
|
+
|
|
118
140
|
const executionRefs = [...new Set([
|
|
119
141
|
...(stateSource.value.checks ?? []),
|
|
120
142
|
...(receiptSource?.value?.checks ?? []),
|
|
@@ -180,6 +202,8 @@ export async function readTaskBundle(target, taskId, packageRoot) {
|
|
|
180
202
|
"config.json": ["config", "config"],
|
|
181
203
|
"continuity.json": ["continuity", "continuity"],
|
|
182
204
|
"task.json": ["descriptor", "task-descriptor"],
|
|
205
|
+
"recovery.json": ["recovery", "task-recovery"],
|
|
206
|
+
...Object.fromEntries(manifest.value.artifacts.filter((artifact) => artifact.startsWith("actions/")).map((artifact) => [artifact, ["action", "action"]])),
|
|
183
207
|
};
|
|
184
208
|
const executions = {};
|
|
185
209
|
for (const artifact of manifest.value.artifacts) {
|
|
@@ -188,6 +212,12 @@ export async function readTaskBundle(target, taskId, packageRoot) {
|
|
|
188
212
|
executions[execution.value.executionId] = execution.value;
|
|
189
213
|
continue;
|
|
190
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
|
+
}
|
|
191
221
|
const mapping = mappings[artifact];
|
|
192
222
|
if (!mapping) continue;
|
|
193
223
|
const loadedArtifact = await readJsonArtifact(target, `${directory}/${artifact}`, mapping[1], packageRoot);
|