@rosenfan/matrix 0.1.3 → 0.1.5

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