@cassiomc1/forgeloop 1.2.4 → 1.5.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 (92) hide show
  1. package/.github/copilot-instructions.md +1 -0
  2. package/AGENTS.md +1 -0
  3. package/AGENT_COMPATIBILITY.md +4 -0
  4. package/CLAUDE.md +1 -0
  5. package/DOCS_INDEX.md +14 -0
  6. package/EXECUTION_STATE.md +48 -0
  7. package/LOOP_ENGINEERING.md +55 -6
  8. package/LOOP_SYSTEM_DESIGN.md +32 -1
  9. package/PROTOCOL_INTEGRATION.md +71 -0
  10. package/README.md +86 -0
  11. package/TERMINOLOGY.md +15 -0
  12. package/THIRD_PARTY_NOTICES.md +15 -0
  13. package/THREAT_MODEL.md +22 -1
  14. package/docs/ARTIFACT_REFERENCE.md +54 -0
  15. package/docs/CLI_REFERENCE.md +177 -5
  16. package/docs/CROSS_HARNESS_CONTINUITY.md +34 -0
  17. package/docs/DOCUMENTATION_GUIDE.md +31 -0
  18. package/docs/GETTING_STARTED.md +10 -0
  19. package/docs/MCP.md +126 -0
  20. package/docs/RECIPES.md +87 -1
  21. package/docs/RELEASE_CHECKLIST_1_4.md +38 -0
  22. package/docs/RELEASE_CHECKLIST_1_5_MCP.md +78 -0
  23. package/docs/TROUBLESHOOTING.md +243 -40
  24. package/docs/UNIVERSAL_INTEGRATION.md +48 -0
  25. package/package.json +17 -3
  26. package/schemas/execution.schema.json +11 -1
  27. package/schemas/task-recovery.schema.json +61 -0
  28. package/schemas/work-state.schema.json +1 -0
  29. package/src/cli.js +182 -337
  30. package/src/commands/audit.js +5 -0
  31. package/src/commands/doctor.js +22 -0
  32. package/src/commands/inspect.js +6 -0
  33. package/src/commands/migrate-protocol.js +18 -0
  34. package/src/commands/progress.js +6 -2
  35. package/src/commands/protocol-info.js +16 -0
  36. package/src/commands/run-check.js +2 -0
  37. package/src/commands/status.js +17 -0
  38. package/src/commands/task-create.js +42 -3
  39. package/src/commands/task-list.js +14 -1
  40. package/src/commands/task-lock-status.js +29 -0
  41. package/src/commands/task-recover.js +202 -0
  42. package/src/commands/task-repair-legacy-recovery.js +417 -0
  43. package/src/commands/task-resume.js +172 -0
  44. package/src/commands/task-scope.js +23 -4
  45. package/src/commands/task-show.js +21 -7
  46. package/src/commands/task-unlock.js +8 -6
  47. package/src/commands/validate-protocol.js +19 -2
  48. package/src/core/artifact-registry.js +12 -0
  49. package/src/core/artifacts.js +17 -4
  50. package/src/core/audit.js +20 -4
  51. package/src/core/bundles.js +15 -0
  52. package/src/core/cli-command-definitions.js +94 -4
  53. package/src/core/command-executors.js +387 -0
  54. package/src/core/command-input.js +107 -0
  55. package/src/core/command-runtime.js +106 -0
  56. package/src/core/completion-artifacts.js +17 -6
  57. package/src/core/completion-ownership.js +88 -0
  58. package/src/core/completion.js +15 -3
  59. package/src/core/diagnosis.js +15 -11
  60. package/src/core/error-codes.js +136 -1
  61. package/src/core/events.js +239 -9
  62. package/src/core/execution.js +73 -9
  63. package/src/core/filesystem.js +75 -8
  64. package/src/core/inspect.js +27 -0
  65. package/src/core/integration-invocation-policy.js +170 -0
  66. package/src/core/integration-limits.js +20 -0
  67. package/src/core/integration-resources.js +127 -0
  68. package/src/core/next-action-model.js +60 -0
  69. package/src/core/next-action.js +31 -0
  70. package/src/core/phase.js +10 -3
  71. package/src/core/project-root.js +21 -0
  72. package/src/core/protocol-info.js +54 -0
  73. package/src/core/protocol-migration.js +59 -0
  74. package/src/core/reconcile-closure.js +54 -14
  75. package/src/core/recovery-history.js +116 -0
  76. package/src/core/resumability.js +8 -6
  77. package/src/core/schema-validation.js +1 -0
  78. package/src/core/task-claim-state.js +272 -0
  79. package/src/core/task-command.js +8 -4
  80. package/src/core/task-conflict-inspection.js +321 -0
  81. package/src/core/task-context.js +32 -29
  82. package/src/core/task-discovery.js +14 -1
  83. package/src/core/task-lock.js +248 -18
  84. package/src/core/task-migration.js +24 -1
  85. package/src/core/task-paths.js +6 -3
  86. package/src/core/task-recovery-migration.js +192 -0
  87. package/src/core/task-recovery.js +205 -0
  88. package/src/core/task-scope.js +33 -1
  89. package/src/core/templates.js +1 -0
  90. package/src/core/transaction.js +285 -0
  91. package/src/core/work-state.js +70 -6
  92. package/src/integration.js +47 -0
