@rosenfan/matrix 0.1.2 → 0.1.4

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 +59 -62
  2. package/.claude/skills/matrix/scripts/matrix_claude.py +11 -11
  3. package/.claude/skills/matrix/scripts/matrix_state.py +53 -53
  4. package/.claude/skills/matrix-archive/SKILL.md +36 -33
  5. package/.claude/skills/matrix-build/SKILL.md +28 -23
  6. package/.claude/skills/matrix-claude/SKILL.md +13 -11
  7. package/.claude/skills/matrix-design/SKILL.md +32 -26
  8. package/.claude/skills/matrix-hotfix/SKILL.md +1 -1
  9. package/.claude/skills/matrix-open/SKILL.md +27 -15
  10. package/.claude/skills/matrix-status/SKILL.md +5 -5
  11. package/.claude/skills/matrix-tweak/SKILL.md +1 -1
  12. package/.claude/skills/matrix-verify/SKILL.md +35 -17
  13. package/README.md +131 -96
  14. package/README.zh-CN.md +127 -92
  15. package/assets/skills-zh-CN/matrix/SKILL.md +75 -72
  16. package/assets/skills-zh-CN/matrix-archive/SKILL.md +36 -33
  17. package/assets/skills-zh-CN/matrix-build/SKILL.md +41 -37
  18. package/assets/skills-zh-CN/matrix-claude/SKILL.md +33 -31
  19. package/assets/skills-zh-CN/matrix-design/SKILL.md +13 -7
  20. package/assets/skills-zh-CN/matrix-hotfix/SKILL.md +1 -1
  21. package/assets/skills-zh-CN/matrix-open/SKILL.md +18 -6
  22. package/assets/skills-zh-CN/matrix-status/SKILL.md +5 -5
  23. package/assets/skills-zh-CN/matrix-tweak/SKILL.md +1 -1
  24. package/assets/skills-zh-CN/matrix-verify/SKILL.md +27 -9
  25. package/package.json +2 -2
  26. package/src/catalog.js +29 -18
  27. package/src/cli.js +150 -39
  28. package/src/distribution.js +268 -114
  29. package/src/installer.js +10 -10
  30. package/src/matt-catalog.mjs +67 -0
  31. package/src/matt.js +115 -33
  32. package/src/messages.js +18 -14
  33. package/src/runtime-resolver.js +76 -0
  34. package/src/self-update.js +81 -0
  35. package/src/ui.js +1 -1
  36. package/src/workflow-transaction.js +622 -618
  37. package/src/workflow.js +949 -464
package/src/workflow.js CHANGED
@@ -1,551 +1,1036 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
3
  import crypto from "node:crypto";
4
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";
5
+ import { spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { MATT_AUTOMATIC_SKILLS, MATT_CATALOG_DIGEST, MATT_COMPATIBILITY, MATT_CONTENT_HASHES } from "./matt-catalog.mjs";
8
+ import {
9
+ captureWorkflowEntry,
10
+ runWorkflowTransaction,
11
+ workflowDoctor,
12
+ workflowMkdirOperation,
13
+ workflowMutationGate,
14
+ workflowMoveOperation,
15
+ workflowRemoveOperation,
16
+ workflowWriteOperation
17
+ } from "./workflow-transaction.js";
15
18
 
16
- const PHASES = ["open", "design", "build", "verify", "archive"];
17
- const NEXT = { open: "design", design: "build", build: "verify", verify: "archive" };
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"]);
19
+ const PHASES = ["open", "design", "build", "verify", "archive"];
20
+ const FULL_PROFILE = {
21
+ id: "full",
22
+ next: { open: "design", design: "build", build: "verify", verify: "archive" },
23
+ contractFiles: ["proposal.md", "design.md", "plan.md"]
24
+ };
25
+ const LIGHTWEIGHT_PROFILE = {
26
+ id: "lightweight",
27
+ next: { open: "build", build: "verify", verify: "archive" },
28
+ contractFiles: ["proposal.md"]
29
+ };
30
+ const FULL_RETURNS = [
31
+ { from: "build", to: "design", reason: "design-gap" },
32
+ { from: "verify", to: "build", reason: "verification-failed" },
33
+ { from: "verify", to: "design", reason: "acceptance-or-design-gap" },
34
+ { from: "archive", to: "design", reason: "acceptance-or-design-gap" }
35
+ ];
36
+ const LIGHTWEIGHT_RETURNS = [
37
+ { from: "build", to: "design", reason: "design-gap", upgrade: true },
38
+ { from: "verify", to: "build", reason: "verification-failed" },
39
+ { from: "verify", to: "design", reason: "acceptance-or-design-gap", upgrade: true },
40
+ { from: "archive", to: "design", reason: "acceptance-or-design-gap", upgrade: true }
41
+ ];
42
+ const RETURN_REASONS = new Set([...FULL_RETURNS, ...LIGHTWEIGHT_RETURNS].map(({ reason }) => reason));
43
+ const INVALIDATED_EVIDENCE = ["build", "test", "review", "archive"];
44
+ const ABORT_REASONS = new Set(["requirement-cancelled", "superseded", "no-longer-valuable", "blocked", "other"]);
27
45
  const ORCHESTRATIONS = ["prim", "arch"];
28
- const ARCH_SKILLS = [
46
+ const quoteCommandPath = (value) => process.platform === "win32" ? `"${value}"` : `'${value.replaceAll("'", "'\\''")}'`;
47
+ const workflowCommand = (argumentsText) => `node ${quoteCommandPath(fileURLToPath(import.meta.url))} ${argumentsText}`;
48
+ const LEGACY_ARCH_SKILLS = [
29
49
  "grilling", "domain-modeling", "research", "wayfinder", "prototype",
30
50
  "codebase-design", "tdd", "diagnosing-bugs", "resolving-merge-conflicts", "code-review"
31
51
  ];
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"];
52
+ const ARTIFACT_POLICY = {
53
+ proposal: ["## Goal", "## Scope", "## Non-goals", "## Acceptance", "## Risks"],
54
+ design: ["## Decisions", "## Boundaries", "## Test seams", "## Risks"],
55
+ plan: ["## Steps", "## Validation", "## Stop conditions"],
56
+ build: ["## Build evidence"],
57
+ verification: ["## Test evidence", "## Review evidence"]
58
+ };
59
+ const LIGHTWEIGHT_COMMON_PROPOSAL = ["## Goal", "## Scope", "## Non-goals", "## Approach", "## Acceptance", "## Validation", "## Risks", "## Upgrade conditions"];
60
+ const LIGHTWEIGHT_POLICY = {
61
+ hotfix: {
62
+ proposal: [...LIGHTWEIGHT_COMMON_PROPOSAL, "## Expected behavior", "## Actual behavior", "## Reproduction"],
63
+ build: ["## Build evidence", "## Reproduction evidence", "## Root cause"],
64
+ verification: ["## Test evidence", "## Regression evidence", "## Review evidence"]
65
+ },
66
+ tweak: {
67
+ proposal: [...LIGHTWEIGHT_COMMON_PROPOSAL, "## Current behavior", "## Preserved behavior", "## Diff boundary"],
68
+ build: ["## Build evidence", "## Scope evidence"],
69
+ verification: ["## Test evidence", "## Scope review evidence", "## Review evidence"]
70
+ }
71
+ };
72
+ const WORKSPACE_BASELINE_FILE = "workspace-baseline.json";
73
+ const WORKSPACE_EXCLUDES = new Set([".git", ".matrix", ".comet", "node_modules"]);
74
+ const WORKSPACE_MAX_FILES = 50000;
75
+ const WORKSPACE_MAX_BYTES = 512 * 1024 * 1024;
76
+ const WORKSPACE_MAX_DURATION_MS = 60000;
77
+ const REVIEW_RECEIPT_SCHEMA = "matrix/review-receipt/v1";
78
+ const REVIEW_SOURCES = new Set(["code-review", "matrix-fallback"]);
79
+ const REVIEW_OUTCOMES = new Set(["passed", "failed"]);
80
+ const REVIEW_FALLBACK_REASONS = new Set(["git-baseline-unavailable", "empty-head-diff", "uncommitted-worktree", "capability-execution-failed"]);
40
81
  const skill = (phase) => `$matrix-${phase}`;
41
82
  const stamp = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
42
- const rootFor = (cwd) => path.join(cwd, ".matrix");
43
- const exists = (file) => fs.existsSync(file);
44
- const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
83
+ const rootFor = (cwd) => path.join(cwd, ".matrix");
84
+ const exists = (file) => fs.existsSync(file);
85
+ const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
45
86
 
46
- function fail(code, message) { return { ok: false, code, message }; }
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
- }
87
+ function fail(code, message) { return { ok: false, code, message }; }
88
+ function option(args, name) { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : null; }
89
+ function flatYaml(file) {
90
+ try {
91
+ return Object.fromEntries(fs.readFileSync(file, "utf8").split(/\r?\n/).filter((line) => line.includes(":") && !line.startsWith(" ")).map((line) => {
92
+ const [key, ...value] = line.split(":");
93
+ return [key.trim(), value.join(":").trim().replace(/^"|"$/g, "")];
94
+ }));
95
+ } catch { return {}; }
96
+ }
97
+ function excludedWorkspacePath(relative) {
98
+ const first = relative.replaceAll("\\", "/").split("/")[0];
99
+ return WORKSPACE_EXCLUDES.has(first);
100
+ }
101
+ function workspaceFiles(cwd) {
102
+ const git = spawnSync("git", ["-C", cwd, "ls-files", "-co", "--exclude-standard", "-z"], {
103
+ encoding: "buffer",
104
+ windowsHide: true,
105
+ maxBuffer: 64 * 1024 * 1024
106
+ });
107
+ if (!git.error && git.status === 0) {
108
+ return {
109
+ provider: "git",
110
+ files: [...new Set(git.stdout.toString("utf8").split("\0").filter(Boolean))]
111
+ .map((relative) => relative.replaceAll("\\", "/"))
112
+ .filter((relative) => !excludedWorkspacePath(relative))
113
+ .sort()
114
+ };
115
+ }
116
+ const files = [];
117
+ const visit = (directory, prefix = "") => {
118
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
119
+ const relative = path.join(prefix, entry.name).replaceAll("\\", "/");
120
+ if (!prefix && WORKSPACE_EXCLUDES.has(entry.name)) continue;
121
+ if (entry.isDirectory()) visit(path.join(directory, entry.name), relative);
122
+ else files.push(relative);
123
+ if (files.length > WORKSPACE_MAX_FILES) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_FILES} files.`);
124
+ }
125
+ };
126
+ visit(cwd);
127
+ return { provider: "physical", files };
128
+ }
129
+ function captureWorkspaceBaseline(cwd) {
130
+ const startedAt = Date.now();
131
+ const selection = workspaceFiles(cwd);
132
+ const hash = crypto.createHash("sha256");
133
+ hash.update("matrix/workspace-baseline/v1\0", "utf8");
134
+ let totalBytes = 0;
135
+ let fileCount = 0;
136
+ for (const relative of selection.files) {
137
+ if (Date.now() - startedAt > WORKSPACE_MAX_DURATION_MS) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_DURATION_MS} milliseconds.`);
138
+ if (fileCount >= WORKSPACE_MAX_FILES) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_FILES} files.`);
139
+ const absolute = path.join(cwd, relative);
140
+ const pathBytes = Buffer.from(relative, "utf8");
141
+ const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
142
+ hash.update(pathLength); hash.update(pathBytes);
143
+ let stat;
144
+ try { stat = fs.lstatSync(absolute); }
145
+ catch (error) {
146
+ if (error.code !== "ENOENT") throw error;
147
+ hash.update("missing\0", "utf8");
148
+ fileCount += 1;
149
+ continue;
150
+ }
151
+ if (stat.isSymbolicLink()) {
152
+ const target = Buffer.from(fs.readlinkSync(absolute), "utf8");
153
+ hash.update("symlink\0", "utf8"); hash.update(target);
154
+ totalBytes += target.length;
155
+ } else if (stat.isFile()) {
156
+ const bytes = fs.readFileSync(absolute);
157
+ totalBytes += bytes.length;
158
+ if (totalBytes > WORKSPACE_MAX_BYTES) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_BYTES} bytes.`);
159
+ hash.update("file\0", "utf8"); hash.update(bytes);
160
+ } else {
161
+ hash.update("special\0", "utf8");
162
+ }
163
+ fileCount += 1;
164
+ }
165
+ return {
166
+ schema: "matrix/workspace-baseline/v1",
167
+ provider: selection.provider,
168
+ hash: `sha256:${hash.digest("hex")}`,
169
+ file_count: fileCount,
170
+ content_bytes: totalBytes
171
+ };
172
+ }
173
+ function workspaceOrderGuard(cwd, p) {
174
+ let baseline;
175
+ try { baseline = read(p.workspaceBaseline); }
176
+ catch (error) {
177
+ return fail("WORKSPACE_BASELINE_MISSING", `Lightweight workflow baseline is unavailable: ${error.message}`);
178
+ }
179
+ let current;
180
+ try { current = captureWorkspaceBaseline(cwd); }
181
+ catch (error) {
182
+ return fail("WORKSPACE_INSPECTION_FAILED", `Could not inspect the lightweight workflow boundary: ${error.message}`);
183
+ }
184
+ if (baseline.schema !== "matrix/workspace-baseline/v1" || !/^sha256:[0-9a-f]{64}$/.test(baseline.hash ?? "")) {
185
+ return fail("WORKSPACE_BASELINE_INVALID", "Lightweight workflow baseline is invalid.");
186
+ }
187
+ if (current.hash !== baseline.hash) {
188
+ return {
189
+ ...fail("PREMATURE_IMPLEMENTATION", "Project files changed after lightweight initialization and before Build approval."),
190
+ baseline_hash: baseline.hash,
191
+ current_hash: current.hash
192
+ };
193
+ }
194
+ return { ok: true, code: "OK", baseline_hash: baseline.hash, current_hash: current.hash };
195
+ }
196
+ function hashDirectory(directory) {
197
+ const hash = crypto.createHash("sha256");
198
+ const files = [];
199
+ const visit = (current, relative = "") => {
200
+ for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
201
+ const child = path.join(current, entry.name);
202
+ const childRelative = path.join(relative, entry.name).replaceAll("\\", "/");
203
+ if (entry.isDirectory()) visit(child, childRelative);
204
+ else if (entry.isFile()) files.push([childRelative, fs.readFileSync(child)]);
205
+ }
206
+ };
207
+ visit(directory);
208
+ for (const [relative, contents] of files.sort(([left], [right]) => left.localeCompare(right))) {
209
+ hash.update(`${relative}\0`);
210
+ hash.update(contents);
211
+ }
212
+ return hash.digest("hex");
213
+ }
74
214
  function orchestrationConfig(cwd) {
75
215
  const config = flatYaml(path.join(rootFor(cwd), "config.yaml"));
76
216
  const scope = config.installation_scope === "global" ? "global" : "project";
77
217
  const manifestFile = scope === "global" ? path.join(os.homedir(), ".matrix", "installation.json") : path.join(rootFor(cwd), "installation.json");
218
+ const mattReceiptFile = path.join(rootFor(cwd), "matt-installation.json");
78
219
  let manifest = null;
220
+ let mattReceipt = null;
79
221
  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 };
