@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.
- package/LICENSE +21 -21
- package/README.md +72 -63
- package/README.zh-CN.md +70 -61
- package/{.claude → assets}/skills/matrix/SKILL.md +81 -86
- package/{.claude → assets}/skills/matrix/agents/openai.yaml +4 -4
- package/{.claude → assets}/skills/matrix/scripts/matrix_claude.py +11 -11
- package/{.claude → assets}/skills/matrix/scripts/matrix_state.py +60 -60
- package/assets/skills/matrix/scripts/test_matrix_claude.py +69 -0
- package/assets/skills/matrix/scripts/test_matrix_state.py +69 -0
- package/{.claude → assets}/skills/matrix-archive/SKILL.md +27 -27
- package/{.claude/skills/matrix-hotfix → assets/skills/matrix-archive}/agents/openai.yaml +4 -4
- package/{.claude → assets}/skills/matrix-build/SKILL.md +41 -38
- package/{.claude/skills/matrix-archive → assets/skills/matrix-build}/agents/openai.yaml +4 -4
- package/{.claude → assets}/skills/matrix-claude/SKILL.md +93 -93
- package/{.claude → assets}/skills/matrix-claude/agents/openai.yaml +4 -4
- package/{.claude → assets}/skills/matrix-design/SKILL.md +69 -67
- package/{.claude → assets}/skills/matrix-design/agents/openai.yaml +4 -4
- package/{.claude → assets}/skills/matrix-hotfix/SKILL.md +7 -7
- package/{.claude/skills/matrix-build → assets/skills/matrix-hotfix}/agents/openai.yaml +4 -4
- package/{.claude → assets}/skills/matrix-open/SKILL.md +26 -24
- package/assets/skills/matrix-open/agents/openai.yaml +4 -0
- package/{.claude → assets}/skills/matrix-status/SKILL.md +14 -14
- package/{.claude → assets}/skills/matrix-status/agents/openai.yaml +4 -4
- package/{.claude → assets}/skills/matrix-tweak/SKILL.md +7 -7
- package/assets/skills/matrix-tweak/agents/openai.yaml +4 -0
- package/assets/skills/matrix-verify/SKILL.md +89 -0
- package/assets/skills/matrix-verify/agents/openai.yaml +4 -0
- package/assets/skills-zh-CN/matrix/SKILL.md +27 -27
- package/assets/skills-zh-CN/matrix-archive/SKILL.md +10 -10
- package/assets/skills-zh-CN/matrix-build/SKILL.md +13 -11
- package/assets/skills-zh-CN/matrix-claude/SKILL.md +10 -10
- package/assets/skills-zh-CN/matrix-design/SKILL.md +12 -10
- package/assets/skills-zh-CN/matrix-hotfix/SKILL.md +1 -1
- package/assets/skills-zh-CN/matrix-open/SKILL.md +18 -16
- package/assets/skills-zh-CN/matrix-status/SKILL.md +3 -3
- package/assets/skills-zh-CN/matrix-tweak/SKILL.md +1 -1
- package/assets/skills-zh-CN/matrix-verify/SKILL.md +25 -9
- package/bin/matrix.js +15 -15
- package/package.json +4 -13
- package/src/catalog.js +110 -92
- package/src/cli.js +138 -80
- package/src/distribution.js +379 -253
- package/src/installer.js +4 -4
- package/src/matt-catalog.mjs +67 -0
- package/src/matt.js +91 -9
- package/src/messages.js +14 -10
- package/src/runtime-resolver.js +76 -0
- package/src/self-update.js +2 -2
- package/src/workflow-transaction.js +9 -5
- package/src/workflow.js +688 -429
- package/.claude/skills/matrix-open/agents/openai.yaml +0 -4
- package/.claude/skills/matrix-tweak/agents/openai.yaml +0 -4
- package/.claude/skills/matrix-verify/SKILL.md +0 -73
- 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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
hash.update(
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
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(
|
|
278
|
-
(
|
|
279
|
-
(
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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) => (
|
|
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: "
|
|
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
|
-
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
...(
|
|
673
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
717
|
-
if (expected && shortcut(flow) && !confirmedByUser) return { ...fail("CONFIRMATION_REQUIRED", "Shortcut workflow Archive requires explicit user confirmation."), recovery_command: `
|
|
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: `
|
|
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
|
+
}
|