@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,417 @@
1
+ import { resolveTaskContext } from "../core/task-context.js";
2
+ import { readTaskDescriptor } from "../core/task-descriptor.js";
3
+ import { normalizeWriteClaims } from "../core/task-scope.js";
4
+ import {
5
+ appendProtocolEvent,
6
+ validateEventLedger,
7
+ validateStateLedgerCoherence,
8
+ } from "../core/events.js";
9
+ import {
10
+ readLockInfo,
11
+ releaseStaleTaskLockIfUnchanged,
12
+ withProjectClaimsLock,
13
+ } from "../core/task-lock.js";
14
+ import { readWorkState } from "../core/work-state.js";
15
+ import { inspectTaskConflictState, MEANINGFUL_ACTIVITY_EVENTS } from "../core/task-conflict-inspection.js";
16
+ import { resolveTaskClaimState } from "../core/task-claim-state.js";
17
+ import { currentRepositoryFingerprint } from "../core/repository.js";
18
+ import { withTaskTransaction } from "../core/transaction.js";
19
+ import {
20
+ createTaskRecovery,
21
+ readTaskRecovery,
22
+ writeTaskRecovery,
23
+ } from "../core/task-recovery.js";
24
+ import {
25
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
26
+ E_TASK_LOCKED,
27
+ E_TASK_RECOVERY_AUTHORIZATION_REQUIRED,
28
+ E_TASK_RECOVERY_INCONSISTENT,
29
+ } from "../core/error-codes.js";
30
+ import {
31
+ LEGACY_RECOVERY_DEFECT,
32
+ LEGACY_RECOVERY_MIGRATION_EVENT,
33
+ MIGRATED_RECOVERY_CLASSIFICATION,
34
+ assertLegacyMigrationDetails,
35
+ isLegacyRecoveryEventShape,
36
+ legacyRecoveryMigrationId,
37
+ } from "../core/task-recovery-migration.js";
38
+
39
+ const MODERN_RECOVERY_EVENT_TYPES = new Set(["TASK_RECOVERY_RECORDED", "OPERATOR_RECOVERY_RECORDED"]);
40
+
41
+ function repairError(code, message, details = {}) {
42
+ const error = new Error(message);
43
+ error.code = code;
44
+ Object.assign(error, details);
45
+ return error;
46
+ }
47
+
48
+ /**
49
+ * Narrow migration inspection: the ledger must be structurally trustworthy
50
+ * with the single narrowly scoped exception of unmigrated legacy recovery
51
+ * events. Any other error fails closed. Returns the recognized candidates.
52
+ */
53
+ async function inspectLegacyRecoveryMigration(target, { taskId, packageRoot }) {
54
+ const tolerated = await validateEventLedger(target, packageRoot, {
55
+ taskId,
56
+ allowUnmigratedLegacyRecoveryEvents: true,
57
+ });
58
+ if (!tolerated.valid) {
59
+ throw repairError(
60
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
61
+ "Ledger is not structurally trustworthy for legacy recovery migration",
62
+ { errors: tolerated.errors },
63
+ );
64
+ }
65
+
66
+ const events = tolerated.events;
67
+ for (const event of events) {
68
+ if (MODERN_RECOVERY_EVENT_TYPES.has(event.event)
69
+ && !isLegacyRecoveryEventShape(event)
70
+ && !event.details?.recoveryId) {
71
+ throw repairError(
72
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
73
+ `Modern recovery event ${event.seq} has no recoveryId; ambiguous evidence cannot be migrated`,
74
+ );
75
+ }
76
+ if (event.event === LEGACY_RECOVERY_MIGRATION_EVENT) {
77
+ try {
78
+ assertLegacyMigrationDetails(event.details);
79
+ } catch (error) {
80
+ throw repairError(
81
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
82
+ `Migration event ${event.seq} is invalid: ${error.message}`,
83
+ );
84
+ }
85
+ }
86
+ }
87
+ // A strictly valid ledger means every legacy event is already migrated.
88
+ const strict = await validateEventLedger(target, packageRoot, { taskId });
89
+ if (strict.valid) {
90
+ return { alreadyMigrated: true, events, candidates: [] };
91
+ }
92
+
93
+ const candidates = events.filter(isLegacyRecoveryEventShape);
94
+ return { alreadyMigrated: false, events, candidates };
95
+ }
96
+
97
+ function requireSingleTailCandidate({ taskId, events, candidates }) {
98
+ if (candidates.length === 0) {
99
+ throw repairError(
100
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
101
+ `No recognizable legacy recovery event to migrate in task ${taskId}`,
102
+ );
103
+ }
104
+ if (candidates.length > 1) {
105
+ throw repairError(
106
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
107
+ `${candidates.length} unresolved legacy recovery events found; this release migrates exactly one per task`,
108
+ { legacyEventSeqs: candidates.map((candidate) => candidate.seq) },
109
+ );
110
+ }
111
+ const candidate = candidates[0];
112
+ if (events.at(-1)?.seq !== candidate.seq) {
113
+ throw repairError(
114
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
115
+ `Legacy recovery event ${candidate.seq} is not at the effective ledger boundary; post-boundary activity makes migration unsafe`,
116
+ );
117
+ }
118
+ return candidate;
119
+ }
120
+
121
+ function refuseUnsafeBoundary(candidate, events) {
122
+ const after = events.filter((event) => event.seq > candidate.seq);
123
+ const meaningful = after.filter((event) => MEANINGFUL_ACTIVITY_EVENTS.has(event.event));
124
+ if (meaningful.length > 0 || after.some((event) => event.event.startsWith("TASK_RECOVERY"))) {
125
+ throw repairError(
126
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
127
+ `Activity exists after legacy recovery event ${candidate.seq}; the recorded boundary is no longer safe to migrate`,
128
+ { activityAfter: meaningful.map((event) => ({ seq: event.seq, event: event.event })) },
129
+ );
130
+ }
131
+ }
132
+
133
+ async function readRepairContext(target, taskId, packageRoot) {
134
+ const descriptorArtifact = await readTaskDescriptor(target, taskId, packageRoot);
135
+ const state = await readWorkState(target, { packageRoot, taskId });
136
+ if (!state) {
137
+ throw repairError(E_TASK_RECOVERY_INCONSISTENT, "Work state is unreadable; boundary proof impossible");
138
+ }
139
+ const claims = normalizeWriteClaims(descriptorArtifact.value.writeClaims ?? []);
140
+ return { descriptor: descriptorArtifact.value, state, historicalClaims: claims };
141
+ }
142
+
143
+ export async function runTaskRepairLegacyRecovery({ target, packageRoot, taskId, acknowledgeRecovery } = {}) {
144
+ if (!acknowledgeRecovery) {
145
+ throw repairError(
146
+ E_TASK_RECOVERY_AUTHORIZATION_REQUIRED,
147
+ "task-repair-legacy-recovery requires fresh explicit --acknowledge-recovery",
148
+ );
149
+ }
150
+
151
+ const context = await resolveTaskContext(target, { taskId, packageRoot, explicitRequired: true });
152
+ const effectiveTaskId = context.taskId;
153
+
154
+ const initial = await inspectLegacyRecoveryMigration(target, { taskId: effectiveTaskId, packageRoot });
155
+ if (initial.alreadyMigrated) {
156
+ return alreadyRepairedResult(target, { taskId: effectiveTaskId, packageRoot, events: initial.events });
157
+ }
158
+ const initialCandidate = requireSingleTailCandidate({
159
+ taskId: effectiveTaskId,
160
+ events: initial.events,
161
+ candidates: initial.candidates,
162
+ });
163
+
164
+ return withProjectClaimsLock(target, "task-repair-legacy-recovery", async () => {
165
+ const lockedInspection = await inspectTaskConflictState(target, {
166
+ taskId: effectiveTaskId,
167
+ packageRoot,
168
+ });
169
+ if (lockedInspection.evidence.lockStatus === "LIVE") {
170
+ throw repairError(E_TASK_LOCKED, `Task ${effectiveTaskId} has a live mutation lock`, {
171
+ lockStatus: lockedInspection.evidence.lockStatus,
172
+ });
173
+ }
174
+ if (lockedInspection.evidence.lockStatus !== "NONE"
175
+ && lockedInspection.evidence.lockStatus !== "STALE") {
176
+ throw repairError(
177
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
178
+ `Task ${effectiveTaskId} lock ownership is ${lockedInspection.evidence.lockStatus}; refusing unsafe migration`,
179
+ { lockStatus: lockedInspection.evidence.lockStatus },
180
+ );
181
+ }
182
+ // A stale lease may only be settled through the CAS-safe path; a lock that
183
+ // changed under us (replacement owner) aborts the repair.
184
+ if (lockedInspection.evidence.lockStatus === "STALE") {
185
+ const observedLock = await readLockInfo(target, effectiveTaskId);
186
+ const released = await releaseStaleTaskLockIfUnchanged(target, effectiveTaskId, observedLock);
187
+ if (!released.released && released.reason !== "LOCK_MISSING") {
188
+ throw repairError(
189
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
190
+ `Task ${effectiveTaskId} stale lock changed during settlement (${released.reason}); replacement owner preserved`,
191
+ { lockRelease: released },
192
+ );
193
+ }
194
+ }
195
+
196
+ const existingRecovery = await readTaskRecovery(target, { taskId: effectiveTaskId, packageRoot });
197
+ const locked = await inspectLegacyRecoveryMigration(target, { taskId: effectiveTaskId, packageRoot });
198
+ let candidate;
199
+ if (locked.alreadyMigrated) {
200
+ return verifyAlreadyRepaired(target, {
201
+ taskId: effectiveTaskId,
202
+ packageRoot,
203
+ events: locked.events,
204
+ existingRecovery,
205
+ });
206
+ }
207
+ candidate = requireSingleTailCandidate({
208
+ taskId: effectiveTaskId,
209
+ events: locked.events,
210
+ candidates: locked.candidates,
211
+ });
212
+ if (candidate.hash !== initialCandidate.hash || candidate.seq !== initialCandidate.seq) {
213
+ throw repairError(
214
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
215
+ "Ledger changed between inspection and project lock; aborting",
216
+ );
217
+ }
218
+ refuseUnsafeBoundary(candidate, locked.events);
219
+
220
+ if (existingRecovery) {
221
+ throw repairError(
222
+ E_TASK_RECOVERY_INCONSISTENT,
223
+ `An unrelated or orphaned recovery artifact exists for task ${effectiveTaskId}; resolve it before migrating the legacy recovery`,
224
+ );
225
+ }
226
+
227
+ const repairContext = await readRepairContext(target, effectiveTaskId, packageRoot);
228
+ const coherenceErrors = validateStateLedgerCoherence(repairContext.state, locked.events);
229
+ if (coherenceErrors.length > 0) {
230
+ throw repairError(
231
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
232
+ "Work state and ledger are not coherent; the task may have progressed beyond the legacy boundary",
233
+ { errors: coherenceErrors },
234
+ );
235
+ }
236
+
237
+ const repository = await currentRepositoryFingerprint(target);
238
+ const expectedRecoveryId = legacyRecoveryMigrationId({
239
+ taskId: candidate.taskId,
240
+ seq: candidate.seq,
241
+ hash: candidate.hash,
242
+ });
243
+
244
+ const result = await withTaskTransaction({
245
+ target,
246
+ taskId: effectiveTaskId,
247
+ operation: "task-repair-legacy-recovery",
248
+ packageRoot,
249
+ recordCommitEvent: true,
250
+ }, async () => {
251
+ const revalidated = await validateEventLedger(target, packageRoot, {
252
+ taskId: effectiveTaskId,
253
+ allowUnmigratedLegacyRecoveryEvents: true,
254
+ });
255
+ if (!revalidated.valid) {
256
+ throw repairError(
257
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
258
+ "Pre-commit ledger revalidation failed inside the transaction",
259
+ { errors: revalidated.errors },
260
+ );
261
+ }
262
+ const tailCandidate = requireSingleTailCandidate({
263
+ taskId: effectiveTaskId,
264
+ events: revalidated.events,
265
+ candidates: revalidated.events.filter(isLegacyRecoveryEventShape),
266
+ });
267
+ refuseUnsafeBoundary(tailCandidate, revalidated.events);
268
+
269
+ const migratedAt = new Date().toISOString();
270
+ const migrationEvent = await appendProtocolEvent(target, {
271
+ taskId: effectiveTaskId,
272
+ event: LEGACY_RECOVERY_MIGRATION_EVENT,
273
+ at: migratedAt,
274
+ details: {
275
+ recoveryId: expectedRecoveryId,
276
+ defect: LEGACY_RECOVERY_DEFECT,
277
+ legacyEventType: tailCandidate.event,
278
+ legacyEventSeq: tailCandidate.seq,
279
+ legacyEventHash: tailCandidate.hash,
280
+ legacyEventAt: tailCandidate.at,
281
+ legacyTaskId: tailCandidate.taskId,
282
+ legacyClassification: tailCandidate.details.classification,
283
+ legacyAuthority: tailCandidate.details.authorization,
284
+ classification: MIGRATED_RECOVERY_CLASSIFICATION,
285
+ reasonCodes: [...tailCandidate.details.reasonCodes],
286
+ releasedClaims: repairContext.historicalClaims,
287
+ previousPhase: repairContext.state.phase,
288
+ previousRevision: repairContext.state.revision ?? 0,
289
+ currentBranch: repository.branch,
290
+ currentHead: repository.head,
291
+ recoveredAt: migratedAt,
292
+ repairObservedClassification: lockedInspection.classification,
293
+ repairObservedReasonCodes: lockedInspection.reasonCodes,
294
+ authorityKind: "CALLER_ACKNOWLEDGED",
295
+ },
296
+ }, packageRoot, { taskId: effectiveTaskId });
297
+
298
+ const recovery = createTaskRecovery({
299
+ taskId: effectiveTaskId,
300
+ recoveredAt: migrationEvent.at,
301
+ recoveryId: expectedRecoveryId,
302
+ recoveryEventSeq: migrationEvent.seq,
303
+ classificationAtRecovery: MIGRATED_RECOVERY_CLASSIFICATION,
304
+ reasonCodes: [...tailCandidate.details.reasonCodes],
305
+ releasedClaims: repairContext.historicalClaims,
306
+ previousPhase: repairContext.state.phase,
307
+ previousRevision: repairContext.state.revision ?? 0,
308
+ repositoryFingerprint: { branch: repository.branch, head: repository.head },
309
+ authority: { kind: "CALLER_ACKNOWLEDGED" },
310
+ });
311
+ await writeTaskRecovery(target, recovery, packageRoot);
312
+
313
+ return {
314
+ repaired: 1,
315
+ alreadyRepaired: false,
316
+ taskId: effectiveTaskId,
317
+ taskKey: context.taskKey,
318
+ recoveryId: expectedRecoveryId,
319
+ migrationEventSeq: migrationEvent.seq,
320
+ legacyEventSeq: tailCandidate.seq,
321
+ legacyEventAt: tailCandidate.at,
322
+ classificationAtRecovery: MIGRATED_RECOVERY_CLASSIFICATION,
323
+ releasedClaims: repairContext.historicalClaims,
324
+ previousPhase: repairContext.state.phase,
325
+ repairObservedClassification: lockedInspection.classification,
326
+ };
327
+ });
328
+
329
+ const finalLedger = await validateEventLedger(target, packageRoot, { taskId: effectiveTaskId });
330
+ if (!finalLedger.valid) {
331
+ throw repairError(
332
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
333
+ "Post-migration ledger validation failed; ownership stays fail-closed",
334
+ { errors: finalLedger.errors },
335
+ );
336
+ }
337
+ return result;
338
+ });
339
+ }
340
+
341
+ async function verifyAlreadyRepaired(target, {
342
+ taskId,
343
+ packageRoot,
344
+ events,
345
+ existingRecovery,
346
+ }) {
347
+ const migrations = events.filter((event) => event.event === LEGACY_RECOVERY_MIGRATION_EVENT);
348
+ if (migrations.length !== 1) {
349
+ throw repairError(
350
+ E_LEGACY_RECOVERY_MIGRATION_INVALID,
351
+ `Expected exactly one migration event, found ${migrations.length}`,
352
+ );
353
+ }
354
+ const migration = migrations[0];
355
+ if (!existingRecovery) {
356
+ throw repairError(
357
+ E_TASK_RECOVERY_INCONSISTENT,
358
+ "Migration event exists without its durable recovery artifact; repair state is incomplete",
359
+ );
360
+ }
361
+ if (existingRecovery.value.recoveryEventSeq !== migration.seq
362
+ || existingRecovery.value.recoveryId !== migration.details.recoveryId) {
363
+ throw repairError(
364
+ E_TASK_RECOVERY_INCONSISTENT,
365
+ "Existing recovery artifact does not match the migrated recovery relationship",
366
+ );
367
+ }
368
+
369
+ // alreadyRepaired means the WHOLE canonical recovery relationship is valid:
370
+ // ownership must resolve to validated RELEASED_BY_RECOVERY and every
371
+ // artifact field must agree with the canonical migration event.
372
+ const projection = await resolveTaskClaimState(target, { taskId, packageRoot });
373
+ const relationshipValid = projection.valid === true
374
+ && projection.ownershipValid === true
375
+ && projection.claimState === "RELEASED_BY_RECOVERY"
376
+ && projection.recovery?.recoveryId === migration.details.recoveryId
377
+ && projection.recovery?.recoveryEventSeq === migration.seq;
378
+ if (!relationshipValid) {
379
+ throw repairError(
380
+ E_TASK_RECOVERY_INCONSISTENT,
381
+ "Migrated recovery relationship is not fully validated; refusing idempotent no-op",
382
+ {
383
+ claimState: projection.claimState,
384
+ reasonCodes: projection.reasonCodes,
385
+ ownershipErrors: projection.ownershipErrors,
386
+ },
387
+ );
388
+ }
389
+
390
+ return {
391
+ repaired: 0,
392
+ alreadyRepaired: true,
393
+ taskId,
394
+ recoveryId: migration.details.recoveryId,
395
+ migrationEventSeq: migration.seq,
396
+ };
397
+ }
398
+
399
+ async function alreadyRepairedResult(target, { taskId, packageRoot, events }) {
400
+ const existingRecovery = await readTaskRecovery(target, { taskId, packageRoot });
401
+ return verifyAlreadyRepaired(target, { taskId, packageRoot, events, existingRecovery });
402
+ }
403
+
404
+ export function formatTaskRepairLegacyRecoveryResult(result) {
405
+ if (result.alreadyRepaired) {
406
+ return `task: ${result.taskId}\nstatus: already repaired (recovery ${result.recoveryId})\n`;
407
+ }
408
+ return [
409
+ `repaired task: ${result.taskId}`,
410
+ `migration event seq: ${result.migrationEventSeq}`,
411
+ `legacy event seq: ${result.legacyEventSeq} (${result.legacyEventAt})`,
412
+ `recovery id: ${result.recoveryId}`,
413
+ `classification: ${result.classificationAtRecovery}`,
414
+ `released claims: ${result.releasedClaims.join(", ") || "none"}`,
415
+ "",
416
+ ].join("\n");
417
+ }
@@ -0,0 +1,172 @@
1
+ import { resolveTaskContext } from "../core/task-context.js";
2
+ import { readTaskDescriptor, writeTaskDescriptor } from "../core/task-descriptor.js";
3
+ import { discoverTasks } from "../core/task-discovery.js";
4
+ import { appendProtocolEvent } from "../core/events.js";
5
+ import { readWorkState } from "../core/work-state.js";
6
+ import { normalizeWriteClaims, assertScopeClean } from "../core/task-scope.js";
7
+ import { assertNoScopeConflictsWithInspection } from "./task-create.js";
8
+ import {
9
+ classifyLockStaleness,
10
+ readLockInfo,
11
+ releaseStaleTaskLockIfUnchanged,
12
+ withProjectClaimsLock,
13
+ } from "../core/task-lock.js";
14
+ import { withTaskTransaction } from "../core/transaction.js";
15
+ import {
16
+ clearTaskRecovery,
17
+ } from "../core/task-recovery.js";
18
+ import { resolveTaskClaimState } from "../core/task-claim-state.js";
19
+ import {
20
+ E_TASK_LOCKED,
21
+ E_TASK_NOT_RECOVERED,
22
+ E_TASK_RECOVERY_INCONSISTENT,
23
+ } from "../core/error-codes.js";
24
+
25
+ function resumeError(code, message, details = {}) {
26
+ const error = new Error(message);
27
+ error.code = code;
28
+ Object.assign(error, details);
29
+ return error;
30
+ }
31
+
32
+ function sameClaims(left, right) {
33
+ return left.length === right.length && left.every((claim, index) => claim === right[index]);
34
+ }
35
+
36
+ async function requireActiveRecovery(target, taskId, packageRoot) {
37
+ const projection = await resolveTaskClaimState(target, { taskId, packageRoot });
38
+ if (!projection.valid) {
39
+ throw resumeError(
40
+ E_TASK_RECOVERY_INCONSISTENT,
41
+ `Task ${taskId} claim ownership is inconsistent; repair it before task-resume`,
42
+ { reasonCodes: projection.reasonCodes, recoveryErrors: projection.ownershipErrors },
43
+ );
44
+ }
45
+ if (projection.claimState !== "RELEASED_BY_RECOVERY" || !projection.recovery) {
46
+ throw resumeError(E_TASK_NOT_RECOVERED, `Task ${taskId} is not RECOVERED`);
47
+ }
48
+ return projection;
49
+ }
50
+
51
+ export async function runTaskResume({ target, packageRoot, taskId, claims } = {}) {
52
+ const context = await resolveTaskContext(target, { taskId, packageRoot, explicitRequired: true });
53
+ const effectiveTaskId = context.taskId;
54
+ const initialProjection = await requireActiveRecovery(target, effectiveTaskId, packageRoot);
55
+ const initialRecovery = initialProjection.recovery;
56
+
57
+ return withProjectClaimsLock(target, "task-resume", async () => {
58
+ const lockedProjection = await requireActiveRecovery(target, effectiveTaskId, packageRoot);
59
+ if (lockedProjection.recovery.recoveryId !== initialRecovery.recoveryId) {
60
+ throw resumeError(
61
+ E_TASK_RECOVERY_INCONSISTENT,
62
+ `Task ${effectiveTaskId} recovery state changed before resume`,
63
+ );
64
+ }
65
+
66
+ const observedLock = await readLockInfo(target, effectiveTaskId);
67
+ const lockClassification = classifyLockStaleness(observedLock);
68
+ if (lockClassification.status === "LIVE") {
69
+ throw resumeError(E_TASK_LOCKED, `Task ${effectiveTaskId} has a live mutation lock`, {
70
+ lockInfo: observedLock,
71
+ lockClassification,
72
+ });
73
+ }
74
+ if (lockClassification.status === "UNKNOWN" || lockClassification.status === "CORRUPT") {
75
+ throw resumeError(
76
+ E_TASK_RECOVERY_INCONSISTENT,
77
+ `Task ${effectiveTaskId} lock ownership is ${lockClassification.status}; resume is blocked`,
78
+ { lockInfo: observedLock, lockClassification },
79
+ );
80
+ }
81
+ if (lockClassification.status === "STALE") {
82
+ const released = await releaseStaleTaskLockIfUnchanged(target, effectiveTaskId, observedLock);
83
+ if (!released.released && released.reason !== "LOCK_MISSING") {
84
+ throw resumeError(
85
+ E_TASK_RECOVERY_INCONSISTENT,
86
+ `Task ${effectiveTaskId} lock changed during resume (${released.reason})`,
87
+ { lockRelease: released },
88
+ );
89
+ }
90
+ }
91
+
92
+ return withTaskTransaction({
93
+ target,
94
+ taskId: effectiveTaskId,
95
+ operation: "task-resume",
96
+ packageRoot,
97
+ recordCommitEvent: true,
98
+ }, async () => {
99
+ const descriptorArtifact = await readTaskDescriptor(target, effectiveTaskId, packageRoot);
100
+
101
+ const state = await readWorkState(target, { packageRoot, taskId: effectiveTaskId });
102
+ const claimProjection = await resolveTaskClaimState(target, {
103
+ taskId: effectiveTaskId,
104
+ packageRoot,
105
+ descriptor: descriptorArtifact.value,
106
+ state,
107
+ });
108
+ const recovery = claimProjection.recovery;
109
+ if (!claimProjection.valid
110
+ || claimProjection.claimState !== "RELEASED_BY_RECOVERY"
111
+ || recovery?.recoveryId !== initialRecovery.recoveryId) {
112
+ throw resumeError(
113
+ E_TASK_RECOVERY_INCONSISTENT,
114
+ `Task ${effectiveTaskId} recovery state does not establish validated released ownership`,
115
+ { recoveryErrors: claimProjection.ownershipErrors },
116
+ );
117
+ }
118
+ if (!state
119
+ || state.phase !== recovery.previousPhase
120
+ || (state.revision ?? 0) !== recovery.previousRevision) {
121
+ throw resumeError(
122
+ E_TASK_RECOVERY_INCONSISTENT,
123
+ `Task ${effectiveTaskId} lifecycle state no longer matches recovery revision`,
124
+ );
125
+ }
126
+
127
+ const desiredClaims = normalizeWriteClaims(
128
+ Array.isArray(claims) && claims.length > 0 ? claims : recovery.releasedClaims,
129
+ );
130
+ const allTasks = await discoverTasks(target, packageRoot);
131
+ await assertNoScopeConflictsWithInspection(desiredClaims, allTasks, effectiveTaskId, { target, packageRoot });
132
+ if (desiredClaims.length > 0) {
133
+ await assertScopeClean(target, desiredClaims);
134
+ }
135
+
136
+ const descriptorClaims = normalizeWriteClaims(descriptorArtifact.value.writeClaims ?? []);
137
+ if (!sameClaims(descriptorClaims, desiredClaims)) {
138
+ await writeTaskDescriptor(target, {
139
+ ...descriptorArtifact.value,
140
+ writeClaims: desiredClaims,
141
+ updatedAt: new Date().toISOString(),
142
+ }, packageRoot);
143
+ }
144
+
145
+ await clearTaskRecovery(target, { taskId: effectiveTaskId });
146
+ await appendProtocolEvent(target, {
147
+ taskId: effectiveTaskId,
148
+ event: "TASK_RECOVERY_RESUMED",
149
+ details: {
150
+ recoveryId: recovery.recoveryId,
151
+ reacquiredClaims: desiredClaims,
152
+ previousClassification: recovery.classificationAtRecovery,
153
+ },
154
+ }, packageRoot, { taskId: effectiveTaskId });
155
+
156
+ return {
157
+ taskId: effectiveTaskId,
158
+ taskKey: context.taskKey,
159
+ resumed: true,
160
+ recoveryId: recovery.recoveryId,
161
+ phase: state.phase,
162
+ reacquiredClaims: desiredClaims,
163
+ mutationAllowed: true,
164
+ };
165
+ });
166
+ });
167
+ }
168
+
169
+ export function formatTaskResumeResult(result) {
170
+ const claims = result.reacquiredClaims.length === 0 ? "none" : result.reacquiredClaims.join(", ");
171
+ return `resumed task: ${result.taskId}\nphase: ${result.phase} (unchanged)\nreacquired claims: ${claims}\n`;
172
+ }
@@ -1,10 +1,15 @@
1
1
  import { resolveTaskContext } from "../core/task-context.js";