222
+ try { mattReceipt = JSON.parse(fs.readFileSync(mattReceiptFile, "utf8")); } catch {}
223
+ const defaultOrchestration = ORCHESTRATIONS.includes(config.default_orchestration) ? config.default_orchestration
224
+ : ORCHESTRATIONS.includes(manifest?.orchestration?.default) ? manifest.orchestration.default : "prim";
225
+ const artifactLanguage = ["en", "zh-CN"].includes(config.language) ? config.language
226
+ : ["en", "zh-CN"].includes(manifest?.language) ? manifest.language : "en";
227
+ return { config, scope, manifestFile, manifest, mattReceiptFile, mattReceipt, defaultOrchestration, artifactLanguage };
83
228
  }
84
- function archIntegrity(cwd) {
85
- const { manifest, manifestFile } = orchestrationConfig(cwd);
229
+ function archIntegrity(cwd, flow = null, contentHashes = MATT_CONTENT_HASHES) {
230
+ const { manifest, manifestFile, mattReceiptFile, mattReceipt } = orchestrationConfig(cwd);
86
231
  const platforms = Object.entries(manifest?.platforms ?? {});
87
232
  if (!manifest?.orchestration?.available?.includes("arch") || !platforms.length) {
88
233
  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
234
  }
235
+ const legacy = Boolean(flow && !flow.arch_catalog_digest);
236
+ if (!legacy && (mattReceipt?.compatibility?.release !== MATT_COMPATIBILITY.release
237
+ || mattReceipt?.compatibility?.commit !== MATT_COMPATIBILITY.commit
238
+ || mattReceipt?.catalogDigest !== MATT_CATALOG_DIGEST)) {
239
+ return { ok: false, code: "ARCH_INSTALLATION_INCOMPLETE", message: "Arch requires the project-local Matt compatibility receipt for this Matrix release.", manifest: mattReceiptFile, recovery_command: "matrix init --with-mattpocock" };
240
+ }
241
+ if (!legacy && flow && (flow.arch_catalog_digest !== MATT_CATALOG_DIGEST || flow.matt_release !== MATT_COMPATIBILITY.release)) {
242
+ return { ok: false, code: "ARCH_INSTALLATION_INCOMPLETE", message: "The active Arch catalog identity does not match this Runtime.", manifest: mattReceiptFile, recovery_command: "matrix update" };
243
+ }
90
244
  const findings = [];
91
245
  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"))) {
246
+ for (const skill of legacy ? LEGACY_ARCH_SKILLS : MATT_AUTOMATIC_SKILLS) {
247
+ const installed = legacy ? record?.matt?.skills?.[skill] : mattReceipt?.platforms?.[platform]?.skills?.[skill];
248
+ const expectedRoot = path.join(cwd, platform === "codex" ? ".agents" : ".claude", "skills", skill);
249
+ const target = legacy ? installed?.root : expectedRoot;
250
+ if (!legacy && installed?.root && path.resolve(installed.root) !== path.resolve(expectedRoot)) {
251
+ findings.push({ platform, skill, reason: "non-project" });
252
+ continue;
253
+ }
254
+ if (!target || !installed?.hash || !fs.existsSync(path.join(target, "SKILL.md"))) {
95
255
  findings.push({ platform, skill, reason: "missing" });
96
256
  continue;
97
257
  }
98
258
  try {
99
- if (hashDirectory(installed.root) !== installed.hash) findings.push({ platform, skill, reason: "modified" });
100
- } catch { findings.push({ platform, skill, reason: "unreadable" }); }
101
- }
102
- }
259
+ const actual = hashDirectory(target);
260
+ if (legacy ? actual !== installed.hash : actual !== installed.hash || actual !== contentHashes[skill]) findings.push({ platform, skill, reason: "modified" });
261
+ } catch { findings.push({ platform, skill, reason: "unreadable" }); }
262
+ }
263
+ }
103
264
  return findings.length ? {
104
265
  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) };
