@cassiomc1/forgeloop 1.0.0 → 1.1.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 (95) hide show
  1. package/.cursor/rules/project-loop.mdc +3 -2
  2. package/.github/copilot-instructions.md +1 -0
  3. package/AGENTS.md +2 -1
  4. package/CLAUDE.md +1 -0
  5. package/DOCS_INDEX.md +36 -0
  6. package/ENG/design-code-eng.md +31 -0
  7. package/ENG/documentation-quality-eng.md +400 -0
  8. package/EXECUTION_STATE.md +23 -13
  9. package/GUIDE_ROUTER.md +23 -8
  10. package/LOOP_ENGINEERING.md +77 -12
  11. package/PROTOCOL_INTEGRATION.md +9 -6
  12. package/README.md +82 -39
  13. package/docs/ARTIFACT_REFERENCE.md +442 -0
  14. package/docs/CLI_REFERENCE.md +892 -0
  15. package/docs/CROSS_HARNESS_CONTINUITY.md +198 -0
  16. package/docs/DOCUMENTATION_GUIDE.md +161 -0
  17. package/docs/GETTING_STARTED.md +348 -0
  18. package/docs/RECIPES.md +250 -0
  19. package/docs/TROUBLESHOOTING.md +345 -0
  20. package/docs/assets/forgeloop-flow.svg +1 -1
  21. package/docs/forgeloop-flow.mmd +1 -1
  22. package/package.json +13 -2
  23. package/schemas/task-descriptor.schema.json +56 -0
  24. package/schemas/work-state.schema.json +18 -1
  25. package/scripts/CI_VALIDATORS.md +7 -0
  26. package/src/cli.js +280 -388
  27. package/src/commands/advance.js +5 -2
  28. package/src/commands/audit.js +11 -1
  29. package/src/commands/clear-continuity.js +5 -2
  30. package/src/commands/clear-state.js +5 -2
  31. package/src/commands/complete.js +9 -1
  32. package/src/commands/continuity.js +5 -2
  33. package/src/commands/inspect.js +10 -2
  34. package/src/commands/next.js +5 -2
  35. package/src/commands/preflight.js +9 -1
  36. package/src/commands/prepare-completion.js +5 -2
  37. package/src/commands/reconcile-continuity.js +5 -2
  38. package/src/commands/record-check.js +7 -1
  39. package/src/commands/record-continuity.js +21 -14
  40. package/src/commands/record-terminal-result.js +7 -1
  41. package/src/commands/route.js +22 -18
  42. package/src/commands/run-check.js +52 -44
  43. package/src/commands/status.js +18 -12
  44. package/src/commands/task-create.js +94 -0
  45. package/src/commands/task-list.js +48 -0
  46. package/src/commands/task-migrate.js +34 -0
  47. package/src/commands/task-scope.js +75 -0
  48. package/src/commands/task-show.js +81 -0
  49. package/src/commands/task-unlock.js +35 -0
  50. package/src/commands/validate-protocol.js +37 -20
  51. package/src/commands/validate-state.js +24 -18
  52. package/src/config/guides.json +42 -0
  53. package/src/core/activation.js +8 -4
  54. package/src/core/artifact-registry.js +166 -0
  55. package/src/core/audit.js +65 -12
  56. package/src/core/bundles.js +76 -50
  57. package/src/core/cli-command-definitions.js +611 -0
  58. package/src/core/cli-metadata.js +23 -0
  59. package/src/core/completion-artifacts.js +161 -74
  60. package/src/core/completion.js +134 -76
  61. package/src/core/continuity.js +20 -13
  62. package/src/core/contract.js +6 -3
  63. package/src/core/error-codes.js +197 -0
  64. package/src/core/events.js +19 -14
  65. package/src/core/execution.js +38 -6
  66. package/src/core/gate-artifact.js +12 -9
  67. package/src/core/gates.js +4 -2
  68. package/src/core/guide-metadata.js +7 -11
  69. package/src/core/guide-registry.js +29 -0
  70. package/src/core/inspect.js +7 -4
  71. package/src/core/native-adapters.js +6 -0
  72. package/src/core/phase.js +85 -33
  73. package/src/core/preflight-consistency.js +24 -14
  74. package/src/core/preflight-loaders.js +16 -11
  75. package/src/core/preflight.js +44 -25
  76. package/src/core/protocol.js +2 -11
  77. package/src/core/receipt.js +1 -1
  78. package/src/core/report.js +2 -2
  79. package/src/core/repository.js +46 -12
  80. package/src/core/resumability.js +6 -4
  81. package/src/core/route-artifact.js +9 -5
  82. package/src/core/router.js +11 -7
  83. package/src/core/schema-validation.js +1 -0
  84. package/src/core/task-command.js +41 -0
  85. package/src/core/task-context.js +126 -0
  86. package/src/core/task-descriptor.js +81 -0
  87. package/src/core/task-discovery.js +116 -0
  88. package/src/core/task-identity.js +76 -0
  89. package/src/core/task-lock.js +209 -0
  90. package/src/core/task-migration-validation.js +140 -0
  91. package/src/core/task-migration.js +361 -0
  92. package/src/core/task-paths.js +96 -0
  93. package/src/core/task-scope.js +179 -0
  94. package/src/core/templates.js +3 -9
  95. package/src/core/work-state.js +24 -13
