@danielblomma/cortex-mcp 2.0.16 → 2.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/scaffold/mcp/package-lock.json +59 -60
  4. package/scaffold/mcp/package.json +2 -2
  5. package/scaffold/mcp/src/cli/stage.ts +15 -18
  6. package/scaffold/mcp/src/core/validators/builtins.ts +24 -4
  7. package/scaffold/mcp/src/core/workflow/index.ts +1 -0
  8. package/scaffold/mcp/src/core/workflow/mcp-tools.ts +15 -25
  9. package/scaffold/mcp/src/core/workflow/resolution.ts +113 -0
  10. package/scaffold/mcp/src/enterprise/index.ts +16 -1
  11. package/scaffold/mcp/src/enterprise/reviews/policy-selection.ts +65 -0
  12. package/scaffold/mcp/src/enterprise/reviews/push.ts +55 -4
  13. package/scaffold/mcp/src/enterprise/reviews/trust-state.ts +305 -0
  14. package/scaffold/mcp/src/enterprise/tools/enterprise.ts +40 -47
  15. package/scaffold/mcp/src/enterprise/tools/harness.ts +2 -0
  16. package/scaffold/mcp/src/enterprise/workflow/state.ts +21 -0
  17. package/scaffold/mcp/tests/enterprise-review-trust.test.mjs +318 -0
  18. package/scaffold/mcp/tests/fixtures/org-skillz-contract.json +59 -0
  19. package/scaffold/mcp/tests/fixtures/review-trust-contract.json +111 -0
  20. package/scaffold/mcp/tests/review-policy-selection.test.mjs +85 -0
  21. package/scaffold/mcp/tests/review-trust-contract.test.mjs +167 -0
  22. package/scaffold/mcp/tests/review-validator-source.test.mjs +52 -0
  23. package/scaffold/mcp/tests/skill-sync-checker.test.mjs +277 -0
  24. package/scaffold/mcp/tests/workflow-cli.test.mjs +102 -1
  25. package/scaffold/mcp/tests/workflow-mcp-tools.test.mjs +22 -0
  26. package/scaffold/mcp/tests/workflow-synced-registry.test.mjs +56 -0
  27. package/scaffold/scripts/parsers/node_modules/.package-lock.json +0 -1
