@rosenfan/matrix 0.1.0 → 0.1.2

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 (37) hide show
  1. package/.claude/skills/matrix/SKILL.md +62 -21
  2. package/.claude/skills/matrix/agents/openai.yaml +4 -4
  3. package/.claude/skills/matrix/scripts/matrix_claude.py +11 -166
  4. package/.claude/skills/matrix/scripts/matrix_state.py +60 -114
  5. package/.claude/skills/matrix-archive/SKILL.md +33 -26
  6. package/.claude/skills/matrix-build/SKILL.md +26 -39
  7. package/.claude/skills/matrix-claude/SKILL.md +22 -22
  8. package/.claude/skills/matrix-design/SKILL.md +28 -29
  9. package/.claude/skills/matrix-hotfix/SKILL.md +8 -8
  10. package/.claude/skills/matrix-open/SKILL.md +17 -20
  11. package/.claude/skills/matrix-status/SKILL.md +4 -3
  12. package/.claude/skills/matrix-tweak/SKILL.md +8 -8
  13. package/.claude/skills/matrix-verify/SKILL.md +18 -29
  14. package/LICENSE +21 -21
  15. package/README.md +255 -242
  16. package/README.zh-CN.md +254 -242
  17. package/assets/skills-zh-CN/matrix/SKILL.md +57 -5
  18. package/assets/skills-zh-CN/matrix-archive/SKILL.md +23 -2
  19. package/assets/skills-zh-CN/matrix-build/SKILL.md +26 -2
  20. package/assets/skills-zh-CN/matrix-claude/SKILL.md +12 -11
  21. package/assets/skills-zh-CN/matrix-design/SKILL.md +16 -11
  22. package/assets/skills-zh-CN/matrix-hotfix/SKILL.md +8 -8
  23. package/assets/skills-zh-CN/matrix-open/SKILL.md +16 -11
  24. package/assets/skills-zh-CN/matrix-status/SKILL.md +12 -11
  25. package/assets/skills-zh-CN/matrix-tweak/SKILL.md +8 -8
  26. package/assets/skills-zh-CN/matrix-verify/SKILL.md +22 -8
  27. package/bin/matrix.js +15 -15
  28. package/package.json +56 -56
  29. package/src/catalog.js +85 -80
  30. package/src/cli.js +116 -111
  31. package/src/distribution.js +216 -152
  32. package/src/installer.js +45 -45
  33. package/src/matt.js +45 -34
  34. package/src/messages.js +33 -33
  35. package/src/ui.js +89 -89
  36. package/src/workflow-transaction.js +622 -0
  37. package/src/workflow.js +547 -74