266
+ manifest: legacy ? manifestFile : mattReceiptFile, findings, recovery_command: "matrix init --with-mattpocock"
267
+ } : { ok: true, code: "OK", manifest: legacy ? manifestFile : mattReceiptFile, platforms: platforms.map(([platform]) => platform), cohort: legacy ? "legacy-v0.1.3" : MATT_CATALOG_DIGEST, matt_release: legacy ? null : MATT_COMPATIBILITY.release };
107
268
  }
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`;
269
+ function taskContent(change, taskId, proposal, design, plan, resultPath, contractHash, approvalRevision) {
270
+ 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
271
  }
111
272
  function activeBoard(taskId, taskPath, resultPath, change) {
112
273
  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
274
  }
114
275
  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") };
276
+ const root = rootFor(cwd);
277
+ const change = path.join(root, archived ? "archive" : "changes", id);
278
+ return {
279
+ root,
280
+ active: path.join(root, "active.json"),
281
+ change,
282
+ flow: path.join(change, "matrix.yaml"),
283
+ artifacts: path.join(change, "artifacts"),
284
+ reviewReceipt: path.join(change, "artifacts", "review-receipt.json"),
285
+ workspaceBaseline: path.join(change, WORKSPACE_BASELINE_FILE)
286
+ };
118
287
  }
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;
288
+ function normalizeFlow(flow) {
289
+ if (!flow) return null;
290
+ if (flow.schema !== "matrix/change/v2") return flow.schema ? { unsupported_schema: flow.schema } : null;
291
+ const revision = Number(flow.revision);
292
+ const approved = flow.approved_contract_hash || null;
124
293
  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 };
294
+ const workflowProfile = flow.workflow_profile || null;
295
+ const archCatalogDigest = flow.arch_catalog_digest || null;
296
+ const mattRelease = flow.matt_release || null;
297
+ if (
298
+ typeof flow.id !== "string" || !flow.id ||
299
+ !["full", "hotfix", "tweak"].includes(flow.workflow) ||
300
+ !ORCHESTRATIONS.includes(flow.orchestration) ||
301
+ !["active", "archived", "aborted"].includes(flow.status) ||
302
+ !PHASES.includes(flow.phase) ||
303
+ !Number.isInteger(revision) || revision < 1 ||
304
+ (workflowProfile !== null && workflowProfile !== "lightweight") ||
305
+ (workflowProfile === "lightweight" && !["hotfix", "tweak"].includes(flow.workflow)) ||
306
+ (flow.artifact_language != null && !["en", "zh-CN"].includes(flow.artifact_language)) ||
307
+ Boolean(archCatalogDigest) !== Boolean(mattRelease) ||
308
+ (archCatalogDigest && !/^[0-9a-f]{64}$/.test(archCatalogDigest)) ||
309
+ Boolean(approved) !== Boolean(approvalRevision) ||
310
+ (approved && !/^sha256:[0-9a-f]{64}$/.test(approved)) ||
311
+ (approvalRevision != null && (!Number.isInteger(approvalRevision) || approvalRevision < 1 || approvalRevision > revision))
312
+ ) return null;
313
+ return { ...flow, revision, workflow_profile: workflowProfile, artifact_language: flow.artifact_language ?? null, arch_catalog_digest: archCatalogDigest, matt_release: mattRelease, approved_contract_hash: approved, contract_approved_revision: approvalRevision };
314
+ }
315
+ function readFlow(p) {
316
+ if (!fs.existsSync(p.flow)) return null;
317
+ 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, "")]; }));
318
+ return normalizeFlow(parsed);
319
+ }
320
+ function flowContent(flow) {
321
+ const order = ["schema", "id", "workflow", "workflow_profile", "orchestration", "artifact_language", "arch_catalog_digest", "matt_release", "status", "phase", "revision", "approved_contract_hash", "contract_approved_revision", "title", "created_at", "updated_at", "acceptance", "scope"];
322
+ return `${order.filter((key) => (!["workflow_profile", "artifact_language", "arch_catalog_digest", "matt_release"].includes(key) || flow[key])).map((key) => `${key}: ${flow[key] ?? ""}`).join("\n")}\n`;
323
+ }
324
+ function active(cwd) { const activeFile = path.join(rootFor(cwd), "active.json"); if (!fs.existsSync(activeFile)) return null; return read(activeFile).change_id; }
325
+ function meaningful(file, headings) {
326
+ if (!fs.existsSync(file)) return false;
327
+ const value = fs.readFileSync(file, "utf8").trim();
328
+ if (value.length < 40) return false;
329
+ return headings.every((heading) => {
330
+ const start = value.indexOf(heading);
331
+ if (start < 0) return false;
332
+ const bodyStart = start + heading.length;
333
+ const next = value.indexOf("\n## ", bodyStart);
334
+ return value.slice(bodyStart, next < 0 ? value.length : next).trim().length >= 8;
335
+ });
137
336
  }
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);
337
+ function digestText(domain, value) {
338
+ return `sha256:${crypto.createHash("sha256").update(`${domain}\0`, "utf8").update(value).digest("hex")}`;
142
339
  }
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`;
340
+ function reviewCandidateHash(diffHash, workspaceHash) {
341
+ return digestText("matrix/review-candidate/v1", `${diffHash}\0${workspaceHash}`);
146
342
  }
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)]];
155
- return { pass: checks.every(([, result]) => result), checks };
343
+ function markdownSection(value, heading) {
344
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
345
+ const match = new RegExp(`^${escaped}\\s*$`, "m").exec(value);
346
+ if (!match) return null;
347
+ const level = heading.match(/^#+/)?.[0].length ?? 1;
348
+ const tail = value.slice(match.index + match[0].length);
349
+ const next = new RegExp(`^#{1,${level}}\\s+`, "m").exec(tail);
350
+ return tail.slice(0, next?.index ?? tail.length).trim();
156
351
  }
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");
352
+ function reviewEvidence(p) {
353
+ const file = path.join(p.artifacts, "verification.md");
354
+ let value;
355
+ try { value = fs.readFileSync(file, "utf8"); }
356
+ catch (error) { return fail("REVIEW_EVIDENCE_INCOMPLETE", `Review evidence is unavailable: ${error.message}`); }
357
+ const review = markdownSection(value, "## Review evidence");
358
+ const standards = review == null ? null : markdownSection(review, "### Standards");
359
+ const spec = review == null ? null : markdownSection(review, "### Spec");
360
+ if (!review || !standards || standards.length < 8 || !spec || spec.length < 8) {
361
+ return fail("REVIEW_EVIDENCE_INCOMPLETE", "Review evidence requires substantive ### Standards and ### Spec sections.");
171
362
  }
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 };
363
+ return { ok: true, code: "OK", hash: digestText("matrix/review-evidence/v1", review), standards, spec };
180
364
  }
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
- };
365
+ function gitText(cwd, args) {
366
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8", windowsHide: true });
367
+ return !result.error && result.status === 0 ? result.stdout.trim() : null;
188
368
  }
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) };
369
+ function gitPaths(cwd, args) {
370
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "buffer", windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
371
+ if (result.error || result.status !== 0) return null;
372
+ return result.stdout.toString("utf8").split("\0").filter(Boolean).map((relative) => relative.replaceAll("\\", "/"));
193
373
  }
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);
374
+ function managedReviewPrefixes(cwd) {
375
+ const { manifest, mattReceipt } = orchestrationConfig(cwd);
376
+ const prefixes = [];
377
+ for (const [platform, record] of Object.entries(manifest?.platforms ?? {})) {
378
+ const root = platform === "codex" ? ".agents/skills" : platform === "claude-code" ? ".claude/skills" : null;
379
+ if (!root) continue;
380
+ for (const skill of Object.keys(record?.skills ?? {})) prefixes.push(`${root}/${skill}`);
381
+ for (const skill of Object.keys(mattReceipt?.platforms?.[platform]?.skills ?? {})) prefixes.push(`${root}/${skill}`);
224
382
  }
383
+ return prefixes;
225
384
  }
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;
385
+ function reviewWorktreeFacts(cwd) {
386
+ const tracked = gitPaths(cwd, ["diff", "--name-only", "-z", "HEAD", "--"]);
387
+ const untracked = gitPaths(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
388
+ if (!tracked || !untracked) return fail("REVIEW_WORKTREE_UNAVAILABLE", "The candidate worktree could not be inspected.");
389
+ const managed = managedReviewPrefixes(cwd);
390
+ const files = [...new Set([...tracked, ...untracked])].filter((relative) => !excludedWorkspacePath(relative)
391
+ && !managed.some((prefix) => relative === prefix || relative.startsWith(`${prefix}/`))).sort();
392
+ return { ok: true, code: "OK", dirty: files.length > 0, files };
246
393
  }
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;
394
+ function codeReviewFacts(cwd, fixedPoint, { allowEmpty = false, requireClean = true } = {}) {
395
+ if (!fixedPoint || fixedPoint.startsWith("-")) return fail("REVIEW_FIXED_POINT_INVALID", "code-review requires a resolvable Git fixed point.");
396
+ const resolved = gitText(cwd, ["rev-parse", "--verify", `${fixedPoint}^{commit}`]);
397
+ const head = gitText(cwd, ["rev-parse", "--verify", "HEAD^{commit}"]);
398
+ if (!resolved || !head) return fail("REVIEW_FIXED_POINT_INVALID", "code-review requires a resolvable fixed point and Git HEAD.");
399
+ const mergeBase = gitText(cwd, ["merge-base", resolved, head]);
400
+ if (!mergeBase) return fail("REVIEW_FIXED_POINT_INVALID", "The fixed point and HEAD have no merge base.");
401
+ const diff = spawnSync("git", ["-C", cwd, "diff", "--binary", `${resolved}...${head}`, "--"], { encoding: "buffer", windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
402
+ if (diff.error || diff.status !== 0) return fail("REVIEW_DIFF_UNAVAILABLE", "The code-review diff could not be captured.");
403
+ if (!diff.stdout.length && !allowEmpty) return fail("REVIEW_DIFF_EMPTY", "The code-review fixed point has an empty diff to HEAD. Use the bounded Matrix fallback for an uncommitted candidate.");
404
+ const worktree = reviewWorktreeFacts(cwd);
405
+ if (!worktree.ok) return worktree;
406
+ if (requireClean && worktree.dirty) return { ...fail("REVIEW_WORKTREE_DIRTY", "code-review covers fixed point to HEAD only. Use the bounded uncommitted-worktree fallback for staged, unstaged, or untracked candidate files."), files: worktree.files };
407
+ return { ok: true, code: "OK", fixed_point: resolved, head, merge_base: mergeBase, diff_hash: digestText("matrix/code-review-diff/v1", diff.stdout), diff_empty: !diff.stdout.length };
253
408
  }
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 };
409
+ function expectedReviewRevision(flow) { return flow.phase === "archive" ? flow.revision - 1 : flow.revision; }
410
+ function reviewReceiptGuard(p, flow) {
411
+ if (flow.orchestration !== "arch" || !["verify", "archive"].includes(flow.phase)) return { ok: true, code: "OK" };
412
+ if (!fs.existsSync(p.reviewReceipt)) return fail("REVIEW_RECEIPT_MISSING", "Arch Verify requires a Runtime-generated review receipt for the current revision.");
413
+ let receipt;
414
+ try { receipt = read(p.reviewReceipt); }
415
+ catch (error) { return fail("REVIEW_RECEIPT_INVALID", `The Arch review receipt is unreadable: ${error.message}`); }
416
+ const expectedRevision = expectedReviewRevision(flow);
417
+ if (receipt.schema !== REVIEW_RECEIPT_SCHEMA || receipt.change_id !== flow.id || receipt.revision !== expectedRevision
418
+ || receipt.orchestration !== "arch" || !REVIEW_SOURCES.has(receipt.source)
419
+ || !REVIEW_OUTCOMES.has(receipt.standards) || !REVIEW_OUTCOMES.has(receipt.specification)
420
+ || receipt.contract_hash !== flow.approved_contract_hash) {
421
+ return fail("REVIEW_RECEIPT_INVALID", "The Arch review receipt does not match the current workflow identity.");
259
422
  }
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)));
423
+ if (receipt.standards !== "passed" || receipt.specification !== "passed") {
424
+ return fail("REVIEW_FAILED", "Standards and Spec review axes must both pass before Archive.");
267
425
  }
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}`) };
426
+ const evidence = reviewEvidence(p);
427
+ if (!evidence.ok) return evidence;
428
+ if (receipt.evidence_hash !== evidence.hash) return fail("REVIEW_RECEIPT_STALE", "Review evidence changed after the receipt was recorded.");
429
+ let workspace;
430
+ try { workspace = captureWorkspaceBaseline(path.dirname(p.root)); }
431
+ catch (error) { return fail("REVIEW_RECEIPT_STALE", `The reviewed workspace can no longer be inspected: ${error.message}`); }
432
+ if (receipt.workspace?.provider !== workspace.provider || receipt.workspace?.hash !== workspace.hash) {
433
+ return fail("REVIEW_RECEIPT_STALE", "Project files changed after the review receipt was recorded.");
271
434
  }
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
- }
435
+ if (receipt.source === "code-review") {
436
+ const facts = codeReviewFacts(path.dirname(p.root), receipt.fixed_point);
437
+ if (!facts.ok || facts.head !== receipt.head || facts.merge_base !== receipt.merge_base || facts.diff_hash !== receipt.diff_hash) {
438
+ return fail("REVIEW_RECEIPT_STALE", "The code-review fixed point, HEAD, or diff changed after receipt recording.");
439
+ }
440
+ } else if (!REVIEW_FALLBACK_REASONS.has(receipt.reason)) {
441
+ return fail("REVIEW_RECEIPT_INVALID", "Matrix fallback receipts require an allowed companion failure reason.");
442
+ } else if (receipt.reason === "git-baseline-unavailable" && gitText(path.dirname(p.root), ["rev-parse", "--verify", "HEAD^{commit}"])) {
443
+ return fail("REVIEW_RECEIPT_STALE", "The fallback claimed no Git baseline, but the project now has a resolvable HEAD.");
444
+ } else if (receipt.reason === "empty-head-diff") {
445
+ const facts = codeReviewFacts(path.dirname(p.root), receipt.fixed_point, { allowEmpty: true, requireClean: false });
446
+ if (!facts.ok || !facts.diff_empty || facts.head !== receipt.head || facts.merge_base !== receipt.merge_base || facts.diff_hash !== receipt.diff_hash) {
447
+ return fail("REVIEW_RECEIPT_STALE", "The empty HEAD diff fallback no longer matches the recorded Git facts.");
448
+ }
449
+ } else if (receipt.reason === "uncommitted-worktree") {
450
+ const facts = codeReviewFacts(path.dirname(p.root), receipt.fixed_point, { allowEmpty: true, requireClean: false });
451
+ const worktree = reviewWorktreeFacts(path.dirname(p.root));
452
+ const candidateHash = facts.ok ? reviewCandidateHash(facts.diff_hash, workspace.hash) : null;
453
+ if (!facts.ok || !worktree.ok || !worktree.dirty || facts.head !== receipt.head || facts.merge_base !== receipt.merge_base
454
+ || facts.diff_hash !== receipt.diff_hash || candidateHash !== receipt.candidate_hash) {
455
+ return fail("REVIEW_RECEIPT_STALE", "The uncommitted worktree fallback no longer matches the reviewed candidate.");
289
456
  }
290
- } catch (error) {
291
- return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Archive inputs changed or could not be captured: ${error.message}`) };
292
457
  }
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
- };
458
+ return { ok: true, code: "OK", receipt };
308
459
  }