@@ -0,0 +1,167 @@
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
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { SECURE_BUILD_WORKFLOW } from "../dist/core/workflow/default-workflows.js";
9
+ import {
10
+ loadReviewTrustState,
11
+ recordQueuedReviewTrustState,
12
+ recordReviewDeliveryStatus,
13
+ } from "../dist/enterprise/reviews/trust-state.js";
14
+ import { syncedWorkflowsCachePath } from "../dist/core/workflow/synced-registry.js";
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const reviewFixture = JSON.parse(
18
+ fs.readFileSync(
19
+ path.resolve(__dirname, "./fixtures/review-trust-contract.json"),
20
+ "utf8",
21
+ ),
22
+ );
23
+
24
+ function makeProject() {
25
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-review-contract-"));
26
+ const contextDir = path.join(root, ".context");
27
+ fs.mkdirSync(contextDir, { recursive: true });
28
+ const taskDir = path.join(root, ".agents", "task-1");
29
+ fs.mkdirSync(taskDir, { recursive: true });
30
+ fs.writeFileSync(
31
+ path.join(taskDir, "state.json"),
32
+ JSON.stringify(
33
+ {
34
+ workflow_id: "secure-build",
35
+ outcome: "in_progress",
36
+ },
37
+ null,
38
+ 2,
39
+ ),
40
+ "utf8",
41
+ );
42
+ return { root, contextDir };
43
+ }
44
+
45
+ function enableSyncedSecureBuild(homeDir) {
46
+ const syncedDir = path.join(homeDir, ".cortex");
47
+ fs.mkdirSync(syncedDir, { recursive: true });
48
+ fs.writeFileSync(
49
+ syncedWorkflowsCachePath(syncedDir),
50
+ JSON.stringify(
51
+ {
52
+ workflows: {
53
+ "secure-build": {
54
+ workflow_id: "secure-build",
55
+ version: 1,
56
+ updated_at: "2026-05-19T09:30:00.000Z",
57
+ definition: SECURE_BUILD_WORKFLOW,
58
+ },
59
+ },
60
+ },
61
+ null,
62
+ 2,
63
+ ),
64
+ "utf8",
65
+ );
66
+ }
67
+
68
+ function normalizeContractShape(state) {
69
+ return {
70
+ workflow: {
71
+ id: state.workflow.id,
72
+ source: state.workflow.source,
73
+ },
74
+ last_review_at: state.last_review_at,
75
+ skipped_policies: state.skipped_policies,
76
+ delivery: {
77
+ status: state.delivery.status,
78
+ review_count: state.delivery.review_count,
79
+ last_accepted_at: state.delivery.last_accepted_at,
80
+ last_attempted_at: state.delivery.last_attempted_at,
81
+ last_error: state.delivery.last_error,
82
+ },
83
+ };
84
+ }
85
+
86
+ test("review trust contract: accepted payload matches the shared cross-repo fixture", () => {
87
+ const { root, contextDir } = makeProject();
88
+ const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-review-home-"));
89
+ process.env.HOME = fakeHome;
90
+ enableSyncedSecureBuild(fakeHome);
91
+
92
+ try {
93
+ const accepted = reviewFixture.accepted.workflow_snapshot.snapshot.review_trust;
94
+ recordQueuedReviewTrustState(
95
+ {
96
+ contextDir,
97
+ projectRoot: root,
98
+ repo: reviewFixture.accepted.workflow_snapshot.repo,
99
+ instance_id: reviewFixture.accepted.workflow_snapshot.instanceId,
100
+ session_id: reviewFixture.accepted.workflow_snapshot.sessionId,
101
+ task_id: "task-1",
102
+ },
103
+ {
104
+ reviewedAt: accepted.last_review_at,
105
+ summary: {
106
+ total: accepted.delivery.review_count,
107
+ passed: 2,
108
+ failed: 0,
109
+ warnings: 0,
110
+ skipped: accepted.skipped_policies.length,
111
+ },
112
+ skippedPolicies: accepted.skipped_policies,
113
+ },
114
+ );
115
+ recordReviewDeliveryStatus(contextDir, "accepted", {
116
+ reviewCount: accepted.delivery.review_count,
117
+ attemptedAt: accepted.delivery.last_accepted_at,
118
+ });
119
+
120
+ const state = loadReviewTrustState(contextDir);
121
+ assert.deepEqual(normalizeContractShape(state), accepted);
122
+ } finally {
123
+ delete process.env.HOME;
124
+ }
125
+ });
126
+
127
+ test("review trust contract: failed payload matches the shared cross-repo fixture", () => {
128
+ const { root, contextDir } = makeProject();
129
+ const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-review-home-"));
130
+ process.env.HOME = fakeHome;
131
+ enableSyncedSecureBuild(fakeHome);
132
+
133
+ try {
134
+ const failed = reviewFixture.failed.workflow_snapshot.snapshot.review_trust;
135
+ recordQueuedReviewTrustState(
136
+ {
137
+ contextDir,
138
+ projectRoot: root,
139
+ repo: reviewFixture.failed.workflow_snapshot.repo,
140
+ instance_id: reviewFixture.failed.workflow_snapshot.instanceId,
141
+ session_id: reviewFixture.failed.workflow_snapshot.sessionId,
142
+ task_id: "task-1",
143
+ },
144
+ {
145
+ reviewedAt: failed.last_review_at,
146
+ summary: {
147
+ total: failed.delivery.review_count,
148
+ passed: 1,
149
+ failed: 1,
150
+ warnings: 0,
151
+ skipped: 0,
152
+ },
153
+ skippedPolicies: [],
154
+ },
155
+ );
156
+ recordReviewDeliveryStatus(contextDir, "failed", {
157
+ reviewCount: failed.delivery.review_count,
158
+ attemptedAt: failed.delivery.last_attempted_at,
159
+ error: failed.delivery.last_error,
160
+ });
161
+
162
+ const state = loadReviewTrustState(contextDir);
163
+ assert.deepEqual(normalizeContractShape(state), failed);
164
+ } finally {
165
+ delete process.env.HOME;
166
+ }
167
+ });
@@ -0,0 +1,52 @@
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 { runValidators } from "../dist/core/validators/engine.js";
8
+ import "../dist/core/validators/builtins.js";
9
+
10
+ function makeProject() {
11
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-review-validator-"));
12
+ const contextDir = path.join(root, ".context");
13
+ fs.mkdirSync(contextDir, { recursive: true });
14
+ return { root, contextDir };
15
+ }
16
+
17
+ test("require-code-review reports source none when no evidence exists", async () => {
18
+ const { root, contextDir } = makeProject();
19
+ const result = await runValidators(
20
+ new Set(["require-code-review"]),
21
+ { projectRoot: root, contextDir, changedFiles: [] },
22
+ {},
23
+ );
24
+
25
+ assert.equal(result.results[0].pass, false);
26
+ assert.match(result.results[0].detail ?? "", /Source: none/);
27
+ });
28
+
29
+ test("require-code-review reports workflow state as the evidence source", async () => {
30
+ const { root, contextDir } = makeProject();
31
+ fs.mkdirSync(path.join(contextDir, "workflow"), { recursive: true });
32
+ fs.writeFileSync(
33
+ path.join(contextDir, "workflow", "state.json"),
34
+ JSON.stringify({
35
+ last_review: {
36
+ status: "passed",
37
+ reviewed_at: "2026-05-17T10:00:00.000Z",
38
+ reviewed_files: [],
39
+ },
40
+ }),
41
+ "utf8",
42
+ );
43
+
44
+ const result = await runValidators(
45
+ new Set(["require-code-review"]),
46
+ { projectRoot: root, contextDir, changedFiles: [] },
47
+ {},
48
+ );
49
+
50
+ assert.equal(result.results[0].pass, true);
51
+ assert.match(result.results[0].detail ?? "", /Source: workflow state/);
52
+ });
@@ -0,0 +1,277 @@
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
+ import { fileURLToPath } from "node:url";
7
+
8
+ import {
9
+ runSkillSyncForCli,
10
+ runSkillSyncOnce,
11
+ } from "../dist/daemon/skill-sync-checker.js";
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const skillFixture = JSON.parse(
15
+ fs.readFileSync(
16
+ path.resolve(__dirname, "./fixtures/org-skillz-contract.json"),
17
+ "utf8",
18
+ ),
19
+ );
20
+
21
+ function makeWorkspace() {
22
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-skill-sync-"));
23
+ const contextDir = path.join(cwd, ".context");
24
+ fs.mkdirSync(contextDir, { recursive: true });
25
+ fs.writeFileSync(
26
+ path.join(contextDir, "enterprise.yml"),
27
+ [
28
+ "enterprise:",
29
+ " api_key: ent_test_12345678",
30
+ " endpoint: https://example.com",
31
+ "",
32
+ ].join("\n"),
33
+ "utf8",
34
+ );
35
+ return { cwd, contextDir };
36
+ }
37
+
38
+ function skillPath(homeDir, cli, name) {
39
+ const root = cli === "codex"
40
+ ? path.join(homeDir, ".codex", "skills")
41
+ : path.join(homeDir, ".claude", "skills");
42
+ return path.join(root, name, "SKILL.md");
43
+ }
44
+
45
+ function daemonStatePath(homeDir) {
46
+ return path.join(homeDir, ".cortex", "skills.local.json");
47
+ }
48
+
49
+ function daemonNotificationPath(homeDir) {
50
+ return path.join(homeDir, ".cortex", ".skills-update-applied.json");
51
+ }
52
+
53
+ function latestHostAuditFile(cwd) {
54
+ const auditDir = path.join(cwd, ".context", "audit");
55
+ const files = fs.existsSync(auditDir)
56
+ ? fs.readdirSync(auditDir).filter((file) => file.startsWith("host-events-"))
57
+ : [];
58
+ assert.ok(files.length > 0, "expected at least one host audit file");
59
+ files.sort();
60
+ return path.join(auditDir, files[files.length - 1]);
61
+ }
62
+
63
+ function createFetchStub(state) {
64
+ return async (input) => {
65
+ const url = input instanceof URL
66
+ ? input
67
+ : new URL(typeof input === "string" ? input : input.url);
68
+ if (url.pathname === "/api/v1/govern/skills/manifest") {
69
+ const cli = url.searchParams.get("cli");
70
+ return {
71
+ ok: true,
72
+ status: 200,
73
+ statusText: "OK",
74
+ json: async () => ({ skills: state.manifests[cli] ?? [] }),
75
+ };
76
+ }
77
+
78
+ if (url.pathname.startsWith("/api/v1/govern/skills/")) {
79
+ const name = decodeURIComponent(url.pathname.split("/").pop() ?? "");
80
+ if (!(name in state.bodies)) {
81
+ return {
82
+ ok: false,
83
+ status: 404,
84
+ statusText: "Not Found",
85
+ text: async () => "",
86
+ };
87
+ }
88
+ return {
89
+ ok: true,
90
+ status: 200,
91
+ statusText: "OK",
92
+ text: async () => state.bodies[name],
93
+ };
94
+ }
95
+
96
+ throw new Error(`Unexpected fetch URL: ${url.toString()}`);
97
+ };
98
+ }
99
+
100
+ test.afterEach(() => {
101
+ delete process.env.HOME;
102
+ globalThis.fetch = undefined;
103
+ });
104
+
105
+ test("runSkillSyncOnce: syncs global and CLI-scoped skills into the correct local roots", async () => {
106
+ const { cwd } = makeWorkspace();
107
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-skill-home-"));
108
+ process.env.HOME = homeDir;
109
+
110
+ const state = {
111
+ manifests: {
112
+ claude: [
113
+ {
114
+ name: "global-skill",
115
+ scope: "global",
116
+ updated_at: "2026-05-19T08:00:00.000Z",
117
+ },
118
+ ],
119
+ codex: [
120
+ {
121
+ name: "global-skill",
122
+ scope: "global",
123
+ updated_at: "2026-05-19T08:00:00.000Z",
124
+ },
125
+ {
126
+ name: "codex-only",
127
+ scope: "cli:codex",
128
+ updated_at: "2026-05-19T08:00:00.000Z",
129
+ },
130
+ ],
131
+ },
132
+ bodies: {
133
+ "global-skill": "---\nname: global-skill\ndescription: \"Global\"\n---\n\n# Global\n",
134
+ "codex-only": "---\nname: codex-only\ndescription: \"Codex\"\n---\n\n# Codex\n",
135
+ },
136
+ };
137
+
138
+ globalThis.fetch = createFetchStub(state);
139
+
140
+ const outcomes = await runSkillSyncOnce(cwd, ["claude", "codex"]);
141
+ assert.equal(outcomes[0].kind, "synced");
142
+ assert.equal(outcomes[1].kind, "synced");
143
+
144
+ assert.ok(fs.existsSync(skillPath(homeDir, "claude", "global-skill")));
145
+ assert.ok(!fs.existsSync(skillPath(homeDir, "claude", "codex-only")));
146
+ assert.ok(fs.existsSync(skillPath(homeDir, "codex", "global-skill")));
147
+ assert.ok(fs.existsSync(skillPath(homeDir, "codex", "codex-only")));
148
+
149
+ const parsedState = JSON.parse(fs.readFileSync(daemonStatePath(homeDir), "utf8"));
150
+ assert.deepEqual(Object.keys(parsedState.skills).sort(), [
151
+ "claude:global-skill",
152
+ "codex:codex-only",
153
+ "codex:global-skill",
154
+ ]);
155
+
156
+ const notification = JSON.parse(
157
+ fs.readFileSync(daemonNotificationPath(homeDir), "utf8"),
158
+ );
159
+ assert.equal(notification.cli, "codex");
160
+
161
+ const auditLines = fs
162
+ .readFileSync(latestHostAuditFile(cwd), "utf8")
163
+ .trim()
164
+ .split("\n")
165
+ .map((line) => JSON.parse(line));
166
+ assert.deepEqual(
167
+ auditLines.map((event) => event.event_type),
168
+ ["skills_synced", "skills_synced"],
169
+ );
170
+ });
171
+
172
+ test("runSkillSyncForCli: rewrites a changed skill and reports an unchanged rerun", async () => {
173
+ const { cwd } = makeWorkspace();
174
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-skill-home-"));
175
+ process.env.HOME = homeDir;
176
+
177
+ const state = {
178
+ manifests: {
179
+ codex: [
180
+ {
181
+ name: "global-skill",
182
+ scope: "global",
183
+ updated_at: "2026-05-19T08:00:00.000Z",
184
+ },
185
+ ],
186
+ },
187
+ bodies: {
188
+ "global-skill": "---\nname: global-skill\ndescription: \"V1\"\n---\n\n# One\n",
189
+ },
190
+ };
191
+ globalThis.fetch = createFetchStub(state);
192
+
193
+ const first = await runSkillSyncForCli(cwd, "codex");
194
+ assert.equal(first.kind, "synced");
195
+ assert.deepEqual(first.added, ["global-skill"]);
196
+
197
+ state.manifests.codex[0].updated_at = "2026-05-19T09:00:00.000Z";
198
+ state.bodies["global-skill"] =
199
+ "---\nname: global-skill\ndescription: \"V2\"\n---\n\n# Two\n";
200
+
201
+ const second = await runSkillSyncForCli(cwd, "codex");
202
+ assert.equal(second.kind, "synced");
203
+ assert.deepEqual(second.changed, ["global-skill"]);
204
+ assert.match(
205
+ fs.readFileSync(skillPath(homeDir, "codex", "global-skill"), "utf8"),
206
+ /# Two/,
207
+ );
208
+
209
+ const third = await runSkillSyncForCli(cwd, "codex");
210
+ assert.deepEqual(third, {
211
+ kind: "unchanged",
212
+ cli: "codex",
213
+ count: 1,
214
+ });
215
+ });
216
+
217
+ test("runSkillSyncForCli: removes skills that disappear from the manifest", async () => {
218
+ const { cwd } = makeWorkspace();
219
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-skill-home-"));
220
+ process.env.HOME = homeDir;
221
+
222
+ const state = {
223
+ manifests: {
224
+ claude: [
225
+ {
226
+ name: "global-skill",
227
+ scope: "global",
228
+ updated_at: "2026-05-19T08:00:00.000Z",
229
+ },
230
+ ],
231
+ },
232
+ bodies: {
233
+ "global-skill": "---\nname: global-skill\ndescription: \"Global\"\n---\n\n# Global\n",
234
+ },
235
+ };
236
+ globalThis.fetch = createFetchStub(state);
237
+
238
+ const first = await runSkillSyncForCli(cwd, "claude");
239
+ assert.equal(first.kind, "synced");
240
+ assert.ok(fs.existsSync(skillPath(homeDir, "claude", "global-skill")));
241
+
242
+ state.manifests.claude = [];
243
+
244
+ const second = await runSkillSyncForCli(cwd, "claude");
245
+ assert.equal(second.kind, "synced");
246
+ assert.deepEqual(second.removed, ["global-skill"]);
247
+ assert.ok(!fs.existsSync(skillPath(homeDir, "claude", "global-skill")));
248
+ });
249
+
250
+ test("runSkillSyncOnce: consumes the shared org-skillz contract fixture end-to-end", async () => {
251
+ const { cwd } = makeWorkspace();
252
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-skill-home-"));
253
+ process.env.HOME = homeDir;
254
+
255
+ globalThis.fetch = createFetchStub({
256
+ manifests: skillFixture.manifest,
257
+ bodies: skillFixture.markdown,
258
+ });
259
+
260
+ const outcomes = await runSkillSyncOnce(cwd, ["claude", "codex"]);
261
+ assert.equal(outcomes[0].kind, "synced");
262
+ assert.equal(outcomes[1].kind, "synced");
263
+
264
+ assert.equal(
265
+ fs.readFileSync(skillPath(homeDir, "codex", "alpha-codex"), "utf8"),
266
+ skillFixture.markdown["alpha-codex"],
267
+ );
268
+ assert.equal(
269
+ fs.readFileSync(skillPath(homeDir, "codex", "global-guard"), "utf8"),
270
+ skillFixture.markdown["global-guard"],
271
+ );
272
+ assert.equal(
273
+ fs.readFileSync(skillPath(homeDir, "claude", "claude-playbook"), "utf8"),
274
+ skillFixture.markdown["claude-playbook"],
275
+ );
276
+ assert.ok(!fs.existsSync(skillPath(homeDir, "claude", "alpha-codex")));
277
+ });
@@ -6,6 +6,32 @@ import path from "node:path";
6
6
 
