@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.
Files changed (163) hide show
  1. package/.github/copilot-instructions.md +1 -0
  2. package/AGENTS.md +1 -0
  3. package/CLAUDE.md +1 -0
  4. package/DOCS_INDEX.md +20 -8
  5. package/EXECUTION_STATE.md +60 -0
  6. package/LOOP_ENGINEERING.md +135 -5
  7. package/LOOP_SYSTEM_DESIGN.md +54 -1
  8. package/PROTOCOL_INTEGRATION.md +87 -0
  9. package/QUALITY_SCORECARD.md +2 -0
  10. package/README.md +69 -9
  11. package/TERMINOLOGY.md +15 -0
  12. package/THIRD_PARTY_NOTICES.md +30 -0
  13. package/THREAT_MODEL.md +59 -1
  14. package/docs/ARTIFACT_REFERENCE.md +183 -0
  15. package/docs/CLI_REFERENCE.md +391 -6
  16. package/docs/CROSS_HARNESS_CONTINUITY.md +23 -0
  17. package/docs/DIAGNOSTIC_MODEL.md +181 -0
  18. package/docs/DOCUMENTATION_GUIDE.md +36 -13
  19. package/docs/EXECUTION_TRACE.md +76 -0
  20. package/docs/GETTING_STARTED.md +1 -0
  21. package/docs/MCP.md +159 -0
  22. package/docs/RECIPES.md +149 -0
  23. package/docs/RELEASE_CHECKLIST_1_4.md +38 -0
  24. package/docs/RELEASE_CHECKLIST_1_5_MCP.md +78 -0
  25. package/docs/TROUBLESHOOTING.md +217 -3
  26. package/docs/UNIVERSAL_INTEGRATION.md +48 -0
  27. package/docs/assets/diagrams/forgeloop-engineering-flow.html +13797 -0
  28. package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +37 -0
  29. package/docs/assets/diagrams/forgeloop-engineering-flow.svg +5002 -0
  30. package/docs/diagrams/README.md +55 -0
  31. package/docs/diagrams/forgeloop-engineering-flow.workflow.json +122 -0
  32. package/docs/diagrams/manifest.json +42 -0
  33. package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +20 -0
  34. package/package.json +21 -8
  35. package/schemas/action.schema.json +100 -0
  36. package/schemas/approval.schema.json +51 -0
  37. package/schemas/capability-policy.schema.json +41 -0
  38. package/schemas/diagnostic-case.schema.json +85 -0
  39. package/schemas/execution-receipt.schema.json +16 -0
  40. package/schemas/hypothesis-disposition.schema.json +16 -0
  41. package/schemas/intervention.schema.json +27 -0
  42. package/schemas/policy-lock.schema.json +1 -0
  43. package/schemas/policy-snapshot.schema.json +2 -0
  44. package/schemas/task-recovery.schema.json +61 -0
  45. package/schemas/trajectory-evaluation.schema.json +64 -0
  46. package/schemas/trajectory-scenario.schema.json +42 -0
  47. package/src/cli.js +267 -347
  48. package/src/commands/action-authorize.js +41 -0
  49. package/src/commands/action-propose.js +10 -0
  50. package/src/commands/action-reconcile.js +10 -0
  51. package/src/commands/action-record.js +47 -0
  52. package/src/commands/action-show.js +10 -0
  53. package/src/commands/action-verify.js +10 -0
  54. package/src/commands/advance.js +7 -2
  55. package/src/commands/approval-request.js +64 -0
  56. package/src/commands/approval-resolve.js +10 -0
  57. package/src/commands/audit.js +5 -0
  58. package/src/commands/baseline.js +3 -3
  59. package/src/commands/eval.js +6 -0
  60. package/src/commands/history.js +18 -0
  61. package/src/commands/init.js +2 -2
  62. package/src/commands/inspect.js +55 -0
  63. package/src/commands/metrics.js +7 -0
  64. package/src/commands/next.js +8 -2
  65. package/src/commands/policy-discover.js +2 -2
  66. package/src/commands/progress.js +6 -2
  67. package/src/commands/record-diagnosis.js +37 -1
  68. package/src/commands/record-hypothesis-disposition.js +45 -0
  69. package/src/commands/record-intervention.js +35 -0
  70. package/src/commands/reflect.js +38 -0
  71. package/src/commands/report.js +9 -1
  72. package/src/commands/run-action.js +18 -0
  73. package/src/commands/status.js +17 -0
  74. package/src/commands/task-create.js +39 -1
  75. package/src/commands/task-list.js +14 -1
  76. package/src/commands/task-lock-status.js +2 -2
  77. package/src/commands/task-recover.js +202 -0
  78. package/src/commands/task-repair-legacy-recovery.js +417 -0
  79. package/src/commands/task-resume.js +172 -0
  80. package/src/commands/task-scope.js +23 -4
  81. package/src/commands/task-show.js +18 -4
  82. package/src/commands/trace.js +34 -0
  83. package/src/commands/validate-protocol.js +40 -15
  84. package/src/core/action-authorization.js +106 -0
  85. package/src/core/action-constants.js +86 -0
  86. package/src/core/action-execution.js +105 -0
  87. package/src/core/action-ledger-projection.js +302 -0
  88. package/src/core/action-model.js +581 -0
  89. package/src/core/action-readiness.js +141 -0
  90. package/src/core/action-reconciliation-policy.js +49 -0
  91. package/src/core/action-reconciliation.js +66 -0
  92. package/src/core/action-verification.js +111 -0
  93. package/src/core/actions.js +462 -0
  94. package/src/core/approvals.js +405 -0
  95. package/src/core/artifact-registry.js +60 -0
  96. package/src/core/audit.js +45 -4
  97. package/src/core/bundles.js +30 -0
  98. package/src/core/capability-policy.js +226 -0
  99. package/src/core/cli-command-definitions.js +260 -5
  100. package/src/core/command-executors.js +543 -0
  101. package/src/core/command-input.js +107 -0
  102. package/src/core/command-runtime.js +117 -0
  103. package/src/core/completion-artifacts.js +39 -15
  104. package/src/core/completion-ownership.js +88 -0
  105. package/src/core/completion-recovery-rebind.js +194 -0
  106. package/src/core/completion.js +70 -0
  107. package/src/core/continuity-reconciliation.js +24 -5
  108. package/src/core/diagnostic-model.js +396 -0
  109. package/src/core/diagnostic-projection.js +51 -0
  110. package/src/core/diagnostic-record.js +360 -0
  111. package/src/core/error-codes.js +461 -1
  112. package/src/core/events.js +171 -2
  113. package/src/core/execution-prerequisites.js +4 -1
  114. package/src/core/execution.js +26 -188
  115. package/src/core/failure-signature.js +70 -0
  116. package/src/core/failure-surface.js +57 -0
  117. package/src/core/filesystem.js +55 -6
  118. package/src/core/history.js +110 -0
  119. package/src/core/hypothesis-projection.js +85 -0
  120. package/src/core/information-gain-projection.js +283 -0
  121. package/src/core/information-gain.js +138 -0
  122. package/src/core/inspect.js +132 -7
  123. package/src/core/integration-invocation-policy.js +217 -0
  124. package/src/core/integration-limits.js +20 -0
  125. package/src/core/integration-resources.js +178 -0
  126. package/src/core/next-action-model.js +94 -0
  127. package/src/core/next-action.js +490 -3
  128. package/src/core/phase.js +42 -22
  129. package/src/core/policy-engine.js +113 -6
  130. package/src/core/preflight-consistency.js +31 -5
  131. package/src/core/preflight.js +19 -2
  132. package/src/core/prepared-execution.js +227 -0
  133. package/src/core/progress.js +41 -4
  134. package/src/core/project-root.js +21 -0
  135. package/src/core/protocol-info.js +61 -0
  136. package/src/core/protocol.js +14 -0
  137. package/src/core/receipt.js +1 -0
  138. package/src/core/reconcile-closure.js +35 -10
  139. package/src/core/recovery-history.js +116 -0
  140. package/src/core/reflection.js +305 -0
  141. package/src/core/resumability.js +57 -3
  142. package/src/core/schema-validation.js +9 -0
  143. package/src/core/strategy-analysis.js +97 -0
  144. package/src/core/task-claim-state.js +272 -0
  145. package/src/core/task-command.js +5 -1
  146. package/src/core/task-conflict-inspection.js +321 -0
  147. package/src/core/task-context.js +32 -29
  148. package/src/core/task-discovery.js +14 -1
  149. package/src/core/task-lock.js +216 -22
  150. package/src/core/task-paths.js +31 -2
  151. package/src/core/task-recovery-migration.js +192 -0
  152. package/src/core/task-recovery.js +205 -0
  153. package/src/core/task-scope.js +33 -1
  154. package/src/core/task-snapshot.js +53 -0
  155. package/src/core/templates.js +9 -0
  156. package/src/core/trace.js +548 -0
  157. package/src/core/trajectory-evaluation.js +71 -0
  158. package/src/core/trajectory-metrics.js +80 -0
  159. package/src/core/transaction.js +36 -2
  160. package/src/core/work-state.js +10 -5
  161. package/src/integration.js +47 -0
  162. package/docs/assets/forgeloop-flow.svg +0 -1
  163. 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 { readTaskDescriptor } from "./task-descriptor.js";
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 desc = await readTaskDescriptor(target, options.taskId, packageRoot);
29
- writeClaims = desc.value.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 errors = sortErrors([...completion.errors, ...readyConsistencyErrors]);
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,
@@ -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);