@danielblomma/cortex-mcp 2.0.6 → 2.0.8
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/package.json +1 -1
- package/scaffold/mcp/src/cli/stage.ts +42 -0
- package/scaffold/mcp/src/core/workflow/default-workflows.ts +35 -0
- package/scaffold/mcp/src/core/workflow/envelope.ts +26 -1
- package/scaffold/mcp/src/core/workflow/index.ts +1 -0
- package/scaffold/mcp/src/core/workflow/mcp-tools.ts +24 -1
- package/scaffold/mcp/src/core/workflow/run-lifecycle.ts +49 -2
- package/scaffold/mcp/src/core/workflow/schemas.ts +45 -0
- package/scaffold/mcp/src/core/workflow/synced-registry.ts +64 -0
- package/scaffold/mcp/src/daemon/main.ts +15 -0
- package/scaffold/mcp/src/daemon/workflow-sync-checker.ts +301 -0
- package/scaffold/mcp/src/enterprise/index.ts +5 -0
- package/scaffold/mcp/src/enterprise/tools/harness.ts +98 -0
- package/scaffold/mcp/src/server.ts +4 -74
- package/scaffold/mcp/tests/workflow-cli.test.mjs +41 -0
- package/scaffold/mcp/tests/workflow-synced-registry.test.mjs +179 -0
- package/scaffold/mcp/tests/workflow-validators-override.test.mjs +272 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
loadSyncedWorkflows,
|
|
9
|
+
syncedWorkflowsCachePath,
|
|
10
|
+
} from "../dist/core/workflow/synced-registry.js";
|
|
11
|
+
import { runWorkflowStart } from "../dist/core/workflow/mcp-tools.js";
|
|
12
|
+
import { SECURE_BUILD_WORKFLOW } from "../dist/core/workflow/default-workflows.js";
|
|
13
|
+
|
|
14
|
+
function makeWorkspace() {
|
|
15
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "cortex-synced-registry-"));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TINY_WORKFLOW = {
|
|
19
|
+
id: "tiny",
|
|
20
|
+
description: "Two-stage tests workflow",
|
|
21
|
+
version: 1,
|
|
22
|
+
stages: [
|
|
23
|
+
{
|
|
24
|
+
name: "plan",
|
|
25
|
+
artifact: "plan.md",
|
|
26
|
+
reads: [],
|
|
27
|
+
required_fields: [],
|
|
28
|
+
capability: "planner",
|
|
29
|
+
description: "Produce a plan.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "review",
|
|
33
|
+
artifact: "review.md",
|
|
34
|
+
reads: ["plan"],
|
|
35
|
+
required_fields: ["approved"],
|
|
36
|
+
capability: "reviewer",
|
|
37
|
+
description: "Review the plan.",
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function writeCache(dir, payload) {
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
44
|
+
fs.writeFileSync(
|
|
45
|
+
syncedWorkflowsCachePath(dir),
|
|
46
|
+
JSON.stringify(payload, null, 2),
|
|
47
|
+
"utf8",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("syncedWorkflowsCachePath: defaults to ~/.cortex/workflows.local.json", () => {
|
|
52
|
+
const expected = path.join(os.homedir(), ".cortex", "workflows.local.json");
|
|
53
|
+
assert.equal(syncedWorkflowsCachePath(), expected);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("loadSyncedWorkflows: returns {} when cache is missing", () => {
|
|
57
|
+
const dir = makeWorkspace();
|
|
58
|
+
assert.deepEqual(loadSyncedWorkflows(dir), {});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("loadSyncedWorkflows: returns {} when cache is unreadable JSON", () => {
|
|
62
|
+
const dir = makeWorkspace();
|
|
63
|
+
fs.writeFileSync(syncedWorkflowsCachePath(dir), "not valid json", "utf8");
|
|
64
|
+
assert.deepEqual(loadSyncedWorkflows(dir), {});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("loadSyncedWorkflows: returns {} when 'workflows' key is missing", () => {
|
|
68
|
+
const dir = makeWorkspace();
|
|
69
|
+
writeCache(dir, { last_synced_at: "x" });
|
|
70
|
+
assert.deepEqual(loadSyncedWorkflows(dir), {});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("loadSyncedWorkflows: drops entries whose definition fails schema", () => {
|
|
74
|
+
const dir = makeWorkspace();
|
|
75
|
+
writeCache(dir, {
|
|
76
|
+
workflows: {
|
|
77
|
+
"valid-one": {
|
|
78
|
+
workflow_id: "valid-one",
|
|
79
|
+
version: 1,
|
|
80
|
+
updated_at: "2026-05-06T12:00:00.000Z",
|
|
81
|
+
definition: TINY_WORKFLOW,
|
|
82
|
+
},
|
|
83
|
+
"broken-one": {
|
|
84
|
+
workflow_id: "broken-one",
|
|
85
|
+
version: 1,
|
|
86
|
+
updated_at: "2026-05-06T12:00:00.000Z",
|
|
87
|
+
definition: { id: "broken-one" /* missing stages */ },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const loaded = loadSyncedWorkflows(dir);
|
|
92
|
+
assert.deepEqual(Object.keys(loaded).sort(), ["valid-one"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("loadSyncedWorkflows: returns valid workflow definitions keyed by workflow_id", () => {
|
|
96
|
+
const dir = makeWorkspace();
|
|
97
|
+
writeCache(dir, {
|
|
98
|
+
workflows: {
|
|
99
|
+
tiny: {
|
|
100
|
+
workflow_id: "tiny",
|
|
101
|
+
version: 1,
|
|
102
|
+
updated_at: "2026-05-06T12:00:00.000Z",
|
|
103
|
+
definition: TINY_WORKFLOW,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const loaded = loadSyncedWorkflows(dir);
|
|
108
|
+
assert.equal(loaded.tiny.id, "tiny");
|
|
109
|
+
assert.equal(loaded.tiny.stages.length, 2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("resolveWorkflow integration: synced workflow takes precedence over bundled default", () => {
|
|
113
|
+
// We can't easily intercept loadSyncedWorkflows() from inside
|
|
114
|
+
// mcp-tools.ts (it reads from a fixed home-dir path). Instead, exercise
|
|
115
|
+
// the explicit-registry path which mirrors what the merge would do
|
|
116
|
+
// and confirm the contract: passing a registry that includes the same
|
|
117
|
+
// id as a bundled default uses the registry version.
|
|
118
|
+
const cwd = makeWorkspace();
|
|
119
|
+
process.env.HOME = cwd; // sandbox the cache lookup
|
|
120
|
+
try {
|
|
121
|
+
const overridden = {
|
|
122
|
+
...SECURE_BUILD_WORKFLOW,
|
|
123
|
+
description: "Org-overridden secure-build",
|
|
124
|
+
};
|
|
125
|
+
const registry = { "secure-build": overridden };
|
|
126
|
+
const result = runWorkflowStart(
|
|
127
|
+
{
|
|
128
|
+
task_id: "task-1",
|
|
129
|
+
task_description: "test",
|
|
130
|
+
workflow_id: "secure-build",
|
|
131
|
+
},
|
|
132
|
+
{ cwd, workflows: registry },
|
|
133
|
+
);
|
|
134
|
+
assert.equal(result.state.workflow_id, "secure-build");
|
|
135
|
+
// The envelope renders the workflow description verbatim — confirms
|
|
136
|
+
// we got the org-overridden version and not the bundled one.
|
|
137
|
+
assert.match(result.envelope.prompt, /Org-overridden secure-build/);
|
|
138
|
+
} finally {
|
|
139
|
+
delete process.env.HOME;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("resolveWorkflow integration: synced cache adds new workflow_ids beyond defaults", () => {
|
|
144
|
+
const cwd = makeWorkspace();
|
|
145
|
+
// Build a cache under a tmp HOME and point HOME at it, so that the
|
|
146
|
+
// home-dir-based loader picks it up.
|
|
147
|
+
const fakeHome = makeWorkspace();
|
|
148
|
+
process.env.HOME = fakeHome;
|
|
149
|
+
try {
|
|
150
|
+
fs.mkdirSync(path.join(fakeHome, ".cortex"), { recursive: true });
|
|
151
|
+
fs.writeFileSync(
|
|
152
|
+
path.join(fakeHome, ".cortex", "workflows.local.json"),
|
|
153
|
+
JSON.stringify({
|
|
154
|
+
workflows: {
|
|
155
|
+
tiny: {
|
|
156
|
+
workflow_id: "tiny",
|
|
157
|
+
version: 1,
|
|
158
|
+
updated_at: "2026-05-06T12:00:00.000Z",
|
|
159
|
+
definition: TINY_WORKFLOW,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
"utf8",
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const result = runWorkflowStart(
|
|
167
|
+
{
|
|
168
|
+
task_id: "task-2",
|
|
169
|
+
task_description: "Use synced workflow",
|
|
170
|
+
workflow_id: "tiny",
|
|
171
|
+
},
|
|
172
|
+
{ cwd },
|
|
173
|
+
);
|
|
174
|
+
assert.equal(result.state.workflow_id, "tiny");
|
|
175
|
+
assert.equal(result.state.current_stage, "plan");
|
|
176
|
+
} finally {
|
|
177
|
+
delete process.env.HOME;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createRun,
|
|
9
|
+
advanceStage,
|
|
10
|
+
} from "../dist/core/workflow/run-lifecycle.js";
|
|
11
|
+
import { composeStageEnvelope } from "../dist/core/workflow/envelope.js";
|
|
12
|
+
import {
|
|
13
|
+
runWorkflowAdvance,
|
|
14
|
+
runWorkflowStart,
|
|
15
|
+
} from "../dist/core/workflow/mcp-tools.js";
|
|
16
|
+
|
|
17
|
+
function makeWorkspace() {
|
|
18
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "cortex-validators-"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const WORKFLOW = {
|
|
22
|
+
id: "validated",
|
|
23
|
+
description: "One stage that requires two validators",
|
|
24
|
+
version: 1,
|
|
25
|
+
stages: [
|
|
26
|
+
{
|
|
27
|
+
name: "build",
|
|
28
|
+
artifact: "changes.md",
|
|
29
|
+
reads: [],
|
|
30
|
+
required_fields: [],
|
|
31
|
+
validators: [
|
|
32
|
+
{ id: "tests-pass", description: "Test suite must pass" },
|
|
33
|
+
{ id: "build-passes", description: "Build must succeed" },
|
|
34
|
+
],
|
|
35
|
+
capability: "builder",
|
|
36
|
+
description: "Implement the change.",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const REGISTRY = { validated: WORKFLOW };
|
|
42
|
+
|
|
43
|
+
test("advanceStage: blocks when validators_passed misses required ids", () => {
|
|
44
|
+
const cwd = makeWorkspace();
|
|
45
|
+
createRun({
|
|
46
|
+
cwd,
|
|
47
|
+
taskId: "task-1",
|
|
48
|
+
workflow: WORKFLOW,
|
|
49
|
+
taskDescription: "Test",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
assert.throws(
|
|
53
|
+
() =>
|
|
54
|
+
advanceStage({
|
|
55
|
+
cwd,
|
|
56
|
+
taskId: "task-1",
|
|
57
|
+
workflow: WORKFLOW,
|
|
58
|
+
stageName: "build",
|
|
59
|
+
artifactName: "changes.md",
|
|
60
|
+
frontmatter: { stage: "build", status: "complete", references: [] },
|
|
61
|
+
body: "# Changes",
|
|
62
|
+
validatorsPassed: ["tests-pass"], // missing build-passes
|
|
63
|
+
}),
|
|
64
|
+
/Missing: build-passes/,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("advanceStage: allows when validators_passed covers required ids", () => {
|
|
69
|
+
const cwd = makeWorkspace();
|
|
70
|
+
createRun({
|
|
71
|
+
cwd,
|
|
72
|
+
taskId: "task-1",
|
|
73
|
+
workflow: WORKFLOW,
|
|
74
|
+
taskDescription: "Test",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const next = advanceStage({
|
|
78
|
+
cwd,
|
|
79
|
+
taskId: "task-1",
|
|
80
|
+
workflow: WORKFLOW,
|
|
81
|
+
stageName: "build",
|
|
82
|
+
artifactName: "changes.md",
|
|
83
|
+
frontmatter: { stage: "build", status: "complete", references: [] },
|
|
84
|
+
body: "# Changes",
|
|
85
|
+
validatorsPassed: ["tests-pass", "build-passes"],
|
|
86
|
+
});
|
|
87
|
+
assert.equal(next.outcome, "complete");
|
|
88
|
+
assert.deepEqual(next.stages[0].validators_passed, ["tests-pass", "build-passes"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("advanceStage: override.skipped_validators bypasses missing-validator block", () => {
|
|
92
|
+
const cwd = makeWorkspace();
|
|
93
|
+
createRun({
|
|
94
|
+
cwd,
|
|
95
|
+
taskId: "task-1",
|
|
96
|
+
workflow: WORKFLOW,
|
|
97
|
+
taskDescription: "Test",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const next = advanceStage({
|
|
101
|
+
cwd,
|
|
102
|
+
taskId: "task-1",
|
|
103
|
+
workflow: WORKFLOW,
|
|
104
|
+
stageName: "build",
|
|
105
|
+
artifactName: "changes.md",
|
|
106
|
+
frontmatter: { stage: "build", status: "complete", references: [] },
|
|
107
|
+
body: "# Changes",
|
|
108
|
+
validatorsPassed: ["tests-pass"],
|
|
109
|
+
override: {
|
|
110
|
+
reason: "Build infra is down on CI; skipping per ops-incident-2026-05-06",
|
|
111
|
+
skipped_validators: ["build-passes"],
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
assert.equal(next.outcome, "complete");
|
|
116
|
+
assert.equal(next.stages[0].override?.reason.includes("Build infra is down"), true);
|
|
117
|
+
assert.deepEqual(next.stages[0].override?.skipped_validators, ["build-passes"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("advanceStage: blocked status is exempt from validator coverage check", () => {
|
|
121
|
+
const cwd = makeWorkspace();
|
|
122
|
+
createRun({
|
|
123
|
+
cwd,
|
|
124
|
+
taskId: "task-1",
|
|
125
|
+
workflow: WORKFLOW,
|
|
126
|
+
taskDescription: "Test",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const next = advanceStage({
|
|
130
|
+
cwd,
|
|
131
|
+
taskId: "task-1",
|
|
132
|
+
workflow: WORKFLOW,
|
|
133
|
+
stageName: "build",
|
|
134
|
+
artifactName: "changes.md",
|
|
135
|
+
frontmatter: { stage: "build", status: "blocked", references: [] },
|
|
136
|
+
body: "# Plan blocked\n\nMissing context.",
|
|
137
|
+
status: "blocked",
|
|
138
|
+
validatorsPassed: [], // exempt
|
|
139
|
+
});
|
|
140
|
+
assert.equal(next.outcome, "blocked");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("advanceStage: override is stamped into the artifact frontmatter", () => {
|
|
144
|
+
const cwd = makeWorkspace();
|
|
145
|
+
createRun({
|
|
146
|
+
cwd,
|
|
147
|
+
taskId: "task-1",
|
|
148
|
+
workflow: WORKFLOW,
|
|
149
|
+
taskDescription: "Test",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
advanceStage({
|
|
153
|
+
cwd,
|
|
154
|
+
taskId: "task-1",
|
|
155
|
+
workflow: WORKFLOW,
|
|
156
|
+
stageName: "build",
|
|
157
|
+
artifactName: "changes.md",
|
|
158
|
+
frontmatter: { stage: "build", status: "complete", references: [] },
|
|
159
|
+
body: "# Changes",
|
|
160
|
+
validatorsPassed: ["tests-pass"],
|
|
161
|
+
override: {
|
|
162
|
+
reason: "Hot fix, will follow up",
|
|
163
|
+
skipped_validators: ["build-passes"],
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const text = fs.readFileSync(
|
|
168
|
+
path.join(cwd, ".agents", "task-1", "changes.md"),
|
|
169
|
+
"utf8",
|
|
170
|
+
);
|
|
171
|
+
assert.match(text, /override:/);
|
|
172
|
+
assert.match(text, /reason: Hot fix/);
|
|
173
|
+
assert.match(text, /- build-passes/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("composeStageEnvelope: renders VALIDATORS section when stage declares them", () => {
|
|
177
|
+
const cwd = makeWorkspace();
|
|
178
|
+
createRun({
|
|
179
|
+
cwd,
|
|
180
|
+
taskId: "task-1",
|
|
181
|
+
workflow: WORKFLOW,
|
|
182
|
+
taskDescription: "Test",
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const env = composeStageEnvelope({
|
|
186
|
+
cwd,
|
|
187
|
+
taskId: "task-1",
|
|
188
|
+
workflow: WORKFLOW,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
assert.match(env.prompt, /# VALIDATORS/);
|
|
192
|
+
assert.match(env.prompt, /`tests-pass` — Test suite must pass/);
|
|
193
|
+
assert.match(env.prompt, /`build-passes` — Build must succeed/);
|
|
194
|
+
assert.match(env.prompt, /`validators_passed: \[<id1>, <id2>, \.\.\.\]`/);
|
|
195
|
+
assert.deepEqual(env.validators.map((v) => v.id), ["tests-pass", "build-passes"]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("composeStageEnvelope: VALIDATORS section is empty when stage has none", () => {
|
|
199
|
+
const cwd = makeWorkspace();
|
|
200
|
+
const noValidators = {
|
|
201
|
+
...WORKFLOW,
|
|
202
|
+
stages: [{ ...WORKFLOW.stages[0], validators: [] }],
|
|
203
|
+
};
|
|
204
|
+
createRun({
|
|
205
|
+
cwd,
|
|
206
|
+
taskId: "task-1",
|
|
207
|
+
workflow: noValidators,
|
|
208
|
+
taskDescription: "Test",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const env = composeStageEnvelope({
|
|
212
|
+
cwd,
|
|
213
|
+
taskId: "task-1",
|
|
214
|
+
workflow: noValidators,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
assert.match(env.prompt, /No validators required for this stage/);
|
|
218
|
+
assert.equal(env.validators.length, 0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("runWorkflowAdvance MCP runner: forwards validators_passed + override", () => {
|
|
222
|
+
const cwd = makeWorkspace();
|
|
223
|
+
runWorkflowStart(
|
|
224
|
+
{ task_id: "task-1", task_description: "x", workflow_id: "validated" },
|
|
225
|
+
{ cwd, workflows: REGISTRY },
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const result = runWorkflowAdvance(
|
|
229
|
+
{
|
|
230
|
+
task_id: "task-1",
|
|
231
|
+
stage: "build",
|
|
232
|
+
frontmatter: {},
|
|
233
|
+
body: "# Changes",
|
|
234
|
+
validators_passed: ["tests-pass"],
|
|
235
|
+
override: {
|
|
236
|
+
reason: "CI builder offline; manual verification done",
|
|
237
|
+
skipped_validators: ["build-passes"],
|
|
238
|
+
skipped_requirements: [],
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{ cwd, workflows: REGISTRY },
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
assert.equal(result.state.outcome, "complete");
|
|
245
|
+
assert.equal(
|
|
246
|
+
result.state.stages[0].override?.reason.includes("CI builder offline"),
|
|
247
|
+
true,
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("runWorkflowAdvance MCP runner: rejects missing validators without override", () => {
|
|
252
|
+
const cwd = makeWorkspace();
|
|
253
|
+
runWorkflowStart(
|
|
254
|
+
{ task_id: "task-1", task_description: "x", workflow_id: "validated" },
|
|
255
|
+
{ cwd, workflows: REGISTRY },
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
assert.throws(
|
|
259
|
+
() =>
|
|
260
|
+
runWorkflowAdvance(
|
|
261
|
+
{
|
|
262
|
+
task_id: "task-1",
|
|
263
|
+
stage: "build",
|
|
264
|
+
frontmatter: {},
|
|
265
|
+
body: "# Changes",
|
|
266
|
+
validators_passed: [],
|
|
267
|
+
},
|
|
268
|
+
{ cwd, workflows: REGISTRY },
|
|
269
|
+
),
|
|
270
|
+
/Missing: tests-pass, build-passes/,
|
|
271
|
+
);
|
|
272
|
+
});
|