@cassiomc1/forgeloop 1.3.0 → 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 (76) 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 +7 -0
  5. package/EXECUTION_STATE.md +40 -0
  6. package/LOOP_ENGINEERING.md +54 -5
  7. package/LOOP_SYSTEM_DESIGN.md +22 -1
  8. package/PROTOCOL_INTEGRATION.md +41 -0
  9. package/README.md +38 -0
  10. package/TERMINOLOGY.md +15 -0
  11. package/THIRD_PARTY_NOTICES.md +15 -0
  12. package/THREAT_MODEL.md +20 -1
  13. package/docs/ARTIFACT_REFERENCE.md +43 -0
  14. package/docs/CLI_REFERENCE.md +97 -3
  15. package/docs/CROSS_HARNESS_CONTINUITY.md +23 -0
  16. package/docs/DOCUMENTATION_GUIDE.md +14 -0
  17. package/docs/GETTING_STARTED.md +1 -0
  18. package/docs/MCP.md +126 -0
  19. package/docs/RECIPES.md +82 -0
  20. package/docs/RELEASE_CHECKLIST_1_4.md +38 -0
  21. package/docs/RELEASE_CHECKLIST_1_5_MCP.md +78 -0
  22. package/docs/TROUBLESHOOTING.md +111 -1
  23. package/docs/UNIVERSAL_INTEGRATION.md +48 -0
  24. package/package.json +14 -3
  25. package/schemas/task-recovery.schema.json +61 -0
  26. package/src/cli.js +173 -347
  27. package/src/commands/audit.js +5 -0
  28. package/src/commands/inspect.js +6 -0
  29. package/src/commands/progress.js +6 -2
  30. package/src/commands/status.js +17 -0
  31. package/src/commands/task-create.js +39 -1
  32. package/src/commands/task-list.js +14 -1
  33. package/src/commands/task-lock-status.js +2 -2
  34. package/src/commands/task-recover.js +202 -0
  35. package/src/commands/task-repair-legacy-recovery.js +417 -0
  36. package/src/commands/task-resume.js +172 -0
  37. package/src/commands/task-scope.js +23 -4
  38. package/src/commands/task-show.js +18 -4
  39. package/src/commands/validate-protocol.js +19 -2
  40. package/src/core/artifact-registry.js +12 -0
  41. package/src/core/audit.js +20 -4
  42. package/src/core/bundles.js +15 -0
  43. package/src/core/cli-command-definitions.js +50 -4
  44. package/src/core/command-executors.js +387 -0
  45. package/src/core/command-input.js +107 -0
  46. package/src/core/command-runtime.js +106 -0
  47. package/src/core/completion-artifacts.js +2 -3
  48. package/src/core/completion-ownership.js +88 -0
  49. package/src/core/error-codes.js +118 -1
  50. package/src/core/events.js +130 -1
  51. package/src/core/filesystem.js +55 -6
  52. package/src/core/inspect.js +27 -0
  53. package/src/core/integration-invocation-policy.js +170 -0
  54. package/src/core/integration-limits.js +20 -0
  55. package/src/core/integration-resources.js +127 -0
  56. package/src/core/next-action-model.js +60 -0
  57. package/src/core/next-action.js +31 -0
  58. package/src/core/phase.js +2 -1
  59. package/src/core/project-root.js +21 -0
  60. package/src/core/protocol-info.js +13 -0
  61. package/src/core/reconcile-closure.js +32 -10
  62. package/src/core/recovery-history.js +116 -0
  63. package/src/core/schema-validation.js +1 -0
  64. package/src/core/task-claim-state.js +272 -0
  65. package/src/core/task-command.js +5 -1
  66. package/src/core/task-conflict-inspection.js +321 -0
  67. package/src/core/task-context.js +32 -29
  68. package/src/core/task-discovery.js +14 -1
  69. package/src/core/task-lock.js +216 -22
  70. package/src/core/task-paths.js +3 -2
  71. package/src/core/task-recovery-migration.js +192 -0
  72. package/src/core/task-recovery.js +205 -0
  73. package/src/core/task-scope.js +33 -1
  74. package/src/core/templates.js +1 -0
  75. package/src/core/transaction.js +28 -2
  76. package/src/integration.js +47 -0