460
+ function shortcut(flow) { return flow.workflow === "hotfix" || flow.workflow === "tweak"; }
461
+ function contractSnapshotForFiles(p, files) {
462
+ const hash = crypto.createHash("sha256");
463
+ hash.update("matrix/workflow-contract/v1\\0", "utf8");
464
+ const contents = {};
465
+ for (const name of files) {
466
+ const logical = `artifacts/${name}`;
467
+ let bytes;
468
+ try { bytes = fs.readFileSync(path.join(p.artifacts, name)); }
469
+ catch (error) { if (error.code === "ENOENT") return { missing: true, contents }; return { error, contents }; }
470
+ const pathBytes = Buffer.from(logical, "utf8");
471
+ const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
472
+ const contentLength = Buffer.alloc(8); contentLength.writeBigUInt64BE(BigInt(bytes.length));
473
+ hash.update(pathLength); hash.update(pathBytes); hash.update(contentLength); hash.update(bytes);
474
+ contents[name] = bytes.toString("utf8");
475
+ }
476
+ return { hash: `sha256:${hash.digest("hex")}`, contents };
477
+ }
478
+ function workflowProfile(p, flow) {
479
+ if (!shortcut(flow)) return FULL_PROFILE;
480
+ return flow.workflow_profile === "lightweight" ? LIGHTWEIGHT_PROFILE : { ...FULL_PROFILE, id: "legacy-full" };
481
+ }
482
+ function evidencePolicy(flow, profile) {
483
+ return profile.id === "lightweight" ? flow.workflow : "full";
484
+ }
485
+ function structuralGuard(p, flow, phase) {
486
+ const base = p.artifacts;
487
+ const profile = workflowProfile(p, flow);
488
+ const policy = evidencePolicy(flow, profile);
489
+ const shortcutPolicy = LIGHTWEIGHT_POLICY[policy];
490
+ const checks = phase === "open" ? [["proposal", meaningful(path.join(base, "proposal.md"), shortcutPolicy?.proposal ?? ARTIFACT_POLICY.proposal)]]
491
+ : 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)]]
492
+ : phase === "build" ? [
493
+ ...(profile.id === "lightweight" ? [] : [["plan", meaningful(path.join(base, "plan.md"), ARTIFACT_POLICY.plan)]]),
494
+ ["build evidence", meaningful(path.join(base, "verification.md"), shortcutPolicy?.build ?? ARTIFACT_POLICY.build)]
495
+ ]
496
+ : [["verification", meaningful(path.join(base, "verification.md"), shortcutPolicy?.verification ?? ARTIFACT_POLICY.verification)]];
497
+ return { pass: checks.every(([, result]) => result), checks };
498
+ }
499
+ function contractSnapshot(p, flow) {
500
+ return contractSnapshotForFiles(p, workflowProfile(p, flow).contractFiles);
501
+ }
502
+ function contractState(p, flow) {
503
+ const snapshot = contractSnapshot(p, flow);
504
+ if (snapshot.error) return { status: "unreadable", snapshot };
505
+ if (!flow.approved_contract_hash) return { status: "unapproved", snapshot };
506
+ if (snapshot.missing || snapshot.hash !== flow.approved_contract_hash) return { status: "changed", snapshot };
507
+ return { status: "approved-and-matching", snapshot };
508
+ }
509
+ function contractFields(flow, contract) {
510
+ return {
511
+ contract_status: contract.status,
512
+ current_contract_hash: contract.snapshot.hash ?? null,
513
+ approved_contract_hash: flow.approved_contract_hash,
514
+ contract_approved_revision: flow.contract_approved_revision
515
+ };
516
+ }
517
+ function contractFailure(flow, contract) {
518
+ if (contract.status === "approved-and-matching") return null;
519
+ if (contract.status === "unreadable") return { ...fail("CONTRACT_READ_FAILED", contract.snapshot.error.message), ...contractFields(flow, contract) };
520
+ return { ...fail(contract.status === "unapproved" ? "CONTRACT_UNAPPROVED" : "CONTRACT_CHANGED", `Workflow contract is ${contract.status}.`), ...contractFields(flow, contract) };
521
+ }
522
+ function addFramedEntry(hash, type, logicalPath, content = Buffer.alloc(0)) {
523
+ const typeBytes = Buffer.from(type, "utf8");
524
+ const pathBytes = Buffer.from(logicalPath, "utf8");
525
+ const typeLength = Buffer.alloc(4); typeLength.writeUInt32BE(typeBytes.length);
526
+ const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
527
+ const contentLength = Buffer.alloc(8); contentLength.writeBigUInt64BE(BigInt(content.length));
528
+ hash.update(typeLength); hash.update(typeBytes); hash.update(pathLength); hash.update(pathBytes); hash.update(contentLength); hash.update(content);
529
+ }
530
+ function addFramedFile(hash, entry) {
531
+ const typeBytes = Buffer.from(entry.type, "utf8");
532
+ const pathBytes = Buffer.from(entry.logicalPath, "utf8");
533
+ const typeLength = Buffer.alloc(4); typeLength.writeUInt32BE(typeBytes.length);
534
+ const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
535
+ const contentLength = Buffer.alloc(8); contentLength.writeBigUInt64BE(BigInt(entry.size));
536
+ hash.update(typeLength); hash.update(typeBytes); hash.update(pathLength); hash.update(pathBytes); hash.update(contentLength);
537
+ const descriptor = fs.openSync(entry.source, "r");
538
+ try {
539
+ const opened = fs.fstatSync(descriptor);
540
+ if (!opened.isFile() || opened.size !== entry.size) throw new Error(`Archive file changed while opening: ${entry.logicalPath}.`);
541
+ const chunk = Buffer.allocUnsafe(64 * 1024);
542
+ let offset = 0;
543
+ while (offset < entry.size) {
544
+ const length = fs.readSync(descriptor, chunk, 0, Math.min(chunk.length, entry.size - offset), offset);
545
+ if (length === 0) throw new Error(`Archive file changed while reading: ${entry.logicalPath}.`);
546
+ hash.update(chunk.subarray(0, length));
547
+ offset += length;
548
+ }
549
+ if (fs.readSync(descriptor, chunk, 0, 1, offset) !== 0) throw new Error(`Archive file changed while reading: ${entry.logicalPath}.`);
550
+ } finally {
551
+ fs.closeSync(descriptor);
552
+ }
553
+ }
554
+ function archiveDirectoryEntries(root, relative = "") {
555
+ const entries = [];
556
+ const names = fs.readdirSync(path.join(root, relative)).sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
557
+ for (const name of names) {
558
+ const childRelative = relative ? `${relative}/${name}` : name;
559
+ const child = path.join(root, ...childRelative.split("/"));
560
+ const stat = fs.lstatSync(child);
561
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
562
+ const error = new Error(`Archive change contains unsupported filesystem entry: ${childRelative}.`);
563
+ error.code = "ARCHIVE_ENTRY_UNSUPPORTED";
564
+ throw error;
565
+ }
566
+ if (stat.isDirectory()) {
567
+ entries.push({ type: "directory", logicalPath: `change/${childRelative}`, content: Buffer.alloc(0) });
568
+ entries.push(...archiveDirectoryEntries(root, childRelative));
569
+ } else {
570
+ entries.push({ type: "file", logicalPath: `change/${childRelative}`, source: child, size: stat.size });
571
+ }
572
+ }
573
+ return entries;
574
+ }
575
+ function requireArchiveEntry(file, kind, label) {
576
+ let stat;
577
+ try { stat = fs.lstatSync(file); }
578
+ catch (error) { return fail("ARCHIVE_PREFLIGHT_UNSTABLE", `${label} could not be inspected: ${error.message}`); }
579
+ const valid = kind === "directory" ? stat.isDirectory() : stat.isFile();
580
+ return stat.isSymbolicLink() || !valid ? fail("ARCHIVE_ENTRY_UNSUPPORTED", `${label} must be an ordinary ${kind}.`) : null;
581
+ }
582
+ function captureArchivePreflightOnce(p, id) {
583
+ const target = path.join(p.root, "archive", id);
584
+ if (fs.existsSync(target)) return { failure: fail("ARCHIVE_TARGET_EXISTS", `Archive target already exists: ${id}.`) };
585
+ 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"]]) {
586
+ const failure = requireArchiveEntry(file, kind, label); if (failure) return { failure };
587
+ }
588
+ let selection;
589
+ try { selection = fs.readFileSync(p.active); }
590
+ catch (error) { return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Active Selection could not be captured: ${error.message}`) }; }
591
+ let entries;
592
+ try {
593
+ entries = archiveDirectoryEntries(p.change);
594
+ entries.sort((left, right) => Buffer.compare(Buffer.from(left.logicalPath), Buffer.from(right.logicalPath)));
595
+ }
596
+ catch (error) {
597
+ if (error.code === "ARCHIVE_ENTRY_UNSUPPORTED") return { failure: fail(error.code, error.message) };
598
+ return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Archive inputs changed or could not be captured: ${error.message}`) };
599
+ }
600
+ const hash = crypto.createHash("sha256");
601
+ hash.update("matrix/archive-preflight/v1\\0", "utf8");
602
+ addFramedEntry(hash, "identity", "change-id", Buffer.from(id, "utf8"));
603
+ addFramedEntry(hash, "identity", "archive-target", Buffer.from(`.matrix/archive/${id}`, "utf8"));
604
+ addFramedEntry(hash, "selection", ".matrix/active.json", selection);
605
+ addFramedEntry(hash, "fact", "archive-target-state", Buffer.from("absent", "utf8"));
606
+ let fileCount = 0; let directoryCount = 0; let totalBytes = 0;
607
+ try {
608
+ for (const entry of entries) {
609
+ if (entry.type === "file") {
610
+ addFramedFile(hash, entry);
611
+ fileCount += 1;
612
+ totalBytes += entry.size;
613
+ } else {
614
+ addFramedEntry(hash, entry.type, entry.logicalPath, entry.content);
615
+ directoryCount += 1;
616
+ }
617
+ }
618
+ } catch (error) {
619
+ return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Archive inputs changed or could not be captured: ${error.message}`) };
620
+ }
621
+ return { hash: `sha256:${hash.digest("hex")}`, fileCount, directoryCount, totalBytes };
622
+ }
623
+ function captureArchivePreflight(p, id) {
624
+ const first = captureArchivePreflightOnce(p, id); if (first.failure) return first;
625
+ const second = captureArchivePreflightOnce(p, id); if (second.failure) return second;
626
+ if (first.hash !== second.hash) return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Archive inputs changed while the preflight snapshot was being captured. Retry dry-run.") };
627
+ return second;
628
+ }
629
+ function archivePreflightChanged(expected, current) {
630
+ return {
631
+ ...fail("ARCHIVE_PREFLIGHT_CHANGED", "Archive inputs changed after dry-run. Run dry-run again and review the new preflight."),
632
+ expected_preflight_hash: expected,
633
+ current_preflight_hash: current,
634
+ recovery_command: workflowCommand("archive --dry-run")
635
+ };
636
+ }
309
637
  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
- }
638
+ if (!["build", "verify", "archive"].includes(phase)) {
639
+ const structural = structuralGuard(p, flow, phase);
640
+ return { ok: structural.pass, code: structural.pass ? "OK" : "GUARD_FAILED", ...structural };
641
+ }
314
642
  const contract = contractState(p, flow); const failure = contractFailure(flow, contract);
315
643
  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`;
644
+ const structural = structuralGuard(p, flow, phase);
645
+ const checks = [["contract", true], ...structural.checks];
646
+ if (!structural.pass) return { ok: false, code: "GUARD_FAILED", ...structural, ...contractFields(flow, contract), checks };
647
+ if (["verify", "archive"].includes(phase)) {
648
+ const evidence = reviewEvidence(p);
649
+ if (!evidence.ok) return { ...evidence, pass: false, ...contractFields(flow, contract), checks: [...checks, ["review axes", false]] };
650
+ checks.push(["review axes", true]);
651
+ }
652
+ if (flow.orchestration === "arch" && ["verify", "archive"].includes(phase)) {
653
+ const receipt = reviewReceiptGuard(p, flow);
654
+ if (!receipt.ok) return { ...receipt, pass: false, ...contractFields(flow, contract), checks: [...checks, ["arch review receipt", false]] };
655
+ checks.push(["arch review receipt", true]);
656
+ }
657
+ return { ok: true, code: "OK", pass: true, ...contractFields(flow, contract), checks };
322
658
  }