@@ -1,9 +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
+ import os from "node:os";
4
5
  import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
5
6
  import { taskLockPath } from "./task-paths.js";
6
- 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";
7
11
 
8
12
  export async function readLockInfo(target, taskId) {
9
13
  const relativePath = taskLockPath(taskId);
@@ -22,6 +26,61 @@ export async function readLockInfo(target, taskId) {
22
26
  }
23
27
  }
24
28
 
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) {
61
+ const heartbeat = Date.parse(lock.heartbeatAt ?? lock.acquiredAt);
62
+ if (!Number.isFinite(heartbeat)) return { status: "UNKNOWN", stale: false };
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);
73
+ }
74
+
75
+ /**
76
+ * PID values can be reused. Pairing the PID with the process start epoch gives
77
+ * a portable, serializable ownership token without a daemon or platform-only
78
+ * process inspector. Consumers must still treat remote owners as unknown.
79
+ */
80
+ export function currentProcessStartToken(now = Date.now(), uptimeSeconds = process.uptime()) {
81
+ return `${process.pid}:${Math.max(0, Math.floor(now - (uptimeSeconds * 1000)))}`;
82
+ }
83
+
25
84
  export const CLAIMS_LOCK_REL_PATH = ".forgeloop/.claims.lock";
26
85
 
27
86
  export async function readProjectClaimsLockInfo(target) {
@@ -40,34 +99,118 @@ export async function readProjectClaimsLockInfo(target) {
40
99
  }
41
100
  }
42
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
+
43
171
  export async function acquireProjectClaimsLock(target, operation = "claim-reservation") {
44
172
  await assertSafePath(target, CLAIMS_LOCK_REL_PATH);
45
173
  const fullPath = ensureWithin(target, CLAIMS_LOCK_REL_PATH);
46
174
 
47
175
  await mkdir(path.dirname(fullPath), { recursive: true });
48
176
 
177
+ const acquiredAt = new Date().toISOString();
49
178
  const lockData = {
50
179
  lockId: randomUUID(),
51
180
  scope: "claims-reservation",
52
181
  operation,
53
182
  pid: process.pid,
54
- acquiredAt: new Date().toISOString(),
183
+ hostname: os.hostname(),
184
+ processStartToken: currentProcessStartToken(),
185
+ ownerInstanceId: randomUUID(),
186
+ acquiredAt,
187
+ heartbeatAt: acquiredAt,
188
+ leaseMs: 300000,
55
189
  };
56
190
 
57
- let fileHandle;
58
- try {
59
- fileHandle = await open(fullPath, "wx");
60
- } catch (error) {
61
- 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;
62
198
  const existing = await readProjectClaimsLockInfo(target);
63
- const err = new Error(
64
- `Project write claims reservation is locked by operation "${existing?.operation ?? "unknown"}" (pid: ${existing?.pid ?? "unknown"}, acquired: ${existing?.acquiredAt ?? "unknown"}).`,
65
- );
66
- err.code = E_TASK_LOCKED;
67
- err.lockInfo = existing;
68
- 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);
69
210
  }
70
- throw error;
211
+ }
212
+ if (!fileHandle) {
213
+ throw projectClaimsLockError({ status: "UNKNOWN", stale: false }, null, "ACQUISITION_RETRY_EXHAUSTED");
71
214
  }
72
215
 
73
216
  try {
@@ -123,12 +266,18 @@ export async function acquireTaskLock(target, taskId, operation = "mutation") {
123
266
 
124
267
  await mkdir(path.dirname(fullPath), { recursive: true });
125
268
 
269
+ const acquiredAt = new Date().toISOString();
126
270
  const lockData = {
127
271
  lockId: randomUUID(),
128
272
  taskId,
129
273
  operation,
130
274
  pid: process.pid,
131
- acquiredAt: new Date().toISOString(),
275
+ hostname: os.hostname(),
276
+ processStartToken: currentProcessStartToken(),
277
+ ownerInstanceId: randomUUID(),
278
+ acquiredAt,
279
+ heartbeatAt: acquiredAt,
280
+ leaseMs: 300000,
132
281
  };
133
282
 
134
283
  let fileHandle;
@@ -179,7 +328,7 @@ export async function acquireTaskLock(target, taskId, operation = "mutation") {
179
328
  }
180
329
  }
181
330
 
182
- export async function forceUnlockTask(target, taskId) {
331
+ export async function forceUnlockTask(target, taskId, { staleOnly = false } = {}) {
183
332
  const relativePath = taskLockPath(taskId);
184
333
  await assertSafePath(target, relativePath);
185
334
  const fullPath = ensureWithin(target, relativePath);
@@ -189,8 +338,89 @@ export async function forceUnlockTask(target, taskId) {
189
338
  }
190
339
 
191
340
  const existing = await readLockInfo(target, taskId);
341
+ const classification = classifyLockStaleness(existing);
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
+ };
353
+ }
192
354
  await unlink(fullPath);
193
- return { unlocked: true, previousLock: existing };
355
+ return { unlocked: true, previousLock: existing, classification };
356
+ }
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 };
194
424
  }
