@rosenfan/matrix 0.1.1 → 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 -45
  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
package/src/workflow.js CHANGED
@@ -1,95 +1,568 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
-
3
+ import crypto from "node:crypto";
4
+ import os from "node:os";
5
+ import {
6
+ captureWorkflowEntry,
7
+ runWorkflowTransaction,
8
+ workflowDoctor,
9
+ workflowMkdirOperation,
10
+ workflowMutationGate,
11
+ workflowMoveOperation,
12
+ workflowRemoveOperation,
13
+ workflowWriteOperation
14
+ } from "./workflow-transaction.js";
15
+
4
16
  const PHASES = ["open", "design", "build", "verify", "archive"];
5
17
  const NEXT = { open: "design", design: "build", build: "verify", verify: "archive" };
6
- const skill = (phase) => `$matrix-${phase}`;
7
- const stamp = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
18
+ const RETURNS = [
19
+ { from: "build", to: "design", reason: "design-gap" },
20
+ { from: "verify", to: "build", reason: "verification-failed" },
21
+ { from: "verify", to: "design", reason: "acceptance-or-design-gap" },
22
+ { from: "archive", to: "design", reason: "acceptance-or-design-gap" }
23
+ ];
24
+ const RETURN_REASONS = new Set(RETURNS.map(({ reason }) => reason));
25
+ const INVALIDATED_EVIDENCE = ["build", "test", "review", "archive"];
26
+ const ABORT_REASONS = new Set(["requirement-cancelled", "superseded", "no-longer-valuable", "blocked", "other"]);
27
+ const ORCHESTRATIONS = ["prim", "arch"];
28
+ const ARCH_SKILLS = [
29
+ "grilling", "domain-modeling", "research", "wayfinder", "prototype",
30
+ "codebase-design", "tdd", "diagnosing-bugs", "resolving-merge-conflicts", "code-review"
31
+ ];
32
+ const ARTIFACT_POLICY = {
33
+ proposal: ["## Goal", "## Scope", "## Non-goals", "## Acceptance", "## Risks"],
34
+ design: ["## Decisions", "## Boundaries", "## Test seams", "## Risks"],
35
+ plan: ["## Steps", "## Validation", "## Stop conditions"],
36
+ build: ["## Build evidence"],
37
+ verification: ["## Test evidence", "## Review evidence"]
38
+ };
39
+ const CONTRACT_FILES = ["proposal.md", "design.md", "plan.md"];
40
+ const skill = (phase) => `$matrix-${phase}`;
41
+ const stamp = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
8
42
  const rootFor = (cwd) => path.join(cwd, ".matrix");
9
43
  const exists = (file) => fs.existsSync(file);
10
44
  const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
11
- const write = (file, value) => fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
12
-
45
+
13
46
  function fail(code, message) { return { ok: false, code, message }; }