323
- export function invoke(argv, { cwd = process.cwd(), failAfterOperation = null } = {}) {
324
- const [command, ...args] = argv;
325
- if (command === "init") {
326
- const id = (args.shift() ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
327
- const workflow = args.includes("--workflow") ? args[args.indexOf("--workflow") + 1] : "full";
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;
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
- }
338
- if (active(cwd)) return fail("ACTIVE_CHANGE_EXISTS", `Active Matrix change already exists: ${active(cwd)}.`);
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}.`);
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
- });
659
+ function eventContent(file, name, before, after, revision, details = {}) {
660
+ const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
661
+ return `${current}${JSON.stringify({ schema: "matrix/event/v1", at: stamp(), event: name, revision, from: before, to: after, ...details })}\n`;
662
+ }
663
+ export function invoke(argv, { cwd = process.cwd(), failAfterOperation = null, mattContentHashes = MATT_CONTENT_HASHES } = {}) {
664
+ const [command, ...args] = argv;
665
+ if (command === "init") {
666
+ const id = (args.shift() ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
667
+ const workflow = args.includes("--workflow") ? args[args.indexOf("--workflow") + 1] : "full";
668
+ const title = args.includes("--title") ? args[args.indexOf("--title") + 1] : null;
669
+ const requestedOrchestration = option(args, "--orchestration");
670
+ if (requestedOrchestration != null && !ORCHESTRATIONS.includes(requestedOrchestration)) return fail("INVALID_INTENT", "orchestration must be prim or arch.");
671
+ const configured = orchestrationConfig(cwd);
672
+ const orchestration = requestedOrchestration ?? configured.defaultOrchestration;
673
+ if (!id || !title || !["full", "hotfix", "tweak"].includes(workflow)) return fail("INVALID_INTENT", "init requires a valid change id and --title.");
674
+ if (orchestration === "arch") {
675
+ const integrity = archIntegrity(cwd, null, mattContentHashes);
676
+ if (!integrity.ok) return { ...integrity, orchestration };
677
+ }
678
+ if (active(cwd)) return fail("ACTIVE_CHANGE_EXISTS", `Active Matrix change already exists: ${active(cwd)}.`);
679
+ 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}.`);
680
+ let workspaceBaseline = null;
681
+ if (workflow === "hotfix" || workflow === "tweak") {
682
+ try { workspaceBaseline = captureWorkspaceBaseline(cwd); }
683
+ catch (error) { return fail("WORKSPACE_BASELINE_FAILED", `Could not initialize the lightweight workflow boundary: ${error.message}`); }
684
+ }
685
+ const flow = { schema: "matrix/change/v2", id, workflow, ...(workflow === "full" ? {} : { workflow_profile: "lightweight" }), orchestration, artifact_language: configured.artifactLanguage, ...(orchestration === "arch" ? { arch_catalog_digest: MATT_CATALOG_DIGEST, matt_release: MATT_COMPATIBILITY.release } : {}), status: "active", phase: "open", revision: 1, title, created_at: stamp(), updated_at: stamp(), acceptance: "pending", scope: "pending" };
686
+ const operations = [
687
+ ...(!fs.existsSync(path.join(p.root, "archive")) ? [workflowMkdirOperation(cwd, path.join(p.root, "archive"))] : []),
688
+ workflowMkdirOperation(cwd, p.artifacts),
689
+ ...(!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\nlanguage: ${configured.artifactLanguage}\n`)] : []),
690
+ ...(!fs.existsSync(path.join(p.root, ".gitignore")) ? [workflowWriteOperation(cwd, path.join(p.root, ".gitignore"), "active.json\ninstallation.json\nmatt-installation.json\nchanges/*/events.jsonl\nchanges/*/artifacts/handoff.md\n")] : []),
691
+ workflowWriteOperation(cwd, p.flow, flowContent(flow)),
692
+ ...(workspaceBaseline ? [workflowWriteOperation(cwd, p.workspaceBaseline, `${JSON.stringify(workspaceBaseline, null, 2)}\n`)] : []),
693
+ workflowWriteOperation(cwd, path.join(p.change, "events.jsonl"), eventContent(path.join(p.change, "events.jsonl"), "initialized", null, "open", flow.revision, { workflow, orchestration, ...(orchestration === "arch" ? { arch_catalog_digest: MATT_CATALOG_DIGEST, matt_release: MATT_COMPATIBILITY.release } : {}), ...(shortcut(flow) ? { profile: "lightweight" } : {}) })),
694
+ workflowWriteOperation(cwd, p.active, `${JSON.stringify({ change_id: id }, null, 2)}\n`)
695
+ ];
696
+ return runWorkflowTransaction({
697
+ cwd,
698
+ kind: "init",
699
+ changeId: id,
700
+ operations,
701
+ failAfterOperation,
702
+ result: { ok: true, code: "OK", change_id: id, workflow, profile: shortcut(flow) ? "lightweight" : "full", evidence_policy: workflow === "full" ? "full" : workflow, orchestration, artifact_language: flow.artifact_language, arch_catalog_digest: flow.arch_catalog_digest ?? null, matt_release: flow.matt_release ?? null, status: flow.status, phase: flow.phase, revision: flow.revision, next_skill: skill("open") }
703
+ });
704
+ }
705
+ if (command === "doctor") {
706
+ const parsed = { repair: false, transaction: null, strategy: null, lock: null };
707
+ for (let index = 0; index < args.length; index += 1) {
708
+ const token = args[index];
709
+ if (token === "--repair") {
710
+ if (parsed.repair) return fail("INVALID_INTENT", "Workflow doctor options may not be repeated.");
711
+ parsed.repair = true;
712
+ continue;
713
+ }
714
+ const field = ({ "--transaction": "transaction", "--strategy": "strategy", "--lock": "lock" })[token];
715
+ const value = args[index + 1];
716
+ if (!field || parsed[field] || !value || value.startsWith("--")) return fail("INVALID_INTENT", "Workflow doctor accepts --repair with --transaction/--strategy or --lock.");
717
+ parsed[field] = value;
718
+ index += 1;
719
+ }
720
+ return workflowDoctor(cwd, parsed);
721
+ }
722
+ const selected = active(cwd);
723
+ const requested = command === "inspect" ? (option(args, "--change") ?? args.find((arg) => !arg.startsWith("--"))) : null;
724
+ const id = requested ?? selected;
725
+ if (!id) return fail("NO_ACTIVE_CHANGE", "No active Matrix change. Start with $matrix <request>.");
726
+ let p = paths(cwd, id);
727
+ if (command === "inspect" && !fs.existsSync(p.change)) p = paths(cwd, id, true);
728
+ const flow = readFlow(p);
729
+ if (flow?.unsupported_schema) return fail("STATE_VERSION_UNSUPPORTED", `Unsupported Matrix state schema: ${flow.unsupported_schema}.`);
730
+ if (!flow) return fail("STATE_INVALID", "active.json does not reference a valid Matrix change.");
731
+ const artifactLanguage = flow.artifact_language ?? orchestrationConfig(cwd).artifactLanguage;
732
+ const integrity = flow.status === "active" && flow.orchestration === "arch" ? archIntegrity(cwd, flow, mattContentHashes) : { ok: true, code: "OK" };
733
+ if (["transition", "return", "abort"].includes(command) || (command === "archive" && !args.includes("--dry-run"))) {
734
+ const blocked = workflowMutationGate(cwd);
735
+ if (blocked) return blocked;
736
+ }
737
+ if (command === "inspect") {
738
+ const profile = workflowProfile(p, flow);
739
+ const contract = contractState(p, flow);
740
+ const result = guard(p, flow, flow.phase);
741
+ const failure = contract.status === "unreadable" ? contractFailure(flow, contract) : null;
742
+ return {
743
+ ...(failure ?? (!integrity.ok ? integrity : { ok: true, code: "OK" })),
744
+ change_id: id,
745
+ workflow: flow.workflow,
746
+ profile: profile.id,
747
+ evidence_policy: evidencePolicy(flow, profile),
748
+ orchestration: flow.orchestration,
749
+ arch_catalog_digest: flow.arch_catalog_digest ?? null,
750
+ matt_release: flow.matt_release ?? null,
751
+ artifact_language: artifactLanguage,
752
+ status: flow.status,
753
+ phase: flow.phase,
754
+ revision: flow.revision,
755
+ next_skill: flow.status === "active" ? skill(flow.phase) : null,
756
+ guard_pass: result.ok,
757
+ ...contractFields(flow, contract)
758
+ };
759
+ }
760
+ if (!integrity.ok && command !== "abort") {
761
+ const profile = workflowProfile(p, flow);
762
+ return { ...integrity, change_id: id, workflow: flow.workflow, profile: profile.id, evidence_policy: evidencePolicy(flow, profile), orchestration: flow.orchestration, artifact_language: artifactLanguage, status: flow.status, phase: flow.phase, revision: flow.revision, next_skill: skill(flow.phase) };
763
+ }
764
+ if (command === "guard") {
765
+ const phase = args[0] ?? flow.phase;
766
+ if (!PHASES.includes(phase)) return fail("INVALID_INTENT", "Unknown phase.");
767
+ const profile = workflowProfile(p, flow);
768
+ if (profile.id === "lightweight" && phase === "design") return fail("PHASE_UNAVAILABLE", "Lightweight workflows do not have a Design phase.");
769
+ return { phase, profile: profile.id, evidence_policy: evidencePolicy(flow, profile), artifact_language: artifactLanguage, ...guard(p, flow, phase) };
358
770
  }
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;
771
+ if (command === "review") {
772
+ if (flow.status !== "active" || flow.phase !== "verify") return fail("ILLEGAL_PHASE", "Review receipts can only be recorded during Verify.");
773
+ if (flow.orchestration !== "arch") return fail("REVIEW_RECEIPT_UNAVAILABLE", "Prim performs Matrix-owned review without an Arch companion receipt.");
774
+ if (fs.existsSync(p.reviewReceipt)) return fail("REVIEW_RECEIPT_EXISTS", "This revision already has a review receipt. Use a controlled Return before recording another review.");
775
+ const source = option(args, "--source");
776
+ const standards = option(args, "--standards");
777
+ const specification = option(args, "--spec");
778
+ const fixedPoint = option(args, "--fixed-point");
779
+ const reason = option(args, "--reason");
780
+ if (!REVIEW_SOURCES.has(source)) return fail("REVIEW_SOURCE_INVALID", "Review source must be code-review or matrix-fallback.");
781
+ if (!REVIEW_OUTCOMES.has(standards) || !REVIEW_OUTCOMES.has(specification)) return fail("REVIEW_OUTCOME_INVALID", "Standards and Spec outcomes must each be passed or failed.");
782
+ const contract = contractState(p, flow); const contractError = contractFailure(flow, contract);
783
+ if (contractError) return contractError;
784
+ const evidence = reviewEvidence(p);
785
+ if (!evidence.ok) return evidence;
786
+ let sourceFacts = {};
787
+ if (source === "code-review") {
788
+ if (reason) return fail("REVIEW_SOURCE_INVALID", "A successful code-review receipt cannot include a fallback reason.");
789
+ const facts = codeReviewFacts(cwd, fixedPoint);
790
+ if (!facts.ok) return facts;
791
+ sourceFacts = { fixed_point: facts.fixed_point, head: facts.head, merge_base: facts.merge_base, diff_hash: facts.diff_hash };
792
+ } else {
793
+ if (!REVIEW_FALLBACK_REASONS.has(reason)) return fail("REVIEW_FALLBACK_REASON_INVALID", "Matrix fallback requires git-baseline-unavailable, empty-head-diff, uncommitted-worktree, or capability-execution-failed.");
794
+ if (reason === "git-baseline-unavailable") {
795
+ if (fixedPoint) return fail("REVIEW_SOURCE_INVALID", "A missing Git baseline fallback cannot include a fixed point.");
796
+ if (gitText(cwd, ["rev-parse", "--verify", "HEAD^{commit}"])) return fail("REVIEW_FALLBACK_REASON_MISMATCH", "The project has a resolvable Git HEAD; git-baseline-unavailable is not valid.");
797
+ sourceFacts = { reason };
798
+ } else if (reason === "empty-head-diff") {
799
+ const facts = codeReviewFacts(cwd, fixedPoint, { allowEmpty: true, requireClean: false });
800
+ if (!facts.ok) return facts;
801
+ if (!facts.diff_empty) return fail("REVIEW_FALLBACK_REASON_MISMATCH", "The fixed point has a non-empty HEAD diff; use code-review instead of fallback.");
802
+ const worktree = reviewWorktreeFacts(cwd);
803
+ if (!worktree.ok) return worktree;
804
+ if (worktree.dirty) return fail("REVIEW_FALLBACK_REASON_MISMATCH", "The candidate worktree is not clean; use the uncommitted-worktree fallback so Matrix can bind its content.");
805
+ sourceFacts = { reason, fixed_point: facts.fixed_point, head: facts.head, merge_base: facts.merge_base, diff_hash: facts.diff_hash };
806
+ } else if (reason === "uncommitted-worktree") {
807
+ const facts = codeReviewFacts(cwd, fixedPoint, { allowEmpty: true, requireClean: false });
808
+ if (!facts.ok) return facts;
809
+ const worktree = reviewWorktreeFacts(cwd);
810
+ if (!worktree.ok) return worktree;
811
+ if (!worktree.dirty) return fail("REVIEW_FALLBACK_REASON_MISMATCH", "The candidate worktree is clean; use code-review instead of fallback.");
812
+ sourceFacts = { reason, fixed_point: facts.fixed_point, head: facts.head, merge_base: facts.merge_base, diff_hash: facts.diff_hash };
813
+ } else {
814
+ if (fixedPoint) return fail("REVIEW_SOURCE_INVALID", "A capability execution failure does not claim a successful code-review fixed point.");
815
+ sourceFacts = { reason };
367
816
  }
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
817
  }
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)) };
818
+ let workspace;
819
+ try { workspace = captureWorkspaceBaseline(cwd); }
820
+ catch (error) { return fail("WORKSPACE_INSPECTION_FAILED", `Could not bind the reviewed workspace: ${error.message}`); }
821
+ if (sourceFacts.reason === "uncommitted-worktree") sourceFacts.candidate_hash = reviewCandidateHash(sourceFacts.diff_hash, workspace.hash);
822
+ const receipt = {
823
+ schema: REVIEW_RECEIPT_SCHEMA,
824
+ change_id: id,
825
+ revision: flow.revision,
826
+ orchestration: flow.orchestration,
827
+ source,
828
+ standards,
829
+ specification,
830
+ contract_hash: flow.approved_contract_hash,
831
+ evidence_hash: evidence.hash,
832
+ workspace: { provider: workspace.provider, hash: workspace.hash, file_count: workspace.file_count, content_bytes: workspace.content_bytes },
833
+ ...sourceFacts,
834
+ recorded_at: stamp()
835
+ };
410
836
  return runWorkflowTransaction({
411
837
  cwd,
412
- kind: "transition",
838
+ kind: "review",
413
839
  changeId: id,
414
840
  failAfterOperation,
415
841
  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))
842
+ workflowWriteOperation(cwd, p.reviewReceipt, `${JSON.stringify(receipt, null, 2)}\n`),
843
+ workflowWriteOperation(cwd, path.join(p.change, "events.jsonl"), eventContent(path.join(p.change, "events.jsonl"), "review-recorded", "verify", "verify", flow.revision, { source, standards, specification, ...sourceFacts }))
418
844
  ],
419
- result: resultFields
845
+ result: { ok: true, code: "OK", change_id: id, revision: flow.revision, source, standards, specification, receipt: p.reviewReceipt, ...sourceFacts }
420
846
  });
421
847
  }
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}.`);
848
+ if (command === "transition") {
849
+ const to = args[0];
850
+ const confirmed = args.includes("--confirmed");
851
+ if (args.slice(1).some((arg) => arg !== "--confirmed")) return fail("INVALID_INTENT", "Transition accepts only a target phase and optional --confirmed.");
852
+ if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
853
+ const profile = workflowProfile(p, flow);
854
+ if (profile.next[flow.phase] !== to) return fail("ILLEGAL_TRANSITION", `Illegal ${profile.id} transition ${flow.phase} -> ${to}.`);
855
+ const result = guard(p, flow, flow.phase); if (!result.ok) return { ...result, message: result.message ?? "Current phase guard failed." };
856
+ if (profile.id === "lightweight" && flow.phase === "open" && to === "build") {
857
+ if (!confirmed) return fail("CONFIRMATION_REQUIRED", "Lightweight Open requires explicit user confirmation before Build.");
858
+ const order = workspaceOrderGuard(cwd, p);
859
+ if (!order.ok) return order;
860
+ }
861
+ const before = flow.phase;
862
+ const approval = to === "build" ? contractState(p, flow) : null;
863
+ if (approval && approval.status !== "approved-and-matching" && approval.status !== "unapproved") return { ...contractFailure(flow, approval), change_id: id };
864
+ if (to === "build" && approval.snapshot.missing) return { ...fail("GUARD_FAILED", "Workflow contract artifacts are incomplete."), change_id: id };
865
+ 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 } : {}) };
866
+ const nextProfile = workflowProfile(p, next);
867
+ const details = to === "build"
868
+ ? { approved_contract_hash: next.approved_contract_hash, contract_approved_revision: next.contract_approved_revision, ...(shortcut(flow) ? { confirmed, profile: nextProfile.id } : {}) }
869
+ : shortcut(flow) ? { profile: nextProfile.id } : {};
870
+ const resultFields = { ok: true, code: "OK", change_id: id, workflow: next.workflow, profile: nextProfile.id, evidence_policy: evidencePolicy(next, nextProfile), orchestration: next.orchestration, artifact_language: next.artifact_language ?? artifactLanguage, status: next.status, phase: to, revision: next.revision, next_skill: skill(to), ...contractFields(next, contractState(p, next)) };
871
+ return runWorkflowTransaction({
872
+ cwd,
873
+ kind: "transition",
874
+ changeId: id,
875
+ failAfterOperation,
876
+ operations: [
877
+ workflowWriteOperation(cwd, p.flow, flowContent(next)),
878
+ workflowWriteOperation(cwd, path.join(p.change, "events.jsonl"), eventContent(path.join(p.change, "events.jsonl"), "transition", before, to, next.revision, details))
879
+ ],
880
+ result: resultFields
881
+ });
882
+ }
883
+ if (command === "return") {
884
+ const to = args[0];
885
+ const reason = option(args, "--reason");
886
+ if (!reason) return fail("RETURN_REASON_REQUIRED", "Return requires --reason.");
887
+ if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
888
+ if (!RETURN_REASONS.has(reason)) return fail("RETURN_REASON_UNSUPPORTED", `Unsupported Return reason: ${reason}.`);
889
+ const profile = workflowProfile(p, flow);
890
+ const returns = profile.id === "lightweight" ? LIGHTWEIGHT_RETURNS : FULL_RETURNS;
891
+ const returnRule = returns.find((entry) => entry.from === flow.phase && entry.to === to && entry.reason === reason);
892
+ if (!returnRule) return fail("ILLEGAL_RETURN", `Illegal ${profile.id} Return ${flow.phase} -> ${to} for reason ${reason}.`);
429
893
  if (flow.phase === "verify" && to === "build") { const contract = contractState(p, flow); const failure = contractFailure(flow, contract); if (failure) return failure; }
430
894
  const verification = path.join(p.artifacts, "verification.md");
431
- let destination = null;
895
+ let verificationDestination = null;
896
+ let receiptDestination = null;
432
897
  if (fs.existsSync(verification)) {
433
898
  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
- }
477
- if (command === "archive") {
478
- if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
479
- if (flow.phase !== "archive") return fail("ILLEGAL_TRANSITION", "Only an archive-phase change can be archived.");
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" };
899
+ verificationDestination = path.join(history, `revision-${flow.revision}-verification.md`);
900
+ if (fs.existsSync(verificationDestination)) return fail("EVIDENCE_INVALIDATION_FAILED", `Evidence history already exists for revision ${flow.revision}.`);
484
901
  }
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
- };
902
+ if (fs.existsSync(p.reviewReceipt)) {
903
+ const history = path.join(p.artifacts, "evidence-history");
904
+ receiptDestination = path.join(history, `revision-${flow.revision}-review-receipt.json`);
905
+ if (fs.existsSync(receiptDestination)) return fail("EVIDENCE_INVALIDATION_FAILED", `Review receipt history already exists for revision ${flow.revision}.`);
501
906
  }
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,
907
+ const next = {
908
+ ...flow,
909
+ schema: "matrix/change/v2",
910
+ workflow: returnRule.upgrade ? "full" : flow.workflow,
911
+ workflow_profile: returnRule.upgrade ? null : flow.workflow_profile,
912
+ phase: to,
913
+ revision: flow.revision + 1,
914
+ updated_at: stamp(),
915
+ ...(to === "design" ? { approved_contract_hash: null, contract_approved_revision: null } : {})
916
+ };
917
+ const nextProfile = workflowProfile(p, next);
918
+ return runWorkflowTransaction({
919
+ cwd,
920
+ kind: "return",
921
+ changeId: id,
522
922
  failAfterOperation,
523
923
  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 }; }
