@rosenfan/matrix 0.1.2 → 0.1.3
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/.claude/skills/matrix/SKILL.md +58 -56
- package/.claude/skills/matrix/scripts/matrix_claude.py +11 -11
- package/.claude/skills/matrix/scripts/matrix_state.py +60 -60
- package/.claude/skills/matrix-archive/SKILL.md +27 -24
- package/.claude/skills/matrix-build/SKILL.md +26 -24
- package/.claude/skills/matrix-claude/SKILL.md +11 -9
- package/.claude/skills/matrix-design/SKILL.md +31 -27
- package/.claude/skills/matrix-hotfix/SKILL.md +1 -1
- package/.claude/skills/matrix-open/SKILL.md +29 -19
- package/.claude/skills/matrix-status/SKILL.md +5 -5
- package/.claude/skills/matrix-tweak/SKILL.md +1 -1
- package/.claude/skills/matrix-verify/SKILL.md +23 -21
- package/README.md +123 -97
- package/README.zh-CN.md +122 -96
- package/assets/skills-zh-CN/matrix/SKILL.md +78 -75
- package/assets/skills-zh-CN/matrix-archive/SKILL.md +31 -28
- package/assets/skills-zh-CN/matrix-build/SKILL.md +37 -35
- package/assets/skills-zh-CN/matrix-claude/SKILL.md +31 -29
- package/assets/skills-zh-CN/matrix-design/SKILL.md +10 -6
- package/assets/skills-zh-CN/matrix-hotfix/SKILL.md +1 -1
- package/assets/skills-zh-CN/matrix-open/SKILL.md +19 -9
- package/assets/skills-zh-CN/matrix-status/SKILL.md +5 -5
- package/assets/skills-zh-CN/matrix-tweak/SKILL.md +1 -1
- package/assets/skills-zh-CN/matrix-verify/SKILL.md +17 -15
- package/package.json +2 -2
- package/src/catalog.js +16 -16
- package/src/cli.js +81 -28
- package/src/distribution.js +154 -123
- package/src/messages.js +4 -4
- package/src/self-update.js +81 -0
- package/src/ui.js +1 -1
- package/src/workflow-transaction.js +622 -622
- package/src/workflow.js +652 -420
package/src/workflow.js
CHANGED
|
@@ -1,34 +1,50 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
1
|
+
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
4
|
import os from "node:os";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
captureWorkflowEntry,
|
|
8
|
+
runWorkflowTransaction,
|
|
9
|
+
workflowDoctor,
|
|
10
|
+
workflowMkdirOperation,
|
|
11
|
+
workflowMutationGate,
|
|
12
|
+
workflowMoveOperation,
|
|
13
|
+
workflowRemoveOperation,
|
|
14
|
+
workflowWriteOperation
|
|
15
|
+
} from "./workflow-transaction.js";
|
|
15
16
|
|
|
16
17
|
const PHASES = ["open", "design", "build", "verify", "archive"];
|
|
17
|
-
const
|
|
18
|
-
|
|
18
|
+
const FULL_PROFILE = {
|
|
19
|
+
id: "full",
|
|
20
|
+
next: { open: "design", design: "build", build: "verify", verify: "archive" },
|
|
21
|
+
contractFiles: ["proposal.md", "design.md", "plan.md"]
|
|
22
|
+
};
|
|
23
|
+
const LIGHTWEIGHT_PROFILE = {
|
|
24
|
+
id: "lightweight",
|
|
25
|
+
next: { open: "build", build: "verify", verify: "archive" },
|
|
26
|
+
contractFiles: ["proposal.md"]
|
|
27
|
+
};
|
|
28
|
+
const FULL_RETURNS = [
|
|
19
29
|
{ from: "build", to: "design", reason: "design-gap" },
|
|
20
30
|
{ from: "verify", to: "build", reason: "verification-failed" },
|
|
21
31
|
{ from: "verify", to: "design", reason: "acceptance-or-design-gap" },
|
|
22
32
|
{ from: "archive", to: "design", reason: "acceptance-or-design-gap" }
|
|
23
33
|
];
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"grilling", "domain-modeling", "research", "wayfinder", "prototype",
|
|
30
|
-
"codebase-design", "tdd", "diagnosing-bugs", "resolving-merge-conflicts", "code-review"
|
|
34
|
+
const LIGHTWEIGHT_RETURNS = [
|
|
35
|
+
{ from: "build", to: "design", reason: "design-gap", upgrade: true },
|
|
36
|
+
{ from: "verify", to: "build", reason: "verification-failed" },
|
|
37
|
+
{ from: "verify", to: "design", reason: "acceptance-or-design-gap", upgrade: true },
|
|
38
|
+
{ from: "archive", to: "design", reason: "acceptance-or-design-gap", upgrade: true }
|
|
31
39
|
];
|
|
40
|
+
const RETURN_REASONS = new Set([...FULL_RETURNS, ...LIGHTWEIGHT_RETURNS].map(({ reason }) => reason));
|
|
41
|
+
const INVALIDATED_EVIDENCE = ["build", "test", "review", "archive"];
|
|
42
|
+
const ABORT_REASONS = new Set(["requirement-cancelled", "superseded", "no-longer-valuable", "blocked", "other"]);
|
|
43
|
+
const ORCHESTRATIONS = ["prim", "arch"];
|
|
44
|
+
const ARCH_SKILLS = [
|
|
45
|
+
"grilling", "domain-modeling", "research", "wayfinder", "prototype",
|
|
46
|
+
"codebase-design", "tdd", "diagnosing-bugs", "resolving-merge-conflicts", "code-review"
|
|
47
|
+
];
|
|
32
48
|
const ARTIFACT_POLICY = {
|
|
33
49
|
proposal: ["## Goal", "## Scope", "## Non-goals", "## Acceptance", "## Risks"],
|
|
34
50
|
design: ["## Decisions", "## Boundaries", "## Test seams", "## Risks"],
|
|
@@ -36,77 +52,195 @@ const ARTIFACT_POLICY = {
|
|
|
36
52
|
build: ["## Build evidence"],
|
|
37
53
|
verification: ["## Test evidence", "## Review evidence"]
|
|
38
54
|
};
|
|
39
|
-
const
|
|
55
|
+
const LIGHTWEIGHT_COMMON_PROPOSAL = ["## Goal", "## Scope", "## Non-goals", "## Approach", "## Acceptance", "## Validation", "## Risks", "## Upgrade conditions"];
|
|
56
|
+
const LIGHTWEIGHT_POLICY = {
|
|
57
|
+
hotfix: {
|
|
58
|
+
proposal: [...LIGHTWEIGHT_COMMON_PROPOSAL, "## Expected behavior", "## Actual behavior", "## Reproduction"],
|
|
59
|
+
build: ["## Build evidence", "## Reproduction evidence", "## Root cause"],
|
|
60
|
+
verification: ["## Test evidence", "## Regression evidence", "## Review evidence"]
|
|
61
|
+
},
|
|
62
|
+
tweak: {
|
|
63
|
+
proposal: [...LIGHTWEIGHT_COMMON_PROPOSAL, "## Current behavior", "## Preserved behavior", "## Diff boundary"],
|
|
64
|
+
build: ["## Build evidence", "## Scope evidence"],
|
|
65
|
+
verification: ["## Test evidence", "## Scope review evidence", "## Review evidence"]
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const WORKSPACE_BASELINE_FILE = "workspace-baseline.json";
|
|
69
|
+
const WORKSPACE_EXCLUDES = new Set([".git", ".matrix", ".comet", "node_modules"]);
|
|
70
|
+
const WORKSPACE_MAX_FILES = 50000;
|
|
71
|
+
const WORKSPACE_MAX_BYTES = 512 * 1024 * 1024;
|
|
72
|
+
const WORKSPACE_MAX_DURATION_MS = 60000;
|
|
40
73
|
const skill = (phase) => `$matrix-${phase}`;
|
|
41
74
|
const stamp = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
42
|
-
const rootFor = (cwd) => path.join(cwd, ".matrix");
|
|
43
|
-
const exists = (file) => fs.existsSync(file);
|
|
44
|
-
const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
75
|
+
const rootFor = (cwd) => path.join(cwd, ".matrix");
|
|
76
|
+
const exists = (file) => fs.existsSync(file);
|
|
77
|
+
const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
45
78
|
|
|
46
|
-
function fail(code, message) { return { ok: false, code, message }; }
|
|
47
|
-
function option(args, name) { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : null; }
|
|
79
|
+
function fail(code, message) { return { ok: false, code, message }; }
|
|
80
|
+
function option(args, name) { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : null; }
|
|
48
81
|
function flatYaml(file) {
|
|
49
|
-
try {
|
|
50
|
-
return Object.fromEntries(fs.readFileSync(file, "utf8").split(/\r?\n/).filter((line) => line.includes(":") && !line.startsWith(" ")).map((line) => {
|
|
51
|
-
const [key, ...value] = line.split(":");
|
|
52
|
-
return [key.trim(), value.join(":").trim().replace(/^"|"$/g, "")];
|
|
53
|
-
}));
|
|
82
|
+
try {
|
|
83
|
+
return Object.fromEntries(fs.readFileSync(file, "utf8").split(/\r?\n/).filter((line) => line.includes(":") && !line.startsWith(" ")).map((line) => {
|
|
84
|
+
const [key, ...value] = line.split(":");
|
|
85
|
+
return [key.trim(), value.join(":").trim().replace(/^"|"$/g, "")];
|
|
86
|
+
}));
|
|
54
87
|
} catch { return {}; }
|
|
55
88
|
}
|
|
56
|
-
function
|
|
57
|
-
const
|
|
89
|
+
function excludedWorkspacePath(relative) {
|
|
90
|
+
const first = relative.replaceAll("\\", "/").split("/")[0];
|
|
91
|
+
return WORKSPACE_EXCLUDES.has(first);
|
|
92
|
+
}
|
|
93
|
+
function workspaceFiles(cwd) {
|
|
94
|
+
const git = spawnSync("git", ["-C", cwd, "ls-files", "-co", "--exclude-standard", "-z"], {
|
|
95
|
+
encoding: "buffer",
|
|
96
|
+
windowsHide: true,
|
|
97
|
+
maxBuffer: 64 * 1024 * 1024
|
|
98
|
+
});
|
|
99
|
+
if (!git.error && git.status === 0) {
|
|
100
|
+
return {
|
|
101
|
+
provider: "git",
|
|
102
|
+
files: [...new Set(git.stdout.toString("utf8").split("\0").filter(Boolean))]
|
|
103
|
+
.map((relative) => relative.replaceAll("\\", "/"))
|
|
104
|
+
.filter((relative) => !excludedWorkspacePath(relative))
|
|
105
|
+
.sort()
|
|
106
|
+
};
|
|
107
|
+
}
|
|
58
108
|
const files = [];
|
|
59
|
-
const visit = (
|
|
60
|
-
for (const entry of fs.readdirSync(
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
if (entry.isDirectory()) visit(
|
|
64
|
-
else
|
|
109
|
+
const visit = (directory, prefix = "") => {
|
|
110
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
111
|
+
const relative = path.join(prefix, entry.name).replaceAll("\\", "/");
|
|
112
|
+
if (!prefix && WORKSPACE_EXCLUDES.has(entry.name)) continue;
|
|
113
|
+
if (entry.isDirectory()) visit(path.join(directory, entry.name), relative);
|
|
114
|
+
else files.push(relative);
|
|
115
|
+
if (files.length > WORKSPACE_MAX_FILES) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_FILES} files.`);
|
|
65
116
|
}
|
|
66
117
|
};
|
|
67
|
-
visit(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
118
|
+
visit(cwd);
|
|
119
|
+
return { provider: "physical", files };
|
|
120
|
+
}
|
|
121
|
+
function captureWorkspaceBaseline(cwd) {
|
|
122
|
+
const startedAt = Date.now();
|
|
123
|
+
const selection = workspaceFiles(cwd);
|
|
124
|
+
const hash = crypto.createHash("sha256");
|
|
125
|
+
hash.update("matrix/workspace-baseline/v1\0", "utf8");
|
|
126
|
+
let totalBytes = 0;
|
|
127
|
+
let fileCount = 0;
|
|
128
|
+
for (const relative of selection.files) {
|
|
129
|
+
if (Date.now() - startedAt > WORKSPACE_MAX_DURATION_MS) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_DURATION_MS} milliseconds.`);
|
|
130
|
+
if (fileCount >= WORKSPACE_MAX_FILES) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_FILES} files.`);
|
|
131
|
+
const absolute = path.join(cwd, relative);
|
|
132
|
+
const pathBytes = Buffer.from(relative, "utf8");
|
|
133
|
+
const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
|
|
134
|
+
hash.update(pathLength); hash.update(pathBytes);
|
|
135
|
+
let stat;
|
|
136
|
+
try { stat = fs.lstatSync(absolute); }
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (error.code !== "ENOENT") throw error;
|
|
139
|
+
hash.update("missing\0", "utf8");
|
|
140
|
+
fileCount += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (stat.isSymbolicLink()) {
|
|
144
|
+
const target = Buffer.from(fs.readlinkSync(absolute), "utf8");
|
|
145
|
+
hash.update("symlink\0", "utf8"); hash.update(target);
|
|
146
|
+
totalBytes += target.length;
|
|
147
|
+
} else if (stat.isFile()) {
|
|
148
|
+
const bytes = fs.readFileSync(absolute);
|
|
149
|
+
totalBytes += bytes.length;
|
|
150
|
+
if (totalBytes > WORKSPACE_MAX_BYTES) throw new Error(`Workspace baseline exceeds ${WORKSPACE_MAX_BYTES} bytes.`);
|
|
151
|
+
hash.update("file\0", "utf8"); hash.update(bytes);
|
|
152
|
+
} else {
|
|
153
|
+
hash.update("special\0", "utf8");
|
|
154
|
+
}
|
|
155
|
+
fileCount += 1;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
schema: "matrix/workspace-baseline/v1",
|
|
159
|
+
provider: selection.provider,
|
|
160
|
+
hash: `sha256:${hash.digest("hex")}`,
|
|
161
|
+
file_count: fileCount,
|
|
162
|
+
content_bytes: totalBytes
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function workspaceOrderGuard(cwd, p) {
|
|
166
|
+
let baseline;
|
|
167
|
+
try { baseline = read(p.workspaceBaseline); }
|
|
168
|
+
catch (error) {
|
|
169
|
+
return fail("WORKSPACE_BASELINE_MISSING", `Lightweight workflow baseline is unavailable: ${error.message}`);
|
|
170
|
+
}
|
|
171
|
+
let current;
|
|
172
|
+
try { current = captureWorkspaceBaseline(cwd); }
|
|
173
|
+
catch (error) {
|
|
174
|
+
return fail("WORKSPACE_INSPECTION_FAILED", `Could not inspect the lightweight workflow boundary: ${error.message}`);
|
|
71
175
|
}
|
|
72
|
-
|
|
176
|
+
if (baseline.schema !== "matrix/workspace-baseline/v1" || !/^sha256:[0-9a-f]{64}$/.test(baseline.hash ?? "")) {
|
|
177
|
+
return fail("WORKSPACE_BASELINE_INVALID", "Lightweight workflow baseline is invalid.");
|
|
178
|
+
}
|
|
179
|
+
if (current.hash !== baseline.hash) {
|
|
180
|
+
return {
|
|
181
|
+
...fail("PREMATURE_IMPLEMENTATION", "Project files changed after lightweight initialization and before Build approval."),
|
|
182
|
+
baseline_hash: baseline.hash,
|
|
183
|
+
current_hash: current.hash
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return { ok: true, code: "OK", baseline_hash: baseline.hash, current_hash: current.hash };
|
|
73
187
|
}
|
|
188
|
+
function hashDirectory(directory) {
|
|
189
|
+
const hash = crypto.createHash("sha256");
|
|
190
|
+
const files = [];
|
|
191
|
+
const visit = (current, relative = "") => {
|
|
192
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
193
|
+
const child = path.join(current, entry.name);
|
|
194
|
+
const childRelative = path.join(relative, entry.name).replaceAll("\\", "/");
|
|
195
|
+
if (entry.isDirectory()) visit(child, childRelative);
|
|
196
|
+
else if (entry.isFile()) files.push([childRelative, fs.readFileSync(child)]);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
visit(directory);
|
|
200
|
+
for (const [relative, contents] of files.sort(([left], [right]) => left.localeCompare(right))) {
|
|
201
|
+
hash.update(`${relative}\0`);
|
|
202
|
+
hash.update(contents);
|
|
203
|
+
}
|
|
204
|
+
return hash.digest("hex");
|
|
205
|
+
}
|
|
74
206
|
function orchestrationConfig(cwd) {
|
|
75
207
|
const config = flatYaml(path.join(rootFor(cwd), "config.yaml"));
|
|
76
208
|
const scope = config.installation_scope === "global" ? "global" : "project";
|
|
77
209
|
const manifestFile = scope === "global" ? path.join(os.homedir(), ".matrix", "installation.json") : path.join(rootFor(cwd), "installation.json");
|
|
78
|
-
let manifest = null;
|
|
79
|
-
try { manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8")); } catch {}
|
|
210
|
+
let manifest = null;
|
|
211
|
+
try { manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8")); } catch {}
|
|
80
212
|
const defaultOrchestration = ORCHESTRATIONS.includes(config.default_orchestration) ? config.default_orchestration
|
|
81
213
|
: ORCHESTRATIONS.includes(manifest?.orchestration?.default) ? manifest.orchestration.default : "prim";
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
214
|
+
const artifactLanguage = ["en", "zh-CN"].includes(config.language) ? config.language
|
|
215
|
+
: ["en", "zh-CN"].includes(manifest?.language) ? manifest.language : "en";
|
|
216
|
+
return { config, scope, manifestFile, manifest, defaultOrchestration, artifactLanguage };
|
|
217
|
+
}
|
|
218
|
+
function archIntegrity(cwd) {
|
|
219
|
+
const { manifest, manifestFile } = orchestrationConfig(cwd);
|
|
220
|
+
const platforms = Object.entries(manifest?.platforms ?? {});
|
|
221
|
+
if (!manifest?.orchestration?.available?.includes("arch") || !platforms.length) {
|
|
222
|
+
return { ok: false, code: "ARCH_INSTALLATION_INCOMPLETE", message: "Arch requires a verified managed Skill cohort on every configured platform.", recovery_command: "matrix init --with-mattpocock" };
|
|
223
|
+
}
|
|
224
|
+
const findings = [];
|
|
225
|
+
for (const [platform, record] of platforms) {
|
|
226
|
+
for (const skill of ARCH_SKILLS) {
|
|
227
|
+
const installed = record?.matt?.skills?.[skill];
|
|
228
|
+
if (!installed?.root || !installed?.hash || !fs.existsSync(path.join(installed.root, "SKILL.md"))) {
|
|
229
|
+
findings.push({ platform, skill, reason: "missing" });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
if (hashDirectory(installed.root) !== installed.hash) findings.push({ platform, skill, reason: "modified" });
|
|
234
|
+
} catch { findings.push({ platform, skill, reason: "unreadable" }); }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return findings.length ? {
|
|
238
|
+
ok: false, code: "ARCH_INSTALLATION_INCOMPLETE", message: "Arch installation integrity failed. Repair the managed cohort before continuing.",
|
|
239
|
+
manifest: manifestFile, findings, recovery_command: "matrix init --with-mattpocock"
|
|
240
|
+
} : { ok: true, code: "OK", manifest: manifestFile, platforms: platforms.map(([platform]) => platform) };
|
|
241
|
+
}
|
|
242
|
+
function taskContent(change, taskId, proposal, design, plan, resultPath, contractHash, approvalRevision) {
|
|
243
|
+
return `# Claude Code task: ${taskId}\n\n## Metadata\n\n| Field | Value |\n| --- | --- |\n| Status | CLAUDE_QUEUED |\n| Matrix change | ${change} |\n| Decision owner | Codex |\n| Implementation actor | Claude Code |\n| Approved contract | ${contractHash} |\n| Approval revision | ${approvalRevision} |\n| Result | ${resultPath} |\n\n## Objective\n\nThe frozen Matrix artifacts below are authoritative. Do not expand their scope.\n\n## Proposal\n\n${proposal}\n\n## Frozen design\n\n${design}\n\n## Ordered plan\n\n${plan}\n\n## Allowed work\n\n- Read repository instructions and existing tests before implementing.\n- Change only files required by the frozen design and plan.\n\n## Prohibited work\n\n- Do not edit Matrix state, transition its phase, archive it, or alter the frozen design.\n- Do not add unrelated features, public APIs, dependencies, or external side effects.\n- Do not claim completion without recording verifiable evidence.\n\n## Stop and report\n\nStop with \`NEEDS_DECISION\` if the scope must expand, the design conflicts with the repository, validation cannot pass within scope, or an authorization/safety issue arises.\n\n## Required result\n\nCreate \`${resultPath}\` with metadata, changed files and reasons, commands and outcomes, test results, boundary confirmation, residual risks, and any \`NEEDS_DECISION\` items. Codex reviews the result and the actual diff.\n`;
|
|
110
244
|
}
|
|
111
245
|
function activeBoard(taskId, taskPath, resultPath, change) {
|
|
112
246
|
return `# Active task board\n\n## Current objective\n\n| Field | Value |\n| --- | --- |\n| ID | ${taskId} |\n| Status | CLAUDE_QUEUED |\n| Matrix change | ${change} |\n| Task | ${taskPath} |\n| Result | ${resultPath} |\n`;
|
|
@@ -114,51 +248,66 @@ function activeBoard(taskId, taskPath, resultPath, change) {
|
|
|
114
248
|
function paths(cwd, id, archived = false) {
|
|
115
249
|
const root = rootFor(cwd);
|
|
116
250
|
const change = path.join(root, archived ? "archive" : "changes", id);
|
|
117
|
-
return {
|
|
251
|
+
return {
|
|
252
|
+
root,
|
|
253
|
+
active: path.join(root, "active.json"),
|
|
254
|
+
change,
|
|
255
|
+
flow: path.join(change, "matrix.yaml"),
|
|
256
|
+
artifacts: path.join(change, "artifacts"),
|
|
257
|
+
workspaceBaseline: path.join(change, WORKSPACE_BASELINE_FILE)
|
|
258
|
+
};
|
|
118
259
|
}
|
|
119
|
-
function normalizeFlow(flow) {
|
|
120
|
-
if (!flow) return null;
|
|
121
|
-
if (flow.schema !== "matrix/change/v2") return flow.schema ? { unsupported_schema: flow.schema } : null;
|
|
122
|
-
const revision = Number(flow.revision);
|
|
260
|
+
function normalizeFlow(flow) {
|
|
261
|
+
if (!flow) return null;
|
|
262
|
+
if (flow.schema !== "matrix/change/v2") return flow.schema ? { unsupported_schema: flow.schema } : null;
|
|
263
|
+
const revision = Number(flow.revision);
|
|
123
264
|
const approved = flow.approved_contract_hash || null;
|
|
124
265
|
const approvalRevision = flow.contract_approved_revision === "" || flow.contract_approved_revision == null ? null : Number(flow.contract_approved_revision);
|
|
266
|
+
const workflowProfile = flow.workflow_profile || null;
|
|
125
267
|
if (
|
|
126
|
-
typeof flow.id !== "string" || !flow.id ||
|
|
127
|
-
!["full", "hotfix", "tweak"].includes(flow.workflow) ||
|
|
128
|
-
!ORCHESTRATIONS.includes(flow.orchestration) ||
|
|
129
|
-
!["active", "archived", "aborted"].includes(flow.status) ||
|
|
130
|
-
!PHASES.includes(flow.phase) ||
|
|
268
|
+
typeof flow.id !== "string" || !flow.id ||
|
|
269
|
+
!["full", "hotfix", "tweak"].includes(flow.workflow) ||
|
|
270
|
+
!ORCHESTRATIONS.includes(flow.orchestration) ||
|
|
271
|
+
!["active", "archived", "aborted"].includes(flow.status) ||
|
|
272
|
+
!PHASES.includes(flow.phase) ||
|
|
131
273
|
!Number.isInteger(revision) || revision < 1 ||
|
|
132
|
-
|
|
133
|
-
(
|
|
134
|
-
(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
274
|
+
(workflowProfile !== null && workflowProfile !== "lightweight") ||
|
|
275
|
+
(workflowProfile === "lightweight" && !["hotfix", "tweak"].includes(flow.workflow)) ||
|
|
276
|
+
(flow.artifact_language != null && !["en", "zh-CN"].includes(flow.artifact_language)) ||
|
|
277
|
+
Boolean(approved) !== Boolean(approvalRevision) ||
|
|
278
|
+
(approved && !/^sha256:[0-9a-f]{64}$/.test(approved)) ||
|
|
279
|
+
(approvalRevision != null && (!Number.isInteger(approvalRevision) || approvalRevision < 1 || approvalRevision > revision))
|
|
280
|
+
) return null;
|
|
281
|
+
return { ...flow, revision, workflow_profile: workflowProfile, artifact_language: flow.artifact_language ?? null, approved_contract_hash: approved, contract_approved_revision: approvalRevision };
|
|
282
|
+
}
|
|
283
|
+
function readFlow(p) {
|
|
284
|
+
if (!fs.existsSync(p.flow)) return null;
|
|
285
|
+
const parsed = Object.fromEntries(fs.readFileSync(p.flow, "utf8").split(/\r?\n/).filter((line) => line.includes(":") && !line.startsWith(" ")).map((line) => { const [key, ...value] = line.split(":"); return [key.trim(), value.join(":").trim().replace(/^"|"$/g, "")]; }));
|
|
286
|
+
return normalizeFlow(parsed);
|
|
287
|
+
}
|
|
143
288
|
function flowContent(flow) {
|
|
144
|
-
const order = ["schema", "id", "workflow", "orchestration", "status", "phase", "revision", "approved_contract_hash", "contract_approved_revision", "title", "created_at", "updated_at", "acceptance", "scope"];
|
|
145
|
-
return `${order.map((key) => `${key}: ${flow[key] ?? ""}`).join("\n")}\n`;
|
|
289
|
+
const order = ["schema", "id", "workflow", "workflow_profile", "orchestration", "artifact_language", "status", "phase", "revision", "approved_contract_hash", "contract_approved_revision", "title", "created_at", "updated_at", "acceptance", "scope"];
|
|
290
|
+
return `${order.filter((key) => (key !== "workflow_profile" || flow[key]) && (key !== "artifact_language" || flow[key])).map((key) => `${key}: ${flow[key] ?? ""}`).join("\n")}\n`;
|
|
146
291
|
}
|
|
147
|
-
function active(cwd) { const activeFile = path.join(rootFor(cwd), "active.json"); if (!fs.existsSync(activeFile)) return null; return read(activeFile).change_id; }
|
|
148
|
-
function meaningful(file, headings) {
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
292
|
+
function active(cwd) { const activeFile = path.join(rootFor(cwd), "active.json"); if (!fs.existsSync(activeFile)) return null; return read(activeFile).change_id; }
|
|
293
|
+
function meaningful(file, headings) {
|
|
294
|
+
if (!fs.existsSync(file)) return false;
|
|
295
|
+
const value = fs.readFileSync(file, "utf8").trim();
|
|
296
|
+
if (value.length < 40) return false;
|
|
297
|
+
return headings.every((heading) => {
|
|
298
|
+
const start = value.indexOf(heading);
|
|
299
|
+
if (start < 0) return false;
|
|
300
|
+
const bodyStart = start + heading.length;
|
|
301
|
+
const next = value.indexOf("\n## ", bodyStart);
|
|
302
|
+
return value.slice(bodyStart, next < 0 ? value.length : next).trim().length >= 8;
|
|
303
|
+
});
|
|
156
304
|
}
|
|
157
|
-
function
|
|
305
|
+
function shortcut(flow) { return flow.workflow === "hotfix" || flow.workflow === "tweak"; }
|
|
306
|
+
function contractSnapshotForFiles(p, files) {
|
|
158
307
|
const hash = crypto.createHash("sha256");
|
|
159
308
|
hash.update("matrix/workflow-contract/v1\\0", "utf8");
|
|
160
309
|
const contents = {};
|
|
161
|
-
for (const name of
|
|
310
|
+
for (const name of files) {
|
|
162
311
|
const logical = `artifacts/${name}`;
|
|
163
312
|
let bytes;
|
|
164
313
|
try { bytes = fs.readFileSync(path.join(p.artifacts, name)); }
|
|
@@ -171,381 +320,464 @@ function contractSnapshot(p) {
|
|
|
171
320
|
}
|
|
172
321
|
return { hash: `sha256:${hash.digest("hex")}`, contents };
|
|
173
322
|
}
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (!flow.approved_contract_hash) return { status: "unapproved", snapshot };
|
|
178
|
-
if (snapshot.missing || snapshot.hash !== flow.approved_contract_hash) return { status: "changed", snapshot };
|
|
179
|
-
return { status: "approved-and-matching", snapshot };
|
|
180
|
-
}
|
|
181
|
-
function contractFields(flow, contract) {
|
|
182
|
-
return {
|
|
183
|
-
contract_status: contract.status,
|
|
184
|
-
current_contract_hash: contract.snapshot.hash ?? null,
|
|
185
|
-
approved_contract_hash: flow.approved_contract_hash,
|
|
186
|
-
contract_approved_revision: flow.contract_approved_revision
|
|
187
|
-
};
|
|
323
|
+
function workflowProfile(p, flow) {
|
|
324
|
+
if (!shortcut(flow)) return FULL_PROFILE;
|
|
325
|
+
return flow.workflow_profile === "lightweight" ? LIGHTWEIGHT_PROFILE : { ...FULL_PROFILE, id: "legacy-full" };
|
|
188
326
|
}
|
|
189
|
-
function
|
|
190
|
-
|
|
191
|
-
if (contract.status === "unreadable") return { ...fail("CONTRACT_READ_FAILED", contract.snapshot.error.message), ...contractFields(flow, contract) };
|
|
192
|
-
return { ...fail(contract.status === "unapproved" ? "CONTRACT_UNAPPROVED" : "CONTRACT_CHANGED", `Workflow contract is ${contract.status}.`), ...contractFields(flow, contract) };
|
|
327
|
+
function evidencePolicy(flow, profile) {
|
|
328
|
+
return profile.id === "lightweight" ? flow.workflow : "full";
|
|
193
329
|
}
|
|
194
|
-
function
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const contentLength = Buffer.alloc(8); contentLength.writeBigUInt64BE(BigInt(entry.size));
|
|
208
|
-
hash.update(typeLength); hash.update(typeBytes); hash.update(pathLength); hash.update(pathBytes); hash.update(contentLength);
|
|
209
|
-
const descriptor = fs.openSync(entry.source, "r");
|
|
210
|
-
try {
|
|
211
|
-
const opened = fs.fstatSync(descriptor);
|
|
212
|
-
if (!opened.isFile() || opened.size !== entry.size) throw new Error(`Archive file changed while opening: ${entry.logicalPath}.`);
|
|
213
|
-
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
214
|
-
let offset = 0;
|
|
215
|
-
while (offset < entry.size) {
|
|
216
|
-
const length = fs.readSync(descriptor, chunk, 0, Math.min(chunk.length, entry.size - offset), offset);
|
|
217
|
-
if (length === 0) throw new Error(`Archive file changed while reading: ${entry.logicalPath}.`);
|
|
218
|
-
hash.update(chunk.subarray(0, length));
|
|
219
|
-
offset += length;
|
|
220
|
-
}
|
|
221
|
-
if (fs.readSync(descriptor, chunk, 0, 1, offset) !== 0) throw new Error(`Archive file changed while reading: ${entry.logicalPath}.`);
|
|
222
|
-
} finally {
|
|
223
|
-
fs.closeSync(descriptor);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
function archiveDirectoryEntries(root, relative = "") {
|
|
227
|
-
const entries = [];
|
|
228
|
-
const names = fs.readdirSync(path.join(root, relative)).sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
229
|
-
for (const name of names) {
|
|
230
|
-
const childRelative = relative ? `${relative}/${name}` : name;
|
|
231
|
-
const child = path.join(root, ...childRelative.split("/"));
|
|
232
|
-
const stat = fs.lstatSync(child);
|
|
233
|
-
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
|
|
234
|
-
const error = new Error(`Archive change contains unsupported filesystem entry: ${childRelative}.`);
|
|
235
|
-
error.code = "ARCHIVE_ENTRY_UNSUPPORTED";
|
|
236
|
-
throw error;
|
|
237
|
-
}
|
|
238
|
-
if (stat.isDirectory()) {
|
|
239
|
-
entries.push({ type: "directory", logicalPath: `change/${childRelative}`, content: Buffer.alloc(0) });
|
|
240
|
-
entries.push(...archiveDirectoryEntries(root, childRelative));
|
|
241
|
-
} else {
|
|
242
|
-
entries.push({ type: "file", logicalPath: `change/${childRelative}`, source: child, size: stat.size });
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return entries;
|
|
246
|
-
}
|
|
247
|
-
function requireArchiveEntry(file, kind, label) {
|
|
248
|
-
let stat;
|
|
249
|
-
try { stat = fs.lstatSync(file); }
|
|
250
|
-
catch (error) { return fail("ARCHIVE_PREFLIGHT_UNSTABLE", `${label} could not be inspected: ${error.message}`); }
|
|
251
|
-
const valid = kind === "directory" ? stat.isDirectory() : stat.isFile();
|
|
252
|
-
return stat.isSymbolicLink() || !valid ? fail("ARCHIVE_ENTRY_UNSUPPORTED", `${label} must be an ordinary ${kind}.`) : null;
|
|
253
|
-
}
|
|
254
|
-
function captureArchivePreflightOnce(p, id) {
|
|
255
|
-
const target = path.join(p.root, "archive", id);
|
|
256
|
-
if (fs.existsSync(target)) return { failure: fail("ARCHIVE_TARGET_EXISTS", `Archive target already exists: ${id}.`) };
|
|
257
|
-
for (const [file, kind, label] of [[p.root, "directory", "Matrix state root"], [p.change, "directory", "Active change root"], [path.join(p.root, "archive"), "directory", "Archive root"], [p.active, "file", "Active Selection"]]) {
|
|
258
|
-
const failure = requireArchiveEntry(file, kind, label); if (failure) return { failure };
|
|
259
|
-
}
|
|
260
|
-
let selection;
|
|
261
|
-
try { selection = fs.readFileSync(p.active); }
|
|
262
|
-
catch (error) { return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Active Selection could not be captured: ${error.message}`) }; }
|
|
263
|
-
let entries;
|
|
264
|
-
try {
|
|
265
|
-
entries = archiveDirectoryEntries(p.change);
|
|
266
|
-
entries.sort((left, right) => Buffer.compare(Buffer.from(left.logicalPath), Buffer.from(right.logicalPath)));
|
|
267
|
-
}
|
|
268
|
-
catch (error) {
|
|
269
|
-
if (error.code === "ARCHIVE_ENTRY_UNSUPPORTED") return { failure: fail(error.code, error.message) };
|
|
270
|
-
return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Archive inputs changed or could not be captured: ${error.message}`) };
|
|
271
|
-
}
|
|
272
|
-
const hash = crypto.createHash("sha256");
|
|
273
|
-
hash.update("matrix/archive-preflight/v1\\0", "utf8");
|
|
274
|
-
addFramedEntry(hash, "identity", "change-id", Buffer.from(id, "utf8"));
|
|
275
|
-
addFramedEntry(hash, "identity", "archive-target", Buffer.from(`.matrix/archive/${id}`, "utf8"));
|
|
276
|
-
addFramedEntry(hash, "selection", ".matrix/active.json", selection);
|
|
277
|
-
addFramedEntry(hash, "fact", "archive-target-state", Buffer.from("absent", "utf8"));
|
|
278
|
-
let fileCount = 0; let directoryCount = 0; let totalBytes = 0;
|
|
279
|
-
try {
|
|
280
|
-
for (const entry of entries) {
|
|
281
|
-
if (entry.type === "file") {
|
|
282
|
-
addFramedFile(hash, entry);
|
|
283
|
-
fileCount += 1;
|
|
284
|
-
totalBytes += entry.size;
|
|
285
|
-
} else {
|
|
286
|
-
addFramedEntry(hash, entry.type, entry.logicalPath, entry.content);
|
|
287
|
-
directoryCount += 1;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
} catch (error) {
|
|
291
|
-
return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Archive inputs changed or could not be captured: ${error.message}`) };
|
|
292
|
-
}
|
|
293
|
-
return { hash: `sha256:${hash.digest("hex")}`, fileCount, directoryCount, totalBytes };
|
|
330
|
+
function structuralGuard(p, flow, phase) {
|
|
331
|
+
const base = p.artifacts;
|
|
332
|
+
const profile = workflowProfile(p, flow);
|
|
333
|
+
const policy = evidencePolicy(flow, profile);
|
|
334
|
+
const shortcutPolicy = LIGHTWEIGHT_POLICY[policy];
|
|
335
|
+
const checks = phase === "open" ? [["proposal", meaningful(path.join(base, "proposal.md"), shortcutPolicy?.proposal ?? ARTIFACT_POLICY.proposal)]]
|
|
336
|
+
: 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)]]
|
|
337
|
+
: phase === "build" ? [
|
|
338
|
+
...(profile.id === "lightweight" ? [] : [["plan", meaningful(path.join(base, "plan.md"), ARTIFACT_POLICY.plan)]]),
|
|
339
|
+
["build evidence", meaningful(path.join(base, "verification.md"), shortcutPolicy?.build ?? ARTIFACT_POLICY.build)]
|
|
340
|
+
]
|
|
341
|
+
: [["verification", meaningful(path.join(base, "verification.md"), shortcutPolicy?.verification ?? ARTIFACT_POLICY.verification)]];
|
|
342
|
+
return { pass: checks.every(([, result]) => result), checks };
|
|
294
343
|
}
|
|
295
|
-
function
|
|
296
|
-
|
|
297
|
-
const second = captureArchivePreflightOnce(p, id); if (second.failure) return second;
|
|
298
|
-
if (first.hash !== second.hash) return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Archive inputs changed while the preflight snapshot was being captured. Retry dry-run.") };
|
|
299
|
-
return second;
|
|
300
|
-
}
|
|
301
|
-
function archivePreflightChanged(expected, current) {
|
|
302
|
-
return {
|
|
303
|
-
...fail("ARCHIVE_PREFLIGHT_CHANGED", "Archive inputs changed after dry-run. Run dry-run again and review the new preflight."),
|
|
304
|
-
expected_preflight_hash: expected,
|
|
305
|
-
current_preflight_hash: current,
|
|
306
|
-
recovery_command: "matrix workflow archive --dry-run"
|
|
307
|
-
};
|
|
344
|
+
function contractSnapshot(p, flow) {
|
|
345
|
+
return contractSnapshotForFiles(p, workflowProfile(p, flow).contractFiles);
|
|
308
346
|
}
|
|
347
|
+
function contractState(p, flow) {
|
|
348
|
+
const snapshot = contractSnapshot(p, flow);
|
|
349
|
+
if (snapshot.error) return { status: "unreadable", snapshot };
|
|
350
|
+
if (!flow.approved_contract_hash) return { status: "unapproved", snapshot };
|
|
351
|
+
if (snapshot.missing || snapshot.hash !== flow.approved_contract_hash) return { status: "changed", snapshot };
|
|
352
|
+
return { status: "approved-and-matching", snapshot };
|
|
353
|
+
}
|
|
354
|
+
function contractFields(flow, contract) {
|
|
355
|
+
return {
|
|
356
|
+
contract_status: contract.status,
|
|
357
|
+
current_contract_hash: contract.snapshot.hash ?? null,
|
|
358
|
+
approved_contract_hash: flow.approved_contract_hash,
|
|
359
|
+
contract_approved_revision: flow.contract_approved_revision
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function contractFailure(flow, contract) {
|
|
363
|
+
if (contract.status === "approved-and-matching") return null;
|
|
364
|
+
if (contract.status === "unreadable") return { ...fail("CONTRACT_READ_FAILED", contract.snapshot.error.message), ...contractFields(flow, contract) };
|
|
365
|
+
return { ...fail(contract.status === "unapproved" ? "CONTRACT_UNAPPROVED" : "CONTRACT_CHANGED", `Workflow contract is ${contract.status}.`), ...contractFields(flow, contract) };
|
|
366
|
+
}
|
|
367
|
+
function addFramedEntry(hash, type, logicalPath, content = Buffer.alloc(0)) {
|
|
368
|
+
const typeBytes = Buffer.from(type, "utf8");
|
|
369
|
+
const pathBytes = Buffer.from(logicalPath, "utf8");
|
|
370
|
+
const typeLength = Buffer.alloc(4); typeLength.writeUInt32BE(typeBytes.length);
|
|
371
|
+
const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
|
|
372
|
+
const contentLength = Buffer.alloc(8); contentLength.writeBigUInt64BE(BigInt(content.length));
|
|
373
|
+
hash.update(typeLength); hash.update(typeBytes); hash.update(pathLength); hash.update(pathBytes); hash.update(contentLength); hash.update(content);
|
|
374
|
+
}
|
|
375
|
+
function addFramedFile(hash, entry) {
|
|
376
|
+
const typeBytes = Buffer.from(entry.type, "utf8");
|
|
377
|
+
const pathBytes = Buffer.from(entry.logicalPath, "utf8");
|
|
378
|
+
const typeLength = Buffer.alloc(4); typeLength.writeUInt32BE(typeBytes.length);
|
|
379
|
+
const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(pathBytes.length);
|
|
380
|
+
const contentLength = Buffer.alloc(8); contentLength.writeBigUInt64BE(BigInt(entry.size));
|
|
381
|
+
hash.update(typeLength); hash.update(typeBytes); hash.update(pathLength); hash.update(pathBytes); hash.update(contentLength);
|
|
382
|
+
const descriptor = fs.openSync(entry.source, "r");
|
|
383
|
+
try {
|
|
384
|
+
const opened = fs.fstatSync(descriptor);
|
|
385
|
+
if (!opened.isFile() || opened.size !== entry.size) throw new Error(`Archive file changed while opening: ${entry.logicalPath}.`);
|
|
386
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
387
|
+
let offset = 0;
|
|
388
|
+
while (offset < entry.size) {
|
|
389
|
+
const length = fs.readSync(descriptor, chunk, 0, Math.min(chunk.length, entry.size - offset), offset);
|
|
390
|
+
if (length === 0) throw new Error(`Archive file changed while reading: ${entry.logicalPath}.`);
|
|
391
|
+
hash.update(chunk.subarray(0, length));
|
|
392
|
+
offset += length;
|
|
393
|
+
}
|
|
394
|
+
if (fs.readSync(descriptor, chunk, 0, 1, offset) !== 0) throw new Error(`Archive file changed while reading: ${entry.logicalPath}.`);
|
|
395
|
+
} finally {
|
|
396
|
+
fs.closeSync(descriptor);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
function archiveDirectoryEntries(root, relative = "") {
|
|
400
|
+
const entries = [];
|
|
401
|
+
const names = fs.readdirSync(path.join(root, relative)).sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
402
|
+
for (const name of names) {
|
|
403
|
+
const childRelative = relative ? `${relative}/${name}` : name;
|
|
404
|
+
const child = path.join(root, ...childRelative.split("/"));
|
|
405
|
+
const stat = fs.lstatSync(child);
|
|
406
|
+
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
|
|
407
|
+
const error = new Error(`Archive change contains unsupported filesystem entry: ${childRelative}.`);
|
|
408
|
+
error.code = "ARCHIVE_ENTRY_UNSUPPORTED";
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
if (stat.isDirectory()) {
|
|
412
|
+
entries.push({ type: "directory", logicalPath: `change/${childRelative}`, content: Buffer.alloc(0) });
|
|
413
|
+
entries.push(...archiveDirectoryEntries(root, childRelative));
|
|
414
|
+
} else {
|
|
415
|
+
entries.push({ type: "file", logicalPath: `change/${childRelative}`, source: child, size: stat.size });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return entries;
|
|
419
|
+
}
|
|
420
|
+
function requireArchiveEntry(file, kind, label) {
|
|
421
|
+
let stat;
|
|
422
|
+
try { stat = fs.lstatSync(file); }
|
|
423
|
+
catch (error) { return fail("ARCHIVE_PREFLIGHT_UNSTABLE", `${label} could not be inspected: ${error.message}`); }
|
|
424
|
+
const valid = kind === "directory" ? stat.isDirectory() : stat.isFile();
|
|
425
|
+
return stat.isSymbolicLink() || !valid ? fail("ARCHIVE_ENTRY_UNSUPPORTED", `${label} must be an ordinary ${kind}.`) : null;
|
|
426
|
+
}
|
|
427
|
+
function captureArchivePreflightOnce(p, id) {
|
|
428
|
+
const target = path.join(p.root, "archive", id);
|
|
429
|
+
if (fs.existsSync(target)) return { failure: fail("ARCHIVE_TARGET_EXISTS", `Archive target already exists: ${id}.`) };
|
|
430
|
+
for (const [file, kind, label] of [[p.root, "directory", "Matrix state root"], [p.change, "directory", "Active change root"], [path.join(p.root, "archive"), "directory", "Archive root"], [p.active, "file", "Active Selection"]]) {
|
|
431
|
+
const failure = requireArchiveEntry(file, kind, label); if (failure) return { failure };
|
|
432
|
+
}
|
|
433
|
+
let selection;
|
|
434
|
+
try { selection = fs.readFileSync(p.active); }
|
|
435
|
+
catch (error) { return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Active Selection could not be captured: ${error.message}`) }; }
|
|
436
|
+
let entries;
|
|
437
|
+
try {
|
|
438
|
+
entries = archiveDirectoryEntries(p.change);
|
|
439
|
+
entries.sort((left, right) => Buffer.compare(Buffer.from(left.logicalPath), Buffer.from(right.logicalPath)));
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
if (error.code === "ARCHIVE_ENTRY_UNSUPPORTED") return { failure: fail(error.code, error.message) };
|
|
443
|
+
return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Archive inputs changed or could not be captured: ${error.message}`) };
|
|
444
|
+
}
|
|
445
|
+
const hash = crypto.createHash("sha256");
|
|
446
|
+
hash.update("matrix/archive-preflight/v1\\0", "utf8");
|
|
447
|
+
addFramedEntry(hash, "identity", "change-id", Buffer.from(id, "utf8"));
|
|
448
|
+
addFramedEntry(hash, "identity", "archive-target", Buffer.from(`.matrix/archive/${id}`, "utf8"));
|
|
449
|
+
addFramedEntry(hash, "selection", ".matrix/active.json", selection);
|
|
450
|
+
addFramedEntry(hash, "fact", "archive-target-state", Buffer.from("absent", "utf8"));
|
|
451
|
+
let fileCount = 0; let directoryCount = 0; let totalBytes = 0;
|
|
452
|
+
try {
|
|
453
|
+
for (const entry of entries) {
|
|
454
|
+
if (entry.type === "file") {
|
|
455
|
+
addFramedFile(hash, entry);
|
|
456
|
+
fileCount += 1;
|
|
457
|
+
totalBytes += entry.size;
|
|
458
|
+
} else {
|
|
459
|
+
addFramedEntry(hash, entry.type, entry.logicalPath, entry.content);
|
|
460
|
+
directoryCount += 1;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
} catch (error) {
|
|
464
|
+
return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", `Archive inputs changed or could not be captured: ${error.message}`) };
|
|
465
|
+
}
|
|
466
|
+
return { hash: `sha256:${hash.digest("hex")}`, fileCount, directoryCount, totalBytes };
|
|
467
|
+
}
|
|
468
|
+
function captureArchivePreflight(p, id) {
|
|
469
|
+
const first = captureArchivePreflightOnce(p, id); if (first.failure) return first;
|
|
470
|
+
const second = captureArchivePreflightOnce(p, id); if (second.failure) return second;
|
|
471
|
+
if (first.hash !== second.hash) return { failure: fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Archive inputs changed while the preflight snapshot was being captured. Retry dry-run.") };
|
|
472
|
+
return second;
|
|
473
|
+
}
|
|
474
|
+
function archivePreflightChanged(expected, current) {
|
|
475
|
+
return {
|
|
476
|
+
...fail("ARCHIVE_PREFLIGHT_CHANGED", "Archive inputs changed after dry-run. Run dry-run again and review the new preflight."),
|
|
477
|
+
expected_preflight_hash: expected,
|
|
478
|
+
current_preflight_hash: current,
|
|
479
|
+
recovery_command: "matrix workflow archive --dry-run"
|
|
480
|
+
};
|
|
481
|
+
}
|
|
309
482
|
function guard(p, flow, phase) {
|
|
310
483
|
if (!["build", "verify", "archive"].includes(phase)) {
|
|
311
|
-
const structural = structuralGuard(p, phase);
|
|
484
|
+
const structural = structuralGuard(p, flow, phase);
|
|
312
485
|
return { ok: structural.pass, code: structural.pass ? "OK" : "GUARD_FAILED", ...structural };
|
|
313
486
|
}
|
|
314
487
|
const contract = contractState(p, flow); const failure = contractFailure(flow, contract);
|
|
315
488
|
if (failure) return { ok: false, ...failure, checks: [["contract", false]] };
|
|
316
|
-
const structural = structuralGuard(p, phase);
|
|
317
|
-
return { ok: structural.pass, code: structural.pass ? "OK" : "GUARD_FAILED", ...structural, ...contractFields(flow, contract), checks: [["contract", true], ...structural.checks] };
|
|
318
|
-
}
|
|
319
|
-
function eventContent(file, name, before, after, revision, details = {}) {
|
|
320
|
-
const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
321
|
-
return `${current}${JSON.stringify({ schema: "matrix/event/v1", at: stamp(), event: name, revision, from: before, to: after, ...details })}\n`;
|
|
322
|
-
}
|
|
323
|
-
export function invoke(argv, { cwd = process.cwd(), failAfterOperation = null } = {}) {
|
|
324
|
-
const [command, ...args] = argv;
|
|
325
|
-
if (command === "init") {
|
|
326
|
-
const id = (args.shift() ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
327
|
-
const workflow = args.includes("--workflow") ? args[args.indexOf("--workflow") + 1] : "full";
|
|
328
|
-
const title = args.includes("--title") ? args[args.indexOf("--title") + 1] : null;
|
|
329
|
-
const requestedOrchestration = option(args, "--orchestration");
|
|
330
|
-
if (requestedOrchestration != null && !ORCHESTRATIONS.includes(requestedOrchestration)) return fail("INVALID_INTENT", "orchestration must be prim or arch.");
|
|
331
|
-
const configured = orchestrationConfig(cwd);
|
|
332
|
-
const orchestration = requestedOrchestration ?? configured.defaultOrchestration;
|
|
333
|
-
if (!id || !title || !["full", "hotfix", "tweak"].includes(workflow)) return fail("INVALID_INTENT", "init requires a valid change id and --title.");
|
|
334
|
-
if (orchestration === "arch") {
|
|
335
|
-
const integrity = archIntegrity(cwd);
|
|
336
|
-
if (!integrity.ok) return { ...integrity, orchestration };
|
|
337
|
-
}
|
|
489
|
+
const structural = structuralGuard(p, flow, phase);
|
|
490
|
+
return { ok: structural.pass, code: structural.pass ? "OK" : "GUARD_FAILED", ...structural, ...contractFields(flow, contract), checks: [["contract", true], ...structural.checks] };
|
|
491
|
+
}
|
|
492
|
+
function eventContent(file, name, before, after, revision, details = {}) {
|
|
493
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
494
|
+
return `${current}${JSON.stringify({ schema: "matrix/event/v1", at: stamp(), event: name, revision, from: before, to: after, ...details })}\n`;
|
|
495
|
+
}
|
|
496
|
+
export function invoke(argv, { cwd = process.cwd(), failAfterOperation = null } = {}) {
|
|
497
|
+
const [command, ...args] = argv;
|
|
498
|
+
if (command === "init") {
|
|
499
|
+
const id = (args.shift() ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
500
|
+
const workflow = args.includes("--workflow") ? args[args.indexOf("--workflow") + 1] : "full";
|
|
501
|
+
const title = args.includes("--title") ? args[args.indexOf("--title") + 1] : null;
|
|
502
|
+
const requestedOrchestration = option(args, "--orchestration");
|
|
503
|
+
if (requestedOrchestration != null && !ORCHESTRATIONS.includes(requestedOrchestration)) return fail("INVALID_INTENT", "orchestration must be prim or arch.");
|
|
504
|
+
const configured = orchestrationConfig(cwd);
|
|
505
|
+
const orchestration = requestedOrchestration ?? configured.defaultOrchestration;
|
|
506
|
+
if (!id || !title || !["full", "hotfix", "tweak"].includes(workflow)) return fail("INVALID_INTENT", "init requires a valid change id and --title.");
|
|
507
|
+
if (orchestration === "arch") {
|
|
508
|
+
const integrity = archIntegrity(cwd);
|
|
509
|
+
if (!integrity.ok) return { ...integrity, orchestration };
|
|
510
|
+
}
|
|
338
511
|
if (active(cwd)) return fail("ACTIVE_CHANGE_EXISTS", `Active Matrix change already exists: ${active(cwd)}.`);
|
|
339
512
|
const p = paths(cwd, id); if (fs.existsSync(p.change) || fs.existsSync(path.join(p.root, "archive", id))) return fail("CHANGE_EXISTS", `Matrix change already exists: ${id}.`);
|
|
340
|
-
|
|
513
|
+
let workspaceBaseline = null;
|
|
514
|
+
if (workflow === "hotfix" || workflow === "tweak") {
|
|
515
|
+
try { workspaceBaseline = captureWorkspaceBaseline(cwd); }
|
|
516
|
+
catch (error) { return fail("WORKSPACE_BASELINE_FAILED", `Could not initialize the lightweight workflow boundary: ${error.message}`); }
|
|
517
|
+
}
|
|
518
|
+
const flow = { schema: "matrix/change/v2", id, workflow, ...(workflow === "full" ? {} : { workflow_profile: "lightweight" }), orchestration, artifact_language: configured.artifactLanguage, status: "active", phase: "open", revision: 1, title, created_at: stamp(), updated_at: stamp(), acceptance: "pending", scope: "pending" };
|
|
341
519
|
const operations = [
|
|
342
|
-
...(!fs.existsSync(path.join(p.root, "archive")) ? [workflowMkdirOperation(cwd, path.join(p.root, "archive"))] : []),
|
|
343
|
-
workflowMkdirOperation(cwd, p.artifacts),
|
|
344
|
-
...(!fs.existsSync(path.join(p.root, "config.yaml")) ? [workflowWriteOperation(cwd, path.join(p.root, "config.yaml"), `schema: matrix/config/v1\nauto_transition: true\ndefault_orchestration: ${configured.defaultOrchestration}\ninstallation_scope: project\n`)] : []),
|
|
520
|
+
...(!fs.existsSync(path.join(p.root, "archive")) ? [workflowMkdirOperation(cwd, path.join(p.root, "archive"))] : []),
|
|
521
|
+
workflowMkdirOperation(cwd, p.artifacts),
|
|
522
|
+
...(!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`)] : []),
|
|
345
523
|
...(!fs.existsSync(path.join(p.root, ".gitignore")) ? [workflowWriteOperation(cwd, path.join(p.root, ".gitignore"), "active.json\ninstallation.json\nchanges/*/events.jsonl\nchanges/*/artifacts/handoff.md\n")] : []),
|
|
346
524
|
workflowWriteOperation(cwd, p.flow, flowContent(flow)),
|
|
347
|
-
workflowWriteOperation(cwd,
|
|
525
|
+
...(workspaceBaseline ? [workflowWriteOperation(cwd, p.workspaceBaseline, `${JSON.stringify(workspaceBaseline, null, 2)}\n`)] : []),
|
|
526
|
+
workflowWriteOperation(cwd, path.join(p.change, "events.jsonl"), eventContent(path.join(p.change, "events.jsonl"), "initialized", null, "open", flow.revision, { workflow, orchestration, ...(shortcut(flow) ? { profile: "lightweight" } : {}) })),
|
|
348
527
|
workflowWriteOperation(cwd, p.active, `${JSON.stringify({ change_id: id }, null, 2)}\n`)
|
|
349
|
-
];
|
|
350
|
-
return runWorkflowTransaction({
|
|
351
|
-
cwd,
|
|
352
|
-
kind: "init",
|
|
353
|
-
changeId: id,
|
|
354
|
-
operations,
|
|
355
|
-
failAfterOperation,
|
|
356
|
-
result: { ok: true, code: "OK", change_id: id, workflow, orchestration, status: flow.status, phase: flow.phase, revision: flow.revision, next_skill: skill("open") }
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
if (command === "doctor") {
|
|
360
|
-
const parsed = { repair: false, transaction: null, strategy: null, lock: null };
|
|
361
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
362
|
-
const token = args[index];
|
|
363
|
-
if (token === "--repair") {
|
|
364
|
-
if (parsed.repair) return fail("INVALID_INTENT", "Workflow doctor options may not be repeated.");
|
|
365
|
-
parsed.repair = true;
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
const field = ({ "--transaction": "transaction", "--strategy": "strategy", "--lock": "lock" })[token];
|
|
369
|
-
const value = args[index + 1];
|
|
370
|
-
if (!field || parsed[field] || !value || value.startsWith("--")) return fail("INVALID_INTENT", "Workflow doctor accepts --repair with --transaction/--strategy or --lock.");
|
|
371
|
-
parsed[field] = value;
|
|
372
|
-
index += 1;
|
|
373
|
-
}
|
|
374
|
-
return workflowDoctor(cwd, parsed);
|
|
375
|
-
}
|
|
376
|
-
const selected = active(cwd);
|
|
377
|
-
const requested = command === "inspect" ? (option(args, "--change") ?? args.find((arg) => !arg.startsWith("--"))) : null;
|
|
378
|
-
const id = requested ?? selected;
|
|
379
|
-
if (!id) return fail("NO_ACTIVE_CHANGE", "No active Matrix change. Start with $matrix <request>.");
|
|
380
|
-
let p = paths(cwd, id);
|
|
381
|
-
if (command === "inspect" && !fs.existsSync(p.change)) p = paths(cwd, id, true);
|
|
528
|
+
];
|
|
529
|
+
return runWorkflowTransaction({
|
|
530
|
+
cwd,
|
|
531
|
+
kind: "init",
|
|
532
|
+
changeId: id,
|
|
533
|
+
operations,
|
|
534
|
+
failAfterOperation,
|
|
535
|
+
result: { ok: true, code: "OK", change_id: id, workflow, profile: shortcut(flow) ? "lightweight" : "full", evidence_policy: workflow === "full" ? "full" : workflow, orchestration, artifact_language: flow.artifact_language, status: flow.status, phase: flow.phase, revision: flow.revision, next_skill: skill("open") }
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
if (command === "doctor") {
|
|
539
|
+
const parsed = { repair: false, transaction: null, strategy: null, lock: null };
|
|
540
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
541
|
+
const token = args[index];
|
|
542
|
+
if (token === "--repair") {
|
|
543
|
+
if (parsed.repair) return fail("INVALID_INTENT", "Workflow doctor options may not be repeated.");
|
|
544
|
+
parsed.repair = true;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const field = ({ "--transaction": "transaction", "--strategy": "strategy", "--lock": "lock" })[token];
|
|
548
|
+
const value = args[index + 1];
|
|
549
|
+
if (!field || parsed[field] || !value || value.startsWith("--")) return fail("INVALID_INTENT", "Workflow doctor accepts --repair with --transaction/--strategy or --lock.");
|
|
550
|
+
parsed[field] = value;
|
|
551
|
+
index += 1;
|
|
552
|
+
}
|
|
553
|
+
return workflowDoctor(cwd, parsed);
|
|
554
|
+
}
|
|
555
|
+
const selected = active(cwd);
|
|
556
|
+
const requested = command === "inspect" ? (option(args, "--change") ?? args.find((arg) => !arg.startsWith("--"))) : null;
|
|
557
|
+
const id = requested ?? selected;
|
|
558
|
+
if (!id) return fail("NO_ACTIVE_CHANGE", "No active Matrix change. Start with $matrix <request>.");
|
|
559
|
+
let p = paths(cwd, id);
|
|
560
|
+
if (command === "inspect" && !fs.existsSync(p.change)) p = paths(cwd, id, true);
|
|
382
561
|
const flow = readFlow(p);
|
|
383
|
-
if (flow?.unsupported_schema) return fail("STATE_VERSION_UNSUPPORTED", `Unsupported Matrix state schema: ${flow.unsupported_schema}.`);
|
|
562
|
+
if (flow?.unsupported_schema) return fail("STATE_VERSION_UNSUPPORTED", `Unsupported Matrix state schema: ${flow.unsupported_schema}.`);
|
|
384
563
|
if (!flow) return fail("STATE_INVALID", "active.json does not reference a valid Matrix change.");
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
564
|
+
const artifactLanguage = flow.artifact_language ?? orchestrationConfig(cwd).artifactLanguage;
|
|
565
|
+
const integrity = flow.status === "active" && flow.orchestration === "arch" ? archIntegrity(cwd) : { ok: true, code: "OK" };
|
|
566
|
+
if (["transition", "return", "abort"].includes(command) || (command === "archive" && !args.includes("--dry-run"))) {
|
|
567
|
+
const blocked = workflowMutationGate(cwd);
|
|
568
|
+
if (blocked) return blocked;
|
|
569
|
+
}
|
|
390
570
|
if (command === "inspect") {
|
|
571
|
+
const profile = workflowProfile(p, flow);
|
|
391
572
|
const contract = contractState(p, flow);
|
|
392
573
|
const result = guard(p, flow, flow.phase);
|
|
393
574
|
const failure = contract.status === "unreadable" ? contractFailure(flow, contract) : null;
|
|
394
|
-
return {
|
|
575
|
+
return {
|
|
576
|
+
...(failure ?? (!integrity.ok ? integrity : { ok: true, code: "OK" })),
|
|
577
|
+
change_id: id,
|
|
578
|
+
workflow: flow.workflow,
|
|
579
|
+
profile: profile.id,
|
|
580
|
+
evidence_policy: evidencePolicy(flow, profile),
|
|
581
|
+
orchestration: flow.orchestration,
|
|
582
|
+
artifact_language: artifactLanguage,
|
|
583
|
+
status: flow.status,
|
|
584
|
+
phase: flow.phase,
|
|
585
|
+
revision: flow.revision,
|
|
586
|
+
next_skill: flow.status === "active" ? skill(flow.phase) : null,
|
|
587
|
+
guard_pass: result.ok,
|
|
588
|
+
...contractFields(flow, contract)
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
if (!integrity.ok && command !== "abort") {
|
|
592
|
+
const profile = workflowProfile(p, flow);
|
|
593
|
+
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) };
|
|
594
|
+
}
|
|
595
|
+
if (command === "guard") {
|
|
596
|
+
const phase = args[0] ?? flow.phase;
|
|
597
|
+
if (!PHASES.includes(phase)) return fail("INVALID_INTENT", "Unknown phase.");
|
|
598
|
+
const profile = workflowProfile(p, flow);
|
|
599
|
+
if (profile.id === "lightweight" && phase === "design") return fail("PHASE_UNAVAILABLE", "Lightweight workflows do not have a Design phase.");
|
|
600
|
+
return { phase, profile: profile.id, evidence_policy: evidencePolicy(flow, profile), artifact_language: artifactLanguage, ...guard(p, flow, phase) };
|
|
395
601
|
}
|
|
396
|
-
if (!integrity.ok && command !== "abort") return { ...integrity, change_id: id, workflow: flow.workflow, orchestration: flow.orchestration, status: flow.status, phase: flow.phase, revision: flow.revision, next_skill: skill(flow.phase) };
|
|
397
|
-
if (command === "guard") { const phase = args[0] ?? flow.phase; if (!PHASES.includes(phase)) return fail("INVALID_INTENT", "Unknown phase."); return { phase, ...guard(p, flow, phase) }; }
|
|
398
602
|
if (command === "transition") {
|
|
399
603
|
const to = args[0];
|
|
604
|
+
const confirmed = args.includes("--confirmed");
|
|
605
|
+
if (args.slice(1).some((arg) => arg !== "--confirmed")) return fail("INVALID_INTENT", "Transition accepts only a target phase and optional --confirmed.");
|
|
400
606
|
if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
|
|
401
|
-
|
|
607
|
+
const profile = workflowProfile(p, flow);
|
|
608
|
+
if (profile.next[flow.phase] !== to) return fail("ILLEGAL_TRANSITION", `Illegal ${profile.id} transition ${flow.phase} -> ${to}.`);
|
|
402
609
|
const result = guard(p, flow, flow.phase); if (!result.ok) return { ...result, message: result.message ?? "Current phase guard failed." };
|
|
610
|
+
if (profile.id === "lightweight" && flow.phase === "open" && to === "build") {
|
|
611
|
+
if (!confirmed) return fail("CONFIRMATION_REQUIRED", "Lightweight Open requires explicit user confirmation before Build.");
|
|
612
|
+
const order = workspaceOrderGuard(cwd, p);
|
|
613
|
+
if (!order.ok) return order;
|
|
614
|
+
}
|
|
403
615
|
const before = flow.phase;
|
|
404
616
|
const approval = to === "build" ? contractState(p, flow) : null;
|
|
405
617
|
if (approval && approval.status !== "approved-and-matching" && approval.status !== "unapproved") return { ...contractFailure(flow, approval), change_id: id };
|
|
406
|
-
if (to === "build" && approval.snapshot.missing) return { ...fail("GUARD_FAILED", "
|
|
618
|
+
if (to === "build" && approval.snapshot.missing) return { ...fail("GUARD_FAILED", "Workflow contract artifacts are incomplete."), change_id: id };
|
|
407
619
|
const next = { ...flow, schema: "matrix/change/v2", phase: to, revision: flow.revision + 1, updated_at: stamp(), ...(to === "build" ? { approved_contract_hash: approval.snapshot.hash, contract_approved_revision: flow.revision + 1 } : {}) };
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
620
|
+
const nextProfile = workflowProfile(p, next);
|
|
621
|
+
const details = to === "build"
|
|
622
|
+
? { approved_contract_hash: next.approved_contract_hash, contract_approved_revision: next.contract_approved_revision, ...(shortcut(flow) ? { confirmed, profile: nextProfile.id } : {}) }
|
|
623
|
+
: shortcut(flow) ? { profile: nextProfile.id } : {};
|
|
624
|
+
const resultFields = { ok: true, code: "OK", change_id: id, workflow: next.workflow, profile: nextProfile.id, evidence_policy: evidencePolicy(next, nextProfile), orchestration: next.orchestration, artifact_language: next.artifact_language ?? artifactLanguage, status: next.status, phase: to, revision: next.revision, next_skill: skill(to), ...contractFields(next, contractState(p, next)) };
|
|
625
|
+
return runWorkflowTransaction({
|
|
626
|
+
cwd,
|
|
627
|
+
kind: "transition",
|
|
628
|
+
changeId: id,
|
|
629
|
+
failAfterOperation,
|
|
630
|
+
operations: [
|
|
631
|
+
workflowWriteOperation(cwd, p.flow, flowContent(next)),
|
|
632
|
+
workflowWriteOperation(cwd, path.join(p.change, "events.jsonl"), eventContent(path.join(p.change, "events.jsonl"), "transition", before, to, next.revision, details))
|
|
633
|
+
],
|
|
634
|
+
result: resultFields
|
|
635
|
+
});
|
|
636
|
+
}
|
|
422
637
|
if (command === "return") {
|
|
423
638
|
const to = args[0];
|
|
424
639
|
const reason = option(args, "--reason");
|
|
425
640
|
if (!reason) return fail("RETURN_REASON_REQUIRED", "Return requires --reason.");
|
|
426
641
|
if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
|
|
427
642
|
if (!RETURN_REASONS.has(reason)) return fail("RETURN_REASON_UNSUPPORTED", `Unsupported Return reason: ${reason}.`);
|
|
428
|
-
|
|
643
|
+
const profile = workflowProfile(p, flow);
|
|
644
|
+
const returns = profile.id === "lightweight" ? LIGHTWEIGHT_RETURNS : FULL_RETURNS;
|
|
645
|
+
const returnRule = returns.find((entry) => entry.from === flow.phase && entry.to === to && entry.reason === reason);
|
|
646
|
+
if (!returnRule) return fail("ILLEGAL_RETURN", `Illegal ${profile.id} Return ${flow.phase} -> ${to} for reason ${reason}.`);
|
|
429
647
|
if (flow.phase === "verify" && to === "build") { const contract = contractState(p, flow); const failure = contractFailure(flow, contract); if (failure) return failure; }
|
|
430
|
-
const verification = path.join(p.artifacts, "verification.md");
|
|
431
|
-
let destination = null;
|
|
432
|
-
if (fs.existsSync(verification)) {
|
|
433
|
-
const history = path.join(p.artifacts, "evidence-history");
|
|
434
|
-
destination = path.join(history, `revision-${flow.revision}-verification.md`);
|
|
435
|
-
if (fs.existsSync(destination)) return fail("EVIDENCE_INVALIDATION_FAILED", `Evidence history already exists for revision ${flow.revision}.`);
|
|
436
|
-
}
|
|
437
|
-
const next = {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const next = { ...flow, schema: "matrix/change/v2", status: "aborted", revision: flow.revision + 1, updated_at: stamp() };
|
|
458
|
-
const target = path.join(p.root, "archive", id);
|
|
459
|
-
if (fs.existsSync(target)) return fail("ARCHIVE_TARGET_EXISTS", `Archive target already exists: ${id}.`);
|
|
460
|
-
const sourceEvents = path.join(p.change, "events.jsonl");
|
|
461
|
-
const targetFlow = path.join(target, "matrix.yaml");
|
|
462
|
-
const targetEvents = path.join(target, "events.jsonl");
|
|
463
|
-
return runWorkflowTransaction({
|
|
464
|
-
cwd,
|
|
465
|
-
kind: "abort",
|
|
466
|
-
changeId: id,
|
|
467
|
-
failAfterOperation,
|
|
468
|
-
operations: [
|
|
469
|
-
workflowMoveOperation(cwd, p.change, target),
|
|
470
|
-
workflowWriteOperation(cwd, targetFlow, flowContent(next), { before: captureWorkflowEntry(p.flow) }),
|
|
471
|
-
workflowWriteOperation(cwd, targetEvents, eventContent(sourceEvents, "aborted", flow.phase, flow.phase, next.revision, { reason, ...(note ? { note } : {}) }), { before: captureWorkflowEntry(sourceEvents) }),
|
|
472
|
-
workflowRemoveOperation(cwd, p.active)
|
|
648
|
+
const verification = path.join(p.artifacts, "verification.md");
|
|
649
|
+
let destination = null;
|
|
650
|
+
if (fs.existsSync(verification)) {
|
|
651
|
+
const history = path.join(p.artifacts, "evidence-history");
|
|
652
|
+
destination = path.join(history, `revision-${flow.revision}-verification.md`);
|
|
653
|
+
if (fs.existsSync(destination)) return fail("EVIDENCE_INVALIDATION_FAILED", `Evidence history already exists for revision ${flow.revision}.`);
|
|
654
|
+
}
|
|
655
|
+
const next = {
|
|
656
|
+
...flow,
|
|
657
|
+
schema: "matrix/change/v2",
|
|
658
|
+
workflow: returnRule.upgrade ? "full" : flow.workflow,
|
|
659
|
+
workflow_profile: returnRule.upgrade ? null : flow.workflow_profile,
|
|
660
|
+
phase: to,
|
|
661
|
+
revision: flow.revision + 1,
|
|
662
|
+
updated_at: stamp(),
|
|
663
|
+
...(to === "design" ? { approved_contract_hash: null, contract_approved_revision: null } : {})
|
|
664
|
+
};
|
|
665
|
+
const nextProfile = workflowProfile(p, next);
|
|
666
|
+
return runWorkflowTransaction({
|
|
667
|
+
cwd,
|
|
668
|
+
kind: "return",
|
|
669
|
+
changeId: id,
|
|
670
|
+
failAfterOperation,
|
|
671
|
+
operations: [
|
|
672
|
+
...(destination ? [workflowMoveOperation(cwd, verification, destination)] : []),
|
|
673
|
+
workflowWriteOperation(cwd, p.flow, flowContent(next)),
|
|
674
|
+
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" } : {}) }))
|
|
473
675
|
],
|
|
474
|
-
result: { ok: true, code: "OK", change_id: id, workflow: next.workflow, orchestration: next.orchestration, status: next.status, phase:
|
|
676
|
+
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)) }
|
|
475
677
|
});
|
|
476
678
|
}
|
|
679
|
+
if (command === "abort") {
|
|
680
|
+
const reason = option(args, "--reason");
|
|
681
|
+
const note = option(args, "--note");
|
|
682
|
+
if (!reason) return fail("ABORT_REASON_REQUIRED", "Abort requires --reason.");
|
|
683
|
+
if (!ABORT_REASONS.has(reason)) return fail("ABORT_REASON_UNSUPPORTED", `Unsupported Abort reason: ${reason}.`);
|
|
684
|
+
if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
|
|
685
|
+
const next = { ...flow, schema: "matrix/change/v2", status: "aborted", revision: flow.revision + 1, updated_at: stamp() };
|
|
686
|
+
const target = path.join(p.root, "archive", id);
|
|
687
|
+
if (fs.existsSync(target)) return fail("ARCHIVE_TARGET_EXISTS", `Archive target already exists: ${id}.`);
|
|
688
|
+
const sourceEvents = path.join(p.change, "events.jsonl");
|
|
689
|
+
const targetFlow = path.join(target, "matrix.yaml");
|
|
690
|
+
const targetEvents = path.join(target, "events.jsonl");
|
|
691
|
+
return runWorkflowTransaction({
|
|
692
|
+
cwd,
|
|
693
|
+
kind: "abort",
|
|
694
|
+
changeId: id,
|
|
695
|
+
failAfterOperation,
|
|
696
|
+
operations: [
|
|
697
|
+
workflowMoveOperation(cwd, p.change, target),
|
|
698
|
+
workflowWriteOperation(cwd, targetFlow, flowContent(next), { before: captureWorkflowEntry(p.flow) }),
|
|
699
|
+
workflowWriteOperation(cwd, targetEvents, eventContent(sourceEvents, "aborted", flow.phase, flow.phase, next.revision, { reason, ...(note ? { note } : {}) }), { before: captureWorkflowEntry(sourceEvents) }),
|
|
700
|
+
workflowRemoveOperation(cwd, p.active)
|
|
701
|
+
],
|
|
702
|
+
result: { ok: true, code: "OK", change_id: id, workflow: next.workflow, orchestration: next.orchestration, artifact_language: next.artifact_language ?? artifactLanguage, status: next.status, phase: next.phase, revision: next.revision, next_skill: null, event: "aborted" }
|
|
703
|
+
});
|
|
704
|
+
}
|
|
477
705
|
if (command === "archive") {
|
|
478
706
|
if (flow.status !== "active") return fail("CHANGE_NOT_ACTIVE", `Matrix change is ${flow.status}.`);
|
|
479
707
|
if (flow.phase !== "archive") return fail("ILLEGAL_TRANSITION", "Only an archive-phase change can be archived.");
|
|
708
|
+
const profile = workflowProfile(p, flow);
|
|
480
709
|
const dryRun = args.includes("--dry-run");
|
|
481
710
|
const expected = option(args, "--expect-preflight");
|
|
482
|
-
|
|
711
|
+
const confirmedByUser = args.includes("--confirmed");
|
|
712
|
+
if ((dryRun && (expected || confirmedByUser)) || args.some((arg) => !["--dry-run", "--expect-preflight", "--confirmed", expected].includes(arg))) {
|
|
483
713
|
return { ...fail("ARCHIVE_PREFLIGHT_INVALID", "Archive accepts either --dry-run or --expect-preflight <sha256>, not both or additional arguments."), recovery_command: "matrix workflow archive --dry-run" };
|
|
484
714
|
}
|
|
485
715
|
if (!dryRun && !expected) return { ...fail("ARCHIVE_PREFLIGHT_REQUIRED", "Archive requires an explicit dry-run followed by --expect-preflight <sha256>."), recovery_command: "matrix workflow archive --dry-run" };
|
|
486
716
|
if (expected && !/^sha256:[0-9a-f]{64}$/.test(expected)) return { ...fail("ARCHIVE_PREFLIGHT_INVALID", "Expected preflight must be sha256 followed by 64 lowercase hexadecimal characters."), recovery_command: "matrix workflow archive --dry-run" };
|
|
717
|
+
if (expected && shortcut(flow) && !confirmedByUser) return { ...fail("CONFIRMATION_REQUIRED", "Shortcut workflow Archive requires explicit user confirmation."), recovery_command: `matrix workflow archive --expect-preflight ${expected} --confirmed` };
|
|
487
718
|
if (dryRun) {
|
|
488
|
-
const result = guard(p, flow, "archive"); if (!result.ok) return { ...result, message: result.message ?? "Archive guard failed." };
|
|
489
|
-
const captured = captureArchivePreflight(p, id); if (captured.failure) return captured.failure;
|
|
490
|
-
const currentFlow = readFlow(p); if (!currentFlow) return fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Canonical workflow state changed during dry-run.");
|
|
491
|
-
const finalGuard = guard(p, currentFlow, "archive"); if (!finalGuard.ok) return { ...finalGuard, message: finalGuard.message ?? "Archive guard failed." };
|
|
492
|
-
const confirmed = captureArchivePreflight(p, id); if (confirmed.failure) return confirmed.failure;
|
|
493
|
-
if (captured.hash !== confirmed.hash) return fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Archive inputs changed during dry-run. Retry dry-run.");
|
|
719
|
+
const result = guard(p, flow, "archive"); if (!result.ok) return { ...result, message: result.message ?? "Archive guard failed." };
|
|
720
|
+
const captured = captureArchivePreflight(p, id); if (captured.failure) return captured.failure;
|
|
721
|
+
const currentFlow = readFlow(p); if (!currentFlow) return fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Canonical workflow state changed during dry-run.");
|
|
722
|
+
const finalGuard = guard(p, currentFlow, "archive"); if (!finalGuard.ok) return { ...finalGuard, message: finalGuard.message ?? "Archive guard failed." };
|
|
723
|
+
const confirmed = captureArchivePreflight(p, id); if (confirmed.failure) return confirmed.failure;
|
|
724
|
+
if (captured.hash !== confirmed.hash) return fail("ARCHIVE_PREFLIGHT_UNSTABLE", "Archive inputs changed during dry-run. Retry dry-run.");
|
|
494
725
|
return {
|
|
495
|
-
ok: true, code: "OK", change_id: id, workflow: currentFlow.workflow, orchestration: currentFlow.orchestration, status: currentFlow.status, phase: currentFlow.phase, revision: currentFlow.revision,
|
|
726
|
+
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,
|
|
496
727
|
preflight_hash: confirmed.hash,
|
|
497
728
|
effect_summary: { target: `.matrix/archive/${id}`, files: confirmed.fileCount, directories: confirmed.directoryCount, content_bytes: confirmed.totalBytes },
|
|
498
|
-
commit_command: `matrix workflow archive --expect-preflight ${confirmed.hash}`,
|
|
729
|
+
commit_command: `matrix workflow archive --expect-preflight ${confirmed.hash}${shortcut(currentFlow) ? " --confirmed" : ""}`,
|
|
499
730
|
...contractFields(currentFlow, contractState(p, currentFlow))
|
|
500
731
|
};
|
|
501
|
-
}
|
|
502
|
-
const captured = captureArchivePreflight(p, id);
|
|
503
|
-
if (captured.failure) return captured.failure;
|
|
504
|
-
if (captured.hash !== expected) return archivePreflightChanged(expected, captured.hash);
|
|
505
|
-
const currentFlow = readFlow(p);
|
|
506
|
-
if (!currentFlow || currentFlow.status !== "active" || currentFlow.phase !== "archive") return archivePreflightChanged(expected, captured.hash);
|
|
507
|
-
const result = guard(p, currentFlow, "archive");
|
|
508
|
-
if (!result.ok) return { ...result, message: result.message ?? "Archive guard failed." };
|
|
509
|
-
const confirmed = captureArchivePreflight(p, id);
|
|
510
|
-
if (confirmed.failure) return confirmed.failure;
|
|
511
|
-
if (confirmed.hash !== expected) return archivePreflightChanged(expected, confirmed.hash);
|
|
512
|
-
const contract = contractState(p, currentFlow);
|
|
513
|
-
const next = { ...currentFlow, schema: "matrix/change/v2", status: "archived", revision: currentFlow.revision + 1, updated_at: stamp() };
|
|
514
|
-
const target = path.join(p.root, "archive", id);
|
|
515
|
-
const sourceEvents = path.join(p.change, "events.jsonl");
|
|
516
|
-
const targetFlow = path.join(target, "matrix.yaml");
|
|
517
|
-
const targetEvents = path.join(target, "events.jsonl");
|
|
518
|
-
return runWorkflowTransaction({
|
|
519
|
-
cwd,
|
|
520
|
-
kind: "archive",
|
|
521
|
-
changeId: id,
|
|
522
|
-
failAfterOperation,
|
|
523
|
-
operations: [
|
|
524
|
-
workflowMoveOperation(cwd, p.change, target),
|
|
525
|
-
workflowWriteOperation(cwd, targetFlow, flowContent(next), { before: captureWorkflowEntry(p.flow) }),
|
|
526
|
-
workflowWriteOperation(cwd, targetEvents, eventContent(sourceEvents, "archived", "archive", "archive", next.revision, { preflight_hash: expected }), { before: captureWorkflowEntry(sourceEvents) }),
|
|
527
|
-
workflowRemoveOperation(cwd, p.active)
|
|
528
|
-
],
|
|
529
|
-
result: {
|
|
530
|
-
ok: true, code: "OK", archived: id, change_id: id, workflow: next.workflow, orchestration: next.orchestration, status: next.status, phase: next.phase, revision: next.revision, next_skill: null, event: "archived",
|
|
531
|
-
preflight_hash: expected,
|
|
532
|
-
effect_summary: { target: `.matrix/archive/${id}`, files: confirmed.fileCount, directories: confirmed.directoryCount, content_bytes: confirmed.totalBytes },
|
|
533
|
-
...contractFields(next, contract)
|
|
534
|
-
}
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
if (command === "handoff") { const destination = path.join(p.artifacts, "handoff.md"); fs.writeFileSync(destination, `# Matrix handoff: ${id}\n\n- Workflow: ${flow.workflow}\n- Current phase: ${flow.phase}\n- Resume with: \`${skill(flow.phase)}\`\n- Guard passes: ${guard(p, flow, flow.phase).ok}\n`); return { ok: true, code: "OK", path: destination }; }
|
|
538
|
-
if (command === "export") {
|
|
732
|
+
}
|
|
733
|
+
const captured = captureArchivePreflight(p, id);
|
|
734
|
+
if (captured.failure) return captured.failure;
|
|
735
|
+
if (captured.hash !== expected) return archivePreflightChanged(expected, captured.hash);
|
|
736
|
+
const currentFlow = readFlow(p);
|
|
737
|
+
if (!currentFlow || currentFlow.status !== "active" || currentFlow.phase !== "archive") return archivePreflightChanged(expected, captured.hash);
|
|
738
|
+
const result = guard(p, currentFlow, "archive");
|
|
739
|
+
if (!result.ok) return { ...result, message: result.message ?? "Archive guard failed." };
|
|
740
|
+
const confirmed = captureArchivePreflight(p, id);
|
|
741
|
+
if (confirmed.failure) return confirmed.failure;
|
|
742
|
+
if (confirmed.hash !== expected) return archivePreflightChanged(expected, confirmed.hash);
|
|
743
|
+
const contract = contractState(p, currentFlow);
|
|
744
|
+
const next = { ...currentFlow, schema: "matrix/change/v2", status: "archived", revision: currentFlow.revision + 1, updated_at: stamp() };
|
|
745
|
+
const target = path.join(p.root, "archive", id);
|
|
746
|
+
const sourceEvents = path.join(p.change, "events.jsonl");
|
|
747
|
+
const targetFlow = path.join(target, "matrix.yaml");
|
|
748
|
+
const targetEvents = path.join(target, "events.jsonl");
|
|
749
|
+
return runWorkflowTransaction({
|
|
750
|
+
cwd,
|
|
751
|
+
kind: "archive",
|
|
752
|
+
changeId: id,
|
|
753
|
+
failAfterOperation,
|
|
754
|
+
operations: [
|
|
755
|
+
workflowMoveOperation(cwd, p.change, target),
|
|
756
|
+
workflowWriteOperation(cwd, targetFlow, flowContent(next), { before: captureWorkflowEntry(p.flow) }),
|
|
757
|
+
workflowWriteOperation(cwd, targetEvents, eventContent(sourceEvents, "archived", "archive", "archive", next.revision, { preflight_hash: expected, confirmed: confirmedByUser }), { before: captureWorkflowEntry(sourceEvents) }),
|
|
758
|
+
workflowRemoveOperation(cwd, p.active)
|
|
759
|
+
],
|
|
760
|
+
result: {
|
|
761
|
+
ok: true, code: "OK", archived: id, change_id: id, workflow: next.workflow, profile: profile.id, evidence_policy: evidencePolicy(next, profile), orchestration: next.orchestration, artifact_language: next.artifact_language ?? artifactLanguage, status: next.status, phase: next.phase, revision: next.revision, next_skill: null, event: "archived",
|
|
762
|
+
preflight_hash: expected,
|
|
763
|
+
effect_summary: { target: `.matrix/archive/${id}`, files: confirmed.fileCount, directories: confirmed.directoryCount, content_bytes: confirmed.totalBytes },
|
|
764
|
+
...contractFields(next, contract)
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
if (command === "handoff") { const destination = path.join(p.artifacts, "handoff.md"); fs.writeFileSync(destination, `# Matrix handoff: ${id}\n\n- Workflow: ${flow.workflow}\n- Current phase: ${flow.phase}\n- Resume with: \`${skill(flow.phase)}\`\n- Guard passes: ${guard(p, flow, flow.phase).ok}\n`); return { ok: true, code: "OK", path: destination }; }
|
|
769
|
+
if (command === "export") {
|
|
539
770
|
if (flow.phase !== "build") return fail("ILLEGAL_PHASE", "Export requires the build phase.");
|
|
771
|
+
if (workflowProfile(p, flow).id === "lightweight") return fail("EXPORT_UNSUPPORTED_WORKFLOW", "Claude export requires the full three-artifact workflow contract.");
|
|
540
772
|
const contract = contractState(p, flow); const failure = contractFailure(flow, contract); if (failure) return failure;
|
|
541
773
|
const taskId = args.includes("--task-id") ? args[args.indexOf("--task-id") + 1] : args[0];
|
|
542
774
|
const target = args.includes("--target") ? args[args.indexOf("--target") + 1] : "generic";
|
|
543
775
|
const applyBoard = args.includes("--apply-fnsec-board");
|
|
544
776
|
if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(taskId ?? "") || !["generic", "fnsec"].includes(target)) return fail("INVALID_INTENT", "export requires a task id and target generic or fnsec.");
|
|
545
|
-
const { proposal, design, plan } = contract.snapshot.contents;
|
|
777
|
+
const { proposal, design, plan } = contract.snapshot.contents;
|
|
546
778
|
if (target === "generic") {
|
|
547
779
|
const destination = path.join(p.artifacts, "claude-task.md"); const result = path.join(p.artifacts, "claude-result.md");
|
|
548
|
-
fs.writeFileSync(destination, taskContent(id, taskId, proposal, design, plan, path.relative(cwd, result).replaceAll("\\", "/"), flow.approved_contract_hash, flow.contract_approved_revision));
|
|
780
|
+
fs.writeFileSync(destination, taskContent(id, taskId, proposal, design, plan, path.relative(cwd, result).replaceAll("\\", "/"), flow.approved_contract_hash, flow.contract_approved_revision));
|
|
549
781
|
return { ok: true, code: "OK", path: destination };
|
|
550
782
|
}
|
|
551
783
|
const tasks = path.join(cwd, "docs", "tasks"); const board = path.join(tasks, "active.md");
|
|
@@ -554,7 +786,7 @@ export function invoke(argv, { cwd = process.cwd(), failAfterOperation = null }
|
|
|
554
786
|
if (exists(destination) || exists(result)) return fail("FNSEC_TASK_EXISTS", `FnSec task or result path already exists for ${taskId}.`);
|
|
555
787
|
const currentBoard = fs.readFileSync(board, "utf8");
|
|
556
788
|
if (applyBoard && !currentBoard.includes("NO_ACTIVE_OBJECTIVE")) return fail("FNSEC_BOARD_OCCUPIED", "FnSec active.md already has an objective; refused to overwrite it.");
|
|
557
|
-
fs.writeFileSync(destination, taskContent(id, taskId, proposal, design, plan, path.relative(cwd, result).replaceAll("\\", "/"), flow.approved_contract_hash, flow.contract_approved_revision));
|
|
789
|
+
fs.writeFileSync(destination, taskContent(id, taskId, proposal, design, plan, path.relative(cwd, result).replaceAll("\\", "/"), flow.approved_contract_hash, flow.contract_approved_revision));
|
|
558
790
|
if (applyBoard) fs.writeFileSync(board, activeBoard(taskId, path.relative(cwd, destination).replaceAll("\\", "/"), path.relative(cwd, result).replaceAll("\\", "/"), id));
|
|
559
791
|
return { ok: true, code: "OK", path: destination, board: applyBoard ? board : null };
|
|
560
792
|
}
|