14
- function taskContent(change, taskId, proposal, design, plan, resultPath) {
15
- return `# Claude Code task: ${taskId}\n\n## Metadata\n\n| Field | Value |\n| --- | --- |\n| Status | CLAUDE_QUEUED |\n| Matrix change | ${change} |\n| Decision owner | Codex |\n| Implementation actor | Claude Code |\n| Result | ${resultPath} |\n\n## Objective\n\nThe frozen Matrix artifacts below are authoritative. Do not expand their scope.\n\n## Proposal\n\n${proposal}\n\n## Frozen design\n\n${design}\n\n## Ordered plan\n\n${plan}\n\n## Allowed work\n\n- Read repository instructions and existing tests before implementing.\n- Change only files required by the frozen design and plan.\n- Run every validation command specified by the plan.\n\n## Prohibited work\n\n- Do not edit Matrix state, transition its phase, archive it, or alter the frozen design.\n- Do not add unrelated features, public APIs, dependencies, or external side effects.\n- Do not claim completion without recording verifiable evidence.\n\n## Stop and report\n\nStop with \`NEEDS_DECISION\` if the scope must expand, the design conflicts with the repository, validation cannot pass within scope, or an authorization/safety issue arises.\n\n## Required result\n\nCreate \`${resultPath}\` with metadata, changed files and reasons, commands and outcomes, test results, boundary confirmation, residual risks, and any \`NEEDS_DECISION\` items. Codex reviews the result and the actual diff.\n`;
16
- }
17
- function activeBoard(taskId, taskPath, resultPath, change) {
18
- return `# Active task board\n\n## Current objective\n\n| Field | Value |\n| --- | --- |\n| ID | ${taskId} |\n| Status | CLAUDE_QUEUED |\n| Matrix change | ${change} |\n| Task | ${taskPath} |\n| Result | ${resultPath} |\n`;
19
- }
20
- function paths(cwd, id) { const root = rootFor(cwd); return { root, active: path.join(root, "active.json"), change: path.join(root, "changes", id), flow: path.join(root, "changes", id, "matrix.json"), legacyFlow: path.join(root, "changes", id, "matrix.yaml"), artifacts: path.join(root, "changes", id, "artifacts") }; }
21
- function readFlow(p) { if (fs.existsSync(p.flow)) return read(p.flow); if (!fs.existsSync(p.legacyFlow)) return null; return Object.fromEntries(fs.readFileSync(p.legacyFlow, "utf8").split(/\r?\n/).filter((line) => line.includes(":") && !line.startsWith(" ")).map((line) => { const [key, ...value] = line.split(":"); return [key.trim(), value.join(":").trim().replace(/^"|"$/g, "")]; })); }
22
- function writeFlow(p, flow) { if (fs.existsSync(p.legacyFlow)) { const order = ["schema", "id", "workflow", "status", "phase", "title", "created_at", "updated_at", "acceptance", "scope"]; fs.writeFileSync(p.legacyFlow, `${order.map((key) => `${key}: ${flow[key] ?? ""}`).join("\n")}\n`); } else write(p.flow, flow); }
23
- function active(cwd) { const activeFile = path.join(rootFor(cwd), "active.json"); if (!fs.existsSync(activeFile)) return null; return read(activeFile).change_id; }
24
- function meaningful(file, headings) { if (!fs.existsSync(file)) return false; const value = fs.readFileSync(file, "utf8").trim(); return value.length >= 40 && headings.every((heading) => value.includes(heading)); }
25
- function guard(cwd, id, phase) {
26
- const base = paths(cwd, id).artifacts;
27
- const checks = phase === "open" ? [["proposal", meaningful(path.join(base, "proposal.md"), ["## Goal", "## Scope", "## Acceptance"])]]
28
- : phase === "design" ? [["design", meaningful(path.join(base, "design.md"), ["## Decisions", "## Test seams"])], ["plan", meaningful(path.join(base, "plan.md"), ["## Steps", "## Validation"])]]
29
- : phase === "build" ? [["plan", meaningful(path.join(base, "plan.md"), ["## Steps"])], ["build evidence", meaningful(path.join(base, "verification.md"), ["## Build evidence"])]]
30
- : [["verification", meaningful(path.join(base, "verification.md"), ["## Test evidence", "## Review evidence"])]];
47
+ function option(args, name) { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : null; }
48
+ function flatYaml(file) {
49
+ try {
50
+ return Object.fromEntries(fs.readFileSync(file, "utf8").split(/\r?\n/).filter((line) => line.includes(":") && !line.startsWith(" ")).map((line) => {
51
+ const [key, ...value] = line.split(":");
52
+ return [key.trim(), value.join(":").trim().replace(/^"|"$/g, "")];
53
+ }));
54
+ } catch { return {}; }
55
+ }
56
+ function hashDirectory(directory) {
57
+ const hash = crypto.createHash("sha256");
58
+ const files = [];
59
+ const visit = (current, relative = "") => {
60
+ for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
61
+ const child = path.join(current, entry.name);
62
+ const childRelative = path.join(relative, entry.name).replaceAll("\\", "/");
63
+ if (entry.isDirectory()) visit(child, childRelative);
64
+ else if (entry.isFile()) files.push([childRelative, fs.readFileSync(child)]);
65
+ }
66
+ };
67
+ visit(directory);
68
+ for (const [relative, contents] of files.sort(([left], [right]) => left.localeCompare(right))) {
69
+ hash.update(`${relative}\0`);
70
+ hash.update(contents);
71
+ }
72
+ return hash.digest("hex");
73
+ }
74
+ function orchestrationConfig(cwd) {
75
+ const config = flatYaml(path.join(rootFor(cwd), "config.yaml"));
76
+ const scope = config.installation_scope === "global" ? "global" : "project";
77
+ const manifestFile = scope === "global" ? path.join(os.homedir(), ".matrix", "installation.json") : path.join(rootFor(cwd), "installation.json");
78
+ let manifest = null;
79
+ try { manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8")); } catch {}
80
+ const defaultOrchestration = ORCHESTRATIONS.includes(config.default_orchestration) ? config.default_orchestration
81
+ : ORCHESTRATIONS.includes(manifest?.orchestration?.default) ? manifest.orchestration.default : "prim";
82
+ return { config, scope, manifestFile, manifest, defaultOrchestration };
83
+ }
84
+ function archIntegrity(cwd) {
85
+ const { manifest, manifestFile } = orchestrationConfig(cwd);
86
+ const platforms = Object.entries(manifest?.platforms ?? {});
87
+ if (!manifest?.orchestration?.available?.includes("arch") || !platforms.length) {
88
+ return { ok: false, code: "ARCH_INSTALLATION_INCOMPLETE", message: "Arch requires a verified managed Skill cohort on every configured platform.", recovery_command: "matrix init --with-mattpocock" };
89
+ }
90
+ const findings = [];
91
+ for (const [platform, record] of platforms) {
92
+ for (const skill of ARCH_SKILLS) {
93
+ const installed = record?.matt?.skills?.[skill];
94
+ if (!installed?.root || !installed?.hash || !fs.existsSync(path.join(installed.root, "SKILL.md"))) {
95
+ findings.push({ platform, skill, reason: "missing" });
96
+ continue;
97
+ }
98
+ try {
99
+ if (hashDirectory(installed.root) !== installed.hash) findings.push({ platform, skill, reason: "modified" });
100
+ } catch { findings.push({ platform, skill, reason: "unreadable" }); }
101
+ }
102
+ }
103
+ return findings.length ? {
104
+ ok: false, code: "ARCH_INSTALLATION_INCOMPLETE", message: "Arch installation integrity failed. Repair the managed cohort before continuing.",
105
+ manifest: manifestFile, findings, recovery_command: "matrix init --with-mattpocock"
106
+ } : { ok: true, code: "OK", manifest: manifestFile, platforms: platforms.map(([platform]) => platform) };
107
+ }
108
+ function taskContent(change, taskId, proposal, design, plan, resultPath, contractHash, approvalRevision) {
109
+ return `# Claude Code task: ${taskId}\n\n## Metadata\n\n| Field | Value |\n| --- | --- |\n| Status | CLAUDE_QUEUED |\n| Matrix change | ${change} |\n| Decision owner | Codex |\n| Implementation actor | Claude Code |\n| Approved contract | ${contractHash} |\n| Approval revision | ${approvalRevision} |\n| Result | ${resultPath} |\n\n## Objective\n\nThe frozen Matrix artifacts below are authoritative. Do not expand their scope.\n\n## Proposal\n\n${proposal}\n\n## Frozen design\n\n${design}\n\n## Ordered plan\n\n${plan}\n\n## Allowed work\n\n- Read repository instructions and existing tests before implementing.\n- Change only files required by the frozen design and plan.\n\n## Prohibited work\n\n- Do not edit Matrix state, transition its phase, archive it, or alter the frozen design.\n- Do not add unrelated features, public APIs, dependencies, or external side effects.\n- Do not claim completion without recording verifiable evidence.\n\n## Stop and report\n\nStop with \`NEEDS_DECISION\` if the scope must expand, the design conflicts with the repository, validation cannot pass within scope, or an authorization/safety issue arises.\n\n## Required result\n\nCreate \`${resultPath}\` with metadata, changed files and reasons, commands and outcomes, test results, boundary confirmation, residual risks, and any \`NEEDS_DECISION\` items. Codex reviews the result and the actual diff.\n`;
110
+ }
111
+ function activeBoard(taskId, taskPath, resultPath, change) {
112
+ return `# Active task board\n\n## Current objective\n\n| Field | Value |\n| --- | --- |\n| ID | ${taskId} |\n| Status | CLAUDE_QUEUED |\n| Matrix change | ${change} |\n| Task | ${taskPath} |\n| Result | ${resultPath} |\n`;
113
+ }
114
+ function paths(cwd, id, archived = false) {
115
+ const root = rootFor(cwd);
116
+ const change = path.join(root, archived ? "archive" : "changes", id);
117
+ return { root, active: path.join(root, "active.json"), change, flow: path.join(change, "matrix.yaml"), artifacts: path.join(change, "artifacts") };
118
+ }
119
+ function normalizeFlow(flow) {
120
+ if (!flow) return null;
121
+ if (flow.schema !== "matrix/change/v2") return flow.schema ? { unsupported_schema: flow.schema } : null;
122
+ const revision = Number(flow.revision);
123
+ const approved = flow.approved_contract_hash || null;
124
+ const approvalRevision = flow.contract_approved_revision === "" || flow.contract_approved_revision == null ? null : Number(flow.contract_approved_revision);
125
+ if (
126
+ typeof flow.id !== "string" || !flow.id ||
127
+ !["full", "hotfix", "tweak"].includes(flow.workflow) ||
128
+ !ORCHESTRATIONS.includes(flow.orchestration) ||
129
+ !["active", "archived", "aborted"].includes(flow.status) ||
130
+ !PHASES.includes(flow.phase) ||
131
+ !Number.isInteger(revision) || revision < 1 ||
132
+ Boolean(approved) !== Boolean(approvalRevision) ||
133
+ (approved && !/^sha256:[0-9a-f]{64}$/.test(approved)) ||
134
+ (approvalRevision != null && (!Number.isInteger(approvalRevision) || approvalRevision < 1 || approvalRevision > revision))
135
+ ) return null;
136
+ return { ...flow, revision, approved_contract_hash: approved, contract_approved_revision: approvalRevision };
137
+ }
138
+ function readFlow(p) {
139
+ if (!fs.existsSync(p.flow)) return null;
140
+ const parsed = Object.fromEntries(fs.readFileSync(p.flow, "utf8").split(/\r?\n/).filter((line) => line.includes(":") && !line.startsWith(" ")).map((line) => { const [key, ...value] = line.split(":"); return [key.trim(), value.join(":").trim().replace(/^"|"$/g, "")]; }));
141
+ return normalizeFlow(parsed);
142
+ }
143
+ function flowContent(flow) {
144
+ const order = ["schema", "id", "workflow", "orchestration", "status", "phase", "revision", "approved_contract_hash", "contract_approved_revision", "title", "created_at", "updated_at", "acceptance", "scope"];
145
+ return `${order.map((key) => `${key}: ${flow[key] ?? ""}`).join("\n")}\n`;
146
+ }
147
+ function active(cwd) { const activeFile = path.join(rootFor(cwd), "active.json"); if (!fs.existsSync(activeFile)) return null; return read(activeFile).change_id; }
148
+ function meaningful(file, headings) { if (!fs.existsSync(file)) return false; const value = fs.readFileSync(file, "utf8").trim(); return value.length >= 40 && headings.every((heading) => value.includes(heading)); }
149
+ function structuralGuard(p, phase) {
150
+ const base = p.artifacts;
151
+ const checks = phase === "open" ? [["proposal", meaningful(path.join(base, "proposal.md"), ARTIFACT_POLICY.proposal)]]
152
+ : phase === "design" ? [["proposal", meaningful(path.join(base, "proposal.md"), ARTIFACT_POLICY.proposal)], ["design", meaningful(path.join(base, "design.md"), ARTIFACT_POLICY.design)], ["plan", meaningful(path.join(base, "plan.md"), ARTIFACT_POLICY.plan)]]
153
+ : phase === "build" ? [["plan", meaningful(path.join(base, "plan.md"), ARTIFACT_POLICY.plan)], ["build evidence", meaningful(path.join(base, "verification.md"), ARTIFACT_POLICY.build)]]
154
+ : [["verification", meaningful(path.join(base, "verification.md"), ARTIFACT_POLICY.verification)]];
31
155
  return { pass: checks.every(([, result]) => result), checks };
32
156
  }
33
- function event(p, name, before, after) { fs.appendFileSync(path.join(p.change, "events.jsonl"), `${JSON.stringify({ at: stamp(), event: name, from: before, to: after })}\n`); }
34
-
35
- export function invoke(argv, { cwd = process.cwd() } = {}) {
157
+ function contractSnapshot(p) {
158
+ const hash = crypto.createHash("sha256");
159
+ hash.update("matrix/workflow-contract/v1\\0", "utf8");
160
+ const contents = {};
161
+ for (const name of CONTRACT_FILES) {
162
+ const logical = `artifacts/${name}`;
163
+ let bytes;
164
+ try { bytes = fs.readFileSync(path.join(p.artifacts, name)); }
165
+ catch (error) { if (error.code === "ENOENT") return { missing: true, contents }; return { error, contents }; }
166
+ const pathBytes = Buffer.from(logical, "utf8");
167
+ const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
168
+ const contentLength = Buffer.alloc(8); contentLength.writeBigUInt64BE(BigInt(bytes.length));
169
+ hash.update(pathLength); hash.update(pathBytes); hash.update(contentLength); hash.update(bytes);
170
+ contents[name] = bytes.toString("utf8");
171
+ }
172
+ return { hash: `sha256:${hash.digest("hex")}`, contents };
173
+ }
174
+ function contractState(p, flow) {
175
+ const snapshot = contractSnapshot(p);
176
+ if (snapshot.error) return { status: "unreadable", snapshot };
177
+ if (!flow.approved_contract_hash) return { status: "unapproved", snapshot };
178
+ if (snapshot.missing || snapshot.hash !== flow.approved_contract_hash) return { status: "changed", snapshot };
179
+ return { status: "approved-and-matching", snapshot };
180
+ }
181
+ function contractFields(flow, contract) {
182
+ return {
183
+ contract_status: contract.status,
184
+ current_contract_hash: contract.snapshot.hash ?? null,
185
+ approved_contract_hash: flow.approved_contract_hash,
186
+ contract_approved_revision: flow.contract_approved_revision
187
+ };
188
+ }
189
+ function contractFailure(flow, contract) {
190
+ if (contract.status === "approved-and-matching") return null;
191
+ if (contract.status === "unreadable") return { ...fail("CONTRACT_READ_FAILED", contract.snapshot.error.message), ...contractFields(flow, contract) };
192
+ return { ...fail(contract.status === "unapproved" ? "CONTRACT_UNAPPROVED" : "CONTRACT_CHANGED", `Workflow contract is ${contract.status}.`), ...contractFields(flow, contract) };
193
+ }
194
+ function addFramedEntry(hash, type, logicalPath, content = Buffer.alloc(0)) {
195
+ const typeBytes = Buffer.from(type, "utf8");
196
+ const pathBytes = Buffer.from(logicalPath, "utf8");
197
+ const typeLength = Buffer.alloc(4); typeLength.writeUInt32BE(typeBytes.length);
198
+ const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
199
+ const contentLength = Buffer.alloc(8); contentLength.writeBigUInt64BE(BigInt(content.length));
200
+ hash.update(typeLength); hash.update(typeBytes); hash.update(pathLength); hash.update(pathBytes); hash.update(contentLength); hash.update(content);
201
+ }
202
+ function addFramedFile(hash, entry) {
203
+ const typeBytes = Buffer.from(entry.type, "utf8");
204
+ const pathBytes = Buffer.from(entry.logicalPath, "utf8");
205
+ const typeLength = Buffer.alloc(4); typeLength.writeUInt32BE(typeBytes.length);
206
+ const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
207
+ const contentLength = Buffer.alloc(8); contentLength.writeBigUInt64BE(BigInt(entry.size));
208
+ hash.update(typeLength); hash.update(typeBytes); hash.update(pathLength); hash.update(pathBytes); hash.update(contentLength);
209
+ const descriptor = fs.openSync(entry.source, "r");
210
+ try {
211
+ const opened = fs.fstatSync(descriptor);
212
+ if (!opened.isFile() || opened.size !== entry.size) throw new Error(`Archive file changed while opening: ${entry.logicalPath}.`);
213
+ const chunk = Buffer.allocUnsafe(64 * 1024);
214
+ let offset = 0;
215
+ while (offset < entry.size) {
216
+ const length = fs.readSync(descriptor, chunk, 0, Math.min(chunk.length, entry.size - offset), offset);
217
+ if (length === 0) throw new Error(`Archive file changed while reading: ${entry.logicalPath}.`);
218
+ hash.update(chunk.subarray(0, length));
219
+ offset += length;
220
+ }
221
+ if (fs.readSync(descriptor, chunk, 0, 1, offset) !== 0) throw new Error(`Archive file changed while reading: ${entry.logicalPath}.`);
222
+ } finally {
223
+ fs.closeSync(descriptor);
224
+ }
225
+ }
226
+ function archiveDirectoryEntries(root, relative = "") {
227
+ const entries = [];
228
+ const names = fs.readdirSync(path.join(root, relative)).sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
229
+ for (const name of names) {
230
+ const childRelative = relative ? `${relative}/${name}` : name;
231
+ const child = path.join(root, ...childRelative.split("/"));
232
+ const stat = fs.lstatSync(child);
233
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
234
+ const error = new Error(`Archive change contains unsupported filesystem entry: ${childRelative}.`);
235
+ error.code = "ARCHIVE_ENTRY_UNSUPPORTED";
236
+ throw error;
237
+ }
238
+ if (stat.isDirectory()) {
239
+ entries.push({ type: "directory", logicalPath: `change/${childRelative}`, content: Buffer.alloc(0) });
240
+ entries.push(...archiveDirectoryEntries(root, childRelative));
241
+ } else {
242
+ entries.push({ type: "file", logicalPath: `change/${childRelative}`, source: child, size: stat.size });
243
+ }
244
+ }
245
+ return entries;
246
+ }
247
+ function requireArchiveEntry(file, kind, label) {
248
+ let stat;
249
+ try { stat = fs.lstatSync(file); }
250
+ catch (error) { return fail("ARCHIVE_PREFLIGHT_UNSTABLE", `${label} could not be inspected: ${error.message}`); }
251
+ const valid = kind === "directory" ? stat.isDirectory() : stat.isFile();
252
+ return stat.isSymbolicLink() || !valid ? fail("ARCHIVE_ENTRY_UNSUPPORTED", `${label} must be an ordinary ${kind}.`) : null;
253
+ }
254
+ function captureArchivePreflightOnce(p, id) {
255
+ const target = path.join(p.root, "archive", id);
256
+ if (fs.existsSync(target)) return { failure: fail("ARCHIVE_TARGET_EXISTS", `Archive target already exists: ${id}.`) };
257
+ for (const [file, kind, label] of [[p.root, "directory", "Matrix state root"], [p.change, "directory", "Active change root"], [path.join(p.root, "archive"), "directory", "Archive root"], [p.active, "file", "Active Selection"]]) {
258
+ const failure = requireArchiveEntry(file, kind, label); if (failure) return { failure };
259
+ }
260
+ let selection;
261
+ try { selection = fs.readFileSync(p.active); }
262
+ catch (error) { return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Active Selection could not be captured: ${error.message}`) }; }
263
+ let entries;
264
+ try {
265
+ entries = archiveDirectoryEntries(p.change);
266
+ entries.sort((left, right) => Buffer.compare(Buffer.from(left.logicalPath), Buffer.from(right.logicalPath)));
267
+ }
268
+ catch (error) {
269
+ if (error.code === "ARCHIVE_ENTRY_UNSUPPORTED") return { failure: fail(error.code, error.message) };
270
+ return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Archive inputs changed or could not be captured: ${error.message}`) };
271
+ }
272
+ const hash = crypto.createHash("sha256");
273
+ hash.update("matrix/archive-preflight/v1\\0", "utf8");
274
+ addFramedEntry(hash, "identity", "change-id", Buffer.from(id, "utf8"));
275
+ addFramedEntry(hash, "identity", "archive-target", Buffer.from(`.matrix/archive/${id}`, "utf8"));
276
+ addFramedEntry(hash, "selection", ".matrix/active.json", selection);
277
+ addFramedEntry(hash, "fact", "archive-target-state", Buffer.from("absent", "utf8"));
278
+ let fileCount = 0; let directoryCount = 0; let totalBytes = 0;
279
+ try {
280
+ for (const entry of entries) {
281
+ if (entry.type === "file") {
282
+ addFramedFile(hash, entry);
283
+ fileCount += 1;
284
+ totalBytes += entry.size;
285
+ } else {
286
+ addFramedEntry(hash, entry.type, entry.logicalPath, entry.content);
287
+ directoryCount += 1;
288
+ }
289
+ }
290
+ } catch (error) {
291
+ return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Archive inputs changed or could not be captured: ${error.message}`) };
292
+ }
293
+ return { hash: `sha256:${hash.digest("hex")}`, fileCount, directoryCount, totalBytes };
294
+ }
295
+ function captureArchivePreflight(p, id) {
296
+ const first = captureArchivePreflightOnce(p, id); if (first.failure) return first;
297
+ const second = captureArchivePreflightOnce(p, id); if (second.failure) return second;
298
+ if (first.hash !== second.hash) return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Archive inputs changed while the preflight snapshot was being captured. Retry dry-run.") };
299
+ return second;
300
+ }
301
+ function archivePreflightChanged(expected, current) {
302
+ return {
303
+ ...fail("ARCHIVE_PREFLIGHT_CHANGED", "Archive inputs changed after dry-run. Run dry-run again and review the new preflight."),
304
+ expected_preflight_hash: expected,
305
+ current_preflight_hash: current,
306
+ recovery_command: "matrix workflow archive --dry-run"
307
+ };
308
+ }
309
+ function guard(p, flow, phase) {
310
+ if (!["build", "verify", "archive"].includes(phase)) {
311
+ const structural = structuralGuard(p, phase);
312
+ return { ok: structural.pass, code: structural.pass ? "OK" : "GUARD_FAILED", ...structural };
313
+ }
314
+ const contract = contractState(p, flow); const failure = contractFailure(flow, contract);
315
+ if (failure) return { ok: false, ...failure, checks: [["contract", false]] };
316
+ const structural = structuralGuard(p, phase);
317
+ return { ok: structural.pass, code: structural.pass ? "OK" : "GUARD_FAILED", ...structural, ...contractFields(flow, contract), checks: [["contract", true], ...structural.checks] };
318
+ }
319
+ function eventContent(file, name, before, after, revision, details = {}) {
320
+ const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
321
+ return `${current}${JSON.stringify({ schema: "matrix/event/v1", at: stamp(), event: name, revision, from: before, to: after, ...details })}\n`;
322
+ }
323
+ export function invoke(argv, { cwd = process.cwd(), failAfterOperation = null } = {}) {
36
324
  const [command, ...args] = argv;
37
325
  if (command === "init") {
38
326
  const id = (args.shift() ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
39
327
  const workflow = args.includes("--workflow") ? args[args.indexOf("--workflow") + 1] : "full";
40
328
  const title = args.includes("--title") ? args[args.indexOf("--title") + 1] : null;
329
+ const requestedOrchestration = option(args, "--orchestration");
330
+ if (requestedOrchestration != null && !ORCHESTRATIONS.includes(requestedOrchestration)) return fail("INVALID_INTENT", "orchestration must be prim or arch.");
331
+ const configured = orchestrationConfig(cwd);
332
+ const orchestration = requestedOrchestration ?? configured.defaultOrchestration;
41
333
  if (!id || !title || !["full", "hotfix", "tweak"].includes(workflow)) return fail("INVALID_INTENT", "init requires a valid change id and --title.");
334
+ if (orchestration === "arch") {
335
+ const integrity = archIntegrity(cwd);
336
+ if (!integrity.ok) return { ...integrity, orchestration };
337
+ }
42
338
  if (active(cwd)) return fail("ACTIVE_CHANGE_EXISTS", `Active Matrix change already exists: ${active(cwd)}.`);
43
339
  const p = paths(cwd, id); if (fs.existsSync(p.change) || fs.existsSync(path.join(p.root, "archive", id))) return fail("CHANGE_EXISTS", `Matrix change already exists: ${id}.`);
44
- fs.mkdirSync(p.artifacts, { recursive: true }); fs.mkdirSync(path.join(p.root, "archive"), { recursive: true });
45
- if (!fs.existsSync(path.join(p.root, "config.yaml"))) fs.writeFileSync(path.join(p.root, "config.yaml"), "schema: matrix/config/v1\nauto_transition: true\n");
46
- if (!fs.existsSync(path.join(p.root, ".gitignore"))) fs.writeFileSync(path.join(p.root, ".gitignore"), "active.json\ninstallation.json\nchanges/*/run-state.json\nchanges/*/events.jsonl\nchanges/*/artifacts/handoff.md\n");
47
- const flow = { schema: "matrix/change/v1", id, workflow, status: "active", phase: "open", title, created_at: stamp(), updated_at: stamp(), acceptance: "pending", scope: "pending" };
48
- fs.writeFileSync(p.legacyFlow, ""); writeFlow(p, flow); write(path.join(p.change, "run-state.json"), { schema: "matrix/run/v1", current_step: "open", iteration: 0, pending_gate: null, updated_at: stamp() }); write(p.active, { change_id: id }); event(p, "initialized", null, "open"); return { ok: true, code: "OK", change_id: id, next_skill: skill("open") };
49
- }
50
- const id = active(cwd); if (!id) return fail("NO_ACTIVE_CHANGE", "No active Matrix change. Start with $matrix <request>.");
51
- const p = paths(cwd, id); const flow = readFlow(p); if (!flow) return fail("STATE_INVALID", "active.json does not reference a valid Matrix change.");
52
- if (command === "inspect") { const result = guard(cwd, id, flow.phase); return { ok: true, code: "OK", change_id: id, workflow: flow.workflow, phase: flow.phase, next_skill: skill(flow.phase), guard_pass: result.pass }; }
53
- if (command === "guard") { const phase = args[0] ?? flow.phase; if (!PHASES.includes(phase)) return fail("INVALID_INTENT", "Unknown phase."); const result = guard(cwd, id, phase); return { ok: result.pass, code: result.pass ? "OK" : "GUARD_FAILED", phase, ...result }; }
54
- if (command === "transition") { const to = args[0]; if (NEXT[flow.phase] !== to) return fail("ILLEGAL_TRANSITION", `Illegal transition ${flow.phase} -> ${to}.`); const result = guard(cwd, id, flow.phase); if (!result.pass) return fail("GUARD_FAILED", "Current phase guard failed."); const before = flow.phase; flow.phase = to; flow.updated_at = stamp(); writeFlow(p, flow); event(p, "transition", before, to); return { ok: true, code: "OK", change_id: id, phase: to, next_skill: skill(to) }; }
340
+ const flow = { schema: "matrix/change/v2", id, workflow, orchestration, status: "active", phase: "open", revision: 1, title, created_at: stamp(), updated_at: stamp(), acceptance: "pending", scope: "pending" };
341
+ const operations = [
342
+ ...(!fs.existsSync(path.join(p.root, "archive")) ? [workflowMkdirOperation(cwd, path.join(p.root, "archive"))] : []),
343
+ workflowMkdirOperation(cwd, p.artifacts),
344
+ ...(!fs.existsSync(path.join(p.root, "config.yaml")) ? [workflowWriteOperation(cwd, path.join(p.root, "config.yaml"), `schema: matrix/config/v1\nauto_transition: true\ndefault_orchestration: ${configured.defaultOrchestration}\ninstallation_scope: project\n`)] : []),
345
+ ...(!fs.existsSync(path.join(p.root, ".gitignore")) ? [workflowWriteOperation(cwd, path.join(p.root, ".gitignore"), "active.json\ninstallation.json\nchanges/*/events.jsonl\nchanges/*/artifacts/handoff.md\n")] : []),
346
+ workflowWriteOperation(cwd, p.flow, flowContent(flow)),
347
+ workflowWriteOperation(cwd, path.join(p.change, "events.jsonl"), eventContent(path.join(p.change, "events.jsonl"), "initialized", null, "open", flow.revision, { workflow, orchestration })),
348
+ workflowWriteOperation(cwd, p.active, `${JSON.stringify({ change_id: id }, null, 2)}\n`)
349
+ ];
350
+ return runWorkflowTransaction({
351
+ cwd,
352
+ kind: "init",
353
+ changeId: id,
354
+ operations,
355
+ failAfterOperation,
356
+ result: { ok: true, code: "OK", change_id: id, workflow, orchestration, status: flow.status, phase: flow.phase, revision: flow.revision, next_skill: skill("open") }
357
+ });
358
+ }
359
+ if (command === "doctor") {
360
+ const parsed = { repair: false, transaction: null, strategy: null, lock: null };
361
+ for (let index = 0; index < args.length; index += 1) {
362
+ const token = args[index];
363
+ if (token === "--repair") {
364
+ if (parsed.repair) return fail("INVALID_INTENT", "Workflow doctor options may not be repeated.");
365
+ parsed.repair = true;
366
+ continue;
367
+ }
368
+ const field = ({ "--transaction": "transaction", "--strategy": "strategy", "--lock": "lock" })[token];
369
+ const value = args[index + 1];
370
+ if (!field || parsed[field] || !value || value.startsWith("--")) return fail("INVALID_INTENT", "Workflow doctor accepts --repair with --transaction/--strategy or --lock.");
371
+ parsed[field] = value;
372
+ index += 1;
373
+ }
374
+ return workflowDoctor(cwd, parsed);
375
+ }
376
+ const selected = active(cwd);
377
+ const requested = command === "inspect" ? (option(args, "--change") ?? args.find((arg) => !arg.startsWith("--"))) : null;
378
+ const id = requested ?? selected;
379
+ if (!id) return fail("NO_ACTIVE_CHANGE", "No active Matrix change. Start with $matrix <request>.");
380
+ let p = paths(cwd, id);
381
+ if (command === "inspect" && !fs.existsSync(p.change)) p = paths(cwd, id, true);
382
+ const flow = readFlow(p);
383
+ if (flow?.unsupported_schema) return fail("STATE_VERSION_UNSUPPORTED", `Unsupported Matrix state schema: ${flow.unsupported_schema}.`);
384
+ if (!flow) return fail("STATE_INVALID", "active.json does not reference a valid Matrix change.");
385
+ const integrity = flow.status === "active" && flow.orchestration === "arch" ? archIntegrity(cwd) : { ok: true, code: "OK" };
386
+ if (["transition", "return", "abort"].includes(command) || (command === "archive" && !args.includes("--dry-run"))) {
387
+ const blocked = workflowMutationGate(cwd);
388
+ if (blocked) return blocked;
389
+ }
390
+ if (command === "inspect") {
391
+ const contract = contractState(p, flow);
392
+ const result = guard(p, flow, flow.phase);
393
+ const failure = contract.status === "unreadable" ? contractFailure(flow, contract) : null;
394
+ return { ...(failure ?? (!integrity.ok ? integrity : { ok: true, code: "OK" })), change_id: id, workflow: flow.workflow, orchestration: flow.orchestration, status: flow.status, phase: flow.phase, revision: flow.revision, next_skill: flow.status === "active" ? skill(flow.phase) : null, guard_pass: result.ok, ...contractFields(flow, contract) };
395
+ }
396
+ if (!integrity.ok && command !== "abort") return { ...integrity, change_id: id, workflow: flow.workflow, orchestration: flow.orchestration, status: flow.status, phase: flow.phase, revision: flow.revision, next_skill: skill(flow.phase) };
397
+ if (command === "guard") { const phase = args[0] ?? flow.phase; if (!PHASES.includes(phase)) return fail("INVALID_INTENT", "Unknown phase."); return { phase, ...guard(p, flow, phase) }; }
398
+ if (command === "transition") {
399
+ const to = args[0];
400
+ if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
401
+ if (NEXT[flow.phase] !== to) return fail("ILLEGAL_TRANSITION", `Illegal transition ${flow.phase} -> ${to}.`);
402
+ const result = guard(p, flow, flow.phase); if (!result.ok) return { ...result, message: result.message ?? "Current phase guard failed." };
403
+ const before = flow.phase;
404
+ const approval = to === "build" ? contractState(p, flow) : null;
405
+ if (approval && approval.status !== "approved-and-matching" && approval.status !== "unapproved") return { ...contractFailure(flow, approval), change_id: id };
406
+ if (to === "build" && approval.snapshot.missing) return { ...fail("GUARD_FAILED", "Design contract artifacts are incomplete."), change_id: id };
407
+ const next = { ...flow, schema: "matrix/change/v2", phase: to, revision: flow.revision + 1, updated_at: stamp(), ...(to === "build" ? { approved_contract_hash: approval.snapshot.hash, contract_approved_revision: flow.revision + 1 } : {}) };
408
+ const details = to === "build" ? { approved_contract_hash: next.approved_contract_hash, contract_approved_revision: next.contract_approved_revision } : {};
409
+ const resultFields = { ok: true, code: "OK", change_id: id, workflow: next.workflow, orchestration: next.orchestration, status: next.status, phase: to, revision: next.revision, next_skill: skill(to), ...contractFields(next, contractState(p, next)) };
410
+ return runWorkflowTransaction({
411
+ cwd,
412
+ kind: "transition",
413
+ changeId: id,
414
+ failAfterOperation,
415
+ operations: [
416
+ workflowWriteOperation(cwd, p.flow, flowContent(next)),
417
+ workflowWriteOperation(cwd, path.join(p.change, "events.jsonl"), eventContent(path.join(p.change, "events.jsonl"), "transition", before, to, next.revision, details))
418
+ ],
419
+ result: resultFields
420
+ });
421
+ }
422
+ if (command === "return") {
423
+ const to = args[0];
424
+ const reason = option(args, "--reason");
425
+ if (!reason) return fail("RETURN_REASON_REQUIRED", "Return requires --reason.");
426
+ if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
427
+ if (!RETURN_REASONS.has(reason)) return fail("RETURN_REASON_UNSUPPORTED", `Unsupported Return reason: ${reason}.`);
428
+ if (!RETURNS.some((entry) => entry.from === flow.phase && entry.to === to && entry.reason === reason)) return fail("ILLEGAL_RETURN", `Illegal Return ${flow.phase} -> ${to} for reason ${reason}.`);
429
+ if (flow.phase === "verify" && to === "build") { const contract = contractState(p, flow); const failure = contractFailure(flow, contract); if (failure) return failure; }
430
+ const verification = path.join(p.artifacts, "verification.md");
431
+ let destination = null;
432
+ if (fs.existsSync(verification)) {
433
+ const history = path.join(p.artifacts, "evidence-history");
434
+ destination = path.join(history, `revision-${flow.revision}-verification.md`);
435
+ if (fs.existsSync(destination)) return fail("EVIDENCE_INVALIDATION_FAILED", `Evidence history already exists for revision ${flow.revision}.`);
436
+ }
437
+ const next = { ...flow, schema: "matrix/change/v2", phase: to, revision: flow.revision + 1, updated_at: stamp(), ...(to === "design" ? { approved_contract_hash: null, contract_approved_revision: null } : {}) };
438
+ return runWorkflowTransaction({
439
+ cwd,
440
+ kind: "return",
441
+ changeId: id,
442
+ failAfterOperation,
443
+ operations: [
444
+ ...(destination ? [workflowMoveOperation(cwd, verification, destination)] : []),
445
+ workflowWriteOperation(cwd, p.flow, flowContent(next)),
446
+ workflowWriteOperation(cwd, path.join(p.change, "events.jsonl"), eventContent(path.join(p.change, "events.jsonl"), "returned", flow.phase, to, next.revision, { reason, invalidated_evidence: INVALIDATED_EVIDENCE }))
447
+ ],
448
+ result: { ok: true, code: "OK", change_id: id, workflow: next.workflow, orchestration: next.orchestration, status: next.status, phase: to, revision: next.revision, next_skill: skill(to), event: "returned", invalidated_evidence: INVALIDATED_EVIDENCE, ...contractFields(next, contractState(p, next)) }
449
+ });
450
+ }
451
+ if (command === "abort") {
452
+ const reason = option(args, "--reason");
453
+ const note = option(args, "--note");
454
+ if (!reason) return fail("ABORT_REASON_REQUIRED", "Abort requires --reason.");
455
+ if (!ABORT_REASONS.has(reason)) return fail("ABORT_REASON_UNSUPPORTED", `Unsupported Abort reason: ${reason}.`);
456
+ if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
457
+ const next = { ...flow, schema: "matrix/change/v2", status: "aborted", revision: flow.revision + 1, updated_at: stamp() };
458
+ const target = path.join(p.root, "archive", id);
459
+ if (fs.existsSync(target)) return fail("ARCHIVE_TARGET_EXISTS", `Archive target already exists: ${id}.`);
460
+ const sourceEvents = path.join(p.change, "events.jsonl");
461
+ const targetFlow = path.join(target, "matrix.yaml");
462
+ const targetEvents = path.join(target, "events.jsonl");
463
+ return runWorkflowTransaction({
464
+ cwd,
465
+ kind: "abort",
466
+ changeId: id,
467
+ failAfterOperation,
468
+ operations: [
469
+ workflowMoveOperation(cwd, p.change, target),
470
+ workflowWriteOperation(cwd, targetFlow, flowContent(next), { before: captureWorkflowEntry(p.flow) }),
471
+ workflowWriteOperation(cwd, targetEvents, eventContent(sourceEvents, "aborted", flow.phase, flow.phase, next.revision, { reason, ...(note ? { note } : {}) }), { before: captureWorkflowEntry(sourceEvents) }),
472
+ workflowRemoveOperation(cwd, p.active)
473
+ ],
474
+ result: { ok: true, code: "OK", change_id: id, workflow: next.workflow, orchestration: next.orchestration, status: next.status, phase: next.phase, revision: next.revision, next_skill: null, event: "aborted" }
475
+ });
476
+ }
55
477
  if (command === "archive") {
478
+ if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
56
479
  if (flow.phase !== "archive") return fail("ILLEGAL_TRANSITION", "Only an archive-phase change can be archived.");
57
- const result = guard(cwd, id, "archive"); if (!result.pass) return fail("GUARD_FAILED", "Archive guard failed.");
58
- flow.status = "archived"; flow.updated_at = stamp(); writeFlow(p, flow); event(p, "archived", "archive", "archived");
59
- const target = path.join(p.root, "archive", id); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.renameSync(p.change, target); fs.rmSync(p.active);
60
- return { ok: true, code: "OK", archived: id };
61
- }
62
- if (command === "handoff") { const destination = path.join(p.artifacts, "handoff.md"); fs.writeFileSync(destination, `# Matrix handoff: ${id}\n\n- Workflow: ${flow.workflow}\n- Current phase: ${flow.phase}\n- Resume with: \`${skill(flow.phase)}\`\n- Guard passes: ${guard(cwd, id, flow.phase).pass}\n`); return { ok: true, code: "OK", path: destination }; }
63
- if (command === "export") {
64
- if (flow.phase !== "design") return fail("ILLEGAL_PHASE", "Export requires the design phase.");
65
- if (!guard(cwd, id, "design").pass) return fail("GUARD_FAILED", "Design guard failed.");
66
- const taskId = args.includes("--task-id") ? args[args.indexOf("--task-id") + 1] : args[0];
67
- const target = args.includes("--target") ? args[args.indexOf("--target") + 1] : "generic";
68
- const applyBoard = args.includes("--apply-fnsec-board");
69
- if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(taskId ?? "") || !["generic", "fnsec"].includes(target)) return fail("INVALID_INTENT", "export requires a task id and target generic or fnsec.");
70
- const artifacts = ["proposal.md", "design.md", "plan.md"].map((name) => path.join(p.artifacts, name));
71
- if (artifacts.some((file) => !fs.existsSync(file))) return fail("ARTIFACT_MISSING", "Export requires proposal.md, design.md, and plan.md.");
72
- const [proposal, design, plan] = artifacts.map((file) => fs.readFileSync(file, "utf8"));
73
- if (target === "generic") {
74
- const destination = path.join(p.artifacts, "claude-task.md"); const result = path.join(p.artifacts, "claude-result.md");
75
- fs.writeFileSync(destination, taskContent(id, taskId, proposal, design, plan, path.relative(cwd, result).replaceAll("\\", "/")));
76
- return { ok: true, code: "OK", path: destination };
480
+ const dryRun = args.includes("--dry-run");
481
+ const expected = option(args, "--expect-preflight");
482
+ if ((dryRun && expected) || args.some((arg) => !["--dry-run", "--expect-preflight", expected].includes(arg))) {
483
+ return { ...fail("ARCHIVE_PREFLIGHT_INVALID", "Archive accepts either --dry-run or --expect-preflight <sha256>, not both or additional arguments."), recovery_command: "matrix workflow archive --dry-run" };
77
484
  }
78
- const tasks = path.join(cwd, "docs", "tasks"); const board = path.join(tasks, "active.md");
79
- if (!exists(tasks) || !exists(board)) return fail("FNSEC_TARGET_UNAVAILABLE", "FnSec target requires docs/tasks/active.md in the current project.");
80
- const destination = path.join(tasks, `claude-task-${taskId}.md`); const result = path.join(tasks, `claude-result-${taskId}.md`);
81
- if (exists(destination) || exists(result)) return fail("FNSEC_TASK_EXISTS", `FnSec task or result path already exists for ${taskId}.`);
82
- const currentBoard = fs.readFileSync(board, "utf8");
83
- if (applyBoard && !currentBoard.includes("NO_ACTIVE_OBJECTIVE")) return fail("FNSEC_BOARD_OCCUPIED", "FnSec active.md already has an objective; refused to overwrite it.");
84
- fs.writeFileSync(destination, taskContent(id, taskId, proposal, design, plan, path.relative(cwd, result).replaceAll("\\", "/")));
85
- if (applyBoard) fs.writeFileSync(board, activeBoard(taskId, path.relative(cwd, destination).replaceAll("\\", "/"), path.relative(cwd, result).replaceAll("\\", "/"), id));
86
- return { ok: true, code: "OK", path: destination, board: applyBoard ? board : null };
87
- }
88
- return fail("INVALID_INTENT", `Unknown workflow command: ${command}.`);
89
- }
90
-
91
- if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1].replaceAll("\\", "/")}`).href) {
92
- const result = invoke(process.argv.slice(2));
93
- console.log(JSON.stringify(result));
94
- if (!result.ok) process.exitCode = 2;
95
- }
485
+ if (!dryRun && !expected) return { ...fail("ARCHIVE_PREFLIGHT_REQUIRED", "Archive requires an explicit dry-run followed by --expect-preflight <sha256>."), recovery_command: "matrix workflow archive --dry-run" };
486
+ if (expected && !/^sha256:[0-9a-f]{64}$/.test(expected)) return { ...fail("ARCHIVE_PREFLIGHT_INVALID", "Expected preflight must be sha256 followed by 64 lowercase hexadecimal characters."), recovery_command: "matrix workflow archive --dry-run" };
487
+ if (dryRun) {
488
+ const result = guard(p, flow, "archive"); if (!result.ok) return { ...result, message: result.message ?? "Archive guard failed." };
489
+ const captured = captureArchivePreflight(p, id); if (captured.failure) return captured.failure;
490
+ const currentFlow = readFlow(p); if (!currentFlow) return fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Canonical workflow state changed during dry-run.");
491
+ const finalGuard = guard(p, currentFlow, "archive"); if (!finalGuard.ok) return { ...finalGuard, message: finalGuard.message ?? "Archive guard failed." };
492
+ const confirmed = captureArchivePreflight(p, id); if (confirmed.failure) return confirmed.failure;
493
+ if (captured.hash !== confirmed.hash) return fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Archive inputs changed during dry-run. Retry dry-run.");
494
+ return {
495
+ ok: true, code: "OK", change_id: id, workflow: currentFlow.workflow, orchestration: currentFlow.orchestration, status: currentFlow.status, phase: currentFlow.phase, revision: currentFlow.revision,
496
+ preflight_hash: confirmed.hash,
497
+ effect_summary: { target: `.matrix/archive/${id}`, files: confirmed.fileCount, directories: confirmed.directoryCount, content_bytes: confirmed.totalBytes },
498
+ commit_command: `matrix workflow archive --expect-preflight ${confirmed.hash}`,
499
+ ...contractFields(currentFlow, contractState(p, currentFlow))
500
+ };
501
+ }
502
+ const captured = captureArchivePreflight(p, id);
503
+ if (captured.failure) return captured.failure;
504
+ if (captured.hash !== expected) return archivePreflightChanged(expected, captured.hash);
505
+ const currentFlow = readFlow(p);
506
+ if (!currentFlow || currentFlow.status !== "active" || currentFlow.phase !== "archive") return archivePreflightChanged(expected, captured.hash);
507
+ const result = guard(p, currentFlow, "archive");
508
+ if (!result.ok) return { ...result, message: result.message ?? "Archive guard failed." };
509
+ const confirmed = captureArchivePreflight(p, id);
510
+ if (confirmed.failure) return confirmed.failure;
511
+ if (confirmed.hash !== expected) return archivePreflightChanged(expected, confirmed.hash);
512
+ const contract = contractState(p, currentFlow);
513
+ const next = { ...currentFlow, schema: "matrix/change/v2", status: "archived", revision: currentFlow.revision + 1, updated_at: stamp() };
514
+ const target = path.join(p.root, "archive", id);
515
+ const sourceEvents = path.join(p.change, "events.jsonl");
516
+ const targetFlow = path.join(target, "matrix.yaml");
517
+ const targetEvents = path.join(target, "events.jsonl");
518
+ return runWorkflowTransaction({
519
+ cwd,
520
+ kind: "archive",
521
+ changeId: id,
522
+ failAfterOperation,
523
+ operations: [
524
+ workflowMoveOperation(cwd, p.change, target),
525
+ workflowWriteOperation(cwd, targetFlow, flowContent(next), { before: captureWorkflowEntry(p.flow) }),
526
+ workflowWriteOperation(cwd, targetEvents, eventContent(sourceEvents, "archived", "archive", "archive", next.revision, { preflight_hash: expected }), { before: captureWorkflowEntry(sourceEvents) }),
527
+ workflowRemoveOperation(cwd, p.active)
528
+ ],
529
+ result: {
530
+ ok: true, code: "OK", archived: id, change_id: id, workflow: next.workflow, orchestration: next.orchestration, status: next.status, phase: next.phase, revision: next.revision, next_skill: null, event: "archived",
531
+ preflight_hash: expected,
532
+ effect_summary: { target: `.matrix/archive/${id}`, files: confirmed.fileCount, directories: confirmed.directoryCount, content_bytes: confirmed.totalBytes },
533
+ ...contractFields(next, contract)
534
+ }
535
+ });
536
+ }
537
+ if (command === "handoff") { const destination = path.join(p.artifacts, "handoff.md"); fs.writeFileSync(destination, `# Matrix handoff: ${id}\n\n- Workflow: ${flow.workflow}\n- Current phase: ${flow.phase}\n- Resume with: \`${skill(flow.phase)}\`\n- Guard passes: ${guard(p, flow, flow.phase).ok}\n`); return { ok: true, code: "OK", path: destination }; }
538
+ if (command === "export") {
539
+ if (flow.phase !== "build") return fail("ILLEGAL_PHASE", "Export requires the build phase.");
540
+ const contract = contractState(p, flow); const failure = contractFailure(flow, contract); if (failure) return failure;
541
+ const taskId = args.includes("--task-id") ? args[args.indexOf("--task-id") + 1] : args[0];
542
+ const target = args.includes("--target") ? args[args.indexOf("--target") + 1] : "generic";
543
+ const applyBoard = args.includes("--apply-fnsec-board");
544
+ if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(taskId ?? "") || !["generic", "fnsec"].includes(target)) return fail("INVALID_INTENT", "export requires a task id and target generic or fnsec.");
545
+ const { proposal, design, plan } = contract.snapshot.contents;
546
+ if (target === "generic") {
547
+ const destination = path.join(p.artifacts, "claude-task.md"); const result = path.join(p.artifacts, "claude-result.md");
548
+ fs.writeFileSync(destination, taskContent(id, taskId, proposal, design, plan, path.relative(cwd, result).replaceAll("\\", "/"), flow.approved_contract_hash, flow.contract_approved_revision));
549
+ return { ok: true, code: "OK", path: destination };
550
+ }
551
+ const tasks = path.join(cwd, "docs", "tasks"); const board = path.join(tasks, "active.md");
552
+ if (!exists(tasks) || !exists(board)) return fail("FNSEC_TARGET_UNAVAILABLE", "FnSec target requires docs/tasks/active.md in the current project.");
553
+ const destination = path.join(tasks, `claude-task-${taskId}.md`); const result = path.join(tasks, `claude-result-${taskId}.md`);
554
+ if (exists(destination) || exists(result)) return fail("FNSEC_TASK_EXISTS", `FnSec task or result path already exists for ${taskId}.`);
555
+ const currentBoard = fs.readFileSync(board, "utf8");
556
+ if (applyBoard && !currentBoard.includes("NO_ACTIVE_OBJECTIVE")) return fail("FNSEC_BOARD_OCCUPIED", "FnSec active.md already has an objective; refused to overwrite it.");
557
+ fs.writeFileSync(destination, taskContent(id, taskId, proposal, design, plan, path.relative(cwd, result).replaceAll("\\", "/"), flow.approved_contract_hash, flow.contract_approved_revision));
558
+ if (applyBoard) fs.writeFileSync(board, activeBoard(taskId, path.relative(cwd, destination).replaceAll("\\", "/"), path.relative(cwd, result).replaceAll("\\", "/"), id));
559
+ return { ok: true, code: "OK", path: destination, board: applyBoard ? board : null };
560
+ }
561
+ return fail("INVALID_INTENT", `Unknown workflow command: ${command}.`);
562
+ }
563
+
564
+ if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1].replaceAll("\\", "/")}`).href) {
565
+ const result = invoke(process.argv.slice(2));
566
+ console.log(JSON.stringify(result));
567
+ if (!result.ok) process.exitCode = 2;
568
+ }