924
+ ...(verificationDestination ? [workflowMoveOperation(cwd, verification, verificationDestination)] : []),
925
+ ...(receiptDestination ? [workflowMoveOperation(cwd, p.reviewReceipt, receiptDestination)] : []),
926
+ workflowWriteOperation(cwd, p.flow, flowContent(next)),
927
+ 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, ...(returnRule.upgrade ? { workflow_from: flow.workflow, workflow_to: "full" } : {}) }))
928
+ ],
929
+ result: { ok: true, code: "OK", change_id: id, workflow: next.workflow, profile: nextProfile.id, evidence_policy: evidencePolicy(next, nextProfile), orchestration: next.orchestration, artifact_language: next.artifact_language ?? artifactLanguage, status: next.status, phase: to, revision: next.revision, next_skill: skill(to), event: "returned", invalidated_evidence: INVALIDATED_EVIDENCE, ...contractFields(next, contractState(p, next)) }
930
+ });
931
+ }
932
+ if (command === "abort") {
933
+ const reason = option(args, "--reason");
934
+ const note = option(args, "--note");
935
+ if (!reason) return fail("ABORT_REASON_REQUIRED", "Abort requires --reason.");
936
+ if (!ABORT_REASONS.has(reason)) return fail("ABORT_REASON_UNSUPPORTED", `Unsupported Abort reason: ${reason}.`);
937
+ if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
938
+ const next = { ...flow, schema: "matrix/change/v2", status: "aborted", revision: flow.revision + 1, updated_at: stamp() };
939
+ const target = path.join(p.root, "archive", id);
940
+ if (fs.existsSync(target)) return fail("ARCHIVE_TARGET_EXISTS", `Archive target already exists: ${id}.`);
941
+ const sourceEvents = path.join(p.change, "events.jsonl");
942
+ const targetFlow = path.join(target, "matrix.yaml");
943
+ const targetEvents = path.join(target, "events.jsonl");
944
+ return runWorkflowTransaction({
945
+ cwd,
946
+ kind: "abort",
947
+ changeId: id,
948
+ failAfterOperation,
949
+ operations: [
950
+ workflowMoveOperation(cwd, p.change, target),
951
+ workflowWriteOperation(cwd, targetFlow, flowContent(next), { before: captureWorkflowEntry(p.flow) }),
952
+ workflowWriteOperation(cwd, targetEvents, eventContent(sourceEvents, "aborted", flow.phase, flow.phase, next.revision, { reason, ...(note ? { note } : {}) }), { before: captureWorkflowEntry(sourceEvents) }),
953
+ workflowRemoveOperation(cwd, p.active)
954
+ ],
955
+ result: { ok: true, code: "OK", change_id: id, workflow: next.workflow, orchestration: next.orchestration, artifact_language: next.artifact_language ?? artifactLanguage, status: next.status, phase: next.phase, revision: next.revision, next_skill: null, event: "aborted" }
956
+ });
957
+ }
958
+ if (command === "archive") {
959
+ if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
960
+ if (flow.phase !== "archive") return fail("ILLEGAL_TRANSITION", "Only an archive-phase change can be archived.");
961
+ const profile = workflowProfile(p, flow);
962
+ const dryRun = args.includes("--dry-run");
963
+ const expected = option(args, "--expect-preflight");
964
+ const confirmedByUser = args.includes("--confirmed");
965
+ if ((dryRun && (expected || confirmedByUser)) || args.some((arg) => !["--dry-run", "--expect-preflight", "--confirmed", expected].includes(arg))) {
966
+ return { ...fail("ARCHIVE_PREFLIGHT_INVALID", "Archive accepts either --dry-run or --expect-preflight <sha256>, not both or additional arguments."), recovery_command: workflowCommand("archive --dry-run") };
967
+ }
968
+ if (!dryRun && !expected) return { ...fail("ARCHIVE_PREFLIGHT_REQUIRED", "Archive requires an explicit dry-run followed by --expect-preflight <sha256>."), recovery_command: workflowCommand("archive --dry-run") };
969
+ 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: workflowCommand("archive --dry-run") };
970
+ if (expected && shortcut(flow) && !confirmedByUser) return { ...fail("CONFIRMATION_REQUIRED", "Shortcut workflow Archive requires explicit user confirmation."), recovery_command: workflowCommand(`archive --expect-preflight ${expected} --confirmed`) };
971
+ if (dryRun) {
972
+ const result = guard(p, flow, "archive"); if (!result.ok) return { ...result, message: result.message ?? "Archive guard failed." };
973
+ const captured = captureArchivePreflight(p, id); if (captured.failure) return captured.failure;
974
+ const currentFlow = readFlow(p); if (!currentFlow) return fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Canonical workflow state changed during dry-run.");
975
+ const finalGuard = guard(p, currentFlow, "archive"); if (!finalGuard.ok) return { ...finalGuard, message: finalGuard.message ?? "Archive guard failed." };
976
+ const confirmed = captureArchivePreflight(p, id); if (confirmed.failure) return confirmed.failure;
977
+ if (captured.hash !== confirmed.hash) return fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Archive inputs changed during dry-run. Retry dry-run.");
978
+ return {
979
+ ok: true, code: "OK", change_id: id, workflow: currentFlow.workflow, profile: profile.id, evidence_policy: evidencePolicy(currentFlow, profile), orchestration: currentFlow.orchestration, artifact_language: currentFlow.artifact_language ?? artifactLanguage, status: currentFlow.status, phase: currentFlow.phase, revision: currentFlow.revision,
980
+ preflight_hash: confirmed.hash,
981
+ effect_summary: { target: `.matrix/archive/${id}`, files: confirmed.fileCount, directories: confirmed.directoryCount, content_bytes: confirmed.totalBytes },
982
+ commit_command: workflowCommand(`archive --expect-preflight ${confirmed.hash}${shortcut(currentFlow) ? " --confirmed" : ""}`),
983
+ ...contractFields(currentFlow, contractState(p, currentFlow))
984
+ };
985
+ }
986
+ const captured = captureArchivePreflight(p, id);
987
+ if (captured.failure) return captured.failure;
988
+ if (captured.hash !== expected) return archivePreflightChanged(expected, captured.hash);
989
+ const currentFlow = readFlow(p);
990
+ if (!currentFlow || currentFlow.status !== "active" || currentFlow.phase !== "archive") return archivePreflightChanged(expected, captured.hash);
991
+ const result = guard(p, currentFlow, "archive");
992
+ if (!result.ok) return { ...result, message: result.message ?? "Archive guard failed." };
993
+ const confirmed = captureArchivePreflight(p, id);
994
+ if (confirmed.failure) return confirmed.failure;
995
+ if (confirmed.hash !== expected) return archivePreflightChanged(expected, confirmed.hash);
996
+ const contract = contractState(p, currentFlow);
997
+ const next = { ...currentFlow, schema: "matrix/change/v2", status: "archived", revision: currentFlow.revision + 1, updated_at: stamp() };
998
+ const target = path.join(p.root, "archive", id);
999
+ const sourceEvents = path.join(p.change, "events.jsonl");
1000
+ const targetFlow = path.join(target, "matrix.yaml");
1001
+ const targetEvents = path.join(target, "events.jsonl");
1002
+ return runWorkflowTransaction({
1003
+ cwd,
1004
+ kind: "archive",
1005
+ changeId: id,
1006
+ failAfterOperation,
1007
+ operations: [
1008
+ workflowMoveOperation(cwd, p.change, target),
1009
+ workflowWriteOperation(cwd, targetFlow, flowContent(next), { before: captureWorkflowEntry(p.flow) }),
1010
+ workflowWriteOperation(cwd, targetEvents, eventContent(sourceEvents, "archived", "archive", "archive", next.revision, { preflight_hash: expected, confirmed: confirmedByUser }), { before: captureWorkflowEntry(sourceEvents) }),
1011
+ workflowRemoveOperation(cwd, p.active)
1012
+ ],
1013
+ result: {
1014
+ ok: true, code: "OK", archived: id, change_id: id, workflow: next.workflow, profile: profile.id, evidence_policy: evidencePolicy(next, profile), orchestration: next.orchestration, artifact_language: next.artifact_language ?? artifactLanguage, status: next.status, phase: next.phase, revision: next.revision, next_skill: null, event: "archived",
1015
+ preflight_hash: expected,
1016
+ effect_summary: { target: `.matrix/archive/${id}`, files: confirmed.fileCount, directories: confirmed.directoryCount, content_bytes: confirmed.totalBytes },
1017
+ ...contractFields(next, contract)
1018
+ }
1019
+ });
1020
+ }
1021
+ 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
1022
  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;
