@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
@@ -1,10 +1,13 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdir, open, readFile, unlink } from "node:fs/promises";
2
+ import { link, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
5
  import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
6
6
  import { taskLockPath } from "./task-paths.js";
7
- import { E_TASK_LOCKED } from "./error-codes.js";
7
+ import {
8
+ E_PROJECT_CLAIMS_LOCK_INCONSISTENT,
9
+ E_TASK_LOCKED,
10
+ } from "./error-codes.js";
8
11
 
9
12
  export async function readLockInfo(target, taskId) {
10
13
  const relativePath = taskLockPath(taskId);
@@ -23,14 +26,50 @@ export async function readLockInfo(target, taskId) {
23
26
  }
24
27
  }
25
28
 
26
- export function classifyLockStaleness(lock, now = Date.now()) {
27
- if (!lock || lock.corrupted) return { status: "UNKNOWN", stale: false };
29
+ /**
30
+ * Structural identity requirements shared by every persisted lease lock.
31
+ * Incomplete identity is UNKNOWN and is never eligible for stale release;
32
+ * default lease values belong at creation time, never validation time.
33
+ */
34
+ function hasLeaseIdentity(lock) {
35
+ return typeof lock.lockId === "string"
36
+ && lock.lockId !== ""
37
+ && typeof lock.ownerInstanceId === "string"
38
+ && lock.ownerInstanceId !== ""
39
+ && typeof lock.operation === "string"
40
+ && lock.operation !== ""
41
+ && Number.isInteger(lock.leaseMs)
42
+ && lock.leaseMs > 0;
43
+ }
44
+
45
+ export function isValidTaskLockIdentity(lock) {
46
+ return Boolean(lock)
47
+ && !lock.corrupted
48
+ && typeof lock.taskId === "string"
49
+ && lock.taskId !== ""
50
+ && hasLeaseIdentity(lock);
51
+ }
52
+
53
+ export function isValidProjectClaimsLockIdentity(lock) {
54
+ return Boolean(lock)
55
+ && !lock.corrupted
56
+ && lock.scope === "claims-reservation"
57
+ && hasLeaseIdentity(lock);
58
+ }
59
+
60
+ function classifyLeaseWindow(lock, now) {
28
61
  const heartbeat = Date.parse(lock.heartbeatAt ?? lock.acquiredAt);
29
- const leaseMs = Number.isInteger(lock.leaseMs) && lock.leaseMs > 0 ? lock.leaseMs : 300000;
30
62
  if (!Number.isFinite(heartbeat)) return { status: "UNKNOWN", stale: false };
31
- return now > heartbeat + leaseMs
32
- ? { status: "STALE", stale: true, expiresAt: new Date(heartbeat + leaseMs).toISOString() }
33
- : { status: "LIVE", stale: false, expiresAt: new Date(heartbeat + leaseMs).toISOString() };
63
+ return now > heartbeat + lock.leaseMs
64
+ ? { status: "STALE", stale: true, expiresAt: new Date(heartbeat + lock.leaseMs).toISOString() }
65
+ : { status: "LIVE", stale: false, expiresAt: new Date(heartbeat + lock.leaseMs).toISOString() };
66
+ }
67
+
68
+ export function classifyLockStaleness(lock, now = Date.now()) {
69
+ if (!lock) return { status: "NONE", stale: false };
70
+ if (lock.corrupted) return { status: "CORRUPT", stale: false };
71
+ if (!isValidTaskLockIdentity(lock)) return { status: "UNKNOWN", stale: false };
72
+ return classifyLeaseWindow(lock, now);
34
73
  }
35
74
 
36
75
  /**
@@ -60,6 +99,75 @@ export async function readProjectClaimsLockInfo(target) {
60
99
  }
61
100
  }
62
101
 
102
+ export function classifyProjectClaimsLock(lock, now = Date.now()) {
103
+ if (!lock) return { status: "NONE", stale: false };
104
+ if (lock.corrupted) return { status: "CORRUPT", stale: false };
105
+ if (!isValidProjectClaimsLockIdentity(lock)) return { status: "UNKNOWN", stale: false };
106
+ return classifyLeaseWindow(lock, now);
107
+ }
108
+
109
+ function projectClaimsLockError(classification, lockInfo, reason = null) {
110
+ if (classification.status === "LIVE") {
111
+ const error = new Error(
112
+ `Project write claims reservation is locked by operation "${lockInfo?.operation ?? "unknown"}" (pid: ${lockInfo?.pid ?? "unknown"}, acquired: ${lockInfo?.acquiredAt ?? "unknown"}).`,
113
+ );
114
+ error.code = E_TASK_LOCKED;
115
+ error.lockInfo = lockInfo;
116
+ error.classification = classification;
117
+ return error;
118
+ }
119
+ const error = new Error(
120
+ `Project write claims lock ownership is ${classification.status}${reason ? ` (${reason})` : ""}; refusing unsafe claim mutation`,
121
+ );
122
+ error.code = E_PROJECT_CLAIMS_LOCK_INCONSISTENT;
123
+ error.lockInfo = lockInfo;
124
+ error.classification = classification;
125
+ if (reason) error.reason = reason;
126
+ return error;
127
+ }
128
+
129
+ export async function releaseStaleProjectClaimsLockIfUnchanged(target, expectedLock, { now = Date.now() } = {}) {
130
+ await assertSafePath(target, CLAIMS_LOCK_REL_PATH);
131
+ const fullPath = ensureWithin(target, CLAIMS_LOCK_REL_PATH);
132
+ const expectedClassification = classifyProjectClaimsLock(expectedLock, now);
133
+ if (expectedClassification.status !== "STALE") {
134
+ return { released: false, reason: "EXPECTED_LOCK_NOT_STALE", classification: expectedClassification };
135
+ }
136
+
137
+ const quarantinePath = `${fullPath}.releasing-${randomUUID()}`;
138
+ try {
139
+ await rename(fullPath, quarantinePath);
140
+ } catch (error) {
141
+ if (error.code === "ENOENT") return { released: false, reason: "LOCK_MISSING" };
142
+ throw error;
143
+ }
144
+
145
+ let observed;
146
+ try {
147
+ observed = JSON.parse(await readFile(quarantinePath, "utf8"));
148
+ } catch {
149
+ await restoreQuarantinedLock(quarantinePath, fullPath);
150
+ return { released: false, reason: "LOCK_CORRUPT", classification: { status: "CORRUPT", stale: false } };
151
+ }
152
+
153
+ const observedClassification = classifyProjectClaimsLock(observed, now);
154
+ if (!sameObservedLock(observed, expectedLock, { identityIsValid: isValidProjectClaimsLockIdentity })
155
+ || observedClassification.status !== "STALE") {
156
+ await restoreQuarantinedLock(quarantinePath, fullPath);
157
+ return {
158
+ released: false,
159
+ reason: sameObservedLock(observed, expectedLock, { identityIsValid: isValidProjectClaimsLockIdentity })
160
+ ? "LOCK_NOT_STALE"
161
+ : "LOCK_CHANGED",
162
+ currentLock: observed,
163
+ classification: observedClassification,
164
+ };
165
+ }
166
+
167
+ await unlink(quarantinePath);
168
+ return { released: true, previousLock: observed, classification: observedClassification };
169
+ }
170
+
63
171
  export async function acquireProjectClaimsLock(target, operation = "claim-reservation") {
64
172
  await assertSafePath(target, CLAIMS_LOCK_REL_PATH);
65
173
  const fullPath = ensureWithin(target, CLAIMS_LOCK_REL_PATH);
@@ -80,20 +188,29 @@ export async function acquireProjectClaimsLock(target, operation = "claim-reserv
80
188
  leaseMs: 300000,
81
189
  };
82
190
 
83
- let fileHandle;
84
- try {
85
- fileHandle = await open(fullPath, "wx");
86
- } catch (error) {
87
- if (error.code === "EEXIST") {
191
+ let fileHandle = null;
192
+ for (let attempt = 0; attempt < 2; attempt += 1) {
193
+ try {
194
+ fileHandle = await open(fullPath, "wx");
195
+ break;
196
+ } catch (error) {
197
+ if (error.code !== "EEXIST") throw error;
88
198
  const existing = await readProjectClaimsLockInfo(target);
89
- const err = new Error(
90
- `Project write claims reservation is locked by operation "${existing?.operation ?? "unknown"}" (pid: ${existing?.pid ?? "unknown"}, acquired: ${existing?.acquiredAt ?? "unknown"}).`,
91
- );
92
- err.code = E_TASK_LOCKED;
93
- err.lockInfo = existing;
94
- throw err;
199
+ const classification = classifyProjectClaimsLock(existing);
200
+ if (classification.status === "STALE" && attempt === 0) {
201
+ const released = await releaseStaleProjectClaimsLockIfUnchanged(target, existing);
202
+ if (released.released || released.reason === "LOCK_MISSING") continue;
203
+ throw projectClaimsLockError(
204
+ released.classification ?? { status: "UNKNOWN", stale: false },
205
+ released.currentLock ?? existing,
206
+ released.reason,
207
+ );
208
+ }
209
+ throw projectClaimsLockError(classification, existing);
95
210
  }
96
- throw error;
211
+ }
212
+ if (!fileHandle) {
213
+ throw projectClaimsLockError({ status: "UNKNOWN", stale: false }, null, "ACQUISITION_RETRY_EXHAUSTED");
97
214
  }
98
215
 
99
216
  try {
@@ -222,13 +339,90 @@ export async function forceUnlockTask(target, taskId, { staleOnly = false } = {}
222
339
 
223
340
  const existing = await readLockInfo(target, taskId);
224
341
  const classification = classifyLockStaleness(existing);
225
- if (staleOnly && !classification.stale) {
226
- return { unlocked: false, previousLock: existing, classification };
342
+ if (staleOnly) {
343
+ if (!classification.stale) {
344
+ return { unlocked: false, previousLock: existing, classification };
345
+ }
346
+ const released = await releaseStaleTaskLockIfUnchanged(target, taskId, existing);
347
+ return {
348
+ unlocked: released.released,
349
+ previousLock: released.previousLock ?? existing,
350
+ classification: released.classification ?? classification,
351
+ ...(released.reason ? { reason: released.reason } : {}),
352
+ };
227
353
  }
228
354
  await unlink(fullPath);
229
355
  return { unlocked: true, previousLock: existing, classification };
230
356
  }
231
357
 
358
+ function sameObservedLock(left, right, { identityIsValid = isValidTaskLockIdentity } = {}) {
359
+ if (!identityIsValid(left) || !identityIsValid(right)) return false;
360
+ return left.lockId === right.lockId
361
+ && left.heartbeatAt === right.heartbeatAt
362
+ && left.ownerInstanceId === right.ownerInstanceId;
363
+ }
364
+
365
+ function sameObservedTaskLock(left, right, taskId) {
366
+ return taskId !== null
367
+ && sameObservedLock(left, right)
368
+ && left.taskId === right.taskId
369
+ && right.taskId === taskId;
370
+ }
371
+
372
+ async function restoreQuarantinedLock(quarantinePath, fullPath) {
373
+ try {
374
+ await link(quarantinePath, fullPath);
375
+ } catch (error) {
376
+ if (error.code !== "EEXIST") throw error;
377
+ } finally {
378
+ try {
379
+ await unlink(quarantinePath);
380
+ } catch {
381
+ // ignore an already-consumed quarantine entry
382
+ }
383
+ }
384
+ }
385
+
386
+ export async function releaseStaleTaskLockIfUnchanged(target, taskId, expectedLock, { now = Date.now() } = {}) {
387
+ const relativePath = taskLockPath(taskId);
388
+ await assertSafePath(target, relativePath);
389
+ const fullPath = ensureWithin(target, relativePath);
390
+ const expectedClassification = classifyLockStaleness(expectedLock, now);
391
+ if (expectedClassification.status !== "STALE") {
392
+ return { released: false, reason: "EXPECTED_LOCK_NOT_STALE", classification: expectedClassification };
393
+ }
394
+
395
+ const quarantinePath = `${fullPath}.releasing-${randomUUID()}`;
396
+ try {
397
+ await rename(fullPath, quarantinePath);
398
+ } catch (error) {
399
+ if (error.code === "ENOENT") return { released: false, reason: "LOCK_MISSING" };
400
+ throw error;
401
+ }
402
+
403
+ let observed;
404
+ try {
405
+ observed = JSON.parse(await readFile(quarantinePath, "utf8"));
406
+ } catch {
407
+ await restoreQuarantinedLock(quarantinePath, fullPath);
408
+ return { released: false, reason: "LOCK_CORRUPT" };
409
+ }
410
+
411
+ const observedClassification = classifyLockStaleness(observed, now);
412
+ if (!sameObservedTaskLock(observed, expectedLock, taskId) || observedClassification.status !== "STALE") {
413
+ await restoreQuarantinedLock(quarantinePath, fullPath);
414
+ return {
415
+ released: false,
416
+ reason: sameObservedTaskLock(observed, expectedLock, taskId) ? "LOCK_NOT_STALE" : "LOCK_CHANGED",
417
+ currentLock: observed,
418
+ classification: observedClassification,
419
+ };
420
+ }
421
+
422
+ await unlink(quarantinePath);
423
+ return { released: true, previousLock: observed, classification: observedClassification };
424
+ }
425
+
232
426
  export async function withTaskLock(target, taskId, operationOrCallback, callback) {
233
427
  let operation = operationOrCallback;
234
428
  let fn = callback;
@@ -15,8 +15,11 @@ export const TASK_ARTIFACT_FILES = Object.freeze({
15
15
  events: "events.ndjson",
16
16
  gates: "gates",
17
17
  executions: "executions",
18
- lock: ".lock",
18
+ actions: "actions",
19
+ approvals: "approvals",
20
+ evaluations: "evaluations",
19
21
  policySnapshot: "policy-snapshot.json",
22
+ recovery: "recovery.json",
20
23
  });
21
24
 
22
25
  export const POLICY_ROOT = ".forgeloop/policy";
@@ -32,6 +35,7 @@ export const PROJECT_ARTIFACT_PATHS = Object.freeze({
32
35
  policyBaseline: ".forgeloop/policy/baseline.json",
33
36
  policyLock: ".forgeloop/policy/policy.lock",
34
37
  policyDiscovery: ".forgeloop/policy/discovery.json",
38
+ capabilityPolicy: ".forgeloop/policy/capabilities.json",
35
39
  });
36
40
 
37
41
  export const LEGACY_TASK_ARTIFACT_PATHS = Object.freeze({
@@ -74,6 +78,27 @@ export function taskExecutionPath(taskId, executionId) {
74
78
  return `${taskDirectory(taskId)}/${TASK_ARTIFACT_FILES.executions}/${executionId}.json`;
75
79
  }
76
80
 
81
+ export function taskActionPath(taskId, actionId) {
82
+ if (typeof actionId !== "string" || !/^action-[A-Za-z0-9_-]+$/.test(actionId)) {
83
+ throw new Error(`Invalid action ID: ${actionId}`);
84
+ }
85
+ return `${taskDirectory(taskId)}/${TASK_ARTIFACT_FILES.actions}/${actionId}.json`;
86
+ }
87
+
88
+ export function taskApprovalPath(taskId, approvalId) {
89
+ if (typeof approvalId !== "string" || !/^approval-[A-Za-z0-9_-]+$/.test(approvalId)) {
90
+ throw new Error(`Invalid approval ID: ${approvalId}`);
91
+ }
92
+ return `${taskDirectory(taskId)}/${TASK_ARTIFACT_FILES.approvals}/${approvalId}.json`;
93
+ }
94
+
95
+ export function taskEvaluationPath(taskId, evaluationId) {
96
+ if (typeof evaluationId !== "string" || !/^eval-[A-Za-z0-9_-]+$/.test(evaluationId)) {
97
+ throw new Error(`Invalid evaluation ID: ${evaluationId}`);
98
+ }
99
+ return `${taskDirectory(taskId)}/${TASK_ARTIFACT_FILES.evaluations}/${evaluationId}.json`;
100
+ }
101
+
77
102
  export function taskLockPath(taskId) {
78
103
  assertTaskId(taskId);
79
104
  return `${TASK_LOCK_ROOT}/${taskStorageKey(taskId)}.lock`;
@@ -101,7 +126,11 @@ export function buildTaskArtifactPaths(taskId) {
101
126
  events: `${dir}/${TASK_ARTIFACT_FILES.events}`,
102
127
  gates: `${dir}/${TASK_ARTIFACT_FILES.gates}`,
103
128
  executions: `${dir}/${TASK_ARTIFACT_FILES.executions}`,
104
- lock: `${dir}/${TASK_ARTIFACT_FILES.lock}`,
129
+ actions: `${dir}/${TASK_ARTIFACT_FILES.actions}`,
130
+ approvals: `${dir}/${TASK_ARTIFACT_FILES.approvals}`,
131
+ evaluations: `${dir}/${TASK_ARTIFACT_FILES.evaluations}`,
132
+ lock: taskLockPath(taskId),
105
133
  policySnapshot: `${dir}/${TASK_ARTIFACT_FILES.policySnapshot}`,
134
+ recovery: `${dir}/${TASK_ARTIFACT_FILES.recovery}`,
106
135
  });
107
136
  }
@@ -0,0 +1,192 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * Canonical, limited, auditable repair path for one known historical defect:
5
+ * an `OPERATOR_RECOVERY_RECORDED` event written by an early adapter whose
6
+ * `details` carried only `{ classification, reasonCodes, authorization, note }`
7
+ * and none of the modern recovery identity fields (most notably `recoveryId`).
8
+ *
9
+ * Invariants:
10
+ * - The strict validator stays the default. An unmigrated legacy event keeps
11
+ * the ledger invalid (fail closed, INCONSISTENT ownership).
12
+ * - Existing ledger events are never rewritten, removed, or edited. Repair
13
+ * appends exactly one `LEGACY_RECOVERY_MIGRATION_RECORDED` event per legacy
14
+ * event at the current ledger tail, binding it by taskId, seq, hash, and a
15
+ * deterministic recoveryId.
16
+ * - Only the exact known legacy signature is eligible. Any other incomplete
17
+ * recovery event (including a modern `TASK_RECOVERY_RECORDED` without
18
+ * `recoveryId`) remains invalid. Ambiguity stays INCONSISTENT.
19
+ * - The migration event is the canonical recovery record for the repaired
20
+ * state; it carries the complete modern recovery projection plus an
21
+ * immutable binding to the historical source. The original legacy event
22
+ * remains unchanged and independently recognizable as legacy evidence.
23
+ * - Migration authority is always CALLER_ACKNOWLEDGED from a fresh explicit
24
+ * caller acknowledgement. Legacy authorization values are preserved only as
25
+ * historical metadata and never grant current authority.
26
+ */
27
+
28
+ export const LEGACY_RECOVERY_MIGRATION_EVENT = "LEGACY_RECOVERY_MIGRATION_RECORDED";
29
+ export const LEGACY_RECOVERY_DEFECT = "OPERATOR_RECOVERY_RECORDED_WITHOUT_RECOVERY_ID";
30
+ export const LEGACY_RECOVERY_MIGRATION_ID_DOMAIN = "forgeloop:legacy-recovery-migration:v1";
31
+ export const MIGRATED_RECOVERY_CLASSIFICATION = "LEGACY_BOUNDARY_MIGRATED";
32
+
33
+ const LEGACY_DETAIL_KEYS = Object.freeze([
34
+ "classification",
35
+ "reasonCodes",
36
+ "authorization",
37
+ "note",
38
+ ]);
39
+
40
+ // Legacy migration v1 is caller-acknowledged only: the official repair
41
+ // command has no host-grant path. Normal recovery authority semantics are
42
+ // unaffected.
43
+ const MIGRATION_AUTHORITY_KINDS = new Set(["CALLER_ACKNOWLEDGED"]);
44
+ const HASH_PATTERN = /^[a-f0-9]{64}$/;
45
+ const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
46
+ const SAFE_OBSERVED_CLASSIFICATIONS = new Set([
47
+ "ACTIVE", "RECOVERABLE", "STALE", "ABANDONED", "COMPLETE", "RECOVERED", "INCONSISTENT",
48
+ ]);
49
+
50
+ function isPlainObject(value) {
51
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
52
+ }
53
+
54
+ function isNonEmptyString(value) {
55
+ return typeof value === "string" && value.length > 0;
56
+ }
57
+
58
+ /**
59
+ * Recognizes ONLY the exact known legacy detail signature. Anything else —
60
+ * including a near miss with extra or missing keys — is not eligible for
61
+ * migration. Event-level validation additionally requires taskId, seq, and
62
+ * hash via {@link isLegacyRecoveryEventShape}.
63
+ */
64
+ export function isLegacyRecoveryDetailsShape(details) {
65
+ if (!isPlainObject(details)) return false;
66
+ const keys = Object.keys(details);
67
+ if (!keys.every((key) => LEGACY_DETAIL_KEYS.includes(key))) return false;
68
+ if (details.classification !== "RECOVERABLE") return false;
69
+ if (details.authorization !== "OPERATOR_AUTHORIZED") return false;
70
+ if (!Array.isArray(details.reasonCodes)
71
+ || !details.reasonCodes.every((code) => isNonEmptyString(code))) return false;
72
+ return isNonEmptyString(details.note);
73
+ }
74
+
75
+ export function isLegacyRecoveryEventShape(event) {
76
+ if (event?.event !== "OPERATOR_RECOVERY_RECORDED") return false;
77
+ if (!isLegacyRecoveryDetailsShape(event.details)) return false;
78
+ if (!isNonEmptyString(event.taskId)) return false;
79
+ if (!Number.isInteger(event.seq) || event.seq < 1) return false;
80
+ return typeof event.hash === "string" && HASH_PATTERN.test(event.hash);
81
+ }
82
+
83
+ /**
84
+ * Deterministic canonical binding between a legacy recovery event and its
85
+ * official migration event. The value is formatted to satisfy the durable
86
+ * task-recovery artifact's recoveryId pattern (`recovery-…`).
87
+ */
88
+ export function legacyRecoveryMigrationId({ taskId, seq, hash }) {
89
+ if (!isNonEmptyString(taskId) || !Number.isInteger(seq) || !isNonEmptyString(hash)) {
90
+ throw new Error("legacyRecoveryMigrationId requires taskId, seq, and hash");
91
+ }
92
+ const digest = createHash("sha256")
93
+ .update(`${LEGACY_RECOVERY_MIGRATION_ID_DOMAIN}:${taskId}:${seq}:${hash}`)
94
+ .digest("hex");
95
+ return `recovery-legacy-${digest}`;
96
+ }
97
+
98
+ const MIGRATED_RECOVERY_ID_PATTERN = /^recovery-legacy-[a-f0-9]{64}$/;
99
+
100
+ function assertNonEmptyIsoTimestamp(value, field) {
101
+ if (!isNonEmptyString(value) || !ISO_TIMESTAMP_PATTERN.test(value)) {
102
+ throw protocolError(`legacy migration event details.${field} must be an ISO-8601 UTC timestamp`);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Strict details validation for appended migration events. Mirrors the
108
+ * assert* helpers in events.js; throws E_EVENT_INVALID on any deviation.
109
+ *
110
+ * Required shape:
111
+ * - immutable legacy binding: defect, legacyEventSeq, legacyEventHash,
112
+ * legacyEventAt, legacyTaskId, legacyClassification, legacyAuthority,
113
+ * legacyNote
114
+ * - complete modern recovery projection: recoveryId, classification
115
+ * (always LEGACY_BOUNDARY_MIGRATED), reasonCodes, releasedClaims,
116
+ * previousPhase, previousRevision, currentBranch, currentHead,
117
+ * recoveredAt, repairObservedClassification,
118
+ * repairObservedReasonCodes, authorityKind. Historical values
119
+ * (legacyClassification, legacyAuthority, legacyEventAt) are metadata only.
120
+ */
121
+ export function assertLegacyMigrationDetails(details) {
122
+ if (!isPlainObject(details)) {
123
+ throw protocolError("legacy migration event requires structured details");
124
+ }
125
+ for (const key of [
126
+ "recoveryId",
127
+ "defect",
128
+ "legacyEventType",
129
+ "legacyEventHash",
130
+ "legacyEventAt",
131
+ "legacyTaskId",
132
+ "legacyClassification",
133
+ "legacyAuthority",
134
+ "classification",
135
+ "previousPhase",
136
+ "repairObservedClassification",
137
+ "authorityKind",
138
+ ]) {
139
+ if (!isNonEmptyString(details[key])) {
140
+ throw protocolError(`legacy migration event details.${key} must be a non-empty string`);
141
+ }
142
+ }
143
+ assertNonEmptyIsoTimestamp(details.legacyEventAt, "legacyEventAt");
144
+ assertNonEmptyIsoTimestamp(details.recoveredAt, "recoveredAt");
145
+ if (!MIGRATED_RECOVERY_ID_PATTERN.test(details.recoveryId)) {
146
+ throw protocolError("legacy migration event details.recoveryId must be a deterministic recovery-legacy-<sha256> identifier");
147
+ }
148
+ if (!HASH_PATTERN.test(details.legacyEventHash)) {
149
+ throw protocolError("legacy migration event details.legacyEventHash must be a lowercase sha256 hex string");
150
+ }
151
+ if (details.defect !== LEGACY_RECOVERY_DEFECT) {
152
+ throw protocolError(`legacy migration event details.defect must be ${LEGACY_RECOVERY_DEFECT}`);
153
+ }
154
+ if (details.classification !== MIGRATED_RECOVERY_CLASSIFICATION) {
155
+ throw protocolError(`legacy migration event details.classification must be ${MIGRATED_RECOVERY_CLASSIFICATION}`);
156
+ }
157
+ if (!Number.isInteger(details.legacyEventSeq) || details.legacyEventSeq < 1) {
158
+ throw protocolError("legacy migration event details.legacyEventSeq must be a positive integer");
159
+ }
160
+ for (const key of ["currentBranch", "currentHead"]) {
161
+ if (details[key] !== null && !isNonEmptyString(details[key])) {
162
+ throw protocolError(`legacy migration event details.${key} must be a non-empty string or null`);
163
+ }
164
+ }
165
+ if (!MIGRATION_AUTHORITY_KINDS.has(details.authorityKind)) {
166
+ throw protocolError("legacy migration event details.authorityKind is invalid");
167
+ }
168
+ if (!SAFE_OBSERVED_CLASSIFICATIONS.has(details.repairObservedClassification)) {
169
+ throw protocolError("legacy migration event details.repairObservedClassification is invalid");
170
+ }
171
+ if (!Array.isArray(details.reasonCodes)
172
+ || !details.reasonCodes.every((code) => isNonEmptyString(code))) {
173
+ throw protocolError("legacy migration event details.reasonCodes must be an array of non-empty strings");
174
+ }
175
+ if (!Array.isArray(details.releasedClaims)
176
+ || !details.releasedClaims.every((claim) => isNonEmptyString(claim))) {
177
+ throw protocolError("legacy migration event details.releasedClaims must be an array of non-empty strings");
178
+ }
179
+ if (!Array.isArray(details.repairObservedReasonCodes)
180
+ || !details.repairObservedReasonCodes.every((code) => isNonEmptyString(code))) {
181
+ throw protocolError("legacy migration event details.repairObservedReasonCodes must be an array of non-empty strings");
182
+ }
183
+ if (!Number.isInteger(details.previousRevision) || details.previousRevision < 0) {
184
+ throw protocolError("legacy migration event details.previousRevision must be a non-negative integer");
185
+ }
186
+ }
187
+
188
+ function protocolError(message) {
189
+ const error = new Error(message);
190
+ error.code = "E_EVENT_INVALID";
191
+ return error;
192
+ }