7
7
  import { runStageCommand } from "../dist/cli/stage.js";
8
8
 
9
+ const TINY_WORKFLOW = {
10
+ id: "tiny",
11
+ description: "Two-stage tests workflow",
12
+ version: 1,
13
+ stages: [
14
+ {
15
+ name: "plan",
16
+ artifact: "plan.md",
17
+ reads: [],
18
+ required_fields: [],
19
+ validators: [],
20
+ capability: "planner",
21
+ description: "Produce a plan.",
22
+ },
23
+ {
24
+ name: "review",
25
+ artifact: "review.md",
26
+ reads: ["plan"],
27
+ required_fields: ["approved"],
28
+ validators: [],
29
+ capability: "reviewer",
30
+ description: "Review the plan.",
31
+ },
32
+ ],
33
+ };
34
+
9
35
  function makeWorkspace() {
10
36
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-stage-cli-"));
11
37
  process.env.CORTEX_PROJECT_ROOT = dir;
@@ -21,6 +47,37 @@ function makeWorkspace() {
21
47
  return dir;
22
48
  }
23
49
 
50
+ function writeSyncedWorkflow(homeDir, workflowId, definition) {
51
+ const cortexDir = path.join(homeDir, ".cortex");
52
+ fs.mkdirSync(cortexDir, { recursive: true });
53
+ fs.writeFileSync(
54
+ path.join(cortexDir, "workflows.local.json"),
55
+ JSON.stringify({
56
+ workflows: {
57
+ [workflowId]: {
58
+ workflow_id: workflowId,
59
+ version: definition.version,
60
+ updated_at: "2026-05-17T10:00:00.000Z",
61
+ definition,
62
+ },
63
+ },
64
+ }),
65
+ "utf8",
66
+ );
67
+ }
68
+
69
+ function setEnforcedMode(projectRoot) {
70
+ fs.writeFileSync(
71
+ path.join(projectRoot, ".context", "govern.local.json"),
72
+ JSON.stringify({
73
+ installs: {
74
+ codex: { mode: "enforced" },
75
+ },
76
+ }),
77
+ "utf8",
78
+ );
79
+ }
80
+
24
81
  function captureStdout(run) {
25
82
  const original = process.stdout.write.bind(process.stdout);
26
83
  let captured = "";
@@ -37,6 +94,7 @@ function captureStdout(run) {
37
94
 
38
95
  test.afterEach(() => {
39
96
  delete process.env.CORTEX_PROJECT_ROOT;
97
+ delete process.env.HOME;
40
98
  });
41
99
 
42
100
  test("stage start: creates run + returns first envelope as JSON on stdout", async () => {
@@ -56,6 +114,8 @@ test("stage start: creates run + returns first envelope as JSON on stdout", asyn
56
114
  assert.equal(parsed.state.task_id, "task-1");
57
115
  assert.equal(parsed.state.current_stage, "plan");
58
116
  assert.equal(parsed.envelope.expectedArtifact, "plan.md");
117
+ assert.equal(parsed.workflow_source, "bundled");
118
+ assert.equal(parsed.warnings.length, 1);
59
119
  assert.ok(fs.existsSync(path.join(cwd, ".agents", "task-1", "state.json")));
60
120
  });
61
121
 
@@ -71,7 +131,48 @@ test("stage start: rejects unknown workflow", async () => {
71
131
  "--workflow",
72
132
  "no-such",
73
133
  ]),
74
- /Unknown workflow_id/,
134
+ /Available bundled: secure-build/,
135
+ );
136
+ });
137
+
138
+ test("stage start: accepts synced workflow ids from the local cache", async () => {
139
+ makeWorkspace();
140
+ const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-stage-home-"));
141
+ process.env.HOME = fakeHome;
142
+ writeSyncedWorkflow(fakeHome, "tiny", TINY_WORKFLOW);
143
+
144
+ const { captured } = await captureStdout(() =>
145
+ runStageCommand([
146
+ "start",
147
+ "--task-id",
148
+ "task-synced",
149
+ "--description",
150
+ "Use synced workflow",
151
+ "--workflow",
152
+ "tiny",
153
+ ]),
154
+ );
155
+ const parsed = JSON.parse(captured);
156
+ assert.equal(parsed.state.workflow_id, "tiny");
157
+ assert.equal(parsed.workflow_source, "synced");
158
+ assert.deepEqual(parsed.warnings, []);
159
+ });
160
+
161
+ test("stage start: blocks bundled workflow fallback in enforced mode", async () => {
162
+ const cwd = makeWorkspace();
163
+ setEnforcedMode(cwd);
164
+
165
+ await assert.rejects(
166
+ runStageCommand([
167
+ "start",
168
+ "--task-id",
169
+ "task-enforced",
170
+ "--description",
171
+ "Use bundled fallback",
172
+ "--workflow",
173
+ "secure-build",
174
+ ]),
175
+ /requires a synced org workflow/,
75
176
  );
76
177
  });
77
178
 
@@ -86,6 +86,7 @@ test("runWorkflowStart: creates run and returns the first envelope", () => {
86
86
  assert.equal(result.state.current_stage, "plan");
87
87
  assert.equal(result.state.outcome, "in_progress");
88
88
  assert.equal(result.envelope.expectedArtifact, "plan.md");
89
+ assert.equal(result.workflow_source, "injected");
89
90
  assert.match(result.envelope.prompt, /# STAGE: plan/);
90
91
 
91
92
  // state.json persisted under .agents/<task-id>/
@@ -117,6 +118,27 @@ test("runWorkflowStart: defaults to bundled DEFAULT_WORKFLOWS when no registry g
117
118
  );
118
119
  assert.equal(result.state.workflow_id, "secure-build");
119
120
  assert.equal(result.envelope.expectedArtifact, "plan.md");
121
+ assert.equal(result.workflow_source, "bundled");
122
+ assert.equal(result.warnings.length, 0);
123
+ });
124
+
125
+ test("runWorkflowStart: blocks bundled fallback when the caller requires synced workflows", () => {
126
+ const cwd = makeWorkspace();
127
+ assert.throws(
128
+ () =>
129
+ runWorkflowStart(
130
+ {
131
+ task_id: "task-enforced",
132
+ task_description: "Use the default workflow",
133
+ workflow_id: SECURE_BUILD_WORKFLOW.id,
134
+ },
135
+ {
136
+ cwd,
137
+ bundledFallbackPolicy: "block",
138
+ },
139
+ ),
140
+ /requires a synced org workflow/,
141
+ );
120
142
  });
121
143
 
122
144
  test("runWorkflowAdvance: writes artifact + state, returns next envelope while in_progress", () => {