@rosenfan/matrix 0.1.4 → 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 +29 -29
- package/README.zh-CN.md +27 -27
- package/{.claude → assets}/skills/matrix/SKILL.md +78 -78
- 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 +49 -49
- 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 +36 -36
- package/{.claude/skills/matrix-hotfix → assets/skills/matrix-archive}/agents/openai.yaml +4 -4
- package/{.claude → assets}/skills/matrix-build/SKILL.md +41 -41
- package/{.claude/skills/matrix-archive → assets/skills/matrix-build}/agents/openai.yaml +4 -4
- package/{.claude → assets}/skills/matrix-claude/SKILL.md +98 -98
- package/{.claude → assets}/skills/matrix-claude/agents/openai.yaml +4 -4
- package/{.claude → assets}/skills/matrix-design/SKILL.md +65 -65
- package/{.claude → assets}/skills/matrix-design/agents/openai.yaml +4 -4
- package/{.claude → assets}/skills/matrix-hotfix/SKILL.md +8 -8
- package/{.claude/skills/matrix-build → assets/skills/matrix-hotfix}/agents/openai.yaml +4 -4
- package/{.claude → assets}/skills/matrix-open/SKILL.md +39 -39
- package/assets/skills/matrix-open/agents/openai.yaml +4 -0
- package/{.claude → assets}/skills/matrix-status/SKILL.md +15 -15
- package/{.claude → assets}/skills/matrix-status/agents/openai.yaml +4 -4
- package/{.claude → assets}/skills/matrix-tweak/SKILL.md +8 -8
- package/assets/skills/matrix-tweak/agents/openai.yaml +4 -0
- package/{.claude → assets}/skills/matrix-verify/SKILL.md +62 -62
- package/assets/skills/matrix-verify/agents/openai.yaml +4 -0
- package/assets/skills-zh-CN/matrix/SKILL.md +14 -14
- package/assets/skills-zh-CN/matrix-build/SKILL.md +4 -4
- package/assets/skills-zh-CN/matrix-design/SKILL.md +4 -4
- package/assets/skills-zh-CN/matrix-open/SKILL.md +3 -3
- package/assets/skills-zh-CN/matrix-verify/SKILL.md +19 -19
- package/bin/matrix.js +15 -15
- package/package.json +4 -13
- package/src/catalog.js +79 -72
- package/src/cli.js +100 -100
- package/src/distribution.js +181 -178
- package/src/installer.js +10 -10
- package/src/matt-catalog.mjs +1 -1
- package/src/matt.js +115 -115
- package/src/messages.js +14 -14
- package/src/runtime-resolver.js +76 -76
- package/src/self-update.js +81 -81
- package/src/workflow-transaction.js +13 -13
- package/src/workflow.js +711 -705
- 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/agents/openai.yaml +0 -4
package/src/workflow.js
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
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
|
-
|
|
19
|
-
const PHASES = ["open", "design", "build", "verify", "archive"];
|
|
20
|
-
const FULL_PROFILE = {
|
|
21
|
-
id: "full",
|
|
22
|
-
next: { open: "design", design: "build", build: "verify", verify: "archive" },
|
|
23
|
-
contractFiles: ["proposal.md", "design.md", "plan.md"]
|
|
24
|
-
};
|
|
25
|
-
const LIGHTWEIGHT_PROFILE = {
|
|
26
|
-
id: "lightweight",
|
|
27
|
-
next: { open: "build", build: "verify", verify: "archive" },
|
|
28
|
-
contractFiles: ["proposal.md"]
|
|
29
|
-
};
|
|
30
|
-
const FULL_RETURNS = [
|
|
31
|
-
{ from: "build", to: "design", reason: "design-gap" },
|
|
32
|
-
{ from: "verify", to: "build", reason: "verification-failed" },
|
|
33
|
-
{ from: "verify", to: "design", reason: "acceptance-or-design-gap" },
|
|
34
|
-
{ from: "archive", to: "design", reason: "acceptance-or-design-gap" }
|
|
35
|
-
];
|
|
36
|
-
const LIGHTWEIGHT_RETURNS = [
|
|
37
|
-
{ from: "build", to: "design", reason: "design-gap", upgrade: true },
|
|
38
|
-
{ from: "verify", to: "build", reason: "verification-failed" },
|
|
39
|
-
{ from: "verify", to: "design", reason: "acceptance-or-design-gap", upgrade: true },
|
|
40
|
-
{ from: "archive", to: "design", reason: "acceptance-or-design-gap", upgrade: true }
|
|
41
|
-
];
|
|
42
|
-
const RETURN_REASONS = new Set([...FULL_RETURNS, ...LIGHTWEIGHT_RETURNS].map(({ reason }) => reason));
|
|
43
|
-
const INVALIDATED_EVIDENCE = ["build", "test", "review", "archive"];
|
|
44
|
-
const ABORT_REASONS = new Set(["requirement-cancelled", "superseded", "no-longer-valuable", "blocked", "other"]);
|
|
8
|
+
import {
|
|
9
|
+
captureWorkflowEntry,
|
|
10
|
+
runWorkflowTransaction,
|
|
11
|
+
workflowDoctor,
|
|
12
|
+
workflowMkdirOperation,
|
|
13
|
+
workflowMutationGate,
|
|
14
|
+
workflowMoveOperation,
|
|
15
|
+
workflowRemoveOperation,
|
|
16
|
+
workflowWriteOperation
|
|
17
|
+
} from "./workflow-transaction.js";
|
|
18
|
+
|
|
19
|
+
const PHASES = ["open", "design", "build", "verify", "archive"];
|
|
20
|
+
const FULL_PROFILE = {
|
|
21
|
+
id: "full",
|
|
22
|
+
next: { open: "design", design: "build", build: "verify", verify: "archive" },
|
|
23
|
+
contractFiles: ["proposal.md", "design.md", "plan.md"]
|
|
24
|
+
};
|
|
25
|
+
const LIGHTWEIGHT_PROFILE = {
|
|
26
|
+
id: "lightweight",
|
|
27
|
+
next: { open: "build", build: "verify", verify: "archive" },
|
|
28
|
+
contractFiles: ["proposal.md"]
|
|
29
|
+
};
|
|
30
|
+
const FULL_RETURNS = [
|
|
31
|
+
{ from: "build", to: "design", reason: "design-gap" },
|
|
32
|
+
{ from: "verify", to: "build", reason: "verification-failed" },
|
|
33
|
+
{ from: "verify", to: "design", reason: "acceptance-or-design-gap" },
|
|
34
|
+
{ from: "archive", to: "design", reason: "acceptance-or-design-gap" }
|
|
35
|
+
];
|
|
36
|
+
const LIGHTWEIGHT_RETURNS = [
|
|
37
|
+
{ from: "build", to: "design", reason: "design-gap", upgrade: true },
|
|
38
|
+
{ from: "verify", to: "build", reason: "verification-failed" },
|
|
39
|
+
{ from: "verify", to: "design", reason: "acceptance-or-design-gap", upgrade: true },
|
|
40
|
+
{ from: "archive", to: "design", reason: "acceptance-or-design-gap", upgrade: true }
|
|
41
|
+
];
|
|
42
|
+
const RETURN_REASONS = new Set([...FULL_RETURNS, ...LIGHTWEIGHT_RETURNS].map(({ reason }) => reason));
|
|
43
|
+
const INVALIDATED_EVIDENCE = ["build", "test", "review", "archive"];
|
|
44
|
+
const ABORT_REASONS = new Set(["requirement-cancelled", "superseded", "no-longer-valuable", "blocked", "other"]);
|
|
45
45
|
const ORCHESTRATIONS = ["prim", "arch"];
|
|
46
46
|
const quoteCommandPath = (value) => process.platform === "win32" ? `"${value}"` : `'${value.replaceAll("'", "'\\''")}'`;
|
|
47
47
|
const workflowCommand = (argumentsText) => `node ${quoteCommandPath(fileURLToPath(import.meta.url))} ${argumentsText}`;
|
|
@@ -49,26 +49,26 @@ const LEGACY_ARCH_SKILLS = [
|
|
|
49
49
|
"grilling", "domain-modeling", "research", "wayfinder", "prototype",
|
|
50
50
|
"codebase-design", "tdd", "diagnosing-bugs", "resolving-merge-conflicts", "code-review"
|
|
51
51
|
];
|
|
52
|
-
const ARTIFACT_POLICY = {
|
|
53
|
-
proposal: ["## Goal", "## Scope", "## Non-goals", "## Acceptance", "## Risks"],
|
|
54
|
-
design: ["## Decisions", "## Boundaries", "## Test seams", "## Risks"],
|
|
55
|
-
plan: ["## Steps", "## Validation", "## Stop conditions"],
|
|
56
|
-
build: ["## Build evidence"],
|
|
57
|
-
verification: ["## Test evidence", "## Review evidence"]
|
|
58
|
-
};
|
|
59
|
-
const LIGHTWEIGHT_COMMON_PROPOSAL = ["## Goal", "## Scope", "## Non-goals", "## Approach", "## Acceptance", "## Validation", "## Risks", "## Upgrade conditions"];
|
|
60
|
-
const LIGHTWEIGHT_POLICY = {
|
|
61
|
-
hotfix: {
|
|
62
|
-
proposal: [...LIGHTWEIGHT_COMMON_PROPOSAL, "## Expected behavior", "## Actual behavior", "## Reproduction"],
|
|
63
|
-
build: ["## Build evidence", "## Reproduction evidence", "## Root cause"],
|
|
64
|
-
verification: ["## Test evidence", "## Regression evidence", "## Review evidence"]
|
|
65
|
-
},
|
|
66
|
-
tweak: {
|
|
67
|
-
proposal: [...LIGHTWEIGHT_COMMON_PROPOSAL, "## Current behavior", "## Preserved behavior", "## Diff boundary"],
|
|
68
|
-
build: ["## Build evidence", "## Scope evidence"],
|
|
69
|
-
verification: ["## Test evidence", "## Scope review evidence", "## Review evidence"]
|
|
70
|
-
}
|
|
71
|
-
};
|
|
52
|
+
const ARTIFACT_POLICY = {
|
|
53
|
+
proposal: ["## Goal", "## Scope", "## Non-goals", "## Acceptance", "## Risks"],
|
|
54
|
+
design: ["## Decisions", "## Boundaries", "## Test seams", "## Risks"],
|
|
55
|
+
plan: ["## Steps", "## Validation", "## Stop conditions"],
|
|
56
|
+
build: ["## Build evidence"],
|
|
57
|
+
verification: ["## Test evidence", "## Review evidence"]
|
|
58
|
+
};
|
|
59
|
+
const LIGHTWEIGHT_COMMON_PROPOSAL = ["## Goal", "## Scope", "## Non-goals", "## Approach", "## Acceptance", "## Validation", "## Risks", "## Upgrade conditions"];
|
|
60
|
+
const LIGHTWEIGHT_POLICY = {
|
|
61
|
+
hotfix: {
|
|
62
|
+
proposal: [...LIGHTWEIGHT_COMMON_PROPOSAL, "## Expected behavior", "## Actual behavior", "## Reproduction"],
|
|
63
|
+
build: ["## Build evidence", "## Reproduction evidence", "## Root cause"],
|
|
64
|
+
verification: ["## Test evidence", "## Regression evidence", "## Review evidence"]
|
|
65
|
+
},
|
|
66
|
+
tweak: {
|
|
67
|
+
proposal: [...LIGHTWEIGHT_COMMON_PROPOSAL, "## Current behavior", "## Preserved behavior", "## Diff boundary"],
|
|
68
|
+
build: ["## Build evidence", "## Scope evidence"],
|
|
69
|
+
verification: ["## Test evidence", "## Scope review evidence", "## Review evidence"]
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
72
|
const WORKSPACE_BASELINE_FILE = "workspace-baseline.json";
|
|
73
73
|
const WORKSPACE_EXCLUDES = new Set([".git", ".matrix", ".comet", "node_modules"]);
|
|
74
74
|
const WORKSPACE_MAX_FILES = 50000;
|
|
@@ -78,139 +78,145 @@ const REVIEW_RECEIPT_SCHEMA = "matrix/review-receipt/v1";
|
|
|
78
78
|
const REVIEW_SOURCES = new Set(["code-review", "matrix-fallback"]);
|
|
79
79
|
const REVIEW_OUTCOMES = new Set(["passed", "failed"]);
|
|
80
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; }
|
|
89
|
-
function flatYaml(file) {
|
|
90
|
-
try {
|
|
91
|
-
return Object.fromEntries(fs.readFileSync(file, "utf8").split(/\r?\n/).filter((line) => line.includes(":") && !line.startsWith(" ")).map((line) => {
|
|
92
|
-
const [key, ...value] = line.split(":");
|
|
93
|
-
return [key.trim(), value.join(":").trim().replace(/^"|"$/g, "")];
|
|
94
|
-
}));
|
|
95
|
-
} catch { return {}; }
|
|
96
|
-
}
|
|
97
|
-
function excludedWorkspacePath(relative) {
|
|
98
|
-
const first = relative.replaceAll("\\", "/").split("/")[0];
|
|
99
|
-
return WORKSPACE_EXCLUDES.has(first);
|
|
100
|
-
}
|
|
101
|
-
function workspaceFiles(cwd) {
|
|
102
|
-
const git = spawnSync("git", ["-C", cwd, "ls-files", "-co", "--exclude-standard", "-z"], {
|
|
103
|
-
encoding: "buffer",
|
|
104
|
-
windowsHide: true,
|
|
105
|
-
maxBuffer: 64 * 1024 * 1024
|
|
106
|
-
});
|
|
107
|
-
if (!git.error && git.status === 0) {
|
|
108
|
-
return {
|
|
109
|
-
provider: "git",
|
|
110
|
-
files: [...new Set(git.stdout.toString("utf8").split("\0").filter(Boolean))]
|
|
111
|
-
.map((relative) => relative.replaceAll("\\", "/"))
|
|
112
|
-
.filter((relative) => !excludedWorkspacePath(relative))
|
|
113
|
-
.sort()
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
const files = [];
|
|
117
|
-
const visit = (directory, prefix = "") => {
|
|
118
|
-
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
119
|
-
const relative = path.join(prefix, entry.name).replaceAll("\\", "/");
|
|
120
|
-
if (!prefix && WORKSPACE_EXCLUDES.has(entry.name)) continue;
|
|
121
|
-
if (entry.isDirectory()) visit(path.join(directory, entry.name), relative);
|
|
122
|
-
else files.push(relative);
|
|
123
|
-
if (files.length > WORKSPACE_MAX_FILES) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_FILES} files.`);
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
visit(cwd);
|
|
127
|
-
return { provider: "physical", files };
|
|
128
|
-
}
|
|
129
|
-
function captureWorkspaceBaseline(cwd) {
|
|
130
|
-
const startedAt = Date.now();
|
|
131
|
-
const selection = workspaceFiles(cwd);
|
|
132
|
-
const hash = crypto.createHash("sha256");
|
|
133
|
-
hash.update("matrix/workspace-baseline/v1\0", "utf8");
|
|
134
|
-
let totalBytes = 0;
|
|
135
|
-
let fileCount = 0;
|
|
136
|
-
for (const relative of selection.files) {
|
|
137
|
-
if (Date.now() - startedAt > WORKSPACE_MAX_DURATION_MS) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_DURATION_MS} milliseconds.`);
|
|
138
|
-
if (fileCount >= WORKSPACE_MAX_FILES) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_FILES} files.`);
|
|
139
|
-
const absolute = path.join(cwd, relative);
|
|
140
|
-
const pathBytes = Buffer.from(relative, "utf8");
|
|
141
|
-
const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
|
|
142
|
-
hash.update(pathLength); hash.update(pathBytes);
|
|
143
|
-
let stat;
|
|
144
|
-
try { stat = fs.lstatSync(absolute); }
|
|
145
|
-
catch (error) {
|
|
146
|
-
if (error.code !== "ENOENT") throw error;
|
|
147
|
-
hash.update("missing\0", "utf8");
|
|
148
|
-
fileCount += 1;
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (stat.isSymbolicLink()) {
|
|
152
|
-
const target = Buffer.from(fs.readlinkSync(absolute), "utf8");
|
|
153
|
-
hash.update("symlink\0", "utf8"); hash.update(target);
|
|
154
|
-
totalBytes += target.length;
|
|
155
|
-
} else if (stat.isFile()) {
|
|
156
|
-
const bytes = fs.readFileSync(absolute);
|
|
157
|
-
totalBytes += bytes.length;
|
|
158
|
-
if (totalBytes > WORKSPACE_MAX_BYTES) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_BYTES} bytes.`);
|
|
159
|
-
hash.update("file\0", "utf8"); hash.update(bytes);
|
|
160
|
-
} else {
|
|
161
|
-
hash.update("special\0", "utf8");
|
|
162
|
-
}
|
|
163
|
-
fileCount += 1;
|
|
164
|
-
}
|
|
165
|
-
return {
|
|
166
|
-
schema: "matrix/workspace-baseline/v1",
|
|
167
|
-
provider: selection.provider,
|
|
168
|
-
hash: `sha256:${hash.digest("hex")}`,
|
|
169
|
-
file_count: fileCount,
|
|
170
|
-
content_bytes: totalBytes
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
function workspaceOrderGuard(cwd, p) {
|
|
174
|
-
let baseline;
|
|
175
|
-
try { baseline = read(p.workspaceBaseline); }
|
|
176
|
-
catch (error) {
|
|
177
|
-
return fail("WORKSPACE_BASELINE_MISSING", `Lightweight workflow baseline is unavailable: ${error.message}`);
|
|
178
|
-
}
|
|
179
|
-
let current;
|
|
180
|
-
try { current = captureWorkspaceBaseline(cwd); }
|
|
181
|
-
catch (error) {
|
|
182
|
-
return fail("WORKSPACE_INSPECTION_FAILED", `Could not inspect the lightweight workflow boundary: ${error.message}`);
|
|
183
|
-
}
|
|
184
|
-
if (baseline.schema !== "matrix/workspace-baseline/v1" || !/^sha256:[0-9a-f]{64}$/.test(baseline.hash ?? "")) {
|
|
185
|
-
return fail("WORKSPACE_BASELINE_INVALID", "Lightweight workflow baseline is invalid.");
|
|
186
|
-
}
|
|
187
|
-
if (current.hash !== baseline.hash) {
|
|
188
|
-
return {
|
|
189
|
-
...fail("PREMATURE_IMPLEMENTATION", "Project files changed after lightweight initialization and before Build approval."),
|
|
190
|
-
baseline_hash: baseline.hash,
|
|
191
|
-
current_hash: current.hash
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
return { ok: true, code: "OK", baseline_hash: baseline.hash, current_hash: current.hash };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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; }
|
|
89
|
+
function flatYaml(file) {
|
|
90
|
+
try {
|
|
91
|
+
return Object.fromEntries(fs.readFileSync(file, "utf8").split(/\r?\n/).filter((line) => line.includes(":") && !line.startsWith(" ")).map((line) => {
|
|
92
|
+
const [key, ...value] = line.split(":");
|
|
93
|
+
return [key.trim(), value.join(":").trim().replace(/^"|"$/g, "")];
|
|
94
|
+
}));
|
|
95
|
+
} catch { return {}; }
|
|
96
|
+
}
|
|
97
|
+
function excludedWorkspacePath(relative) {
|
|
98
|
+
const first = relative.replaceAll("\\", "/").split("/")[0];
|
|
99
|
+
return WORKSPACE_EXCLUDES.has(first);
|
|
100
|
+
}
|
|
101
|
+
function workspaceFiles(cwd) {
|
|
102
|
+
const git = spawnSync("git", ["-C", cwd, "ls-files", "-co", "--exclude-standard", "-z"], {
|
|
103
|
+
encoding: "buffer",
|
|
104
|
+
windowsHide: true,
|
|
105
|
+
maxBuffer: 64 * 1024 * 1024
|
|
106
|
+
});
|
|
107
|
+
if (!git.error && git.status === 0) {
|
|
108
|
+
return {
|
|
109
|
+
provider: "git",
|
|
110
|
+
files: [...new Set(git.stdout.toString("utf8").split("\0").filter(Boolean))]
|
|
111
|
+
.map((relative) => relative.replaceAll("\\", "/"))
|
|
112
|
+
.filter((relative) => !excludedWorkspacePath(relative))
|
|
113
|
+
.sort()
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const files = [];
|
|
117
|
+
const visit = (directory, prefix = "") => {
|
|
118
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
119
|
+
const relative = path.join(prefix, entry.name).replaceAll("\\", "/");
|
|
120
|
+
if (!prefix && WORKSPACE_EXCLUDES.has(entry.name)) continue;
|
|
121
|
+
if (entry.isDirectory()) visit(path.join(directory, entry.name), relative);
|
|
122
|
+
else files.push(relative);
|
|
123
|
+
if (files.length > WORKSPACE_MAX_FILES) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_FILES} files.`);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
visit(cwd);
|
|
127
|
+
return { provider: "physical", files };
|
|
128
|
+
}
|
|
129
|
+
function captureWorkspaceBaseline(cwd) {
|
|
130
|
+
const startedAt = Date.now();
|
|
131
|
+
const selection = workspaceFiles(cwd);
|
|
132
|
+
const hash = crypto.createHash("sha256");
|
|
133
|
+
hash.update("matrix/workspace-baseline/v1\0", "utf8");
|
|
134
|
+
let totalBytes = 0;
|
|
135
|
+
let fileCount = 0;
|
|
136
|
+
for (const relative of selection.files) {
|
|
137
|
+
if (Date.now() - startedAt > WORKSPACE_MAX_DURATION_MS) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_DURATION_MS} milliseconds.`);
|
|
138
|
+
if (fileCount >= WORKSPACE_MAX_FILES) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_FILES} files.`);
|
|
139
|
+
const absolute = path.join(cwd, relative);
|
|
140
|
+
const pathBytes = Buffer.from(relative, "utf8");
|
|
141
|
+
const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
|
|
142
|
+
hash.update(pathLength); hash.update(pathBytes);
|
|
143
|
+
let stat;
|
|
144
|
+
try { stat = fs.lstatSync(absolute); }
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error.code !== "ENOENT") throw error;
|
|
147
|
+
hash.update("missing\0", "utf8");
|
|
148
|
+
fileCount += 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (stat.isSymbolicLink()) {
|
|
152
|
+
const target = Buffer.from(fs.readlinkSync(absolute), "utf8");
|
|
153
|
+
hash.update("symlink\0", "utf8"); hash.update(target);
|
|
154
|
+
totalBytes += target.length;
|
|
155
|
+
} else if (stat.isFile()) {
|
|
156
|
+
const bytes = fs.readFileSync(absolute);
|
|
157
|
+
totalBytes += bytes.length;
|
|
158
|
+
if (totalBytes > WORKSPACE_MAX_BYTES) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_BYTES} bytes.`);
|
|
159
|
+
hash.update("file\0", "utf8"); hash.update(bytes);
|
|
160
|
+
} else {
|
|
161
|
+
hash.update("special\0", "utf8");
|
|
162
|
+
}
|
|
163
|
+
fileCount += 1;
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
schema: "matrix/workspace-baseline/v1",
|
|
167
|
+
provider: selection.provider,
|
|
168
|
+
hash: `sha256:${hash.digest("hex")}`,
|
|
169
|
+
file_count: fileCount,
|
|
170
|
+
content_bytes: totalBytes
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function workspaceOrderGuard(cwd, p) {
|
|
174
|
+
let baseline;
|
|
175
|
+
try { baseline = read(p.workspaceBaseline); }
|
|
176
|
+
catch (error) {
|
|
177
|
+
return fail("WORKSPACE_BASELINE_MISSING", `Lightweight workflow baseline is unavailable: ${error.message}`);
|
|
178
|
+
}
|
|
179
|
+
let current;
|
|
180
|
+
try { current = captureWorkspaceBaseline(cwd); }
|
|
181
|
+
catch (error) {
|
|
182
|
+
return fail("WORKSPACE_INSPECTION_FAILED", `Could not inspect the lightweight workflow boundary: ${error.message}`);
|
|
183
|
+
}
|
|
184
|
+
if (baseline.schema !== "matrix/workspace-baseline/v1" || !/^sha256:[0-9a-f]{64}$/.test(baseline.hash ?? "")) {
|
|
185
|
+
return fail("WORKSPACE_BASELINE_INVALID", "Lightweight workflow baseline is invalid.");
|
|
186
|
+
}
|
|
187
|
+
if (current.hash !== baseline.hash) {
|
|
188
|
+
return {
|
|
189
|
+
...fail("PREMATURE_IMPLEMENTATION", "Project files changed after lightweight initialization and before Build approval."),
|
|
190
|
+
baseline_hash: baseline.hash,
|
|
191
|
+
current_hash: current.hash
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, code: "OK", baseline_hash: baseline.hash, current_hash: current.hash };
|
|
195
|
+
}
|
|
196
|
+
// 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__"]);
|
|
201
|
+
function hashDirectory(directory) {
|
|
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
|
+
}
|
|
214
220
|
function orchestrationConfig(cwd) {
|
|
215
221
|
const config = flatYaml(path.join(rootFor(cwd), "config.yaml"));
|
|
216
222
|
const scope = config.installation_scope === "global" ? "global" : "project";
|
|
@@ -220,10 +226,10 @@ function orchestrationConfig(cwd) {
|
|
|
220
226
|
let mattReceipt = null;
|
|
221
227
|
try { manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8")); } catch {}
|
|
222
228
|
try { mattReceipt = JSON.parse(fs.readFileSync(mattReceiptFile, "utf8")); } catch {}
|
|
223
|
-
const defaultOrchestration = ORCHESTRATIONS.includes(config.default_orchestration) ? config.default_orchestration
|
|
224
|
-
: ORCHESTRATIONS.includes(manifest?.orchestration?.default) ? manifest.orchestration.default : "prim";
|
|
225
|
-
const artifactLanguage = ["en", "zh-CN"].includes(config.language) ? config.language
|
|
226
|
-
: ["en", "zh-CN"].includes(manifest?.language) ? manifest.language : "en";
|
|
229
|
+
const defaultOrchestration = ORCHESTRATIONS.includes(config.default_orchestration) ? config.default_orchestration
|
|
230
|
+
: ORCHESTRATIONS.includes(manifest?.orchestration?.default) ? manifest.orchestration.default : "prim";
|
|
231
|
+
const artifactLanguage = ["en", "zh-CN"].includes(config.language) ? config.language
|
|
232
|
+
: ["en", "zh-CN"].includes(manifest?.language) ? manifest.language : "en";
|
|
227
233
|
return { config, scope, manifestFile, manifest, mattReceiptFile, mattReceipt, defaultOrchestration, artifactLanguage };
|
|
228
234
|
}
|
|
229
235
|
function archIntegrity(cwd, flow = null, contentHashes = MATT_CONTENT_HASHES) {
|
|
@@ -258,26 +264,26 @@ function archIntegrity(cwd, flow = null, contentHashes = MATT_CONTENT_HASHES) {
|
|
|
258
264
|
try {
|
|
259
265
|
const actual = hashDirectory(target);
|
|
260
266
|
if (legacy ? actual !== installed.hash : actual !== installed.hash || actual !== contentHashes[skill]) findings.push({ platform, skill, reason: "modified" });
|
|
261
|
-
} catch { findings.push({ platform, skill, reason: "unreadable" }); }
|
|
262
|
-
}
|
|
263
|
-
}
|
|
267
|
+
} catch { findings.push({ platform, skill, reason: "unreadable" }); }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
264
270
|
return findings.length ? {
|
|
265
271
|
ok: false, code: "ARCH_INSTALLATION_INCOMPLETE", message: "Arch installation integrity failed. Repair the managed cohort before continuing.",
|
|
266
272
|
manifest: legacy ? manifestFile : mattReceiptFile, findings, recovery_command: "matrix init --with-mattpocock"
|
|
267
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 };
|
|
268
274
|
}
|
|
269
|
-
function taskContent(change, taskId, proposal, design, plan, resultPath, contractHash, approvalRevision) {
|
|
270
|
-
return `# Claude Code task: ${taskId}\n\n## Metadata\n\n| Field | Value |\n| --- | --- |\n| Status | CLAUDE_QUEUED |\n| Matrix change | ${change} |\n| Decision owner | Codex |\n| Implementation actor | Claude Code |\n| Approved contract | ${contractHash} |\n| Approval revision | ${approvalRevision} |\n| Result | ${resultPath} |\n\n## Objective\n\nThe frozen Matrix artifacts below are authoritative. Do not expand their scope.\n\n## Proposal\n\n${proposal}\n\n## Frozen design\n\n${design}\n\n## Ordered plan\n\n${plan}\n\n## Allowed work\n\n- Read repository instructions and existing tests before implementing.\n- Change only files required by the frozen design and plan.\n\n## Prohibited work\n\n- Do not edit Matrix state, transition its phase, archive it, or alter the frozen design.\n- Do not add unrelated features, public APIs, dependencies, or external side effects.\n- Do not claim completion without recording verifiable evidence.\n\n## Stop and report\n\nStop with \`NEEDS_DECISION\` if the scope must expand, the design conflicts with the repository, validation cannot pass within scope, or an authorization/safety issue arises.\n\n## Required result\n\nCreate \`${resultPath}\` with metadata, changed files and reasons, commands and outcomes, test results, boundary confirmation, residual risks, and any \`NEEDS_DECISION\` items. Codex reviews the result and the actual diff.\n`;
|
|
271
|
-
}
|
|
272
|
-
function activeBoard(taskId, taskPath, resultPath, change) {
|
|
273
|
-
return `# Active task board\n\n## Current objective\n\n| Field | Value |\n| --- | --- |\n| ID | ${taskId} |\n| Status | CLAUDE_QUEUED |\n| Matrix change | ${change} |\n| Task | ${taskPath} |\n| Result | ${resultPath} |\n`;
|
|
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
|
+
}
|
|
275
281
|
function paths(cwd, id, archived = false) {
|
|
276
|
-
const root = rootFor(cwd);
|
|
277
|
-
const change = path.join(root, archived ? "archive" : "changes", id);
|
|
278
|
-
return {
|
|
279
|
-
root,
|
|
280
|
-
active: path.join(root, "active.json"),
|
|
282
|
+
const root = rootFor(cwd);
|
|
283
|
+
const change = path.join(root, archived ? "archive" : "changes", id);
|
|
284
|
+
return {
|
|
285
|
+
root,
|
|
286
|
+
active: path.join(root, "active.json"),
|
|
281
287
|
change,
|
|
282
288
|
flow: path.join(change, "matrix.yaml"),
|
|
283
289
|
artifacts: path.join(change, "artifacts"),
|
|
@@ -285,53 +291,53 @@ function paths(cwd, id, archived = false) {
|
|
|
285
291
|
workspaceBaseline: path.join(change, WORKSPACE_BASELINE_FILE)
|
|
286
292
|
};
|
|
287
293
|
}
|
|
288
|
-
function normalizeFlow(flow) {
|
|
289
|
-
if (!flow) return null;
|
|
290
|
-
if (flow.schema !== "matrix/change/v2") return flow.schema ? { unsupported_schema: flow.schema } : null;
|
|
291
|
-
const revision = Number(flow.revision);
|
|
292
|
-
const approved = flow.approved_contract_hash || null;
|
|
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);
|
|
298
|
+
const approved = flow.approved_contract_hash || null;
|
|
293
299
|
const approvalRevision = flow.contract_approved_revision === "" || flow.contract_approved_revision == null ? null : Number(flow.contract_approved_revision);
|
|
294
300
|
const workflowProfile = flow.workflow_profile || null;
|
|
295
301
|
const archCatalogDigest = flow.arch_catalog_digest || null;
|
|
296
302
|
const mattRelease = flow.matt_release || null;
|
|
297
|
-
if (
|
|
298
|
-
typeof flow.id !== "string" || !flow.id ||
|
|
299
|
-
!["full", "hotfix", "tweak"].includes(flow.workflow) ||
|
|
300
|
-
!ORCHESTRATIONS.includes(flow.orchestration) ||
|
|
301
|
-
!["active", "archived", "aborted"].includes(flow.status) ||
|
|
302
|
-
!PHASES.includes(flow.phase) ||
|
|
303
|
-
!Number.isInteger(revision) || revision < 1 ||
|
|
304
|
-
(workflowProfile !== null && workflowProfile !== "lightweight") ||
|
|
305
|
-
(workflowProfile === "lightweight" && !["hotfix", "tweak"].includes(flow.workflow)) ||
|
|
303
|
+
if (
|
|
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) ||
|
|
309
|
+
!Number.isInteger(revision) || revision < 1 ||
|
|
310
|
+
(workflowProfile !== null && workflowProfile !== "lightweight") ||
|
|
311
|
+
(workflowProfile === "lightweight" && !["hotfix", "tweak"].includes(flow.workflow)) ||
|
|
306
312
|
(flow.artifact_language != null && !["en", "zh-CN"].includes(flow.artifact_language)) ||
|
|
307
313
|
Boolean(archCatalogDigest) !== Boolean(mattRelease) ||
|
|
308
314
|
(archCatalogDigest && !/^[0-9a-f]{64}$/.test(archCatalogDigest)) ||
|
|
309
|
-
Boolean(approved) !== Boolean(approvalRevision) ||
|
|
310
|
-
(approved && !/^sha256:[0-9a-f]{64}$/.test(approved)) ||
|
|
311
|
-
(approvalRevision != null && (!Number.isInteger(approvalRevision) || approvalRevision < 1 || approvalRevision > revision))
|
|
312
|
-
) return null;
|
|
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;
|
|
313
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 };
|
|
314
|
-
}
|
|
315
|
-
function readFlow(p) {
|
|
316
|
-
if (!fs.existsSync(p.flow)) return null;
|
|
317
|
-
const parsed = Object.fromEntries(fs.readFileSync(p.flow, "utf8").split(/\r?\n/).filter((line) => line.includes(":") && !line.startsWith(" ")).map((line) => { const [key, ...value] = line.split(":"); return [key.trim(), value.join(":").trim().replace(/^"|"$/g, "")]; }));
|
|
318
|
-
return normalizeFlow(parsed);
|
|
319
|
-
}
|
|
320
|
-
function flowContent(flow) {
|
|
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
|
+
}
|
|
326
|
+
function flowContent(flow) {
|
|
321
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"];
|
|
322
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`;
|
|
323
|
-
}
|
|
324
|
-
function active(cwd) { const activeFile = path.join(rootFor(cwd), "active.json"); if (!fs.existsSync(activeFile)) return null; return read(activeFile).change_id; }
|
|
329
|
+
}
|
|
330
|
+
function active(cwd) { const activeFile = path.join(rootFor(cwd), "active.json"); if (!fs.existsSync(activeFile)) return null; return read(activeFile).change_id; }
|
|
325
331
|
function meaningful(file, headings) {
|
|
326
|
-
if (!fs.existsSync(file)) return false;
|
|
327
|
-
const value = fs.readFileSync(file, "utf8").trim();
|
|
328
|
-
if (value.length < 40) return false;
|
|
329
|
-
return headings.every((heading) => {
|
|
330
|
-
const start = value.indexOf(heading);
|
|
331
|
-
if (start < 0) return false;
|
|
332
|
-
const bodyStart = start + heading.length;
|
|
333
|
-
const next = value.indexOf("\n## ", bodyStart);
|
|
334
|
-
return value.slice(bodyStart, next < 0 ? value.length : next).trim().length >= 8;
|
|
332
|
+
if (!fs.existsSync(file)) return false;
|
|
333
|
+
const value = fs.readFileSync(file, "utf8").trim();
|
|
334
|
+
if (value.length < 40) return false;
|
|
335
|
+
return headings.every((heading) => {
|
|
336
|
+
const start = value.indexOf(heading);
|
|
337
|
+
if (start < 0) return false;
|
|
338
|
+
const bodyStart = start + heading.length;
|
|
339
|
+
const next = value.indexOf("\n## ", bodyStart);
|
|
340
|
+
return value.slice(bodyStart, next < 0 ? value.length : next).trim().length >= 8;
|
|
335
341
|
});
|
|
336
342
|
}
|
|
337
343
|
function digestText(domain, value) {
|
|
@@ -457,188 +463,188 @@ function reviewReceiptGuard(p, flow) {
|
|
|
457
463
|
}
|
|
458
464
|
return { ok: true, code: "OK", receipt };
|
|
459
465
|
}
|
|
460
|
-
function shortcut(flow) { return flow.workflow === "hotfix" || flow.workflow === "tweak"; }
|
|
461
|
-
function contractSnapshotForFiles(p, files) {
|
|
462
|
-
const hash = crypto.createHash("sha256");
|
|
463
|
-
hash.update("matrix/workflow-contract/v1\\0", "utf8");
|
|
464
|
-
const contents = {};
|
|
465
|
-
for (const name of files) {
|
|
466
|
-
const logical = `artifacts/${name}`;
|
|
467
|
-
let bytes;
|
|
468
|
-
try { bytes = fs.readFileSync(path.join(p.artifacts, name)); }
|
|
469
|
-
catch (error) { if (error.code === "ENOENT") return { missing: true, contents }; return { error, contents }; }
|
|
470
|
-
const pathBytes = Buffer.from(logical, "utf8");
|
|
471
|
-
const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
|
|
472
|
-
const contentLength = Buffer.alloc(8); contentLength.writeBigUInt64BE(BigInt(bytes.length));
|
|
473
|
-
hash.update(pathLength); hash.update(pathBytes); hash.update(contentLength); hash.update(bytes);
|
|
474
|
-
contents[name] = bytes.toString("utf8");
|
|
475
|
-
}
|
|
476
|
-
return { hash: `sha256:${hash.digest("hex")}`, contents };
|
|
477
|
-
}
|
|
478
|
-
function workflowProfile(p, flow) {
|
|
479
|
-
if (!shortcut(flow)) return FULL_PROFILE;
|
|
480
|
-
return flow.workflow_profile === "lightweight" ? LIGHTWEIGHT_PROFILE : { ...FULL_PROFILE, id: "legacy-full" };
|
|
481
|
-
}
|
|
482
|
-
function evidencePolicy(flow, profile) {
|
|
483
|
-
return profile.id === "lightweight" ? flow.workflow : "full";
|
|
484
|
-
}
|
|
485
|
-
function structuralGuard(p, flow, phase) {
|
|
486
|
-
const base = p.artifacts;
|
|
487
|
-
const profile = workflowProfile(p, flow);
|
|
488
|
-
const policy = evidencePolicy(flow, profile);
|
|
489
|
-
const shortcutPolicy = LIGHTWEIGHT_POLICY[policy];
|
|
490
|
-
const checks = phase === "open" ? [["proposal", meaningful(path.join(base, "proposal.md"), shortcutPolicy?.proposal ?? ARTIFACT_POLICY.proposal)]]
|
|
491
|
-
: phase === "design" ? [["proposal", meaningful(path.join(base, "proposal.md"), ARTIFACT_POLICY.proposal)], ["design", meaningful(path.join(base, "design.md"), ARTIFACT_POLICY.design)], ["plan", meaningful(path.join(base, "plan.md"), ARTIFACT_POLICY.plan)]]
|
|
492
|
-
: phase === "build" ? [
|
|
493
|
-
...(profile.id === "lightweight" ? [] : [["plan", meaningful(path.join(base, "plan.md"), ARTIFACT_POLICY.plan)]]),
|
|
494
|
-
["build evidence", meaningful(path.join(base, "verification.md"), shortcutPolicy?.build ?? ARTIFACT_POLICY.build)]
|
|
495
|
-
]
|
|
496
|
-
: [["verification", meaningful(path.join(base, "verification.md"), shortcutPolicy?.verification ?? ARTIFACT_POLICY.verification)]];
|
|
497
|
-
return { pass: checks.every(([, result]) => result), checks };
|
|
498
|
-
}
|
|
499
|
-
function contractSnapshot(p, flow) {
|
|
500
|
-
return contractSnapshotForFiles(p, workflowProfile(p, flow).contractFiles);
|
|
501
|
-
}
|
|
502
|
-
function contractState(p, flow) {
|
|
503
|
-
const snapshot = contractSnapshot(p, flow);
|
|
504
|
-
if (snapshot.error) return { status: "unreadable", snapshot };
|
|
505
|
-
if (!flow.approved_contract_hash) return { status: "unapproved", snapshot };
|
|
506
|
-
if (snapshot.missing || snapshot.hash !== flow.approved_contract_hash) return { status: "changed", snapshot };
|
|
507
|
-
return { status: "approved-and-matching", snapshot };
|
|
508
|
-
}
|
|
509
|
-
function contractFields(flow, contract) {
|
|
510
|
-
return {
|
|
511
|
-
contract_status: contract.status,
|
|
512
|
-
current_contract_hash: contract.snapshot.hash ?? null,
|
|
513
|
-
approved_contract_hash: flow.approved_contract_hash,
|
|
514
|
-
contract_approved_revision: flow.contract_approved_revision
|
|
515
|
-
};
|
|
516
|
-
}
|
|
517
|
-
function contractFailure(flow, contract) {
|
|
518
|
-
if (contract.status === "approved-and-matching") return null;
|
|
519
|
-
if (contract.status === "unreadable") return { ...fail("CONTRACT_READ_FAILED", contract.snapshot.error.message), ...contractFields(flow, contract) };
|
|
520
|
-
return { ...fail(contract.status === "unapproved" ? "CONTRACT_UNAPPROVED" : "CONTRACT_CHANGED", `Workflow contract is ${contract.status}.`), ...contractFields(flow, contract) };
|
|
521
|
-
}
|
|
522
|
-
function addFramedEntry(hash, type, logicalPath, content = Buffer.alloc(0)) {
|
|
523
|
-
const typeBytes = Buffer.from(type, "utf8");
|
|
524
|
-
const pathBytes = Buffer.from(logicalPath, "utf8");
|
|
525
|
-
const typeLength = Buffer.alloc(4); typeLength.writeUInt32BE(typeBytes.length);
|
|
526
|
-
const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
|
|
527
|
-
const contentLength = Buffer.alloc(8); contentLength.writeBigUInt64BE(BigInt(content.length));
|
|
528
|
-
hash.update(typeLength); hash.update(typeBytes); hash.update(pathLength); hash.update(pathBytes); hash.update(contentLength); hash.update(content);
|
|
529
|
-
}
|
|
530
|
-
function addFramedFile(hash, entry) {
|
|
531
|
-
const typeBytes = Buffer.from(entry.type, "utf8");
|
|
532
|
-
const pathBytes = Buffer.from(entry.logicalPath, "utf8");
|
|
533
|
-
const typeLength = Buffer.alloc(4); typeLength.writeUInt32BE(typeBytes.length);
|
|
534
|
-
const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
|
|
535
|
-
const contentLength = Buffer.alloc(8); contentLength.writeBigUInt64BE(BigInt(entry.size));
|
|
536
|
-
hash.update(typeLength); hash.update(typeBytes); hash.update(pathLength); hash.update(pathBytes); hash.update(contentLength);
|
|
537
|
-
const descriptor = fs.openSync(entry.source, "r");
|
|
538
|
-
try {
|
|
539
|
-
const opened = fs.fstatSync(descriptor);
|
|
540
|
-
if (!opened.isFile() || opened.size !== entry.size) throw new Error(`Archive file changed while opening: ${entry.logicalPath}.`);
|
|
541
|
-
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
542
|
-
let offset = 0;
|
|
543
|
-
while (offset < entry.size) {
|
|
544
|
-
const length = fs.readSync(descriptor, chunk, 0, Math.min(chunk.length, entry.size - offset), offset);
|
|
545
|
-
if (length === 0) throw new Error(`Archive file changed while reading: ${entry.logicalPath}.`);
|
|
546
|
-
hash.update(chunk.subarray(0, length));
|
|
547
|
-
offset += length;
|
|
548
|
-
}
|
|
549
|
-
if (fs.readSync(descriptor, chunk, 0, 1, offset) !== 0) throw new Error(`Archive file changed while reading: ${entry.logicalPath}.`);
|
|
550
|
-
} finally {
|
|
551
|
-
fs.closeSync(descriptor);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
function archiveDirectoryEntries(root, relative = "") {
|
|
555
|
-
const entries = [];
|
|
556
|
-
const names = fs.readdirSync(path.join(root, relative)).sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
557
|
-
for (const name of names) {
|
|
558
|
-
const childRelative = relative ? `${relative}/${name}` : name;
|
|
559
|
-
const child = path.join(root, ...childRelative.split("/"));
|
|
560
|
-
const stat = fs.lstatSync(child);
|
|
561
|
-
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
|
|
562
|
-
const error = new Error(`Archive change contains unsupported filesystem entry: ${childRelative}.`);
|
|
563
|
-
error.code = "ARCHIVE_ENTRY_UNSUPPORTED";
|
|
564
|
-
throw error;
|
|
565
|
-
}
|
|
566
|
-
if (stat.isDirectory()) {
|
|
567
|
-
entries.push({ type: "directory", logicalPath: `change/${childRelative}`, content: Buffer.alloc(0) });
|
|
568
|
-
entries.push(...archiveDirectoryEntries(root, childRelative));
|
|
569
|
-
} else {
|
|
570
|
-
entries.push({ type: "file", logicalPath: `change/${childRelative}`, source: child, size: stat.size });
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
return entries;
|
|
574
|
-
}
|
|
575
|
-
function requireArchiveEntry(file, kind, label) {
|
|
576
|
-
let stat;
|
|
577
|
-
try { stat = fs.lstatSync(file); }
|
|
578
|
-
catch (error) { return fail("ARCHIVE_PREFLIGHT_UNSTABLE", `${label} could not be inspected: ${error.message}`); }
|
|
579
|
-
const valid = kind === "directory" ? stat.isDirectory() : stat.isFile();
|
|
580
|
-
return stat.isSymbolicLink() || !valid ? fail("ARCHIVE_ENTRY_UNSUPPORTED", `${label} must be an ordinary ${kind}.`) : null;
|
|
581
|
-
}
|
|
582
|
-
function captureArchivePreflightOnce(p, id) {
|
|
583
|
-
const target = path.join(p.root, "archive", id);
|
|
584
|
-
if (fs.existsSync(target)) return { failure: fail("ARCHIVE_TARGET_EXISTS", `Archive target already exists: ${id}.`) };
|
|
585
|
-
for (const [file, kind, label] of [[p.root, "directory", "Matrix state root"], [p.change, "directory", "Active change root"], [path.join(p.root, "archive"), "directory", "Archive root"], [p.active, "file", "Active Selection"]]) {
|
|
586
|
-
const failure = requireArchiveEntry(file, kind, label); if (failure) return { failure };
|
|
587
|
-
}
|
|
588
|
-
let selection;
|
|
589
|
-
try { selection = fs.readFileSync(p.active); }
|
|
590
|
-
catch (error) { return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Active Selection could not be captured: ${error.message}`) }; }
|
|
591
|
-
let entries;
|
|
592
|
-
try {
|
|
593
|
-
entries = archiveDirectoryEntries(p.change);
|
|
594
|
-
entries.sort((left, right) => Buffer.compare(Buffer.from(left.logicalPath), Buffer.from(right.logicalPath)));
|
|
595
|
-
}
|
|
596
|
-
catch (error) {
|
|
597
|
-
if (error.code === "ARCHIVE_ENTRY_UNSUPPORTED") return { failure: fail(error.code, error.message) };
|
|
598
|
-
return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Archive inputs changed or could not be captured: ${error.message}`) };
|
|
599
|
-
}
|
|
600
|
-
const hash = crypto.createHash("sha256");
|
|
601
|
-
hash.update("matrix/archive-preflight/v1\\0", "utf8");
|
|
602
|
-
addFramedEntry(hash, "identity", "change-id", Buffer.from(id, "utf8"));
|
|
603
|
-
addFramedEntry(hash, "identity", "archive-target", Buffer.from(`.matrix/archive/${id}`, "utf8"));
|
|
604
|
-
addFramedEntry(hash, "selection", ".matrix/active.json", selection);
|
|
605
|
-
addFramedEntry(hash, "fact", "archive-target-state", Buffer.from("absent", "utf8"));
|
|
606
|
-
let fileCount = 0; let directoryCount = 0; let totalBytes = 0;
|
|
607
|
-
try {
|
|
608
|
-
for (const entry of entries) {
|
|
609
|
-
if (entry.type === "file") {
|
|
610
|
-
addFramedFile(hash, entry);
|
|
611
|
-
fileCount += 1;
|
|
612
|
-
totalBytes += entry.size;
|
|
613
|
-
} else {
|
|
614
|
-
addFramedEntry(hash, entry.type, entry.logicalPath, entry.content);
|
|
615
|
-
directoryCount += 1;
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
} catch (error) {
|
|
619
|
-
return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Archive inputs changed or could not be captured: ${error.message}`) };
|
|
620
|
-
}
|
|
621
|
-
return { hash: `sha256:${hash.digest("hex")}`, fileCount, directoryCount, totalBytes };
|
|
622
|
-
}
|
|
623
|
-
function captureArchivePreflight(p, id) {
|
|
624
|
-
const first = captureArchivePreflightOnce(p, id); if (first.failure) return first;
|
|
625
|
-
const second = captureArchivePreflightOnce(p, id); if (second.failure) return second;
|
|
626
|
-
if (first.hash !== second.hash) return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Archive inputs changed while the preflight snapshot was being captured. Retry dry-run.") };
|
|
627
|
-
return second;
|
|
628
|
-
}
|
|
629
|
-
function archivePreflightChanged(expected, current) {
|
|
630
|
-
return {
|
|
631
|
-
...fail("ARCHIVE_PREFLIGHT_CHANGED", "Archive inputs changed after dry-run. Run dry-run again and review the new preflight."),
|
|
632
|
-
expected_preflight_hash: expected,
|
|
633
|
-
current_preflight_hash: current,
|
|
466
|
+
function shortcut(flow) { return flow.workflow === "hotfix" || flow.workflow === "tweak"; }
|
|
467
|
+
function contractSnapshotForFiles(p, files) {
|
|
468
|
+
const hash = crypto.createHash("sha256");
|
|
469
|
+
hash.update("matrix/workflow-contract/v1\\0", "utf8");
|
|
470
|
+
const contents = {};
|
|
471
|
+
for (const name of files) {
|
|
472
|
+
const logical = `artifacts/${name}`;
|
|
473
|
+
let bytes;
|
|
474
|
+
try { bytes = fs.readFileSync(path.join(p.artifacts, name)); }
|
|
475
|
+
catch (error) { if (error.code === "ENOENT") return { missing: true, contents }; return { error, contents }; }
|
|
476
|
+
const pathBytes = Buffer.from(logical, "utf8");
|
|
477
|
+
const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
|
|
478
|
+
const contentLength = Buffer.alloc(8); contentLength.writeBigUInt64BE(BigInt(bytes.length));
|
|
479
|
+
hash.update(pathLength); hash.update(pathBytes); hash.update(contentLength); hash.update(bytes);
|
|
480
|
+
contents[name] = bytes.toString("utf8");
|
|
481
|
+
}
|
|
482
|
+
return { hash: `sha256:${hash.digest("hex")}`, contents };
|
|
483
|
+
}
|
|
484
|
+
function workflowProfile(p, flow) {
|
|
485
|
+
if (!shortcut(flow)) return FULL_PROFILE;
|
|
486
|
+
return flow.workflow_profile === "lightweight" ? LIGHTWEIGHT_PROFILE : { ...FULL_PROFILE, id: "legacy-full" };
|
|
487
|
+
}
|
|
488
|
+
function evidencePolicy(flow, profile) {
|
|
489
|
+
return profile.id === "lightweight" ? flow.workflow : "full";
|
|
490
|
+
}
|
|
491
|
+
function structuralGuard(p, flow, phase) {
|
|
492
|
+
const base = p.artifacts;
|
|
493
|
+
const profile = workflowProfile(p, flow);
|
|
494
|
+
const policy = evidencePolicy(flow, profile);
|
|
495
|
+
const shortcutPolicy = LIGHTWEIGHT_POLICY[policy];
|
|
496
|
+
const checks = phase === "open" ? [["proposal", meaningful(path.join(base, "proposal.md"), shortcutPolicy?.proposal ?? ARTIFACT_POLICY.proposal)]]
|
|
497
|
+
: phase === "design" ? [["proposal", meaningful(path.join(base, "proposal.md"), ARTIFACT_POLICY.proposal)], ["design", meaningful(path.join(base, "design.md"), ARTIFACT_POLICY.design)], ["plan", meaningful(path.join(base, "plan.md"), ARTIFACT_POLICY.plan)]]
|
|
498
|
+
: phase === "build" ? [
|
|
499
|
+
...(profile.id === "lightweight" ? [] : [["plan", meaningful(path.join(base, "plan.md"), ARTIFACT_POLICY.plan)]]),
|
|
500
|
+
["build evidence", meaningful(path.join(base, "verification.md"), shortcutPolicy?.build ?? ARTIFACT_POLICY.build)]
|
|
501
|
+
]
|
|
502
|
+
: [["verification", meaningful(path.join(base, "verification.md"), shortcutPolicy?.verification ?? ARTIFACT_POLICY.verification)]];
|
|
503
|
+
return { pass: checks.every(([, result]) => result), checks };
|
|
504
|
+
}
|
|
505
|
+
function contractSnapshot(p, flow) {
|
|
506
|
+
return contractSnapshotForFiles(p, workflowProfile(p, flow).contractFiles);
|
|
507
|
+
}
|
|
508
|
+
function contractState(p, flow) {
|
|
509
|
+
const snapshot = contractSnapshot(p, flow);
|
|
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,
|
|
634
640
|
recovery_command: workflowCommand("archive --dry-run")
|
|
635
|
-
};
|
|
636
|
-
}
|
|
641
|
+
};
|
|
642
|
+
}
|
|
637
643
|
function guard(p, flow, phase) {
|
|
638
|
-
if (!["build", "verify", "archive"].includes(phase)) {
|
|
639
|
-
const structural = structuralGuard(p, flow, phase);
|
|
640
|
-
return { ok: structural.pass, code: structural.pass ? "OK" : "GUARD_FAILED", ...structural };
|
|
641
|
-
}
|
|
644
|
+
if (!["build", "verify", "archive"].includes(phase)) {
|
|
645
|
+
const structural = structuralGuard(p, flow, phase);
|
|
646
|
+
return { ok: structural.pass, code: structural.pass ? "OK" : "GUARD_FAILED", ...structural };
|
|
647
|
+
}
|
|
642
648
|
const contract = contractState(p, flow); const failure = contractFailure(flow, contract);
|
|
643
649
|
if (failure) return { ok: false, ...failure, checks: [["contract", false]] };
|
|
644
650
|
const structural = structuralGuard(p, flow, phase);
|
|
@@ -656,115 +662,115 @@ function guard(p, flow, phase) {
|
|
|
656
662
|
}
|
|
657
663
|
return { ok: true, code: "OK", pass: true, ...contractFields(flow, contract), checks };
|
|
658
664
|
}
|
|
659
|
-
function eventContent(file, name, before, after, revision, details = {}) {
|
|
660
|
-
const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
661
|
-
return `${current}${JSON.stringify({ schema: "matrix/event/v1", at: stamp(), event: name, revision, from: before, to: after, ...details })}\n`;
|
|
662
|
-
}
|
|
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
|
+
}
|
|
663
669
|
export function invoke(argv, { cwd = process.cwd(), failAfterOperation = null, mattContentHashes = MATT_CONTENT_HASHES } = {}) {
|
|
664
|
-
const [command, ...args] = argv;
|
|
665
|
-
if (command === "init") {
|
|
666
|
-
const id = (args.shift() ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
667
|
-
const workflow = args.includes("--workflow") ? args[args.indexOf("--workflow") + 1] : "full";
|
|
668
|
-
const title = args.includes("--title") ? args[args.indexOf("--title") + 1] : null;
|
|
669
|
-
const requestedOrchestration = option(args, "--orchestration");
|
|
670
|
-
if (requestedOrchestration != null && !ORCHESTRATIONS.includes(requestedOrchestration)) return fail("INVALID_INTENT", "orchestration must be prim or arch.");
|
|
671
|
-
const configured = orchestrationConfig(cwd);
|
|
672
|
-
const orchestration = requestedOrchestration ?? configured.defaultOrchestration;
|
|
673
|
-
if (!id || !title || !["full", "hotfix", "tweak"].includes(workflow)) return fail("INVALID_INTENT", "init requires a valid change id and --title.");
|
|
674
|
-
if (orchestration === "arch") {
|
|
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") {
|
|
675
681
|
const integrity = archIntegrity(cwd, null, mattContentHashes);
|
|
676
|
-
if (!integrity.ok) return { ...integrity, orchestration };
|
|
677
|
-
}
|
|
678
|
-
if (active(cwd)) return fail("ACTIVE_CHANGE_EXISTS", `Active Matrix change already exists: ${active(cwd)}.`);
|
|
679
|
-
const p = paths(cwd, id); if (fs.existsSync(p.change) || fs.existsSync(path.join(p.root, "archive", id))) return fail("CHANGE_EXISTS", `Matrix change already exists: ${id}.`);
|
|
680
|
-
let workspaceBaseline = null;
|
|
681
|
-
if (workflow === "hotfix" || workflow === "tweak") {
|
|
682
|
-
try { workspaceBaseline = captureWorkspaceBaseline(cwd); }
|
|
683
|
-
catch (error) { return fail("WORKSPACE_BASELINE_FAILED", `Could not initialize the lightweight workflow boundary: ${error.message}`); }
|
|
684
|
-
}
|
|
682
|
+
if (!integrity.ok) return { ...integrity, orchestration };
|
|
683
|
+
}
|
|
684
|
+
if (active(cwd)) return fail("ACTIVE_CHANGE_EXISTS", `Active Matrix change already exists: ${active(cwd)}.`);
|
|
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}.`);
|
|
686
|
+
let workspaceBaseline = null;
|
|
687
|
+
if (workflow === "hotfix" || workflow === "tweak") {
|
|
688
|
+
try { workspaceBaseline = captureWorkspaceBaseline(cwd); }
|
|
689
|
+
catch (error) { return fail("WORKSPACE_BASELINE_FAILED", `Could not initialize the lightweight workflow boundary: ${error.message}`); }
|
|
690
|
+
}
|
|
685
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" };
|
|
686
|
-
const operations = [
|
|
687
|
-
...(!fs.existsSync(path.join(p.root, "archive")) ? [workflowMkdirOperation(cwd, path.join(p.root, "archive"))] : []),
|
|
688
|
-
workflowMkdirOperation(cwd, p.artifacts),
|
|
689
|
-
...(!fs.existsSync(path.join(p.root, "config.yaml")) ? [workflowWriteOperation(cwd, path.join(p.root, "config.yaml"), `schema: matrix/config/v1\nauto_transition: true\ndefault_orchestration: ${configured.defaultOrchestration}\ninstallation_scope: project\nlanguage: ${configured.artifactLanguage}\n`)] : []),
|
|
692
|
+
const operations = [
|
|
693
|
+
...(!fs.existsSync(path.join(p.root, "archive")) ? [workflowMkdirOperation(cwd, path.join(p.root, "archive"))] : []),
|
|
694
|
+
workflowMkdirOperation(cwd, p.artifacts),
|
|
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`)] : []),
|
|
690
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")] : []),
|
|
691
|
-
workflowWriteOperation(cwd, p.flow, flowContent(flow)),
|
|
692
|
-
...(workspaceBaseline ? [workflowWriteOperation(cwd, p.workspaceBaseline, `${JSON.stringify(workspaceBaseline, null, 2)}\n`)] : []),
|
|
697
|
+
workflowWriteOperation(cwd, p.flow, flowContent(flow)),
|
|
698
|
+
...(workspaceBaseline ? [workflowWriteOperation(cwd, p.workspaceBaseline, `${JSON.stringify(workspaceBaseline, null, 2)}\n`)] : []),
|
|
693
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" } : {}) })),
|
|
694
|
-
workflowWriteOperation(cwd, p.active, `${JSON.stringify({ change_id: id }, null, 2)}\n`)
|
|
695
|
-
];
|
|
696
|
-
return runWorkflowTransaction({
|
|
697
|
-
cwd,
|
|
698
|
-
kind: "init",
|
|
699
|
-
changeId: id,
|
|
700
|
-
operations,
|
|
701
|
-
failAfterOperation,
|
|
700
|
+
workflowWriteOperation(cwd, p.active, `${JSON.stringify({ change_id: id }, null, 2)}\n`)
|
|
701
|
+
];
|
|
702
|
+
return runWorkflowTransaction({
|
|
703
|
+
cwd,
|
|
704
|
+
kind: "init",
|
|
705
|
+
changeId: id,
|
|
706
|
+
operations,
|
|
707
|
+
failAfterOperation,
|
|
702
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") }
|
|
703
|
-
});
|
|
704
|
-
}
|
|
705
|
-
if (command === "doctor") {
|
|
706
|
-
const parsed = { repair: false, transaction: null, strategy: null, lock: null };
|
|
707
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
708
|
-
const token = args[index];
|
|
709
|
-
if (token === "--repair") {
|
|
710
|
-
if (parsed.repair) return fail("INVALID_INTENT", "Workflow doctor options may not be repeated.");
|
|
711
|
-
parsed.repair = true;
|
|
712
|
-
continue;
|
|
713
|
-
}
|
|
714
|
-
const field = ({ "--transaction": "transaction", "--strategy": "strategy", "--lock": "lock" })[token];
|
|
715
|
-
const value = args[index + 1];
|
|
716
|
-
if (!field || parsed[field] || !value || value.startsWith("--")) return fail("INVALID_INTENT", "Workflow doctor accepts --repair with --transaction/--strategy or --lock.");
|
|
717
|
-
parsed[field] = value;
|
|
718
|
-
index += 1;
|
|
719
|
-
}
|
|
720
|
-
return workflowDoctor(cwd, parsed);
|
|
721
|
-
}
|
|
722
|
-
const selected = active(cwd);
|
|
723
|
-
const requested = command === "inspect" ? (option(args, "--change") ?? args.find((arg) => !arg.startsWith("--"))) : null;
|
|
724
|
-
const id = requested ?? selected;
|
|
725
|
-
if (!id) return fail("NO_ACTIVE_CHANGE", "No active Matrix change. Start with $matrix <request>.");
|
|
726
|
-
let p = paths(cwd, id);
|
|
727
|
-
if (command === "inspect" && !fs.existsSync(p.change)) p = paths(cwd, id, true);
|
|
728
|
-
const flow = readFlow(p);
|
|
729
|
-
if (flow?.unsupported_schema) return fail("STATE_VERSION_UNSUPPORTED", `Unsupported Matrix state schema: ${flow.unsupported_schema}.`);
|
|
730
|
-
if (!flow) return fail("STATE_INVALID", "active.json does not reference a valid Matrix change.");
|
|
731
|
-
const artifactLanguage = flow.artifact_language ?? orchestrationConfig(cwd).artifactLanguage;
|
|
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);
|
|
734
|
+
const flow = readFlow(p);
|
|
735
|
+
if (flow?.unsupported_schema) return fail("STATE_VERSION_UNSUPPORTED", `Unsupported Matrix state schema: ${flow.unsupported_schema}.`);
|
|
736
|
+
if (!flow) return fail("STATE_INVALID", "active.json does not reference a valid Matrix change.");
|
|
737
|
+
const artifactLanguage = flow.artifact_language ?? orchestrationConfig(cwd).artifactLanguage;
|
|
732
738
|
const integrity = flow.status === "active" && flow.orchestration === "arch" ? archIntegrity(cwd, flow, mattContentHashes) : { ok: true, code: "OK" };
|
|
733
|
-
if (["transition", "return", "abort"].includes(command) || (command === "archive" && !args.includes("--dry-run"))) {
|
|
734
|
-
const blocked = workflowMutationGate(cwd);
|
|
735
|
-
if (blocked) return blocked;
|
|
736
|
-
}
|
|
737
|
-
if (command === "inspect") {
|
|
738
|
-
const profile = workflowProfile(p, flow);
|
|
739
|
-
const contract = contractState(p, flow);
|
|
740
|
-
const result = guard(p, flow, flow.phase);
|
|
741
|
-
const failure = contract.status === "unreadable" ? contractFailure(flow, contract) : null;
|
|
742
|
-
return {
|
|
743
|
-
...(failure ?? (!integrity.ok ? integrity : { ok: true, code: "OK" })),
|
|
744
|
-
change_id: id,
|
|
745
|
-
workflow: flow.workflow,
|
|
746
|
-
profile: profile.id,
|
|
747
|
-
evidence_policy: evidencePolicy(flow, profile),
|
|
739
|
+
if (["transition", "return", "abort"].includes(command) || (command === "archive" && !args.includes("--dry-run"))) {
|
|
740
|
+
const blocked = workflowMutationGate(cwd);
|
|
741
|
+
if (blocked) return blocked;
|
|
742
|
+
}
|
|
743
|
+
if (command === "inspect") {
|
|
744
|
+
const profile = workflowProfile(p, flow);
|
|
745
|
+
const contract = contractState(p, flow);
|
|
746
|
+
const result = guard(p, flow, flow.phase);
|
|
747
|
+
const failure = contract.status === "unreadable" ? contractFailure(flow, contract) : null;
|
|
748
|
+
return {
|
|
749
|
+
...(failure ?? (!integrity.ok ? integrity : { ok: true, code: "OK" })),
|
|
750
|
+
change_id: id,
|
|
751
|
+
workflow: flow.workflow,
|
|
752
|
+
profile: profile.id,
|
|
753
|
+
evidence_policy: evidencePolicy(flow, profile),
|
|
748
754
|
orchestration: flow.orchestration,
|
|
749
755
|
arch_catalog_digest: flow.arch_catalog_digest ?? null,
|
|
750
756
|
matt_release: flow.matt_release ?? null,
|
|
751
|
-
artifact_language: artifactLanguage,
|
|
752
|
-
status: flow.status,
|
|
753
|
-
phase: flow.phase,
|
|
754
|
-
revision: flow.revision,
|
|
755
|
-
next_skill: flow.status === "active" ? skill(flow.phase) : null,
|
|
756
|
-
guard_pass: result.ok,
|
|
757
|
-
...contractFields(flow, contract)
|
|
758
|
-
};
|
|
759
|
-
}
|
|
760
|
-
if (!integrity.ok && command !== "abort") {
|
|
761
|
-
const profile = workflowProfile(p, flow);
|
|
762
|
-
return { ...integrity, change_id: id, workflow: flow.workflow, profile: profile.id, evidence_policy: evidencePolicy(flow, profile), orchestration: flow.orchestration, artifact_language: artifactLanguage, status: flow.status, phase: flow.phase, revision: flow.revision, next_skill: skill(flow.phase) };
|
|
763
|
-
}
|
|
757
|
+
artifact_language: artifactLanguage,
|
|
758
|
+
status: flow.status,
|
|
759
|
+
phase: flow.phase,
|
|
760
|
+
revision: flow.revision,
|
|
761
|
+
next_skill: flow.status === "active" ? skill(flow.phase) : null,
|
|
762
|
+
guard_pass: result.ok,
|
|
763
|
+
...contractFields(flow, contract)
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
if (!integrity.ok && command !== "abort") {
|
|
767
|
+
const profile = workflowProfile(p, flow);
|
|
768
|
+
return { ...integrity, change_id: id, workflow: flow.workflow, profile: profile.id, evidence_policy: evidencePolicy(flow, profile), orchestration: flow.orchestration, artifact_language: artifactLanguage, status: flow.status, phase: flow.phase, revision: flow.revision, next_skill: skill(flow.phase) };
|
|
769
|
+
}
|
|
764
770
|
if (command === "guard") {
|
|
765
|
-
const phase = args[0] ?? flow.phase;
|
|
766
|
-
if (!PHASES.includes(phase)) return fail("INVALID_INTENT", "Unknown phase.");
|
|
767
|
-
const profile = workflowProfile(p, flow);
|
|
771
|
+
const phase = args[0] ?? flow.phase;
|
|
772
|
+
if (!PHASES.includes(phase)) return fail("INVALID_INTENT", "Unknown phase.");
|
|
773
|
+
const profile = workflowProfile(p, flow);
|
|
768
774
|
if (profile.id === "lightweight" && phase === "design") return fail("PHASE_UNAVAILABLE", "Lightweight workflows do not have a Design phase.");
|
|
769
775
|
return { phase, profile: profile.id, evidence_policy: evidencePolicy(flow, profile), artifact_language: artifactLanguage, ...guard(p, flow, phase) };
|
|
770
776
|
}
|
|
@@ -846,50 +852,50 @@ export function invoke(argv, { cwd = process.cwd(), failAfterOperation = null, m
|
|
|
846
852
|
});
|
|
847
853
|
}
|
|
848
854
|
if (command === "transition") {
|
|
849
|
-
const to = args[0];
|
|
850
|
-
const confirmed = args.includes("--confirmed");
|
|
851
|
-
if (args.slice(1).some((arg) => arg !== "--confirmed")) return fail("INVALID_INTENT", "Transition accepts only a target phase and optional --confirmed.");
|
|
852
|
-
if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
|
|
853
|
-
const profile = workflowProfile(p, flow);
|
|
854
|
-
if (profile.next[flow.phase] !== to) return fail("ILLEGAL_TRANSITION", `Illegal ${profile.id} transition ${flow.phase} -> ${to}.`);
|
|
855
|
-
const result = guard(p, flow, flow.phase); if (!result.ok) return { ...result, message: result.message ?? "Current phase guard failed." };
|
|
856
|
-
if (profile.id === "lightweight" && flow.phase === "open" && to === "build") {
|
|
857
|
-
if (!confirmed) return fail("CONFIRMATION_REQUIRED", "Lightweight Open requires explicit user confirmation before Build.");
|
|
858
|
-
const order = workspaceOrderGuard(cwd, p);
|
|
859
|
-
if (!order.ok) return order;
|
|
860
|
-
}
|
|
861
|
-
const before = flow.phase;
|
|
862
|
-
const approval = to === "build" ? contractState(p, flow) : null;
|
|
863
|
-
if (approval && approval.status !== "approved-and-matching" && approval.status !== "unapproved") return { ...contractFailure(flow, approval), change_id: id };
|
|
864
|
-
if (to === "build" && approval.snapshot.missing) return { ...fail("GUARD_FAILED", "Workflow contract artifacts are incomplete."), change_id: id };
|
|
865
|
-
const next = { ...flow, schema: "matrix/change/v2", phase: to, revision: flow.revision + 1, updated_at: stamp(), ...(to === "build" ? { approved_contract_hash: approval.snapshot.hash, contract_approved_revision: flow.revision + 1 } : {}) };
|
|
866
|
-
const nextProfile = workflowProfile(p, next);
|
|
867
|
-
const details = to === "build"
|
|
868
|
-
? { approved_contract_hash: next.approved_contract_hash, contract_approved_revision: next.contract_approved_revision, ...(shortcut(flow) ? { confirmed, profile: nextProfile.id } : {}) }
|
|
869
|
-
: shortcut(flow) ? { profile: nextProfile.id } : {};
|
|
870
|
-
const resultFields = { ok: true, code: "OK", change_id: id, workflow: next.workflow, profile: nextProfile.id, evidence_policy: evidencePolicy(next, nextProfile), orchestration: next.orchestration, artifact_language: next.artifact_language ?? artifactLanguage, status: next.status, phase: to, revision: next.revision, next_skill: skill(to), ...contractFields(next, contractState(p, next)) };
|
|
871
|
-
return runWorkflowTransaction({
|
|
872
|
-
cwd,
|
|
873
|
-
kind: "transition",
|
|
874
|
-
changeId: id,
|
|
875
|
-
failAfterOperation,
|
|
876
|
-
operations: [
|
|
877
|
-
workflowWriteOperation(cwd, p.flow, flowContent(next)),
|
|
878
|
-
workflowWriteOperation(cwd, path.join(p.change, "events.jsonl"), eventContent(path.join(p.change, "events.jsonl"), "transition", before, to, next.revision, details))
|
|
879
|
-
],
|
|
880
|
-
result: resultFields
|
|
881
|
-
});
|
|
882
|
-
}
|
|
883
|
-
if (command === "return") {
|
|
884
|
-
const to = args[0];
|
|
885
|
-
const reason = option(args, "--reason");
|
|
886
|
-
if (!reason) return fail("RETURN_REASON_REQUIRED", "Return requires --reason.");
|
|
887
|
-
if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
|
|
888
|
-
if (!RETURN_REASONS.has(reason)) return fail("RETURN_REASON_UNSUPPORTED", `Unsupported Return reason: ${reason}.`);
|
|
889
|
-
const profile = workflowProfile(p, flow);
|
|
890
|
-
const returns = profile.id === "lightweight" ? LIGHTWEIGHT_RETURNS : FULL_RETURNS;
|
|
891
|
-
const returnRule = returns.find((entry) => entry.from === flow.phase && entry.to === to && entry.reason === reason);
|
|
892
|
-
if (!returnRule) return fail("ILLEGAL_RETURN", `Illegal ${profile.id} Return ${flow.phase} -> ${to} for reason ${reason}.`);
|
|
855
|
+
const to = args[0];
|
|
856
|
+
const confirmed = args.includes("--confirmed");
|
|
857
|
+
if (args.slice(1).some((arg) => arg !== "--confirmed")) return fail("INVALID_INTENT", "Transition accepts only a target phase and optional --confirmed.");
|
|
858
|
+
if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
|
|
859
|
+
const profile = workflowProfile(p, flow);
|
|
860
|
+
if (profile.next[flow.phase] !== to) return fail("ILLEGAL_TRANSITION", `Illegal ${profile.id} transition ${flow.phase} -> ${to}.`);
|
|
861
|
+
const result = guard(p, flow, flow.phase); if (!result.ok) return { ...result, message: result.message ?? "Current phase guard failed." };
|
|
862
|
+
if (profile.id === "lightweight" && flow.phase === "open" && to === "build") {
|
|
863
|
+
if (!confirmed) return fail("CONFIRMATION_REQUIRED", "Lightweight Open requires explicit user confirmation before Build.");
|
|
864
|
+
const order = workspaceOrderGuard(cwd, p);
|
|
865
|
+
if (!order.ok) return order;
|
|
866
|
+
}
|
|
867
|
+
const before = flow.phase;
|
|
868
|
+
const approval = to === "build" ? contractState(p, flow) : null;
|
|
869
|
+
if (approval && approval.status !== "approved-and-matching" && approval.status !== "unapproved") return { ...contractFailure(flow, approval), change_id: id };
|
|
870
|
+
if (to === "build" && approval.snapshot.missing) return { ...fail("GUARD_FAILED", "Workflow contract artifacts are incomplete."), change_id: id };
|
|
871
|
+
const next = { ...flow, schema: "matrix/change/v2", phase: to, revision: flow.revision + 1, updated_at: stamp(), ...(to === "build" ? { approved_contract_hash: approval.snapshot.hash, contract_approved_revision: flow.revision + 1 } : {}) };
|
|
872
|
+
const nextProfile = workflowProfile(p, next);
|
|
873
|
+
const details = to === "build"
|
|
874
|
+
? { approved_contract_hash: next.approved_contract_hash, contract_approved_revision: next.contract_approved_revision, ...(shortcut(flow) ? { confirmed, profile: nextProfile.id } : {}) }
|
|
875
|
+
: shortcut(flow) ? { profile: nextProfile.id } : {};
|
|
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)) };
|
|
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
|
+
}
|
|
889
|
+
if (command === "return") {
|
|
890
|
+
const to = args[0];
|
|
891
|
+
const reason = option(args, "--reason");
|
|
892
|
+
if (!reason) return fail("RETURN_REASON_REQUIRED", "Return requires --reason.");
|
|
893
|
+
if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
|
|
894
|
+
if (!RETURN_REASONS.has(reason)) return fail("RETURN_REASON_UNSUPPORTED", `Unsupported Return reason: ${reason}.`);
|
|
895
|
+
const profile = workflowProfile(p, flow);
|
|
896
|
+
const returns = profile.id === "lightweight" ? LIGHTWEIGHT_RETURNS : FULL_RETURNS;
|
|
897
|
+
const returnRule = returns.find((entry) => entry.from === flow.phase && entry.to === to && entry.reason === reason);
|
|
898
|
+
if (!returnRule) return fail("ILLEGAL_RETURN", `Illegal ${profile.id} Return ${flow.phase} -> ${to} for reason ${reason}.`);
|
|
893
899
|
if (flow.phase === "verify" && to === "build") { const contract = contractState(p, flow); const failure = contractFailure(flow, contract); if (failure) return failure; }
|
|
894
900
|
const verification = path.join(p.artifacts, "verification.md");
|
|
895
901
|
let verificationDestination = null;
|
|
@@ -904,150 +910,150 @@ export function invoke(argv, { cwd = process.cwd(), failAfterOperation = null, m
|
|
|
904
910
|
receiptDestination = path.join(history, `revision-${flow.revision}-review-receipt.json`);
|
|
905
911
|
if (fs.existsSync(receiptDestination)) return fail("EVIDENCE_INVALIDATION_FAILED", `Review receipt history already exists for revision ${flow.revision}.`);
|
|
906
912
|
}
|
|
907
|
-
const next = {
|
|
908
|
-
...flow,
|
|
909
|
-
schema: "matrix/change/v2",
|
|
910
|
-
workflow: returnRule.upgrade ? "full" : flow.workflow,
|
|
911
|
-
workflow_profile: returnRule.upgrade ? null : flow.workflow_profile,
|
|
912
|
-
phase: to,
|
|
913
|
-
revision: flow.revision + 1,
|
|
914
|
-
updated_at: stamp(),
|
|
915
|
-
...(to === "design" ? { approved_contract_hash: null, contract_approved_revision: null } : {})
|
|
916
|
-
};
|
|
917
|
-
const nextProfile = workflowProfile(p, next);
|
|
918
|
-
return runWorkflowTransaction({
|
|
919
|
-
cwd,
|
|
920
|
-
kind: "return",
|
|
921
|
-
changeId: id,
|
|
913
|
+
const next = {
|
|
914
|
+
...flow,
|
|
915
|
+
schema: "matrix/change/v2",
|
|
916
|
+
workflow: returnRule.upgrade ? "full" : flow.workflow,
|
|
917
|
+
workflow_profile: returnRule.upgrade ? null : flow.workflow_profile,
|
|
918
|
+
phase: to,
|
|
919
|
+
revision: flow.revision + 1,
|
|
920
|
+
updated_at: stamp(),
|
|
921
|
+
...(to === "design" ? { approved_contract_hash: null, contract_approved_revision: null } : {})
|
|
922
|
+
};
|
|
923
|
+
const nextProfile = workflowProfile(p, next);
|
|
924
|
+
return runWorkflowTransaction({
|
|
925
|
+
cwd,
|
|
926
|
+
kind: "return",
|
|
927
|
+
changeId: id,
|
|
922
928
|
failAfterOperation,
|
|
923
929
|
operations: [
|
|
924
930
|
...(verificationDestination ? [workflowMoveOperation(cwd, verification, verificationDestination)] : []),
|
|
925
931
|
...(receiptDestination ? [workflowMoveOperation(cwd, p.reviewReceipt, receiptDestination)] : []),
|
|
926
932
|
workflowWriteOperation(cwd, p.flow, flowContent(next)),
|
|
927
|
-
workflowWriteOperation(cwd, path.join(p.change, "events.jsonl"), eventContent(path.join(p.change, "events.jsonl"), "returned", flow.phase, to, next.revision, { reason, invalidated_evidence: INVALIDATED_EVIDENCE, ...(returnRule.upgrade ? { workflow_from: flow.workflow, workflow_to: "full" } : {}) }))
|
|
928
|
-
],
|
|
929
|
-
result: { ok: true, code: "OK", change_id: id, workflow: next.workflow, profile: nextProfile.id, evidence_policy: evidencePolicy(next, nextProfile), orchestration: next.orchestration, artifact_language: next.artifact_language ?? artifactLanguage, status: next.status, phase: to, revision: next.revision, next_skill: skill(to), event: "returned", invalidated_evidence: INVALIDATED_EVIDENCE, ...contractFields(next, contractState(p, next)) }
|
|
930
|
-
});
|
|
931
|
-
}
|
|
932
|
-
if (command === "abort") {
|
|
933
|
-
const reason = option(args, "--reason");
|
|
934
|
-
const note = option(args, "--note");
|
|
935
|
-
if (!reason) return fail("ABORT_REASON_REQUIRED", "Abort requires --reason.");
|
|
936
|
-
if (!ABORT_REASONS.has(reason)) return fail("ABORT_REASON_UNSUPPORTED", `Unsupported Abort reason: ${reason}.`);
|
|
937
|
-
if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
|
|
938
|
-
const next = { ...flow, schema: "matrix/change/v2", status: "aborted", revision: flow.revision + 1, updated_at: stamp() };
|
|
939
|
-
const target = path.join(p.root, "archive", id);
|
|
940
|
-
if (fs.existsSync(target)) return fail("ARCHIVE_TARGET_EXISTS", `Archive target already exists: ${id}.`);
|
|
941
|
-
const sourceEvents = path.join(p.change, "events.jsonl");
|
|
942
|
-
const targetFlow = path.join(target, "matrix.yaml");
|
|
943
|
-
const targetEvents = path.join(target, "events.jsonl");
|
|
944
|
-
return runWorkflowTransaction({
|
|
945
|
-
cwd,
|
|
946
|
-
kind: "abort",
|
|
947
|
-
changeId: id,
|
|
948
|
-
failAfterOperation,
|
|
949
|
-
operations: [
|
|
950
|
-
workflowMoveOperation(cwd, p.change, target),
|
|
951
|
-
workflowWriteOperation(cwd, targetFlow, flowContent(next), { before: captureWorkflowEntry(p.flow) }),
|
|
952
|
-
workflowWriteOperation(cwd, targetEvents, eventContent(sourceEvents, "aborted", flow.phase, flow.phase, next.revision, { reason, ...(note ? { note } : {}) }), { before: captureWorkflowEntry(sourceEvents) }),
|
|
953
|
-
workflowRemoveOperation(cwd, p.active)
|
|
954
|
-
],
|
|
955
|
-
result: { ok: true, code: "OK", change_id: id, workflow: next.workflow, orchestration: next.orchestration, artifact_language: next.artifact_language ?? artifactLanguage, status: next.status, phase: next.phase, revision: next.revision, next_skill: null, event: "aborted" }
|
|
956
|
-
});
|
|
957
|
-
}
|
|
958
|
-
if (command === "archive") {
|
|
959
|
-
if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
|
|
960
|
-
if (flow.phase !== "archive") return fail("ILLEGAL_TRANSITION", "Only an archive-phase change can be archived.");
|
|
961
|
-
const profile = workflowProfile(p, flow);
|
|
962
|
-
const dryRun = args.includes("--dry-run");
|
|
963
|
-
const expected = option(args, "--expect-preflight");
|
|
964
|
-
const confirmedByUser = args.includes("--confirmed");
|
|
965
|
-
if ((dryRun && (expected || confirmedByUser)) || args.some((arg) => !["--dry-run", "--expect-preflight", "--confirmed", expected].includes(arg))) {
|
|
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" } : {}) }))
|
|
934
|
+
],
|
|
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)) }
|
|
936
|
+
});
|
|
937
|
+
}
|
|
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
|
+
],
|
|
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" }
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
if (command === "archive") {
|
|
965
|
+
if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
|
|
966
|
+
if (flow.phase !== "archive") return fail("ILLEGAL_TRANSITION", "Only an archive-phase change can be archived.");
|
|
967
|
+
const profile = workflowProfile(p, flow);
|
|
968
|
+
const dryRun = args.includes("--dry-run");
|
|
969
|
+
const expected = option(args, "--expect-preflight");
|
|
970
|
+
const confirmedByUser = args.includes("--confirmed");
|
|
971
|
+
if ((dryRun && (expected || confirmedByUser)) || args.some((arg) => !["--dry-run", "--expect-preflight", "--confirmed", expected].includes(arg))) {
|
|
966
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") };
|
|
967
|
-
}
|
|
973
|
+
}
|
|
968
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") };
|
|
969
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") };
|
|
970
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`) };
|
|
971
|
-
if (dryRun) {
|
|
972
|
-
const result = guard(p, flow, "archive"); if (!result.ok) return { ...result, message: result.message ?? "Archive guard failed." };
|
|
973
|
-
const captured = captureArchivePreflight(p, id); if (captured.failure) return captured.failure;
|
|
974
|
-
const currentFlow = readFlow(p); if (!currentFlow) return fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Canonical workflow state changed during dry-run.");
|
|
975
|
-
const finalGuard = guard(p, currentFlow, "archive"); if (!finalGuard.ok) return { ...finalGuard, message: finalGuard.message ?? "Archive guard failed." };
|
|
976
|
-
const confirmed = captureArchivePreflight(p, id); if (confirmed.failure) return confirmed.failure;
|
|
977
|
-
if (captured.hash !== confirmed.hash) return fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Archive inputs changed during dry-run. Retry dry-run.");
|
|
978
|
-
return {
|
|
979
|
-
ok: true, code: "OK", change_id: id, workflow: currentFlow.workflow, profile: profile.id, evidence_policy: evidencePolicy(currentFlow, profile), orchestration: currentFlow.orchestration, artifact_language: currentFlow.artifact_language ?? artifactLanguage, status: currentFlow.status, phase: currentFlow.phase, revision: currentFlow.revision,
|
|
980
|
-
preflight_hash: confirmed.hash,
|
|
981
|
-
effect_summary: { target: `.matrix/archive/${id}`, files: confirmed.fileCount, directories: confirmed.directoryCount, content_bytes: confirmed.totalBytes },
|
|
977
|
+
if (dryRun) {
|
|
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.");
|
|
984
|
+
return {
|
|
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,
|
|
986
|
+
preflight_hash: confirmed.hash,
|
|
987
|
+
effect_summary: { target: `.matrix/archive/${id}`, files: confirmed.fileCount, directories: confirmed.directoryCount, content_bytes: confirmed.totalBytes },
|
|
982
988
|
commit_command: workflowCommand(`archive --expect-preflight ${confirmed.hash}${shortcut(currentFlow) ? " --confirmed" : ""}`),
|
|
983
|
-
...contractFields(currentFlow, contractState(p, currentFlow))
|
|
984
|
-
};
|
|
985
|
-
}
|
|
986
|
-
const captured = captureArchivePreflight(p, id);
|
|
987
|
-
if (captured.failure) return captured.failure;
|
|
988
|
-
if (captured.hash !== expected) return archivePreflightChanged(expected, captured.hash);
|
|
989
|
-
const currentFlow = readFlow(p);
|
|
990
|
-
if (!currentFlow || currentFlow.status !== "active" || currentFlow.phase !== "archive") return archivePreflightChanged(expected, captured.hash);
|
|
991
|
-
const result = guard(p, currentFlow, "archive");
|
|
992
|
-
if (!result.ok) return { ...result, message: result.message ?? "Archive guard failed." };
|
|
993
|
-
const confirmed = captureArchivePreflight(p, id);
|
|
994
|
-
if (confirmed.failure) return confirmed.failure;
|
|
995
|
-
if (confirmed.hash !== expected) return archivePreflightChanged(expected, confirmed.hash);
|
|
996
|
-
const contract = contractState(p, currentFlow);
|
|
997
|
-
const next = { ...currentFlow, schema: "matrix/change/v2", status: "archived", revision: currentFlow.revision + 1, updated_at: stamp() };
|
|
998
|
-
const target = path.join(p.root, "archive", id);
|
|
999
|
-
const sourceEvents = path.join(p.change, "events.jsonl");
|
|
1000
|
-
const targetFlow = path.join(target, "matrix.yaml");
|
|
1001
|
-
const targetEvents = path.join(target, "events.jsonl");
|
|
1002
|
-
return runWorkflowTransaction({
|
|
1003
|
-
cwd,
|
|
1004
|
-
kind: "archive",
|
|
1005
|
-
changeId: id,
|
|
1006
|
-
failAfterOperation,
|
|
1007
|
-
operations: [
|
|
1008
|
-
workflowMoveOperation(cwd, p.change, target),
|
|
1009
|
-
workflowWriteOperation(cwd, targetFlow, flowContent(next), { before: captureWorkflowEntry(p.flow) }),
|
|
1010
|
-
workflowWriteOperation(cwd, targetEvents, eventContent(sourceEvents, "archived", "archive", "archive", next.revision, { preflight_hash: expected, confirmed: confirmedByUser }), { before: captureWorkflowEntry(sourceEvents) }),
|
|
1011
|
-
workflowRemoveOperation(cwd, p.active)
|
|
1012
|
-
],
|
|
1013
|
-
result: {
|
|
1014
|
-
ok: true, code: "OK", archived: id, change_id: id, workflow: next.workflow, profile: profile.id, evidence_policy: evidencePolicy(next, profile), orchestration: next.orchestration, artifact_language: next.artifact_language ?? artifactLanguage, status: next.status, phase: next.phase, revision: next.revision, next_skill: null, event: "archived",
|
|
1015
|
-
preflight_hash: expected,
|
|
1016
|
-
effect_summary: { target: `.matrix/archive/${id}`, files: confirmed.fileCount, directories: confirmed.directoryCount, content_bytes: confirmed.totalBytes },
|
|
1017
|
-
...contractFields(next, contract)
|
|
1018
|
-
}
|
|
1019
|
-
});
|
|
1020
|
-
}
|
|
1021
|
-
if (command === "handoff") { const destination = path.join(p.artifacts, "handoff.md"); fs.writeFileSync(destination, `# Matrix handoff: ${id}\n\n- Workflow: ${flow.workflow}\n- Current phase: ${flow.phase}\n- Resume with: \`${skill(flow.phase)}\`\n- Guard passes: ${guard(p, flow, flow.phase).ok}\n`); return { ok: true, code: "OK", path: destination }; }
|
|
1022
|
-
if (command === "export") {
|
|
1023
|
-
if (flow.phase !== "build") return fail("ILLEGAL_PHASE", "Export requires the build phase.");
|
|
1024
|
-
if (workflowProfile(p, flow).id === "lightweight") return fail("EXPORT_UNSUPPORTED_WORKFLOW", "Claude export requires the full three-artifact workflow contract.");
|
|
1025
|
-
const contract = contractState(p, flow); const failure = contractFailure(flow, contract); if (failure) return failure;
|
|
1026
|
-
const taskId = args.includes("--task-id") ? args[args.indexOf("--task-id") + 1] : args[0];
|
|
1027
|
-
const target = args.includes("--target") ? args[args.indexOf("--target") + 1] : "generic";
|
|
1028
|
-
const applyBoard = args.includes("--apply-fnsec-board");
|
|
1029
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(taskId ?? "") || !["generic", "fnsec"].includes(target)) return fail("INVALID_INTENT", "export requires a task id and target generic or fnsec.");
|
|
1030
|
-
const { proposal, design, plan } = contract.snapshot.contents;
|
|
1031
|
-
if (target === "generic") {
|
|
1032
|
-
const destination = path.join(p.artifacts, "claude-task.md"); const result = path.join(p.artifacts, "claude-result.md");
|
|
1033
|
-
fs.writeFileSync(destination, taskContent(id, taskId, proposal, design, plan, path.relative(cwd, result).replaceAll("\\", "/"), flow.approved_contract_hash, flow.contract_approved_revision));
|
|
1034
|
-
return { ok: true, code: "OK", path: destination };
|
|
1035
|
-
}
|
|
1036
|
-
const tasks = path.join(cwd, "docs", "tasks"); const board = path.join(tasks, "active.md");
|
|
1037
|
-
if (!exists(tasks) || !exists(board)) return fail("FNSEC_TARGET_UNAVAILABLE", "FnSec target requires docs/tasks/active.md in the current project.");
|
|
1038
|
-
const destination = path.join(tasks, `claude-task-${taskId}.md`); const result = path.join(tasks, `claude-result-${taskId}.md`);
|
|
1039
|
-
if (exists(destination) || exists(result)) return fail("FNSEC_TASK_EXISTS", `FnSec task or result path already exists for ${taskId}.`);
|
|
1040
|
-
const currentBoard = fs.readFileSync(board, "utf8");
|
|
1041
|
-
if (applyBoard && !currentBoard.includes("NO_ACTIVE_OBJECTIVE")) return fail("FNSEC_BOARD_OCCUPIED", "FnSec active.md already has an objective; refused to overwrite it.");
|
|
1042
|
-
fs.writeFileSync(destination, taskContent(id, taskId, proposal, design, plan, path.relative(cwd, result).replaceAll("\\", "/"), flow.approved_contract_hash, flow.contract_approved_revision));
|
|
1043
|
-
if (applyBoard) fs.writeFileSync(board, activeBoard(taskId, path.relative(cwd, destination).replaceAll("\\", "/"), path.relative(cwd, result).replaceAll("\\", "/"), id));
|
|
1044
|
-
return { ok: true, code: "OK", path: destination, board: applyBoard ? board : null };
|
|
1045
|
-
}
|
|
1046
|
-
return fail("INVALID_INTENT", `Unknown workflow command: ${command}.`);
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1].replaceAll("\\", "/")}`).href) {
|
|
1050
|
-
const result = invoke(process.argv.slice(2));
|
|
1051
|
-
console.log(JSON.stringify(result));
|
|
1052
|
-
if (!result.ok) process.exitCode = 2;
|
|
1053
|
-
}
|
|
989
|
+
...contractFields(currentFlow, contractState(p, currentFlow))
|
|
990
|
+
};
|
|
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) }),
|
|
1016
|
+
workflowWriteOperation(cwd, targetEvents, eventContent(sourceEvents, "archived", "archive", "archive", next.revision, { preflight_hash: expected, confirmed: confirmedByUser }), { before: captureWorkflowEntry(sourceEvents) }),
|
|
1017
|
+
workflowRemoveOperation(cwd, p.active)
|
|
1018
|
+
],
|
|
1019
|
+
result: {
|
|
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",
|
|
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 }; }
|
|
1028
|
+
if (command === "export") {
|
|
1029
|
+
if (flow.phase !== "build") return fail("ILLEGAL_PHASE", "Export requires the build phase.");
|
|
1030
|
+
if (workflowProfile(p, flow).id === "lightweight") return fail("EXPORT_UNSUPPORTED_WORKFLOW", "Claude export requires the full three-artifact workflow contract.");
|
|
1031
|
+
const contract = contractState(p, flow); const failure = contractFailure(flow, contract); if (failure) return failure;
|
|
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
|
+
}
|