@@ -0,0 +1,622 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+
6
+ const JOURNAL_SCHEMA = "matrix/workflow-transaction/v1";
7
+ const RECEIPT_SCHEMA = "matrix/workflow-transaction-receipt/v1";
8
+ const LOCK_SCHEMA = "matrix/workflow-lock/v1";
9
+ const MAX_JOURNAL_BYTES = 1024 * 1024;
10
+ const MAX_TRANSACTIONS = 128;
11
+ const RECEIPT_LIMIT = 32;
12
+
13
+ const stamp = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
14
+ const fail = (code, message, extra = {}) => ({ ok: false, code, message, ...extra });
15
+ const sha = (value) => `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`;
16
+ const rootFor = (cwd) => path.join(cwd, ".matrix");
17
+ const transactionsFor = (cwd) => path.join(rootFor(cwd), "transactions");
18
+ const lockPath = (cwd) => path.join(rootFor(cwd), "workflow.lock");
19
+ const journalPath = (cwd, id) => path.join(transactionsFor(cwd), id, "journal.json");
20
+
21
+ function durableWrite(file, value) {
22
+ fs.mkdirSync(path.dirname(file), { recursive: true });
23
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
24
+ const descriptor = fs.openSync(temporary, "wx");
25
+ try {
26
+ fs.writeFileSync(descriptor, value);
27
+ fs.fsyncSync(descriptor);
28
+ } finally {
29
+ fs.closeSync(descriptor);
30
+ }
31
+ fs.renameSync(temporary, file);
32
+ }
33
+
34
+ function writeJson(file, value) {
35
+ durableWrite(file, `${JSON.stringify(value, null, 2)}\n`);
36
+ }
37
+
38
+ function safeRelative(cwd, file) {
39
+ const relative = path.relative(cwd, file).replaceAll("\\", "/");
40
+ if (!relative || relative === ".." || relative.startsWith("../") || path.isAbsolute(relative)) {
41
+ throw Object.assign(new Error(`Workflow transaction path escapes the project: ${file}.`), { code: "WORKFLOW_ENTRY_UNSUPPORTED" });
42
+ }
43
+ return relative;
44
+ }
45
+
46
+ function absolute(cwd, relative) {
47
+ const resolved = path.resolve(cwd, ...relative.split("/"));
48
+ safeRelative(cwd, resolved);
49
+ return resolved;
50
+ }
51
+
52
+ function ordinaryStat(file) {
53
+ try {
54
+ const stat = fs.lstatSync(file);
55
+ if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) {
56
+ throw Object.assign(new Error(`Workflow transaction path is not an ordinary file or directory: ${file}.`), { code: "WORKFLOW_ENTRY_UNSUPPORTED" });
57
+ }
58
+ return stat;
59
+ } catch (error) {
60
+ if (error.code === "ENOENT") return null;
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ function directoryDigest(root, relative = "") {
66
+ const hash = crypto.createHash("sha256");
67
+ hash.update("matrix/workflow-tree/v1\0");
68
+ const visit = (currentRelative) => {
69
+ const current = path.join(root, currentRelative);
70
+ const names = fs.readdirSync(current).sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
71
+ for (const name of names) {
72
+ const childRelative = currentRelative ? `${currentRelative}/${name}` : name;
73
+ const child = path.join(root, ...childRelative.split("/"));
74
+ const stat = ordinaryStat(child);
75
+ if (!stat) throw Object.assign(new Error(`Workflow transaction entry disappeared: ${child}.`), { code: "WORKFLOW_RECOVERY_CONFLICT" });
76
+ hash.update(Buffer.from(`${stat.isDirectory() ? "d" : "f"}\0${childRelative}\0`, "utf8"));
77
+ if (stat.isDirectory()) visit(childRelative);
78
+ else hash.update(fs.readFileSync(child));
79
+ }
80
+ };
81
+ visit(relative);
82
+ return `sha256:${hash.digest("hex")}`;
83
+ }
84
+
85
+ export function captureWorkflowEntry(file) {
86
+ const stat = ordinaryStat(file);
87
+ if (!stat) return { kind: "absent", hash: sha("absent") };
88
+ if (stat.isDirectory()) return { kind: "directory", hash: directoryDigest(file) };
89
+ const bytes = fs.readFileSync(file);
90
+ return { kind: "file", hash: sha(bytes), bytes: bytes.toString("base64") };
91
+ }
92
+
93
+ function identity(state) {
94
+ return { kind: state.kind, hash: state.hash };
95
+ }
96
+
97
+ function sameState(left, right) {
98
+ return left?.kind === right?.kind && left?.hash === right?.hash;
99
+ }
100
+
101
+ function createdParents(cwd, file) {
102
+ const result = [];
103
+ let current = path.dirname(file);
104
+ const boundary = path.resolve(cwd);
105
+ while (current !== boundary && current.startsWith(`${boundary}${path.sep}`)) {
106
+ if (fs.existsSync(current)) break;
107
+ result.push(safeRelative(cwd, current));
108
+ current = path.dirname(current);
109
+ }
110
+ return result.reverse();
111
+ }
112
+
113
+ function parentFacts(cwd, file) {
114
+ const facts = [];
115
+ const boundary = path.resolve(cwd);
116
+ let current = path.dirname(path.resolve(file));
117
+ while (current !== boundary) {
118
+ if (!current.startsWith(`${boundary}${path.sep}`)) {
119
+ throw Object.assign(new Error(`Workflow transaction parent escapes the project: ${current}.`), { code: "WORKFLOW_ENTRY_UNSUPPORTED" });
120
+ }
121
+ let stat;
122
+ try { stat = fs.lstatSync(current); }
123
+ catch (error) {
124
+ if (error.code === "ENOENT") { current = path.dirname(current); continue; }
125
+ throw error;
126
+ }
127
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
128
+ throw Object.assign(new Error(`Workflow transaction parent is not an ordinary directory: ${current}.`), { code: "WORKFLOW_ENTRY_UNSUPPORTED" });
129
+ }
130
+ facts.push({ path: safeRelative(cwd, current), dev: String(stat.dev), ino: String(stat.ino) });
131
+ current = path.dirname(current);
132
+ }
133
+ return facts;
134
+ }
135
+
136
+ function verifyParentFacts(cwd, operation) {
137
+ for (const fact of operation.parent_facts ?? []) {
138
+ const directory = absolute(cwd, fact.path);
139
+ const stat = fs.lstatSync(directory);
140
+ if (stat.isSymbolicLink() || !stat.isDirectory() || String(stat.dev) !== fact.dev || String(stat.ino) !== fact.ino) {
141
+ throw Object.assign(new Error(`Workflow transaction parent identity changed: ${fact.path}.`), { code: "WORKFLOW_RECOVERY_CONFLICT" });
142
+ }
143
+ }
144
+ }
145
+
146
+ export function workflowWriteOperation(cwd, file, contents, { before = null } = {}) {
147
+ const bytes = Buffer.from(contents);
148
+ return {
149
+ type: "write",
150
+ path: safeRelative(cwd, file),
151
+ before: before ?? captureWorkflowEntry(file),
152
+ after: { kind: "file", hash: sha(bytes), bytes: bytes.toString("base64") },
153
+ created_parents: createdParents(cwd, file),
154
+ parent_facts: parentFacts(cwd, file)
155
+ };
156
+ }
157
+
158
+ export function workflowMkdirOperation(cwd, directory) {
159
+ const emptyHash = (() => {
160
+ const hash = crypto.createHash("sha256");
161
+ hash.update("matrix/workflow-tree/v1\0");
162
+ return `sha256:${hash.digest("hex")}`;
163
+ })();
164
+ return {
165
+ type: "mkdir",
166
+ path: safeRelative(cwd, directory),
167
+ before: captureWorkflowEntry(directory),
168
+ after: { kind: "directory", hash: emptyHash },
169
+ created_parents: createdParents(cwd, directory),
170
+ parent_facts: parentFacts(cwd, directory)
171
+ };
172
+ }
173
+
174
+ export function workflowMoveOperation(cwd, source, target) {
175
+ return {
176
+ type: "move",
177
+ source: safeRelative(cwd, source),
178
+ target: safeRelative(cwd, target),
179
+ source_before: captureWorkflowEntry(source),
180
+ target_before: captureWorkflowEntry(target),
181
+ created_parents: createdParents(cwd, target),
182
+ parent_facts: [...parentFacts(cwd, source), ...parentFacts(cwd, target)]
183
+ };
184
+ }
185
+
186
+ export function workflowRemoveOperation(cwd, file) {
187
+ return {
188
+ type: "remove",
189
+ path: safeRelative(cwd, file),
190
+ before: captureWorkflowEntry(file),
191
+ after: { kind: "absent", hash: sha("absent") },
192
+ parent_facts: parentFacts(cwd, file)
193
+ };
194
+ }
195
+
196
+ function operationSummary(operation) {
197
+ if (operation.type === "move") {
198
+ return {
199
+ id: operation.id,
200
+ type: operation.type,
201
+ source: operation.source,
202
+ target: operation.target,
203
+ pre_hash: operation.source_before.hash,
204
+ post_hash: operation.source_before.hash
205
+ };
206
+ }
207
+ return { id: operation.id, type: operation.type, path: operation.path, pre_hash: operation.before.hash, post_hash: operation.after.hash };
208
+ }
209
+
210
+ function operationDisposition(cwd, operation) {
211
+ if (operation.type === "move") {
212
+ const source = captureWorkflowEntry(absolute(cwd, operation.source));
213
+ const target = captureWorkflowEntry(absolute(cwd, operation.target));
214
+ if (sameState(source, operation.source_before) && sameState(target, operation.target_before)) return "before";
215
+ if (source.kind === "absent" && sameState(target, operation.source_before)) return "after";
216
+ return "conflict";
217
+ }
218
+ const current = captureWorkflowEntry(absolute(cwd, operation.path));
219
+ if (sameState(current, operation.before)) return "before";
220
+ if (sameState(current, operation.after)) return "after";
221
+ return "conflict";
222
+ }
223
+
224
+ function ensureParents(cwd, operation) {
225
+ for (const relative of operation.created_parents ?? []) {
226
+ const directory = absolute(cwd, relative);
227
+ const current = ordinaryStat(directory);
228
+ if (current && !current.isDirectory()) throw Object.assign(new Error(`Workflow transaction parent is not a directory: ${relative}.`), { code: "WORKFLOW_RECOVERY_CONFLICT" });
229
+ if (!current) fs.mkdirSync(directory);
230
+ }
231
+ }
232
+
233
+ function removeCreatedParents(cwd, operation) {
234
+ for (const relative of [...(operation.created_parents ?? [])].reverse()) {
235
+ const directory = absolute(cwd, relative);
236
+ try { fs.rmdirSync(directory); }
237
+ catch (error) { if (!["ENOENT", "ENOTEMPTY"].includes(error.code)) throw error; }
238
+ }
239
+ }
240
+
241
+ function applyOperation(cwd, operation) {
242
+ verifyParentFacts(cwd, operation);
243
+ const disposition = operationDisposition(cwd, operation);
244
+ if (disposition === "after") return;
245
+ if (disposition !== "before") throw Object.assign(new Error(`Workflow operation ${operation.id} no longer matches its pre-state.`), { code: "WORKFLOW_RECOVERY_CONFLICT" });
246
+ ensureParents(cwd, operation);
247
+ if (operation.type === "write") durableWrite(absolute(cwd, operation.path), Buffer.from(operation.after.bytes, "base64"));
248
+ else if (operation.type === "mkdir") fs.mkdirSync(absolute(cwd, operation.path));
249
+ else if (operation.type === "remove") fs.rmSync(absolute(cwd, operation.path));
250
+ else if (operation.type === "move") fs.renameSync(absolute(cwd, operation.source), absolute(cwd, operation.target));
251
+ if (operationDisposition(cwd, operation) !== "after") throw Object.assign(new Error(`Workflow operation ${operation.id} post-state could not be proven.`), { code: "WORKFLOW_RECOVERY_CONFLICT" });
252
+ }
253
+
254
+ function rollbackOperation(cwd, operation) {
255
+ verifyParentFacts(cwd, operation);
256
+ const disposition = operationDisposition(cwd, operation);
257
+ if (disposition === "before") return;
258
+ if (disposition !== "after") throw Object.assign(new Error(`Workflow operation ${operation.id} no longer matches its post-state.`), { code: "WORKFLOW_RECOVERY_CONFLICT" });
259
+ if (operation.type === "write") {
260
+ const file = absolute(cwd, operation.path);
261
+ if (operation.before.kind === "absent") fs.rmSync(file);
262
+ else durableWrite(file, Buffer.from(operation.before.bytes, "base64"));
263
+ } else if (operation.type === "mkdir") {
264
+ fs.rmdirSync(absolute(cwd, operation.path));
265
+ } else if (operation.type === "remove") {
266
+ const file = absolute(cwd, operation.path);
267
+ ensureParents(cwd, operation);
268
+ durableWrite(file, Buffer.from(operation.before.bytes, "base64"));
269
+ } else if (operation.type === "move") {
270
+ fs.renameSync(absolute(cwd, operation.target), absolute(cwd, operation.source));
271
+ }
272
+ removeCreatedParents(cwd, operation);
273
+ if (operationDisposition(cwd, operation) !== "before") throw Object.assign(new Error(`Workflow operation ${operation.id} rollback could not be proven.`), { code: "WORKFLOW_RECOVERY_CONFLICT" });
274
+ }
275
+
276
+ function readBoundedJson(file) {
277
+ const stat = ordinaryStat(file);
278
+ if (!stat || !stat.isFile() || stat.size > MAX_JOURNAL_BYTES) {
279
+ throw Object.assign(new Error(`Workflow transaction journal is missing, invalid or exceeds ${MAX_JOURNAL_BYTES} bytes.`), { code: "WORKFLOW_TRANSACTION_CORRUPT" });
280
+ }
281
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); }
282
+ catch (error) { throw Object.assign(new Error(`Workflow transaction journal is not valid JSON: ${error.message}`), { code: "WORKFLOW_TRANSACTION_CORRUPT" }); }
283
+ }
284
+
285
+ function validateJournal(journal, id) {
286
+ if (
287
+ !journal || journal.schema !== JOURNAL_SCHEMA || journal.id !== id ||
288
+ !["prepared", "applying", "finalizing", "committed", "rolled-back"].includes(journal.status) ||
289
+ !Array.isArray(journal.operations) || journal.operations.length > 128 ||
290
+ journal.integrity !== journalIntegrity(journal)
291
+ ) throw Object.assign(new Error(`Workflow transaction ${id} has an invalid journal.`), { code: "WORKFLOW_TRANSACTION_CORRUPT" });
292
+ return journal;
293
+ }
294
+
295
+ function readJournal(cwd, id) {
296
+ return validateJournal(readBoundedJson(journalPath(cwd, id)), id);
297
+ }
298
+
299
+ function persistJournal(cwd, journal) {
300
+ journal.updated_at = stamp();
301
+ journal.integrity = journalIntegrity(journal);
302
+ const serialized = `${JSON.stringify(journal, null, 2)}\n`;
303
+ if (Buffer.byteLength(serialized) > MAX_JOURNAL_BYTES) {
304
+ throw Object.assign(new Error(`Workflow transaction journal exceeds ${MAX_JOURNAL_BYTES} bytes.`), { code: "WORKFLOW_TRANSACTION_CORRUPT" });
305
+ }
306
+ durableWrite(journalPath(cwd, journal.id), serialized);
307
+ }
308
+
309
+ function journalIntegrity(journal) {
310
+ const protectedJournal = { ...journal };
311
+ delete protectedJournal.integrity;
312
+ return sha(Buffer.from(JSON.stringify(protectedJournal), "utf8"));
313
+ }
314
+
315
+ function processExists(pid) {
316
+ if (!Number.isInteger(pid) || pid <= 0) return null;
317
+ try { process.kill(pid, 0); return true; }
318
+ catch (error) { return error.code === "ESRCH" ? false : null; }
319
+ }
320
+
321
+ function readLock(cwd) {
322
+ const file = lockPath(cwd);
323
+ if (!fs.existsSync(file)) return null;
324
+ const raw = fs.readFileSync(file);
325
+ if (raw.length > 16 * 1024) return { corrupt: true, id: sha(raw), raw };
326
+ try {
327
+ const value = JSON.parse(raw.toString("utf8"));
328
+ if (value.schema !== LOCK_SCHEMA || typeof value.nonce !== "string" || typeof value.transaction_id !== "string") return { corrupt: true, id: sha(raw), raw };
329
+ return { ...value, id: sha(raw), raw };
330
+ } catch {
331
+ return { corrupt: true, id: sha(raw), raw };
332
+ }
333
+ }
334
+
335
+ function lockOwner(lock) {
336
+ if (lock.corrupt || lock.hostname !== os.hostname()) return "unknown";
337
+ const exists = processExists(lock.pid);
338
+ return exists === true ? "active" : exists === false ? "absent" : "unknown";
339
+ }
340
+
341
+ function acquireLock(cwd, transactionId) {
342
+ fs.mkdirSync(rootFor(cwd), { recursive: true });
343
+ const file = lockPath(cwd);
344
+ const lock = {
345
+ schema: LOCK_SCHEMA,
346
+ transaction_id: transactionId,
347
+ pid: process.pid,
348
+ hostname: os.hostname(),
349
+ nonce: crypto.randomUUID(),
350
+ created_at: stamp()
351
+ };
352
+ try {
353
+ const descriptor = fs.openSync(file, "wx");
354
+ try {
355
+ fs.writeFileSync(descriptor, `${JSON.stringify(lock)}\n`);
356
+ fs.fsyncSync(descriptor);
357
+ } finally {
358
+ fs.closeSync(descriptor);
359
+ }
360
+ return { ok: true, lock: readLock(cwd) };
361
+ } catch (error) {
362
+ if (error.code !== "EEXIST") return fail("WORKFLOW_PERSISTENCE_FAILED", `Workflow boundary could not be acquired: ${error.message}`);
363
+ const existing = readLock(cwd);
364
+ const owner = existing ? lockOwner(existing) : "unknown";
365
+ return fail(owner === "active" ? "WORKFLOW_BUSY" : "WORKFLOW_RECOVERY_REQUIRED", owner === "active" ? "An active Workflow mutation owns the project boundary." : "A Workflow lock requires explicit doctor recovery.", { lock_id: existing?.id ?? null });
366
+ }
367
+ }
368
+
369
+ function acquireRecoveryLock(cwd, transactionId) {
370
+ const existing = readLock(cwd);
371
+ if (existing) {
372
+ const owner = lockOwner(existing);
373
+ if (existing.transaction_id !== transactionId) {
374
+ return fail("WORKFLOW_LOCK_CONFLICT", "Workflow lock belongs to a different transaction.", { lock_id: existing.id, transaction_id: existing.transaction_id });
375
+ }
376
+ if (owner === "active") return fail("WORKFLOW_BUSY", "An active Workflow mutation still owns this transaction boundary.", { lock_id: existing.id });
377
+ if (owner !== "absent") return fail("WORKFLOW_LOCK_CONFLICT", "Workflow lock owner cannot be proven absent.", { lock_id: existing.id });
378
+ const confirmed = readLock(cwd);
379
+ if (!confirmed || confirmed.id !== existing.id) return fail("WORKFLOW_LOCK_CONFLICT", "Workflow lock identity changed before recovery.");
380
+ fs.rmSync(lockPath(cwd));
381
+ }
382
+ return acquireLock(cwd, transactionId);
383
+ }
384
+
385
+ function releaseLock(cwd, expected) {
386
+ const current = readLock(cwd);
387
+ if (!current) return;
388
+ if (!expected || current.id !== expected.id) throw Object.assign(new Error("Workflow lock identity changed before release."), { code: "WORKFLOW_LOCK_CONFLICT" });
389
+ fs.rmSync(lockPath(cwd));
390
+ }
391
+
392
+ function transactionDirectories(cwd) {
393
+ const root = transactionsFor(cwd);
394
+ if (!fs.existsSync(root)) return [];
395
+ const stat = ordinaryStat(root);
396
+ if (!stat?.isDirectory()) throw Object.assign(new Error("Workflow transaction root is not an ordinary directory."), { code: "WORKFLOW_ENTRY_UNSUPPORTED" });
397
+ const names = fs.readdirSync(root);
398
+ if (names.length > MAX_TRANSACTIONS) throw Object.assign(new Error(`Workflow transaction root exceeds ${MAX_TRANSACTIONS} entries.`), { code: "WORKFLOW_TRANSACTION_CORRUPT" });
399
+ return names.sort();
400
+ }
401
+
402
+ function inspectTransactions(cwd) {
403
+ const transactions = [];
404
+ for (const id of transactionDirectories(cwd)) {
405
+ try {
406
+ const loaded = readReceiptOrJournal(cwd, id);
407
+ const journal = loaded.value;
408
+ transactions.push({
409
+ id,
410
+ kind: journal.kind,
411
+ change_id: journal.change_id,
412
+ status: journal.status,
413
+ strategies: ["committed", "rolled-back"].includes(journal.status) ? [] : journal.status === "finalizing" ? ["continue"] : ["continue", "rollback"],
414
+ corrupt: false,
415
+ receipt: loaded.receipt,
416
+ journal
417
+ });
418
+ } catch (error) {
419
+ transactions.push({ id, status: "corrupt", strategies: [], corrupt: true, error: error.message });
420
+ }
421
+ }
422
+ return transactions;
423
+ }
424
+
425
+ function pendingTransactions(cwd) {
426
+ return inspectTransactions(cwd).filter((item) => item.corrupt || !["committed", "rolled-back"].includes(item.status));
427
+ }
428
+
429
+ export function workflowMutationGate(cwd) {
430
+ let pending;
431
+ try { pending = pendingTransactions(cwd); }
432
+ catch (error) { return recoverError(error, null); }
433
+ if (pending.length) return fail("WORKFLOW_RECOVERY_REQUIRED", "An unfinished Workflow transaction must be resolved before starting another mutation.", { transactions: pending.map(({ id }) => id), recovery_command: "matrix workflow doctor" });
434
+ const currentLock = readLock(cwd);
435
+ if (!currentLock) return null;
436
+ const owner = lockOwner(currentLock);
437
+ return fail(owner === "active" ? "WORKFLOW_BUSY" : owner === "absent" ? "WORKFLOW_RECOVERY_REQUIRED" : "WORKFLOW_LOCK_CONFLICT", owner === "active" ? "An active Workflow mutation owns the project boundary." : "A Workflow lock requires explicit doctor recovery.", { lock_id: currentLock.id, transaction_id: currentLock.transaction_id, recovery_command: "matrix workflow doctor" });
438
+ }
439
+
440
+ function compactReceipt(cwd, journal, disposition) {
441
+ journal.schema = RECEIPT_SCHEMA;
442
+ journal.status = disposition;
443
+ journal.operations = journal.operations.map(operationSummary);
444
+ delete journal.result;
445
+ persistJournal(cwd, journal);
446
+ pruneReceipts(cwd);
447
+ }
448
+
449
+ function readReceiptOrJournal(cwd, id) {
450
+ const file = journalPath(cwd, id);
451
+ const value = readBoundedJson(file);
452
+ if (
453
+ value.schema === RECEIPT_SCHEMA && value.id === id &&
454
+ ["committed", "rolled-back"].includes(value.status) &&
455
+ value.integrity === journalIntegrity(value)
456
+ ) return { receipt: true, value };
457
+ if (value.schema === RECEIPT_SCHEMA) throw Object.assign(new Error(`Workflow transaction receipt ${id} has invalid integrity.`), { code: "WORKFLOW_TRANSACTION_CORRUPT" });
458
+ return { receipt: false, value: validateJournal(value, id) };
459
+ }
460
+
461
+ function pruneReceipts(cwd) {
462
+ const receipts = [];
463
+ for (const id of transactionDirectories(cwd)) {
464
+ try {
465
+ const parsed = readBoundedJson(journalPath(cwd, id));
466
+ if (parsed.schema === RECEIPT_SCHEMA && ["committed", "rolled-back"].includes(parsed.status) && parsed.integrity === journalIntegrity(parsed)) receipts.push(parsed);
467
+ } catch {}
468
+ }
469
+ receipts.sort((left, right) => String(right.updated_at).localeCompare(String(left.updated_at)) || right.id.localeCompare(left.id));
470
+ for (const receipt of receipts.slice(RECEIPT_LIMIT)) fs.rmSync(path.dirname(journalPath(cwd, receipt.id)), { recursive: true });
471
+ }
472
+
473
+ function recoverError(error, transactionId) {
474
+ const code = error.code && String(error.code).startsWith("WORKFLOW_") ? error.code : "WORKFLOW_PERSISTENCE_FAILED";
475
+ return fail(code, error.message, {
476
+ transaction_id: transactionId,
477
+ recovery_command: `matrix workflow doctor --repair --transaction ${transactionId} --strategy continue`
478
+ });
479
+ }
480
+
481
+ export function runWorkflowTransaction({ cwd, kind, changeId = null, operations, result, failAfterOperation = null }) {
482
+ const blocked = workflowMutationGate(cwd);
483
+ if (blocked) return blocked;
484
+ const transactionId = crypto.randomUUID();
485
+ const acquired = acquireLock(cwd, transactionId);
486
+ if (!acquired.ok) return acquired;
487
+ const journal = {
488
+ schema: JOURNAL_SCHEMA,
489
+ id: transactionId,
490
+ kind,
491
+ change_id: changeId,
492
+ status: "prepared",
493
+ created_at: stamp(),
494
+ updated_at: stamp(),
495
+ operations: operations.map((operation, index) => ({ ...operation, id: `${kind}:${index + 1}`, status: "pending" })),
496
+ result
497
+ };
498
+ try {
499
+ persistJournal(cwd, journal);
500
+ journal.status = "applying";
501
+ persistJournal(cwd, journal);
502
+ for (let index = 0; index < journal.operations.length; index += 1) {
503
+ const operation = journal.operations[index];
504
+ applyOperation(cwd, operation);
505
+ if (failAfterOperation === index) throw Object.assign(new Error(`Injected interruption after operation ${index}.`), { code: "WORKFLOW_PERSISTENCE_FAILED" });
506
+ operation.status = "applied";
507
+ persistJournal(cwd, journal);
508
+ }
509
+ journal.status = "finalizing";
510
+ persistJournal(cwd, journal);
511
+ compactReceipt(cwd, journal, "committed");
512
+ releaseLock(cwd, acquired.lock);
513
+ return { ...result, transaction_id: transactionId };
514
+ } catch (error) {
515
+ try { releaseLock(cwd, acquired.lock); }
516
+ catch {}
517
+ return recoverError(error, transactionId);
518
+ }
519
+ }
520
+
521
+ function repairTransaction(cwd, id, strategy) {
522
+ let loaded;
523
+ try { loaded = readReceiptOrJournal(cwd, id); }
524
+ catch (error) { return recoverError(error, id); }
525
+ if (loaded.receipt) return { ok: true, code: "OK", transaction_id: id, disposition: loaded.value.status, repaired: false };
526
+ const journal = loaded.value;
527
+ const available = journal.status === "finalizing" ? ["continue"] : ["continue", "rollback"];
528
+ if (!available.includes(strategy)) return fail("WORKFLOW_STRATEGY_UNAVAILABLE", `Strategy ${strategy} is not safe for transaction ${id}.`, { transaction_id: id, strategies: available });
529
+ const acquired = acquireRecoveryLock(cwd, id);
530
+ if (!acquired.ok) return acquired;
531
+ try {
532
+ if (strategy === "continue") {
533
+ for (const operation of journal.operations) {
534
+ if (operation.status === "applied") continue;
535
+ applyOperation(cwd, operation);
536
+ operation.status = "applied";
537
+ persistJournal(cwd, journal);
538
+ }
539
+ journal.status = "finalizing";
540
+ persistJournal(cwd, journal);
541
+ const result = journal.result;
542
+ compactReceipt(cwd, journal, "committed");
543
+ releaseLock(cwd, acquired.lock);
544
+ return { ...result, transaction_id: id, disposition: "committed", repaired: true };
545
+ }
546
+ for (const operation of [...journal.operations].reverse()) {
547
+ rollbackOperation(cwd, operation);
548
+ operation.status = "rolled-back";
549
+ persistJournal(cwd, journal);
550
+ }
551
+ compactReceipt(cwd, journal, "rolled-back");
552
+ releaseLock(cwd, acquired.lock);
553
+ return { ok: true, code: "OK", transaction_id: id, disposition: "rolled-back", repaired: true };
554
+ } catch (error) {
555
+ try { releaseLock(cwd, acquired.lock); }
556
+ catch {}
557
+ return recoverError(error, id);
558
+ }
559
+ }
560
+
561
+ function exactCommands(transaction) {
562
+ return transaction.strategies.map((strategy) => `matrix workflow doctor --repair --transaction ${transaction.id} --strategy ${strategy}`);
563
+ }
564
+
565
+ export function workflowDoctor(cwd, { repair = false, transaction = null, strategy = null, lock = null } = {}) {
566
+ if (!repair && (transaction || strategy || lock)) return fail("INVALID_INTENT", "Workflow doctor targets require --repair.");
567
+ let transactions;
568
+ try { transactions = inspectTransactions(cwd); }
569
+ catch (error) { return recoverError(error, null); }
570
+ const currentLock = readLock(cwd);
571
+ const owner = currentLock ? lockOwner(currentLock) : null;
572
+ if (!repair) {
573
+ const findings = [
574
+ ...transactions.filter((item) => item.corrupt).map((item) => ({ code: "WORKFLOW_TRANSACTION_CORRUPT", transaction_id: item.id, message: item.error })),
575
+ ...transactions.filter((item) => !item.corrupt && !["committed", "rolled-back"].includes(item.status)).map((item) => ({
576
+ code: "WORKFLOW_RECOVERY_REQUIRED",
577
+ transaction_id: item.id,
578
+ status: item.status,
579
+ strategies: item.strategies,
580
+ commands: exactCommands(item)
581
+ })),
582
+ ...(currentLock ? [{
583
+ code: owner === "active" ? "WORKFLOW_BUSY" : owner === "absent" ? "WORKFLOW_RECOVERY_REQUIRED" : "WORKFLOW_LOCK_CONFLICT",
584
+ lock_id: currentLock.id,
585
+ transaction_id: currentLock.transaction_id,
586
+ owner,
587
+ commands: owner === "absent" && !transactions.some((item) => item.id === currentLock.transaction_id && (item.corrupt || !["committed", "rolled-back"].includes(item.status)))
588
+ ? [`matrix workflow doctor --repair --lock ${currentLock.id}`]
589
+ : []
590
+ }] : [])
591
+ ];
592
+ return {
593
+ ok: true,
594
+ code: "OK",
595
+ health: findings.some((item) => item.code === "WORKFLOW_TRANSACTION_CORRUPT" || item.code === "WORKFLOW_LOCK_CONFLICT") ? "conflict" : findings.length ? "recovery-required" : "healthy",
596
+ findings,
597
+ transactions: transactions.map(({ journal, error, ...item }) => ({ ...item, commands: item.corrupt ? [] : exactCommands(item) })),
598
+ lock: currentLock ? { id: currentLock.id, transaction_id: currentLock.transaction_id, owner } : null,
599
+ repaired: false
600
+ };
601
+ }
602
+ if (lock) {
603
+ if (transaction || strategy) return fail("INVALID_INTENT", "Standalone lock repair cannot include transaction or strategy.");
604
+ if (!currentLock || currentLock.id !== lock) return fail("WORKFLOW_LOCK_CONFLICT", "The requested Workflow lock identity is missing or changed.");
605
+ if (currentLock.transaction_id && transactions.some((item) => item.id === currentLock.transaction_id && (item.corrupt || !["committed", "rolled-back"].includes(item.status)))) {
606
+ return fail("WORKFLOW_RECOVERY_REQUIRED", "This lock belongs to an unfinished transaction and cannot be cleaned independently.", { transaction_id: currentLock.transaction_id });
607
+ }
608
+ if (owner !== "absent") return fail(owner === "active" ? "WORKFLOW_BUSY" : "WORKFLOW_LOCK_CONFLICT", "Workflow lock owner cannot be proven absent.");
609
+ const confirmed = readLock(cwd);
610
+ if (!confirmed || confirmed.id !== lock) return fail("WORKFLOW_LOCK_CONFLICT", "Workflow lock identity changed before cleanup.");
611
+ fs.rmSync(lockPath(cwd));
612
+ return { ok: true, code: "OK", repaired: true, disposition: "lock-removed", lock_id: lock };
613
+ }
614
+ if (!transaction) {
615
+ const standaloneStaleLock = currentLock && owner === "absent" && !transactions.some((item) => item.id === currentLock.transaction_id && (item.corrupt || !["committed", "rolled-back"].includes(item.status)));
616
+ if (standaloneStaleLock) return fail("WORKFLOW_LOCK_REQUIRED", "Standalone lock repair requires --lock <id>.", { lock_id: currentLock.id });
617
+ return fail("WORKFLOW_TRANSACTION_REQUIRED", "Transaction repair requires --transaction <id>.");
618
+ }
619
+ if (!strategy) return fail("WORKFLOW_STRATEGY_REQUIRED", "Transaction repair requires --strategy continue|rollback.");
620
+ if (!["continue", "rollback"].includes(strategy)) return fail("WORKFLOW_STRATEGY_UNAVAILABLE", `Unsupported transaction repair strategy: ${strategy}.`);
621
+ return repairTransaction(cwd, transaction, strategy);
622
+ }