195
425
 
196
426
  export async function withTaskLock(target, taskId, operationOrCallback, callback) {
@@ -1,6 +1,6 @@
1
1
  import { cp, mkdir, rename, rm } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { ensureWithin, fileExists } from "./filesystem.js";
3
+ import { ensureWithin, fileExists, writeFileAtomic } from "./filesystem.js";
4
4
  import { getPackageRoot } from "./templates.js";
5
5
  import {
6
6
  LEGACY_TASK_ARTIFACT_PATHS,
@@ -308,6 +308,23 @@ export async function migrateLegacyLayout(
308
308
  assertMigrationSnapshotsEqual(sourceSnapshot, finalSnapshot, "published namespace");
309
309
  await readTaskDescriptor(target, canonicalTaskId, packageRoot);
310
310
 
311
+ const migrationReceipt = {
312
+ schemaVersion: 1,
313
+ protocolVersion: 1,
314
+ kind: "LEGACY_TASK_MIGRATION",
315
+ taskId: canonicalTaskId,
316
+ taskKey,
317
+ migratedAt: new Date().toISOString(),
318
+ source: sourceSnapshot,
319
+ destination: finalSnapshot,
320
+ cleanupStatus: "PENDING",
321
+ };
322
+ await writeFileAtomic(
323
+ ensureWithin(target, `${finalDirRel}/migration-receipt.json`),
324
+ `${JSON.stringify(migrationReceipt, null, 2)}\n`,
325
+ );
326
+ migratedArtifacts.push("migration-receipt.json");
327
+
311
328
  // Test hook for cleanup failure testing
312
329
  if (typeof beforeLegacyCleanupForTest === "function") {
313
330
  await beforeLegacyCleanupForTest({ finalDirAbs, finalDirRel, target });
@@ -323,6 +340,12 @@ export async function migrateLegacyLayout(
323
340
  await removeLegacyArtifact(target, item.path);
324
341
  }
325
342
  }
343
+ migrationReceipt.cleanupStatus = "COMPLETED";
344
+ migrationReceipt.cleanedAt = new Date().toISOString();
345
+ await writeFileAtomic(
346
+ ensureWithin(target, `${finalDirRel}/migration-receipt.json`),
347
+ `${JSON.stringify(migrationReceipt, null, 2)}\n`,
348
+ );
326
349
  } catch (cleanupErr) {
327
350
  const error = new Error(
328
351
  `Migration published successfully to ${finalDirRel}, but legacy cleanup failed: ${cleanupErr.message}. Manual cleanup required.`,
@@ -1,6 +1,7 @@
1
1
  import { assertTaskId, taskStorageKey } from "./task-identity.js";
2
2
 
3
3
  export const TASK_STATE_ROOT = ".forgeloop/task-state";
4
+ export const TASK_LOCK_ROOT = ".forgeloop/locks";
4
5
  export const SESSIONS_ROOT = ".forgeloop/sessions";
5
6
 
6
7
  export const TASK_ARTIFACT_FILES = Object.freeze({
@@ -14,8 +15,8 @@ export const TASK_ARTIFACT_FILES = Object.freeze({
14
15
  events: "events.ndjson",
15
16
  gates: "gates",
16
17
  executions: "executions",
17
- lock: ".lock",
18
18
  policySnapshot: "policy-snapshot.json",
19
+ recovery: "recovery.json",
19
20
  });
20
21
 
21
22
  export const POLICY_ROOT = ".forgeloop/policy";
@@ -74,7 +75,8 @@ export function taskExecutionPath(taskId, executionId) {
74
75
  }
75
76
 
76
77
  export function taskLockPath(taskId) {
77
- return taskArtifactPath(taskId, "lock");
78
+ assertTaskId(taskId);
79
+ return `${TASK_LOCK_ROOT}/${taskStorageKey(taskId)}.lock`;
78
80
  }
79
81
 
80
82
  export function sessionArtifactPath(sessionId) {
@@ -99,7 +101,8 @@ export function buildTaskArtifactPaths(taskId) {
99
101
  events: `${dir}/${TASK_ARTIFACT_FILES.events}`,
100
102
  gates: `${dir}/${TASK_ARTIFACT_FILES.gates}`,
101
103
  executions: `${dir}/${TASK_ARTIFACT_FILES.executions}`,
102
- lock: `${dir}/${TASK_ARTIFACT_FILES.lock}`,
104
+ lock: taskLockPath(taskId),
103
105
  policySnapshot: `${dir}/${TASK_ARTIFACT_FILES.policySnapshot}`,
106
+ recovery: `${dir}/${TASK_ARTIFACT_FILES.recovery}`,
104
107
  });
105
108
  }
@@ -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
+ }