@@ -0,0 +1,88 @@
1
+ import { LIFECYCLE_MILESTONES, validateStateLedgerCoherence } from "./events.js";
2
+
3
+ export const CANONICAL_COMPLETION_EVENT = "COMPLETION_VALIDATED";
4
+
5
+ /**
6
+ * Canonical completion ownership proof: the minimal, validator-backed evidence
7
+ * that the lifecycle itself officially reached COMPLETE. This is intentionally
8
+ * NOT a re-run of full publication/receipt/evidence semantics — it only proves
9
+ * that claim ownership may be released because canonical completion exists.
10
+ *
11
+ * Returns `{ valid: true, completionEvent }` or `{ valid: false, errors }`.
12
+ */
13
+ export function validateCompletionOwnershipProof({ taskId, state, ledger }) {
14
+ const errors = [];
15
+ if (!taskId || typeof taskId !== "string") {
16
+ return { valid: false, errors: [{ code: "E_COMPLETION_OWNERSHIP_UNPROVEN", message: "Completion ownership proof requires a taskId" }] };
17
+ }
18
+ if (!state || state.phase !== "COMPLETE") {
19
+ errors.push({ code: "E_COMPLETION_OWNERSHIP_UNPROVEN", message: "Work-state phase is not COMPLETE" });
20
+ }
21
+ if (!ledger || ledger.valid !== true) {
22
+ errors.push({
23
+ code: "E_COMPLETION_OWNERSHIP_UNPROVEN",
24
+ message: `Task event ledger is invalid; completion cannot be proven${ledger?.errors?.length
25
+ ? `: ${ledger.errors.map((error) => error.message).join("; ")}`
26
+ : ""}`,
27
+ });
28
+ }
29
+
30
+ let completionEvent = null;
31
+ if (ledger && Array.isArray(ledger.events)) {
32
+ if (ledger.events.some((event) => event.taskId !== taskId)) {
33
+ errors.push({
34
+ code: "E_COMPLETION_OWNERSHIP_UNPROVEN",
35
+ message: "Ledger contains an event belonging to a different task",
36
+ });
37
+ }
38
+ const candidates = ledger.events
39
+ .filter((event) => event.event === CANONICAL_COMPLETION_EVENT && event.taskId === taskId);
40
+ if (candidates.length === 0) {
41
+ errors.push({
42
+ code: "E_COMPLETION_OWNERSHIP_UNPROVEN",
43
+ message: `No canonical ${CANONICAL_COMPLETION_EVENT} event exists for this task`,
44
+ });
45
+ } else if (candidates.length > 1) {
46
+ errors.push({
47
+ code: "E_COMPLETION_OWNERSHIP_UNPROVEN",
48
+ message: `Multiple ${CANONICAL_COMPLETION_EVENT} events exist; completion is ambiguous`,
49
+ });
50
+ } else {
51
+ completionEvent = candidates[0];
52
+ }
53
+ }
54
+
55
+ if (state && ledger && Array.isArray(ledger.events)) {
56
+ const coherenceErrors = validateStateLedgerCoherence(state, ledger.events);
57
+ if (coherenceErrors.length > 0) {
58
+ for (const error of coherenceErrors) {
59
+ errors.push({
60
+ code: "E_COMPLETION_OWNERSHIP_UNPROVEN",
61
+ message: `State/ledger coherence invalid: ${error.message}`,
62
+ });
63
+ }
64
+ }
65
+ }
66
+
67
+ // No contradictory lifecycle activity may follow the canonical completion:
68
+ // any milestone at or after VERIFICATION_RECORDED occurring after the
69
+ // completion event means the lifecycle moved past terminal state.
70
+ if (completionEvent && ledger && Array.isArray(ledger.events)) {
71
+ const completionIndex = ledger.events.indexOf(completionEvent);
72
+ const contradiction = ledger.events.slice(completionIndex + 1).find((event) => {
73
+ const index = LIFECYCLE_MILESTONES.indexOf(event.event);
74
+ return index >= LIFECYCLE_MILESTONES.indexOf("VERIFICATION_RECORDED");
75
+ });
76
+ if (contradiction) {
77
+ errors.push({
78
+ code: "E_COMPLETION_OWNERSHIP_UNPROVEN",
79
+ message: `Lifecycle event ${contradiction.event} follows canonical completion; terminal state contradicted`,
80
+ });
81
+ }
82
+ }
83
+
84
+ if (errors.length > 0) {
85
+ return { valid: false, completionEvent: null, errors };
86
+ }
87
+ return { valid: true, completionEvent };
88
+ }
@@ -10,6 +10,7 @@ import {
10
10
  export const E_TASK_REQUIRED = "E_TASK_REQUIRED";
11
11
  export const E_TASK_NOT_FOUND = "E_TASK_NOT_FOUND";
12
12
  export const E_TASK_ALREADY_EXISTS = "E_TASK_ALREADY_EXISTS";
13
+ export const E_TASK_COMPLETE = "E_TASK_COMPLETE";
13
14
  export const E_TASK_AMBIGUOUS = "E_TASK_AMBIGUOUS";
14
15
  export const E_TASK_SELECTOR_CONFLICT = "E_TASK_SELECTOR_CONFLICT";
15
16
  export const E_TASK_DESCRIPTOR_INVALID = "E_TASK_DESCRIPTOR_INVALID";
@@ -18,6 +19,7 @@ export const E_TASK_CONTEXT_MISMATCH = "E_TASK_CONTEXT_MISMATCH";
18
19
 
19
20
  export const E_TASK_LOCKED = "E_TASK_LOCKED";
20
21
  export const E_TASK_LOCK_INVALID = "E_TASK_LOCK_INVALID";
22
+ export const E_PROJECT_CLAIMS_LOCK_INCONSISTENT = "E_PROJECT_CLAIMS_LOCK_INCONSISTENT";
21
23
 
22
24
  export const E_TASK_SCOPE_REQUIRED = "E_TASK_SCOPE_REQUIRED";
23
25
  export const E_TASK_SCOPE_CONFLICT = "E_TASK_SCOPE_CONFLICT";
@@ -28,6 +30,17 @@ export const E_TASK_CHANGE_ATTRIBUTION_UNAVAILABLE = "E_TASK_CHANGE_ATTRIBUTION_
28
30
 
29
31
  export const E_TASK_LAYOUT_LEGACY = "E_TASK_LAYOUT_LEGACY";
30
32
  export const E_TASK_MIGRATION_INVALID = "E_TASK_MIGRATION_INVALID";
33
+ export const E_TASK_RECOVERY_UNSAFE = "E_TASK_RECOVERY_UNSAFE";
34
+ export const E_TASK_RECOVERY_INCONSISTENT = "E_TASK_RECOVERY_INCONSISTENT";
35
+ export const E_LEGACY_RECOVERY_MIGRATION_INVALID = "E_LEGACY_RECOVERY_MIGRATION_INVALID";
36
+ export const E_COMPLETION_OWNERSHIP_UNPROVEN = "E_COMPLETION_OWNERSHIP_UNPROVEN";
37
+ export const E_TASK_CLAIM_OWNERSHIP_INCONSISTENT = "E_TASK_CLAIM_OWNERSHIP_INCONSISTENT";
38
+ export const E_TASK_RECOVERY_AUTHORIZATION_REQUIRED = "E_TASK_RECOVERY_AUTHORIZATION_REQUIRED";
39
+ export const E_TASK_RECOVERY_AUTHORITY_INVALID = "E_TASK_RECOVERY_AUTHORITY_INVALID";
40
+ export const E_TASK_RECOVERED = "E_TASK_RECOVERED";
41
+ export const E_TASK_NOT_RECOVERED = "E_TASK_NOT_RECOVERED";
42
+ export const E_TASK_RECOVERY_OFFICIAL_PATH_AVAILABLE = "E_TASK_RECOVERY_OFFICIAL_PATH_AVAILABLE";
43
+ export const E_TASK_ALREADY_RECOVERED = "E_TASK_ALREADY_RECOVERED";
31
44
  export const E_TASK_MIGRATION_IDENTITY_MISMATCH = "E_TASK_MIGRATION_IDENTITY_MISMATCH";
32
45
  export const E_PROTOCOL_MIGRATION_TARGET_UNSUPPORTED = "E_PROTOCOL_MIGRATION_TARGET_UNSUPPORTED";
33
46
 
@@ -151,6 +164,13 @@ export const PUBLIC_ERROR_CODES = Object.freeze({
151
164
  meaning: "Multiple tasks exist in the project but no task selector was provided.",
152
165
  safeResolution: "Select a task explicitly using --task <id> or FORGELOOP_TASK=<id>.",
153
166
  }),
167
+ E_TASK_COMPLETE: Object.freeze({
168
+ code: "E_TASK_COMPLETE",
169
+ category: "task-resolution",
170
+ classification: "PUBLIC_STABLE",
171
+ meaning: "A validator-backed COMPLETE task is terminal and cannot be mutated.",
172
+ safeResolution: "Create or select a non-terminal task for further work; do not modify terminal task state.",
173
+ }),
154
174
  E_TASK_LOCKED: Object.freeze({
155
175
  code: "E_TASK_LOCKED",
156
176
  category: "concurrency",
@@ -158,12 +178,96 @@ export const PUBLIC_ERROR_CODES = Object.freeze({
158
178
  meaning: "Task mutation is currently locked by another concurrent process or run-check.",
159
179
  safeResolution: "Wait for the active mutation to complete or inspect the lock with forgeloop task-show.",
160
180
  }),
181
+ E_PROJECT_CLAIMS_LOCK_INCONSISTENT: Object.freeze({
182
+ code: "E_PROJECT_CLAIMS_LOCK_INCONSISTENT",
183
+ category: "concurrency",
184
+ classification: "PUBLIC_STABLE",
185
+ meaning: "The project-wide claim reservation lock has unknown, corrupt, or concurrently changed ownership metadata.",
186
+ safeResolution: "Inspect .forgeloop/.claims.lock and retry only after its lease and owner identity can be validated; never force-delete unknown ownership.",
187
+ }),
161
188
  E_TASK_SCOPE_CONFLICT: Object.freeze({
162
189
  code: "E_TASK_SCOPE_CONFLICT",
163
190
  category: "scope",
164
191
  classification: "PUBLIC_STABLE",
165
192
  meaning: "Task write claims overlap with another non-complete task in the same checkout.",
166
- safeResolution: "Adjust write claims to non-overlapping paths or run tasks in separate worktrees.",
193
+ safeResolution: "Inspect the conflicting task classification reported in error.conflicts, then reconcile or recover it through its reported official recovery commands before retrying task creation.",
194
+ }),
195
+ E_TASK_RECOVERY_UNSAFE: Object.freeze({
196
+ code: "E_TASK_RECOVERY_UNSAFE",
197
+ category: "recovery",
198
+ classification: "PUBLIC_STABLE",
199
+ meaning: "Claim-release recovery was refused because the conflicting task is active, inconsistent, already complete, or holds a live lease.",
200
+ safeResolution: "Resolve the reported classification first; live leases must expire or be released by their owner before recovery.",
201
+ }),
202
+ E_TASK_RECOVERY_INCONSISTENT: Object.freeze({
203
+ code: "E_TASK_RECOVERY_INCONSISTENT",
204
+ category: "recovery",
205
+ classification: "PUBLIC_STABLE",
206
+ meaning: "Claim-release recovery was refused because the task state, recovery artifact, lock, or event ledger is inconsistent.",
207
+ safeResolution: "Repair the underlying artifact through its dedicated recovery surface; do not force-complete an unreadable task.",
208
+ }),
209
+ E_LEGACY_RECOVERY_MIGRATION_INVALID: Object.freeze({
210
+ code: "E_LEGACY_RECOVERY_MIGRATION_INVALID",
211
+ category: "recovery",
212
+ classification: "PUBLIC_STABLE",
213
+ meaning: "The legacy recovery-event repair was refused because the ledger does not match the exact known legacy defect signature, has incompatible later activity, holds a live lock, or is otherwise ambiguous.",
214
+ safeResolution: "Inspect the structured plan/errors; ambiguous or tampered ledgers stay INCONSISTENT and are never migrated.",
215
+ }),
216
+ E_COMPLETION_OWNERSHIP_UNPROVEN: Object.freeze({
217
+ code: "E_COMPLETION_OWNERSHIP_UNPROVEN",
218
+ category: "recovery",
219
+ classification: "PUBLIC_STABLE",
220
+ meaning: "Work-state claims COMPLETE but the canonical lifecycle/ledger completion proof is missing or invalid, so historical claims stay reserved.",
221
+ safeResolution: "Restore the canonical completion event and a valid ledger, or re-run the official completion pipeline; phase=COMPLETE alone never releases claims.",
222
+ }),
223
+ E_TASK_CLAIM_OWNERSHIP_INCONSISTENT: Object.freeze({
224
+ code: "E_TASK_CLAIM_OWNERSHIP_INCONSISTENT",
225
+ category: "recovery",
226
+ classification: "PUBLIC_STABLE",
227
+ meaning: "ForgeLoop cannot prove whether a task still owns its historical write claims.",
228
+ safeResolution: "Repair and validate the task descriptor, recovery artifact, and complete event ledger before acquiring overlapping claims or mutating the task.",
229
+ }),
230
+ E_TASK_RECOVERY_AUTHORIZATION_REQUIRED: Object.freeze({
231
+ code: "E_TASK_RECOVERY_AUTHORIZATION_REQUIRED",
232
+ category: "recovery",
233
+ classification: "PUBLIC_STABLE",
234
+ meaning: "task-recover requires explicit caller acknowledgement; this is not host-attested authority.",
235
+ safeResolution: "Re-run with --acknowledge-recovery only when evidence shows the task is STALE or ABANDONED; --operator-authorized remains a deprecated alias.",
236
+ }),
237
+ E_TASK_RECOVERY_AUTHORITY_INVALID: Object.freeze({
238
+ code: "E_TASK_RECOVERY_AUTHORITY_INVALID",
239
+ category: "authority",
240
+ classification: "PUBLIC_STABLE",
241
+ meaning: "Recovery authority metadata is invalid or claims host attestation without a host-owned grant reference.",
242
+ safeResolution: "Use caller acknowledgement, or provide a host-attested recovery grant through a trusted host integration.",
243
+ }),
244
+ E_TASK_RECOVERED: Object.freeze({
245
+ code: "E_TASK_RECOVERED",
246
+ category: "recovery",
247
+ classification: "PUBLIC_STABLE",
248
+ meaning: "The task released its write claims through recovery and ordinary mutation is suspended.",
249
+ safeResolution: "Run forgeloop task-resume --task <id> to reacquire the released claims before mutating the task.",
250
+ }),
251
+ E_TASK_NOT_RECOVERED: Object.freeze({
252
+ code: "E_TASK_NOT_RECOVERED",
253
+ category: "recovery",
254
+ classification: "PUBLIC_STABLE",
255
+ meaning: "task-resume was requested for a task without active recovered state.",
256
+ safeResolution: "Inspect the task with forgeloop task-show; task-resume is only valid while recovery.json is active.",
257
+ }),
258
+ E_TASK_RECOVERY_OFFICIAL_PATH_AVAILABLE: Object.freeze({
259
+ code: "E_TASK_RECOVERY_OFFICIAL_PATH_AVAILABLE",
260
+ category: "recovery",
261
+ classification: "PUBLIC_STABLE",
262
+ meaning: "Claim-release recovery was refused because canonical lifecycle reconciliation is available.",
263
+ safeResolution: "Use forgeloop reconcile-closure and the normal verification/completion pipeline instead of task-recover.",
264
+ }),
265
+ E_TASK_ALREADY_RECOVERED: Object.freeze({
266
+ code: "E_TASK_ALREADY_RECOVERED",
267
+ category: "recovery",
268
+ classification: "PUBLIC_STABLE",
269
+ meaning: "The task already has active durable recovered state.",
270
+ safeResolution: "Inspect the existing recovery metadata; use task-resume to reacquire claims or leave the task recovered.",
167
271
  }),
168
272
  E_TASK_SCOPE_DIRTY: Object.freeze({
169
273
  code: "E_TASK_SCOPE_DIRTY",
@@ -446,6 +550,7 @@ export const ALL_KNOWN_ERROR_CODES = Object.freeze(new Set([
446
550
  E_TASK_REQUIRED,
447
551
  E_TASK_NOT_FOUND,
448
552
  E_TASK_ALREADY_EXISTS,
553
+ E_TASK_COMPLETE,
449
554
  E_TASK_AMBIGUOUS,
450
555
  E_TASK_SELECTOR_CONFLICT,
451
556
  E_TASK_DESCRIPTOR_INVALID,
@@ -453,6 +558,7 @@ export const ALL_KNOWN_ERROR_CODES = Object.freeze(new Set([
453
558
  E_TASK_CONTEXT_MISMATCH,
454
559
  E_TASK_LOCKED,
455
560
  E_TASK_LOCK_INVALID,
561
+ E_PROJECT_CLAIMS_LOCK_INCONSISTENT,
456
562
  E_TASK_SCOPE_REQUIRED,
457
563
  E_TASK_SCOPE_CONFLICT,
458
564
  E_TASK_SCOPE_DIRTY,
@@ -469,6 +575,17 @@ export const ALL_KNOWN_ERROR_CODES = Object.freeze(new Set([
469
575
  E_TASK_CHANGE_ATTRIBUTION_UNAVAILABLE,
470
576
  E_TASK_LAYOUT_LEGACY,
471
577
  E_TASK_MIGRATION_INVALID,
578
+ E_TASK_RECOVERY_UNSAFE,
579
+ E_TASK_RECOVERY_INCONSISTENT,
580
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
581
+ E_COMPLETION_OWNERSHIP_UNPROVEN,
582
+ E_TASK_CLAIM_OWNERSHIP_INCONSISTENT,
583
+ E_TASK_RECOVERY_AUTHORIZATION_REQUIRED,
584
+ E_TASK_RECOVERY_AUTHORITY_INVALID,
585
+ E_TASK_RECOVERED,
586
+ E_TASK_NOT_RECOVERED,
587
+ E_TASK_RECOVERY_OFFICIAL_PATH_AVAILABLE,
588
+ E_TASK_ALREADY_RECOVERED,
472
589
  E_TASK_MIGRATION_IDENTITY_MISMATCH,
473
590
  E_PROTOCOL_MIGRATION_TARGET_UNSUPPORTED,
474
591
  E_CHECK_INERT,
@@ -15,6 +15,13 @@ import { getActiveTaskTransaction, withTaskTransaction } from "./transaction.js"
15
15
 
16
16
  import { assertDiagnosisDetails } from "./diagnosis-model.js";
17
17
  import { assertDecisionCriterionDetails } from "./settlement-model.js";
18
+ import {
19
+ LEGACY_RECOVERY_MIGRATION_EVENT,
20
+ assertLegacyMigrationDetails,
21
+ isLegacyRecoveryDetailsShape,
22
+ isLegacyRecoveryEventShape,
23
+ legacyRecoveryMigrationId,
24
+ } from "./task-recovery-migration.js";
18
25
 
19
26
  const EVENT_SCHEMA_VERSION = 1;
20
27
  export const LIFECYCLE_MILESTONES = Object.freeze([
@@ -97,11 +104,63 @@ export function validateKnownEventDetails(event) {
97
104
  case "CHECKPOINT_RECONCILED":
98
105
  assertReconcileClosureDetails(event.details);
99
106
  return;
107
+ case "TASK_RECOVERY_RECORDED":
108
+ assertRecoveryRecordedDetails(event.details);
109
+ return;
110
+ case "OPERATOR_RECOVERY_RECORDED":
111
+ // The exact known legacy defect signature is tolerated here so the
112
+ // ledger can be parsed and classified. It only becomes valid through an
113
+ // official migration event (enforced by validateEventLedger).
114
+ if (!event.details?.recoveryId && isLegacyRecoveryDetailsShape(event.details)) return;
115
+ assertRecoveryRecordedDetails(event.details);
116
+ return;
117
+ case "LEGACY_RECOVERY_MIGRATION_RECORDED":
118
+ assertLegacyMigrationDetails(event.details);
119
+ return;
120
+ case "TASK_RECOVERY_RESUMED":
121
+ assertRecoveryResumedDetails(event.details);
122
+ return;
100
123
  default:
101
124
  return;
102
125
  }
103
126
  }
104
127
 
128
+ function assertStringList(value, label) {
129
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item)) {
130
+ throw protocolError("E_EVENT_INVALID", `${label} must be an array of non-empty strings`);
131
+ }
132
+ }
133
+
134
+ function assertRecoveryRecordedDetails(details) {
135
+ if (!details || typeof details !== "object" || Array.isArray(details)) {
136
+ throw protocolError("E_EVENT_INVALID", "recovery event requires structured details");
137
+ }
138
+ for (const key of ["recoveryId", "classification", "previousPhase", "authorityKind"]) {
139
+ if (typeof details[key] !== "string" || !details[key]) {
140
+ throw protocolError("E_EVENT_INVALID", `recovery event details.${key} must be a non-empty string`);
141
+ }
142
+ }
143
+ if (!Number.isInteger(details.previousRevision) || details.previousRevision < 0) {
144
+ throw protocolError("E_EVENT_INVALID", "recovery event details.previousRevision must be a non-negative integer");
145
+ }
146
+ if (!["STALE", "ABANDONED"].includes(details.classification)) {
147
+ throw protocolError("E_EVENT_INVALID", "recovery event details.classification must be STALE or ABANDONED");
148
+ }
149
+ if (!["CALLER_ACKNOWLEDGED", "HOST_ATTESTED"].includes(details.authorityKind)) {
150
+ throw protocolError("E_EVENT_INVALID", "recovery event details.authorityKind is invalid");
151
+ }
152
+ assertStringList(details.reasonCodes, "recovery event details.reasonCodes");
153
+ assertStringList(details.releasedClaims, "recovery event details.releasedClaims");
154
+ }
155
+
156
+ function assertRecoveryResumedDetails(details) {
157
+ if (!details || typeof details !== "object" || Array.isArray(details)
158
+ || typeof details.recoveryId !== "string" || !details.recoveryId) {
159
+ throw protocolError("E_EVENT_INVALID", "TASK_RECOVERY_RESUMED requires details.recoveryId");
160
+ }
161
+ assertStringList(details.reacquiredClaims, "TASK_RECOVERY_RESUMED details.reacquiredClaims");
162
+ }
163
+
105
164
  function assertReconcileClosureDetails(details) {
106
165
  if (!details || typeof details !== "object" || Array.isArray(details)) {
107
166
  throw protocolError("E_EVENT_INVALID", "CHECKPOINT_RECONCILED requires structured details");
@@ -121,7 +180,7 @@ function assertReconcileClosureDetails(details) {
121
180
  }
122
181
  }
123
182
 
124
- function eventHash(event) {
183
+ export function eventHash(event) {
125
184
  const { hash, ...body } = event;
126
185
  return canonicalFingerprint(body);
127
186
  }
@@ -242,6 +301,73 @@ export async function appendProtocolEvent(target, input, packageRoot, options =
242
301
  return event;
243
302
  }
244
303
 
304
+ /**
305
+ * Validates the append-only pairing between unmigrated legacy recovery events
306
+ * and their official migration events. Strict by default; the official repair
307
+ * command validates intermediate state with `allowUnmigratedLegacyRecoveryEvents`
308
+ * before appending the migration events.
309
+ */
310
+ function validateLegacyRecoveryMigrations(events, errors, { allowUnmigratedLegacyRecoveryEvents = false } = {}) {
311
+ const migrationBySeq = new Map();
312
+ for (const event of events) {
313
+ if (event.event !== LEGACY_RECOVERY_MIGRATION_EVENT) continue;
314
+ try {
315
+ assertLegacyMigrationDetails(event.details);
316
+ } catch (err) {
317
+ errors.push({ code: err.code ?? "E_EVENT_INVALID", message: `event ${event.seq} (${event.event}): ${err.message}` });
318
+ continue;
319
+ }
320
+ if (migrationBySeq.has(event.details.legacyEventSeq)) {
321
+ errors.push({
322
+ code: "E_EVENT_INVALID",
323
+ message: `event ${event.seq} (${event.event}): duplicate migration for legacy recovery event seq ${event.details.legacyEventSeq}`,
324
+ });
325
+ continue;
326
+ }
327
+ migrationBySeq.set(event.details.legacyEventSeq, event);
328
+ }
329
+ for (const event of events) {
330
+ if (!isLegacyRecoveryEventShape(event)) continue;
331
+ const migration = migrationBySeq.get(event.seq);
332
+ if (!migration) {
333
+ if (!allowUnmigratedLegacyRecoveryEvents) {
334
+ errors.push({
335
+ code: "E_EVENT_INVALID",
336
+ message: `legacy recovery event ${event.seq} is not officially migrated (run forgeloop task-repair-legacy-recovery)`,
337
+ });
338
+ }
339
+ continue;
340
+ }
341
+ migrationBySeq.delete(event.seq);
342
+ const expectedRecoveryId = legacyRecoveryMigrationId({ taskId: event.taskId, seq: event.seq, hash: event.hash });
343
+ // Tail-binding: the migration event is appended at the ledger tail and may
344
+ // sit anywhere after its historical source. It binds by reference only.
345
+ if (migration.seq <= event.seq) {
346
+ errors.push({
347
+ code: "E_EVENT_INVALID",
348
+ message: `migration event ${migration.seq} must follow legacy recovery event ${event.seq}`,
349
+ });
350
+ }
351
+ if (migration.taskId !== event.taskId
352
+ || migration.details.legacyTaskId !== event.taskId
353
+ || migration.details.recoveryId !== expectedRecoveryId
354
+ || migration.details.legacyEventHash !== event.hash
355
+ || migration.details.legacyEventAt !== event.at
356
+ || migration.details.legacyEventType !== event.event) {
357
+ errors.push({
358
+ code: "E_LEDGER_HASH_INVALID",
359
+ message: `migration event ${migration.seq} does not bind legacy recovery event ${event.seq}`,
360
+ });
361
+ }
362
+ }
363
+ for (const [legacySeq, migration] of migrationBySeq) {
364
+ errors.push({
365
+ code: "E_EVENT_INVALID",
366
+ message: `migration event ${migration.seq} references unknown legacy recovery event seq ${legacySeq}`,
367
+ });
368
+ }
369
+ }
370
+
245
371
  export async function validateEventLedger(target, packageRoot, options = {}) {
246
372
  const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
247
373
  let events;
@@ -323,6 +449,9 @@ export async function validateEventLedger(target, packageRoot, options = {}) {
323
449
  errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "completion rejected before verification started" });
324
450
  }
325
451
  }
452
+ validateLegacyRecoveryMigrations(events, errors, {
453
+ allowUnmigratedLegacyRecoveryEvents: options?.allowUnmigratedLegacyRecoveryEvents === true,
454
+ });
326
455
  return { valid: errors.length === 0, events, errors };
327
456
  }
328
457
 
@@ -10,6 +10,55 @@ import {
10
10
  unlink,
11
11
  } from "node:fs/promises";
12
12
  import path from "node:path";
13
+ import { setTimeout as delay } from "node:timers/promises";
14
+
15
+ const WINDOWS_TRANSIENT_RETRY_DELAYS_MS = Object.freeze([5, 10, 20, 40]);
16
+
17
+ async function fsCallWithTransientWindowsRetry(fsImpl, filePath, {
18
+ platform = process.platform,
19
+ retryDelaysMs = WINDOWS_TRANSIENT_RETRY_DELAYS_MS,
20
+ delayImpl = delay,
21
+ } = {}) {
22
+ let retryIndex = 0;
23
+ while (true) {
24
+ try {
25
+ return await fsImpl(filePath);
26
+ } catch (error) {
27
+ const retryable = platform === "win32"
28
+ && (error?.code === "EPERM" || error?.code === "EACCES")
29
+ && retryIndex < retryDelaysMs.length;
30
+ if (!retryable) throw error;
31
+ await delayImpl(retryDelaysMs[retryIndex]);
32
+ retryIndex += 1;
33
+ }
34
+ }
35
+ }
36
+
37
+ export async function realpathWithTransientWindowsRetry(filePath, {
38
+ platform = process.platform,
39
+ retryDelaysMs = WINDOWS_TRANSIENT_RETRY_DELAYS_MS,
40
+ realpathImpl = realpath,
41
+ delayImpl = delay,
42
+ } = {}) {
43
+ return fsCallWithTransientWindowsRetry(realpathImpl, filePath, {
44
+ platform,
45
+ retryDelaysMs,
46
+ delayImpl,
47
+ });
48
+ }
49
+
50
+ export function lstatWithTransientWindowsRetry(filePath, {
51
+ platform = process.platform,
52
+ retryDelaysMs = WINDOWS_TRANSIENT_RETRY_DELAYS_MS,
53
+ lstatImpl = lstat,
54
+ delayImpl = delay,
55
+ } = {}) {
56
+ return fsCallWithTransientWindowsRetry(lstatImpl, filePath, {
57
+ platform,
58
+ retryDelaysMs,
59
+ delayImpl,
60
+ });
61
+ }
13
62
 
14
63
  export function ensureWithin(root, relativePath) {
15
64
  if (path.isAbsolute(relativePath)) {
@@ -27,7 +76,7 @@ export function ensureWithin(root, relativePath) {
27
76
  export async function assertSafePath(root, relativePath) {
28
77
  const destination = ensureWithin(root, relativePath);
29
78
  const absoluteRoot = path.resolve(root);
30
- const rootInfo = await lstat(absoluteRoot);
79
+ const rootInfo = await lstatWithTransientWindowsRetry(absoluteRoot);
31
80
  if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) {
32
81
  throw new Error(`Target directory must not be a symlink: ${absoluteRoot}`);
33
82
  }
@@ -37,7 +86,7 @@ export async function assertSafePath(root, relativePath) {
37
86
  for (const segment of segments) {
38
87
  current = path.join(current, segment);
39
88
  try {
40
- const info = await lstat(current);
89
+ const info = await lstatWithTransientWindowsRetry(current);
41
90
  if (info.isSymbolicLink()) {
42
91
  throw new Error(`Path uses a symlink inside target directory: ${relativePath}`);
43
92
  }
@@ -50,12 +99,12 @@ export async function assertSafePath(root, relativePath) {
50
99
  let existing = destination;
51
100
  while (true) {
52
101
  try {
53
- const info = await lstat(existing);
102
+ const info = await lstatWithTransientWindowsRetry(existing);
54
103
  if (info.isSymbolicLink()) {
55
104
  throw new Error(`Path uses a symlink inside target directory: ${relativePath}`);
56
105
  }
57
- const resolvedRoot = await realpath(absoluteRoot);
58
- const resolvedExisting = await realpath(existing);
106
+ const resolvedRoot = await realpathWithTransientWindowsRetry(absoluteRoot);
107
+ const resolvedExisting = await realpathWithTransientWindowsRetry(existing);
59
108
  const relativeResolved = path.relative(resolvedRoot, resolvedExisting);
60
109
  if (relativeResolved === ".." || relativeResolved.startsWith(`..${path.sep}`) || path.isAbsolute(relativeResolved)) {
61
110
  throw new Error(`Path escapes target directory: ${relativePath}`);
@@ -137,4 +186,4 @@ export async function writeFileAtomic(filePath, bytes, { dryRun = false } = {})
137
186
  }
138
187
  throw error;
139
188
  }
140
- }
189
+ }
@@ -11,6 +11,7 @@ import { FORGELOOP_KIT_DIR } from "./target-layout.js";
11
11
  import { trustedAuthorityConfiguration } from "./trusted-authority.js";
12
12
  import { reconcileContinuity } from "./continuity-reconciliation.js";
13
13
  import { continuityFinding, continuityIsHealthy } from "./continuity-observability.js";
14
+ import { findTaskById } from "./task-discovery.js";
14
15
 
15
16
  function profileMetadata(bytes) {
16
17
  const text = bytes.toString("utf8");
@@ -40,6 +41,7 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
40
41
  const statePath = ensureWithin(target, effectiveStateRel);
41
42
  const statePresent = await fileExists(statePath);
42
43
  const state = await readAndClassifyWorkState({ target, packageRoot, contractFile, taskId, stateFile: effectiveStateRel });
44
+ const taskInfo = taskId ? await findTaskById(target, taskId, packageRoot) : null;
43
45
  const continuity = await reconcileContinuity({ target, packageRoot, taskId });
44
46
  const schemaRoot = manifest?.layoutVersion >= 2
45
47
  ? ensureWithin(target, FORGELOOP_KIT_DIR)
@@ -78,6 +80,21 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
78
80
  const continuityIssue = continuityFinding(continuity);
79
81
  if (continuityIssue) findings.push(continuityIssue);
80
82
 
83
+ if (taskInfo?.ownershipValid === false) {
84
+ findings.push({
85
+ code: "task-claim-ownership-inconsistent",
86
+ severity: "error",
87
+ path: taskArtifactPath(taskId, "recovery"),
88
+ message: "Task claim ownership cannot be validated from recovery state and ledger history.",
89
+ remediation: `Run forgeloop validate-protocol --task ${taskId} --json and repair the reported protocol-owned artifact.`,
90
+ evidence: createEvidence({
91
+ kind: "BLOCKED",
92
+ source: taskArtifactPath(taskId, "events"),
93
+ result: "E_TASK_CLAIM_OWNERSHIP_INCONSISTENT",
94
+ }),
95
+ });
96
+ }
97
+
81
98
  if (state.status === "INVALID") {
82
99
  findings.push({
83
100
  code: "state-invalid",
@@ -132,6 +149,15 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
132
149
  evidence: protocolEvidence,
133
150
  },
134
151
  state: { ...state, path: WORK_STATE_PATH, present: statePresent },
152
+ recovery: taskInfo?.recovery ?? null,
153
+ claims: taskInfo ? {
154
+ state: taskInfo.claimState,
155
+ historical: taskInfo.historicalWriteClaims,
156
+ effective: taskInfo.effectiveWriteClaims,
157
+ mutationAllowed: taskInfo.mutationAllowed,
158
+ ownershipValid: taskInfo.ownershipValid,
159
+ ownershipErrors: taskInfo.ownershipErrors ?? taskInfo.errors ?? [],
160
+ } : null,
135
161
  continuity,
136
162
  compatibility: {
137
163
  deprecated: true,
@@ -142,6 +168,7 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
142
168
  ok: doctor.ok
143
169
  && !manifestError
144
170
  && schemaHealth.status === "valid"
171
+ && taskInfo?.ownershipValid !== false
145
172
  && !["INVALID", "REVALIDATION_REQUIRED"].includes(state.status)
146
173
  && continuityIsHealthy(continuity),
147
174
  };