1023
+ if (flow.phase !== "build") return fail("ILLEGAL_PHASE", "Export requires the build phase.");
1024
+ if (workflowProfile(p, flow).id === "lightweight") return fail("EXPORT_UNSUPPORTED_WORKFLOW", "Claude export requires the full three-artifact workflow contract.");
1025
+ const contract = contractState(p, flow); const failure = contractFailure(flow, contract); if (failure) return failure;
541
1026
  const taskId = args.includes("--task-id") ? args[args.indexOf("--task-id") + 1] : args[0];
542
1027
  const target = args.includes("--target") ? args[args.indexOf("--target") + 1] : "generic";
543
1028
  const applyBoard = args.includes("--apply-fnsec-board");
544
1029
  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;
1030
+ const { proposal, design, plan } = contract.snapshot.contents;
546
1031
  if (target === "generic") {
547
1032
  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));
1033
+ fs.writeFileSync(destination, taskContent(id, taskId, proposal, design, plan, path.relative(cwd, result).replaceAll("\\", "/"), flow.approved_contract_hash, flow.contract_approved_revision));
549
1034
  return { ok: true, code: "OK", path: destination };
550
1035
  }
551
1036
  const tasks = path.join(cwd, "docs", "tasks"); const board = path.join(tasks, "active.md");
@@ -554,7 +1039,7 @@ export function invoke(argv, { cwd = process.cwd(), failAfterOperation = null }
554
1039
  if (exists(destination) || exists(result)) return fail("FNSEC_TASK_EXISTS", `FnSec task or result path already exists for ${taskId}.`);
555
1040
  const currentBoard = fs.readFileSync(board, "utf8");
556
1041
  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));
1042
+ fs.writeFileSync(destination, taskContent(id, taskId, proposal, design, plan, path.relative(cwd, result).replaceAll("\\", "/"), flow.approved_contract_hash, flow.contract_approved_revision));
558
1043
  if (applyBoard) fs.writeFileSync(board, activeBoard(taskId, path.relative(cwd, destination).replaceAll("\\", "/"), path.relative(cwd, result).replaceAll("\\", "/"), id));
559
1044
  return { ok: true, code: "OK", path: destination, board: applyBoard ? board : null };
560
1045
  }