2
2
  import { readTaskDescriptor, writeTaskDescriptor } from "../core/task-descriptor.js";
3
- import { normalizeWriteClaims, assertNoScopeConflicts, assertScopeClean, assertScopeNotFrozen } from "../core/task-scope.js";
3
+ import { normalizeWriteClaims, assertScopeClean, assertScopeNotFrozen } from "../core/task-scope.js";
4
+ import { assertNoScopeConflictsWithInspection } from "./task-create.js";
4
5
  import { discoverTasks } from "../core/task-discovery.js";
5
6
  import { readWorkState } from "../core/work-state.js";
6
7
  import { withProjectClaimsLock, withTaskLock } from "../core/task-lock.js";
7
8
  import { E_TASK_NOT_FOUND } from "../core/error-codes.js";
9
+ import {
10
+ assertTaskNotRecovered,
11
+ } from "../core/task-recovery.js";
12
+ import { resolveTaskClaimState } from "../core/task-claim-state.js";
8
13
 
9
14
  function taskError(code, message, artifacts = []) {
10
15
  const error = new Error(message);
@@ -32,6 +37,7 @@ export async function runTaskScope({ target, packageRoot, taskId, claims } = {})
32
37
  if (claims !== undefined && claims !== null) {
33
38
  return withProjectClaimsLock(target, async () => {
34
39
  return withTaskLock(target, effectiveTaskId, "task-scope", async () => {
40
+ await assertTaskNotRecovered(target, { taskId: effectiveTaskId, packageRoot });
35
41
  const state = await readWorkState(target, { packageRoot, taskId: effectiveTaskId });
36
42
  if (state?.phase) {
37
43
  assertScopeNotFrozen(state.phase);
@@ -39,7 +45,7 @@ export async function runTaskScope({ target, packageRoot, taskId, claims } = {})
39
45
 
40
46
  const normalizedClaims = normalizeWriteClaims(claims);
41
47
  const allTasks = await discoverTasks(target, packageRoot);
42
- assertNoScopeConflicts(normalizedClaims, allTasks, effectiveTaskId);
48
+ await assertNoScopeConflictsWithInspection(normalizedClaims, allTasks, effectiveTaskId, { target, packageRoot });
43
49
  if (normalizedClaims.length > 0) {
44
50
  await assertScopeClean(target, normalizedClaims);
45
51
  }
@@ -50,20 +56,33 @@ export async function runTaskScope({ target, packageRoot, taskId, claims } = {})
50
56
  updatedAt: new Date().toISOString(),
51
57
  };
52
58
  await writeTaskDescriptor(target, updatedDescriptor, packageRoot);
59
+ const claimProjection = await resolveTaskClaimState(target, {
60
+ taskId: effectiveTaskId,
61
+ packageRoot,
62
+ descriptor: updatedDescriptor,
63
+ state,
64
+ });
53
65
  return {
54
66
  taskId: effectiveTaskId,
55
67
  taskKey: context.taskKey,
56
- writeClaims: updatedDescriptor.writeClaims,
68
+ ...claimProjection,
57
69
  updated: true,
58
70
  };
59
71
  });
60
72
  });
61
73
  }
62
74
 
75
+ const state = await readWorkState(target, { packageRoot, taskId: effectiveTaskId });
76
+ const claimProjection = await resolveTaskClaimState(target, {
77
+ taskId: effectiveTaskId,
78
+ packageRoot,
79
+ descriptor,
80
+ state,
81
+ });
63
82
  return {
64
83
  taskId: effectiveTaskId,
65
84
  taskKey: context.taskKey,
66
- writeClaims: descriptor.writeClaims ?? [],
85
+ ...claimProjection,
67
86
  updated: false,
68
87
  };
69
88
  }