@@ -0,0 +1,209 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, unlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
5
+ import { taskLockPath } from "./task-paths.js";
6
+ import { E_TASK_LOCKED } from "./error-codes.js";
7
+
8
+ export async function readLockInfo(target, taskId) {
9
+ const relativePath = taskLockPath(taskId);
10
+ await assertSafePath(target, relativePath);
11
+ const fullPath = ensureWithin(target, relativePath);
12
+
13
+ if (!(await fileExists(fullPath))) {
14
+ return null;
15
+ }
16
+
17
+ try {
18
+ const raw = await readFile(fullPath, "utf8");
19
+ return JSON.parse(raw);
20
+ } catch {
21
+ return { taskId, corrupted: true };
22
+ }
23
+ }
24
+
25
+ export const CLAIMS_LOCK_REL_PATH = ".forgeloop/.claims.lock";
26
+
27
+ export async function readProjectClaimsLockInfo(target) {
28
+ await assertSafePath(target, CLAIMS_LOCK_REL_PATH);
29
+ const fullPath = ensureWithin(target, CLAIMS_LOCK_REL_PATH);
30
+
31
+ if (!(await fileExists(fullPath))) {
32
+ return null;
33
+ }
34
+
35
+ try {
36
+ const raw = await readFile(fullPath, "utf8");
37
+ return JSON.parse(raw);
38
+ } catch {
39
+ return { corrupted: true };
40
+ }
41
+ }
42
+
43
+ export async function acquireProjectClaimsLock(target, operation = "claim-reservation") {
44
+ await assertSafePath(target, CLAIMS_LOCK_REL_PATH);
45
+ const fullPath = ensureWithin(target, CLAIMS_LOCK_REL_PATH);
46
+
47
+ await mkdir(path.dirname(fullPath), { recursive: true });
48
+
49
+ const lockData = {
50
+ lockId: randomUUID(),
51
+ scope: "claims-reservation",
52
+ operation,
53
+ pid: process.pid,
54
+ acquiredAt: new Date().toISOString(),
55
+ };
56
+
57
+ let fileHandle;
58
+ try {
59
+ fileHandle = await open(fullPath, "wx");
60
+ } catch (error) {
61
+ if (error.code === "EEXIST") {
62
+ 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;
69
+ }
70
+ throw error;
71
+ }
72
+
73
+ try {
74
+ await fileHandle.writeFile(`${JSON.stringify(lockData, null, 2)}\n`, "utf8");
75
+ await fileHandle.close();
76
+ return {
77
+ lockData,
78
+ release: async () => {
79
+ try {
80
+ const current = await readProjectClaimsLockInfo(target);
81
+ if (current && current.lockId === lockData.lockId) {
82
+ await unlink(fullPath);
83
+ }
84
+ } catch {
85
+ // ignore
86
+ }
87
+ },
88
+ };
89
+ } catch (error) {
90
+ try {
91
+ await fileHandle.close();
92
+ } catch {
93
+ // ignore
94
+ }
95
+ try {
96
+ await unlink(fullPath);
97
+ } catch {
98
+ // ignore
99
+ }
100
+ throw error;
101
+ }
102
+ }
103
+
104
+ export async function withProjectClaimsLock(target, operationOrCallback, callback) {
105
+ let operation = operationOrCallback;
106
+ let fn = callback;
107
+ if (typeof operationOrCallback === "function" && callback === undefined) {
108
+ fn = operationOrCallback;
109
+ operation = "claim-reservation";
110
+ }
111
+ const lock = await acquireProjectClaimsLock(target, operation);
112
+ try {
113
+ return await fn(lock.lockData);
114
+ } finally {
115
+ await lock.release();
116
+ }
117
+ }
118
+
119
+ export async function acquireTaskLock(target, taskId, operation = "mutation") {
120
+ const relativePath = taskLockPath(taskId);
121
+ await assertSafePath(target, relativePath);
122
+ const fullPath = ensureWithin(target, relativePath);
123
+
124
+ await mkdir(path.dirname(fullPath), { recursive: true });
125
+
126
+ const lockData = {
127
+ lockId: randomUUID(),
128
+ taskId,
129
+ operation,
130
+ pid: process.pid,
131
+ acquiredAt: new Date().toISOString(),
132
+ };
133
+
134
+ let fileHandle;
135
+ try {
136
+ fileHandle = await open(fullPath, "wx");
137
+ } catch (error) {
138
+ if (error.code === "EEXIST") {
139
+ const existing = await readLockInfo(target, taskId);
140
+ const err = new Error(
141
+ `Task "${taskId}" is locked by operation "${existing?.operation ?? "unknown"}" (pid: ${existing?.pid ?? "unknown"}, acquired: ${existing?.acquiredAt ?? "unknown"}). Use 'forgeloop task-unlock --task ${taskId} --force' if the process died.`,
142
+ );
143
+ err.code = E_TASK_LOCKED;
144
+ err.lockInfo = existing;
145
+ err.taskId = taskId;
146
+ throw err;
147
+ }
148
+ throw error;
149
+ }
150
+
151
+ try {
152
+ await fileHandle.writeFile(`${JSON.stringify(lockData, null, 2)}\n`, "utf8");
153
+ await fileHandle.close();
154
+ return {
155
+ lockData,
156
+ release: async () => {
157
+ try {
158
+ const current = await readLockInfo(target, taskId);
159
+ if (current && current.lockId === lockData.lockId) {
160
+ await unlink(fullPath);
161
+ }
162
+ } catch {
163
+ // ignore already unlinked or overwritten
164
+ }
165
+ },
166
+ };
167
+ } catch (error) {
168
+ try {
169
+ await fileHandle.close();
170
+ } catch {
171
+ // ignore
172
+ }
173
+ try {
174
+ await unlink(fullPath);
175
+ } catch {
176
+ // ignore
177
+ }
178
+ throw error;
179
+ }
180
+ }
181
+
182
+ export async function forceUnlockTask(target, taskId) {
183
+ const relativePath = taskLockPath(taskId);
184
+ await assertSafePath(target, relativePath);
185
+ const fullPath = ensureWithin(target, relativePath);
186
+
187
+ if (!(await fileExists(fullPath))) {
188
+ return { unlocked: false, message: "No active lock found" };
189
+ }
190
+
191
+ const existing = await readLockInfo(target, taskId);
192
+ await unlink(fullPath);
193
+ return { unlocked: true, previousLock: existing };
194
+ }
195
+
196
+ export async function withTaskLock(target, taskId, operationOrCallback, callback) {
197
+ let operation = operationOrCallback;
198
+ let fn = callback;
199
+ if (typeof operationOrCallback === "function" && callback === undefined) {
200
+ fn = operationOrCallback;
201
+ operation = "mutation";
202
+ }
203
+ const lock = await acquireTaskLock(target, taskId, operation);
204
+ try {
205
+ return await fn(lock.lockData);
206
+ } finally {
207
+ await lock.release();
208
+ }
209
+ }
@@ -0,0 +1,140 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, readdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { ensureWithin, fileExists } from "./filesystem.js";
5
+ import { readJsonArtifact } from "./artifacts.js";
6
+ import { validateEventLedger } from "./events.js";
7
+ import {
8
+ E_TASK_MIGRATION_IDENTITY_MISMATCH,
9
+ E_TASK_MIGRATION_INVALID,
10
+ } from "./error-codes.js";
11
+
12
+ async function fingerprintFile(absolutePath) {
13
+ const bytes = await readFile(absolutePath);
14
+ return createHash("sha256").update(bytes).digest("hex");
15
+ }
16
+
17
+ export async function validateMigrationSnapshot(target, { taskId, packageRoot, paths }) {
18
+ const artifactFingerprints = {};
19
+ const directoryFingerprints = {};
20
+ let eventCount = 0;
21
+
22
+ const checkIdentity = (obj, relPath) => {
23
+ if (obj && obj.taskId && obj.taskId !== taskId) {
24
+ const error = new Error(`Task identity mismatch in ${relPath}: expected ${taskId}, got ${obj.taskId}`);
25
+ error.code = E_TASK_MIGRATION_IDENTITY_MISMATCH;
26
+ throw error;
27
+ }
28
+ };
29
+
30
+ const jsonMap = {
31
+ contract: "current-contract",
32
+ route: "routing-result",
33
+ preflight: "preflight",
34
+ state: "work-state",
35
+ continuity: "continuity",
36
+ receipt: "execution-receipt",
37
+ };
38
+
39
+ for (const [key, schemaName] of Object.entries(jsonMap)) {
40
+ const relPath = paths[key];
41
+ if (relPath) {
42
+ const absPath = ensureWithin(target, relPath);
43
+ if (await fileExists(absPath)) {
44
+ try {
45
+ const { value } = await readJsonArtifact(target, relPath, schemaName, packageRoot);
46
+ checkIdentity(value, relPath);
47
+ artifactFingerprints[key] = await fingerprintFile(absPath);
48
+ } catch (err) {
49
+ if (err.code === E_TASK_MIGRATION_IDENTITY_MISMATCH) throw err;
50
+ const error = new Error(`Validation failed for ${relPath}: ${err.message}`);
51
+ error.code = E_TASK_MIGRATION_INVALID;
52
+ error.cause = err;
53
+ throw error;
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ if (paths.events) {
60
+ const absPath = ensureWithin(target, paths.events);
61
+ if (await fileExists(absPath)) {
62
+ const ledger = await validateEventLedger(target, packageRoot, { eventsPath: paths.events });
63
+ if (!ledger.valid) {
64
+ const error = new Error(`Event ledger validation failed for ${paths.events}: ${ledger.errors?.[0]?.message ?? "invalid ledger"}`);
65
+ error.code = E_TASK_MIGRATION_INVALID;
66
+ error.cause = ledger.errors?.[0];
67
+ throw error;
68
+ }
69
+ for (const event of ledger.events) {
70
+ checkIdentity(event, paths.events);
71
+ }
72
+ artifactFingerprints.events = await fingerprintFile(absPath);
73
+ eventCount = ledger.events.length;
74
+ }
75
+ }
76
+
77
+ const processDir = async (dirKey, dirRelPath, schemaName) => {
78
+ if (!dirRelPath) return;
79
+ const absPath = ensureWithin(target, dirRelPath);
80
+ if (!(await fileExists(absPath))) return;
81
+
82
+ const entries = await readdir(absPath, { withFileTypes: true });
83
+ const fileHashes = [];
84
+
85
+ for (const entry of entries) {
86
+ if (entry.isSymbolicLink()) {
87
+ const error = new Error(`Symlinks not allowed in ${dirRelPath}: ${entry.name}`);
88
+ error.code = E_TASK_MIGRATION_INVALID;
89
+ throw error;
90
+ }
91
+ if (!entry.isFile()) {
92
+ const error = new Error(`Unexpected non-file entry in ${dirRelPath}: ${entry.name}`);
93
+ error.code = E_TASK_MIGRATION_INVALID;
94
+ throw error;
95
+ }
96
+ if (!entry.name.endsWith(".json")) {
97
+ const error = new Error(`Unexpected non-json file in ${dirRelPath}: ${entry.name}`);
98
+ error.code = E_TASK_MIGRATION_INVALID;
99
+ throw error;
100
+ }
101
+
102
+ const fileRelPath = path.posix.join(dirRelPath, entry.name);
103
+ const fileAbsPath = path.join(absPath, entry.name);
104
+
105
+ try {
106
+ const { value } = await readJsonArtifact(target, fileRelPath, schemaName, packageRoot);
107
+ checkIdentity(value, fileRelPath);
108
+ const hash = await fingerprintFile(fileAbsPath);
109
+ fileHashes.push({ relPath: entry.name, hash });
110
+ } catch (err) {
111
+ if (err.code === E_TASK_MIGRATION_IDENTITY_MISMATCH) throw err;
112
+ const error = new Error(`Validation failed for ${fileRelPath}: ${err.message}`);
113
+ error.code = E_TASK_MIGRATION_INVALID;
114
+ error.cause = err;
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ if (fileHashes.length > 0) {
120
+ fileHashes.sort((a, b) => a.relPath.localeCompare(b.relPath));
121
+ let hashStr = "";
122
+ for (const item of fileHashes) {
123
+ hashStr += `${item.relPath}\0${item.hash}\n`;
124
+ }
125
+ directoryFingerprints[dirKey] = createHash("sha256").update(hashStr).digest("hex");
126
+ } else {
127
+ directoryFingerprints[dirKey] = createHash("sha256").update("").digest("hex");
128
+ }
129
+ };
130
+
131
+ await processDir("gates", paths.gates, "gate");
132
+ await processDir("executions", paths.executions, "execution");
133
+
134
+ return {
135
+ taskId,
136
+ artifactFingerprints,
137
+ directoryFingerprints,
138
+ eventCount,
139
+ };
140
+ }
@@ -0,0 +1,361 @@
1
+ import { cp, mkdir, rename, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ensureWithin, fileExists } from "./filesystem.js";
4
+ import { getPackageRoot } from "./templates.js";
5
+ import {
6
+ LEGACY_TASK_ARTIFACT_PATHS,
7
+ TASK_STATE_ROOT,
8
+ taskArtifactPath,
9
+ taskDirectory,
10
+ } from "./task-paths.js";
11
+ import { assertTaskId, taskStorageKey } from "./task-identity.js";
12
+ import { createTaskDescriptor, readTaskDescriptor, writeTaskDescriptor } from "./task-descriptor.js";
13
+ import { readJsonArtifact } from "./artifacts.js";
14
+ import { validateMigrationSnapshot } from "./task-migration-validation.js";
15
+ import {
16
+ E_TASK_MIGRATION_IDENTITY_MISMATCH,
17
+ E_TASK_MIGRATION_INVALID,
18
+ } from "./error-codes.js";
19
+
20
+ export async function detectLegacySingletonLayout(target) {
21
+ const legacyFiles = [];
22
+ for (const [key, relPath] of Object.entries(LEGACY_TASK_ARTIFACT_PATHS)) {
23
+ const fullPath = ensureWithin(target, relPath);
24
+ if (await fileExists(fullPath)) {
25
+ legacyFiles.push({ key, path: relPath });
26
+ }
27
+ }
28
+ return {
29
+ hasLegacy: legacyFiles.length > 0,
30
+ legacyFiles,
31
+ };
32
+ }
33
+
34
+ async function removeLegacyArtifact(target, relativePath) {
35
+ const fullPath = ensureWithin(target, relativePath);
36
+ await rm(fullPath, { recursive: true, force: true });
37
+ if (await fileExists(fullPath)) {
38
+ const error = new Error(
39
+ `Legacy artifact still exists after migration cleanup: ${relativePath}`,
40
+ );
41
+ error.code = E_TASK_MIGRATION_INVALID;
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ function assertMigrationSnapshotsEqual(source, destination, label) {
47
+ for (const [key, hash] of Object.entries(source.artifactFingerprints)) {
48
+ if (destination.artifactFingerprints[key] !== hash) {
49
+ const error = new Error(`Migration fingerprint mismatch in ${label}: ${key}`);
50
+ error.code = E_TASK_MIGRATION_INVALID;
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ for (const [key, hash] of Object.entries(source.directoryFingerprints)) {
56
+ if (destination.directoryFingerprints[key] !== hash) {
57
+ const error = new Error(`Migration directory fingerprint mismatch in ${label}: ${key}`);
58
+ error.code = E_TASK_MIGRATION_INVALID;
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ if (destination.eventCount !== source.eventCount) {
64
+ const error = new Error(`Migration event count mismatch in ${label}`);
65
+ error.code = E_TASK_MIGRATION_INVALID;
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ export async function migrateLegacyLayout(
71
+ target,
72
+ {
73
+ dryRun = false,
74
+ packageRoot = getPackageRoot(),
75
+ afterCopyForTest = null,
76
+ afterPublishForTest = null,
77
+ beforeLegacyCleanupForTest = null,
78
+ removeLegacyArtifactForTest = null,
79
+ } = {},
80
+ ) {
81
+ const detection = await detectLegacySingletonLayout(target);
82
+ if (!detection.hasLegacy) {
83
+ return {
84
+ migrated: false,
85
+ reason: "NO_LEGACY_STATE",
86
+ message: "No legacy ForgeLoop 1.0 singleton artifacts found.",
87
+ };
88
+ }
89
+
90
+ let canonicalTaskId = null;
91
+ const artifactIdentities = [];
92
+
93
+ // Determine candidate canonicalTaskId from known legacy artifacts
94
+ for (const item of detection.legacyFiles) {
95
+ if (item.key === "contract") {
96
+ try {
97
+ const contract = await readJsonArtifact(target, item.path, "current-contract", packageRoot);
98
+ if (contract.value?.taskId) {
99
+ if (!canonicalTaskId) canonicalTaskId = contract.value.taskId;
100
+ artifactIdentities.push({ artifact: "contract", taskId: contract.value.taskId });
101
+ }
102
+ } catch (err) {
103
+ const error = new Error(`Legacy contract artifact is invalid: ${err.message}`);
104
+ error.code = E_TASK_MIGRATION_INVALID;
105
+ error.cause = err;
106
+ throw error;
107
+ }
108
+ } else if (item.key === "state") {
109
+ try {
110
+ const state = await readJsonArtifact(target, item.path, "work-state", packageRoot);
111
+ if (state.value?.taskId) {
112
+ if (!canonicalTaskId) canonicalTaskId = state.value.taskId;
113
+ artifactIdentities.push({ artifact: "state", taskId: state.value.taskId });
114
+ }
115
+ } catch (err) {
116
+ const error = new Error(`Legacy state artifact is invalid: ${err.message}`);
117
+ error.code = E_TASK_MIGRATION_INVALID;
118
+ error.cause = err;
119
+ throw error;
120
+ }
121
+ } else if (item.key === "continuity") {
122
+ try {
123
+ const continuity = await readJsonArtifact(target, item.path, "continuity", packageRoot);
124
+ if (continuity.value?.taskId) {
125
+ if (!canonicalTaskId) canonicalTaskId = continuity.value.taskId;
126
+ artifactIdentities.push({ artifact: "continuity", taskId: continuity.value.taskId });
127
+ }
128
+ } catch (err) {
129
+ const error = new Error(`Legacy continuity artifact is invalid: ${err.message}`);
130
+ error.code = E_TASK_MIGRATION_INVALID;
131
+ error.cause = err;
132
+ throw error;
133
+ }
134
+ } else if (item.key === "receipt") {
135
+ try {
136
+ const receipt = await readJsonArtifact(target, item.path, "execution-receipt", packageRoot);
137
+ if (receipt.value?.taskId) {
138
+ if (!canonicalTaskId) canonicalTaskId = receipt.value.taskId;
139
+ artifactIdentities.push({ artifact: "receipt", taskId: receipt.value.taskId });
140
+ }
141
+ } catch (err) {
142
+ const error = new Error(`Legacy receipt artifact is invalid: ${err.message}`);
143
+ error.code = E_TASK_MIGRATION_INVALID;
144
+ error.cause = err;
145
+ throw error;
146
+ }
147
+ }
148
+ }
149
+
150
+ if (!canonicalTaskId) {
151
+ const error = new Error("Unable to determine task ID from legacy singleton artifacts");
152
+ error.code = E_TASK_MIGRATION_INVALID;
153
+ throw error;
154
+ }
155
+
156
+ assertTaskId(canonicalTaskId);
157
+
158
+ // Check for identity mismatches across legacy artifacts
159
+ const mismatches = artifactIdentities.filter((a) => a.taskId !== canonicalTaskId);
160
+ if (mismatches.length > 0) {
161
+ const error = new Error(
162
+ `Task identity mismatch during legacy migration: primary task is "${canonicalTaskId}", but ${mismatches.map((m) => `${m.artifact} has "${m.taskId}"`).join(", ")}`,
163
+ );
164
+ error.code = E_TASK_MIGRATION_IDENTITY_MISMATCH;
165
+ error.mismatches = mismatches;
166
+ throw error;
167
+ }
168
+
169
+ // 1. Validate complete legacy source snapshot fail-closed before creating any directories
170
+ const sourceSnapshot = await validateMigrationSnapshot(target, {
171
+ taskId: canonicalTaskId,
172
+ packageRoot,
173
+ paths: LEGACY_TASK_ARTIFACT_PATHS,
174
+ });
175
+
176
+ const taskKey = taskStorageKey(canonicalTaskId);
177
+ const finalDirRel = taskDirectory(canonicalTaskId);
178
+ const finalDirAbs = ensureWithin(target, finalDirRel);
179
+ const tempDirRel = `${TASK_STATE_ROOT}/.tmp-${taskKey}`;
180
+ const tempDirAbs = ensureWithin(target, tempDirRel);
181
+
182
+ if (await fileExists(finalDirAbs)) {
183
+ const error = new Error(`Target task directory already exists: ${finalDirRel}`);
184
+ error.code = E_TASK_MIGRATION_INVALID;
185
+ throw error;
186
+ }
187
+
188
+ const migratedArtifacts = [];
189
+
190
+ if (dryRun) {
191
+ return {
192
+ migrated: false,
193
+ dryRun: true,
194
+ taskId: canonicalTaskId,
195
+ taskKey,
196
+ targetDirectory: finalDirRel,
197
+ legacyFiles: detection.legacyFiles.map((f) => f.path),
198
+ };
199
+ }
200
+
201
+ let published = false;
202
+ let cleanupStarted = false;
203
+
204
+ // 2. Create temporary migration directory
205
+ await mkdir(tempDirAbs, { recursive: true });
206
+
207
+ try {
208
+ // 3. Create and write task.json descriptor
209
+ const descriptor = createTaskDescriptor({
210
+ taskId: canonicalTaskId,
211
+ writeClaims: [],
212
+ });
213
+ await writeTaskDescriptor(target, descriptor, packageRoot, {
214
+ relativePathOverride: `${tempDirRel}/task.json`,
215
+ });
216
+ migratedArtifacts.push("task.json");
217
+
218
+ // 4. Copy and map files into temp namespace
219
+ const fileMapping = [
220
+ [LEGACY_TASK_ARTIFACT_PATHS.contract, "contract.json"],
221
+ [LEGACY_TASK_ARTIFACT_PATHS.route, "routing-result.json"],
222
+ [LEGACY_TASK_ARTIFACT_PATHS.preflight, "preflight.json"],
223
+ [LEGACY_TASK_ARTIFACT_PATHS.state, "work-state.json"],
224
+ [LEGACY_TASK_ARTIFACT_PATHS.continuity, "continuity.json"],
225
+ [LEGACY_TASK_ARTIFACT_PATHS.receipt, "execution-receipt.json"],
226
+ [LEGACY_TASK_ARTIFACT_PATHS.events, "events.ndjson"],
227
+ ];
228
+
229
+ for (const [legacyRel, destName] of fileMapping) {
230
+ const srcAbs = ensureWithin(target, legacyRel);
231
+ if (await fileExists(srcAbs)) {
232
+ const destAbs = path.join(tempDirAbs, destName);
233
+ await cp(srcAbs, destAbs);
234
+ migratedArtifacts.push(destName);
235
+ }
236
+ }
237
+
238
+ // 5. Copy directories: gates/ and executions/
239
+ const dirMapping = [
240
+ [LEGACY_TASK_ARTIFACT_PATHS.gates, "gates"],
241
+ [LEGACY_TASK_ARTIFACT_PATHS.executions, "executions"],
242
+ ];
243
+
244
+ for (const [legacyRel, destDirName] of dirMapping) {
245
+ const srcAbs = ensureWithin(target, legacyRel);
246
+ if (await fileExists(srcAbs)) {
247
+ const destAbs = path.join(tempDirAbs, destDirName);
248
+ await cp(srcAbs, destAbs, { recursive: true });
249
+ migratedArtifacts.push(`${destDirName}/`);
250
+ }
251
+ }
252
+
253
+ // Test hook for corruption / failure testing
254
+ if (typeof afterCopyForTest === "function") {
255
+ await afterCopyForTest({ tempDirAbs, tempDirRel, target });
256
+ }
257
+
258
+ // 6. Validate temp snapshot
259
+ const tempPaths = {
260
+ contract: `${tempDirRel}/contract.json`,
261
+ route: `${tempDirRel}/routing-result.json`,
262
+ preflight: `${tempDirRel}/preflight.json`,
263
+ state: `${tempDirRel}/work-state.json`,
264
+ continuity: `${tempDirRel}/continuity.json`,
265
+ receipt: `${tempDirRel}/execution-receipt.json`,
266
+ events: `${tempDirRel}/events.ndjson`,
267
+ gates: `${tempDirRel}/gates`,
268
+ executions: `${tempDirRel}/executions`,
269
+ };
270
+
271
+ const tempSnapshot = await validateMigrationSnapshot(target, {
272
+ taskId: canonicalTaskId,
273
+ packageRoot,
274
+ paths: tempPaths,
275
+ });
276
+
277
+ // 7. Compare copied fingerprints
278
+ assertMigrationSnapshotsEqual(sourceSnapshot, tempSnapshot, "temporary namespace");
279
+
280
+ // 8. Atomically publish
281
+ await mkdir(ensureWithin(target, TASK_STATE_ROOT), { recursive: true });
282
+ await rename(tempDirAbs, finalDirAbs);
283
+ published = true;
284
+
285
+ // Test hook for corruption / failure testing after publication
286
+ if (typeof afterPublishForTest === "function") {
287
+ await afterPublishForTest({ finalDirAbs, finalDirRel, target });
288
+ }
289
+
290
+ // 9. Post-publish validation: verify final namespace snapshot and task descriptor
291
+ const finalPaths = {
292
+ contract: taskArtifactPath(canonicalTaskId, "contract"),
293
+ route: taskArtifactPath(canonicalTaskId, "route"),
294
+ preflight: taskArtifactPath(canonicalTaskId, "preflight"),
295
+ state: taskArtifactPath(canonicalTaskId, "state"),
296
+ continuity: taskArtifactPath(canonicalTaskId, "continuity"),
297
+ receipt: taskArtifactPath(canonicalTaskId, "receipt"),
298
+ events: taskArtifactPath(canonicalTaskId, "events"),
299
+ gates: taskArtifactPath(canonicalTaskId, "gates"),
300
+ executions: taskArtifactPath(canonicalTaskId, "executions"),
301
+ };
302
+
303
+ const finalSnapshot = await validateMigrationSnapshot(target, {
304
+ taskId: canonicalTaskId,
305
+ packageRoot,
306
+ paths: finalPaths,
307
+ });
308
+ assertMigrationSnapshotsEqual(sourceSnapshot, finalSnapshot, "published namespace");
309
+ await readTaskDescriptor(target, canonicalTaskId, packageRoot);
310
+
311
+ // Test hook for cleanup failure testing
312
+ if (typeof beforeLegacyCleanupForTest === "function") {
313
+ await beforeLegacyCleanupForTest({ finalDirAbs, finalDirRel, target });
314
+ }
315
+
316
+ // 10. Cleanup legacy task files (strictly after successful publish and validation)
317
+ cleanupStarted = true;
318
+ try {
319
+ for (const item of detection.legacyFiles) {
320
+ if (typeof removeLegacyArtifactForTest === "function") {
321
+ await removeLegacyArtifactForTest(target, item.path);
322
+ } else {
323
+ await removeLegacyArtifact(target, item.path);
324
+ }
325
+ }
326
+ } catch (cleanupErr) {
327
+ const error = new Error(
328
+ `Migration published successfully to ${finalDirRel}, but legacy cleanup failed: ${cleanupErr.message}. Manual cleanup required.`,
329
+ );
330
+ error.code = E_TASK_MIGRATION_INVALID;
331
+ error.cause = cleanupErr;
332
+ throw error;
333
+ }
334
+
335
+ return {
336
+ migrated: true,
337
+ taskId: canonicalTaskId,
338
+ taskKey,
339
+ targetDirectory: finalDirRel,
340
+ migratedArtifacts,
341
+ };
342
+ } catch (error) {
343
+ if (published && !cleanupStarted && (await fileExists(finalDirAbs))) {
344
+ try {
345
+ await rm(finalDirAbs, { recursive: true, force: true });
346
+ } catch (rollbackErr) {
347
+ error.rollbackError = rollbackErr;
348
+ }
349
+ }
350
+
351
+ // Cleanup temp dir on failure if it still exists
352
+ if (await fileExists(tempDirAbs)) {
353
+ try {
354
+ await rm(tempDirAbs, { recursive: true, force: true });
355
+ } catch (tempErr) {
356
+ error.tempCleanupError = tempErr;
357
+ }
358
+ }
359
+ throw error;
360
+ }
361
+ }