@danielblomma/cortex-mcp 2.0.7 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@danielblomma/cortex-mcp",
3
3
  "mcpName": "io.github.DanielBlomma/cortex",
4
- "version": "2.0.7",
4
+ "version": "2.0.8",
5
5
  "description": "Local, repo-scoped context platform for coding assistants. Semantic search, graph relationships, and architectural rule context.",
6
6
  "type": "module",
7
7
  "author": "Daniel Blomma",
@@ -9,6 +9,7 @@ import {
9
9
  type WorkflowDefinition,
10
10
  } from "../core/workflow/index.js";
11
11
  import { DEFAULT_WORKFLOWS } from "../core/workflow/default-workflows.js";
12
+ import { isEnterpriseProject } from "../hooks/shared.js";
12
13
 
13
14
  /**
14
15
  * `cortex stage` CLI surface. Each subcommand is a thin shell wrapper
@@ -31,10 +32,23 @@ import { DEFAULT_WORKFLOWS } from "../core/workflow/default-workflows.js";
31
32
  * standalone shell calls.
32
33
  */
33
34
 
35
+ const ENTERPRISE_REQUIRED_MESSAGE =
36
+ "cortex stage is part of the Cortex Harness — an enterprise-only feature. " +
37
+ "This project does not have an enterprise license configured (no enterprise.api_key " +
38
+ "in .context/enterprise.yml). Run 'cortex enterprise <api-key>' to enable it, or " +
39
+ "contact your org admin.";
40
+
34
41
  export async function runStageCommand(args: string[]): Promise<void> {
35
42
  const sub = args[0] ?? "help";
36
43
  const rest = args.slice(1);
37
44
 
45
+ // help is allowed in community mode so users can discover the feature exists.
46
+ if (sub !== "help" && sub !== "--help" && sub !== "-h") {
47
+ if (!isEnterpriseProject(projectRoot())) {
48
+ throw new Error(ENTERPRISE_REQUIRED_MESSAGE);
49
+ }
50
+ }
51
+
38
52
  switch (sub) {
39
53
  case "start":
40
54
  return runStart(rest);
@@ -65,6 +79,8 @@ function printHelp(): void {
65
79
  " cortex stage envelope --task-id <id> [--stage <name>]",
66
80
  " cortex stage advance --task-id <id> --stage <name> --body-file <path>",
67
81
  " [--frontmatter-file <path>] [--status <s>] [--outcome-file <path>]",
82
+ " [--validators-passed <id1,id2,...>]",
83
+ " [--override-reason \"...\"] [--override-skipped-validators <id1,id2>]",
68
84
  " cortex stage run --task-id <id> -- <command> [args...]",
69
85
  "",
70
86
  "Status values: complete (default) | blocked | failed",
@@ -193,12 +209,36 @@ async function runAdvance(args: string[]): Promise<void> {
193
209
  typeof flags["outcome-file"] === "string" ? flags["outcome-file"] : null;
194
210
  const statusFlag =
195
211
  typeof flags.status === "string" ? (flags.status as StageStatus) : undefined;
212
+ const validatorsRaw =
213
+ typeof flags["validators-passed"] === "string"
214
+ ? flags["validators-passed"]
215
+ : null;
216
+ const overrideReason =
217
+ typeof flags["override-reason"] === "string"
218
+ ? flags["override-reason"]
219
+ : null;
220
+ const overrideSkippedRaw =
221
+ typeof flags["override-skipped-validators"] === "string"
222
+ ? flags["override-skipped-validators"]
223
+ : null;
196
224
 
197
225
  const body = readFileSync(bodyPath, "utf8");
198
226
  const frontmatter: Record<string, unknown> = frontmatterPath
199
227
  ? parseJsonObject(frontmatterPath)
200
228
  : {};
201
229
  const outcome = outcomePath ? parseJsonObject(outcomePath) : undefined;
230
+ const validatorsPassed = validatorsRaw
231
+ ? validatorsRaw.split(",").map((s) => s.trim()).filter(Boolean)
232
+ : undefined;
233
+ const override = overrideReason
234
+ ? {
235
+ reason: overrideReason,
236
+ skipped_validators: overrideSkippedRaw
237
+ ? overrideSkippedRaw.split(",").map((s) => s.trim()).filter(Boolean)
238
+ : [],
239
+ skipped_requirements: [],
240
+ }
241
+ : undefined;
202
242
 
203
243
  const state = getRunState(projectRoot(), taskId);
204
244
  if (!state) {
@@ -234,6 +274,8 @@ async function runAdvance(args: string[]): Promise<void> {
234
274
  body,
235
275
  status: finalStatus,
236
276
  outcome,
277
+ validatorsPassed,
278
+ override,
237
279
  });
238
280
 
239
281
  let nextEnvelope = null;
@@ -17,6 +17,7 @@ export const SECURE_BUILD_WORKFLOW: WorkflowDefinition = {
17
17
  artifact: "plan.md",
18
18
  reads: [],
19
19
  required_fields: ["files_targeted", "constraints"],
20
+ validators: [],
20
21
  capability: "planner",
21
22
  description:
22
23
  "Produce a step-by-step implementation plan grounded in the repo's rules and memory.",
@@ -26,6 +27,7 @@ export const SECURE_BUILD_WORKFLOW: WorkflowDefinition = {
26
27
  artifact: "plan-review.md",
27
28
  reads: ["plan"],
28
29
  required_fields: ["approved", "blocking_comments"],
30
+ validators: [],
29
31
  capability: "reviewer",
30
32
  description:
31
33
  "Review the plan for architectural fit and rule compliance before any code is written.",
@@ -35,6 +37,18 @@ export const SECURE_BUILD_WORKFLOW: WorkflowDefinition = {
35
37
  artifact: "changes.md",
36
38
  reads: ["plan", "plan-review"],
37
39
  required_fields: ["files_changed"],
40
+ validators: [
41
+ {
42
+ id: "build-passes",
43
+ description:
44
+ "The project's build / typecheck command must succeed on the produced diff.",
45
+ },
46
+ {
47
+ id: "tests-pass",
48
+ description:
49
+ "The project's existing test suite must pass on the produced diff.",
50
+ },
51
+ ],
38
52
  capability: "builder",
39
53
  description:
40
54
  "Implement the approved plan. Produces the diff manifest used by the downstream reviewers.",
@@ -44,6 +58,7 @@ export const SECURE_BUILD_WORKFLOW: WorkflowDefinition = {
44
58
  artifact: "build-review.md",
45
59
  reads: ["plan", "changes"],
46
60
  required_fields: ["approved", "blocking_comments"],
61
+ validators: [],
47
62
  capability: "reviewer",
48
63
  description:
49
64
  "Review the implementation against the plan and the project's rules.",
@@ -53,6 +68,13 @@ export const SECURE_BUILD_WORKFLOW: WorkflowDefinition = {
53
68
  artifact: "mutation-report.md",
54
69
  reads: ["changes"],
55
70
  required_fields: ["score", "survived"],
71
+ validators: [
72
+ {
73
+ id: "mutation-score",
74
+ description:
75
+ "Mutation testing tool of the agent's choice (e.g. stryker, mutmut) must run and report a score against the changed files.",
76
+ },
77
+ ],
56
78
  capability: "tester",
57
79
  description:
58
80
  "Run mutation tests on the changed files. Report score + surviving mutants.",
@@ -62,6 +84,18 @@ export const SECURE_BUILD_WORKFLOW: WorkflowDefinition = {
62
84
  artifact: "security-report.md",
63
85
  reads: ["changes"],
64
86
  required_fields: ["findings", "severity_summary"],
87
+ validators: [
88
+ {
89
+ id: "secret-scan",
90
+ description:
91
+ "Scan the diff for newly committed secrets (API keys, tokens, credentials).",
92
+ },
93
+ {
94
+ id: "dependency-audit",
95
+ description:
96
+ "Audit any added or upgraded dependencies for known vulnerabilities.",
97
+ },
98
+ ],
65
99
  capability: "security-reviewer",
66
100
  description:
67
101
  "Security review focused on the diff: injection, authn/authz, secrets, dependency risk.",
@@ -71,6 +105,7 @@ export const SECURE_BUILD_WORKFLOW: WorkflowDefinition = {
71
105
  artifact: "approval.md",
72
106
  reads: ["plan", "changes", "build-review", "mutation", "security"],
73
107
  required_fields: ["approved", "approver"],
108
+ validators: [],
74
109
  capability: "human",
75
110
  description:
76
111
  "Human sign-off. Pulls every prior artifact for the approver to read; the approver writes the artifact.",
@@ -35,6 +35,8 @@ export type ComposedEnvelope = {
35
35
  requiredFields: string[];
36
36
  /** Capability key the stage runs under (informational). */
37
37
  capability: string | null;
38
+ /** Validators the stage requires the agent to have run. */
39
+ validators: { id: string; description: string }[];
38
40
  };
39
41
 
40
42
  export type ComposeStageEnvelopeOptions = {
@@ -107,6 +109,7 @@ export function composeStageEnvelope(
107
109
 
108
110
  const requiredFields = stage.required_fields;
109
111
  const capability = stage.capability ?? null;
112
+ const validators = stage.validators;
110
113
 
111
114
  const prompt = renderPrompt({
112
115
  taskDescription: state.task_description,
@@ -118,6 +121,7 @@ export function composeStageEnvelope(
118
121
  requiredFields,
119
122
  capability,
120
123
  handoffs,
124
+ validators,
121
125
  });
122
126
 
123
127
  return {
@@ -125,6 +129,7 @@ export function composeStageEnvelope(
125
129
  expectedArtifact: stage.artifact,
126
130
  requiredFields,
127
131
  capability,
132
+ validators,
128
133
  };
129
134
  }
130
135
 
@@ -150,6 +155,7 @@ type RenderPromptOptions = {
150
155
  requiredFields: string[];
151
156
  capability: string | null;
152
157
  handoffs: string[];
158
+ validators: { id: string; description: string }[];
153
159
  };
154
160
 
155
161
  function renderPrompt(o: RenderPromptOptions): string {
@@ -200,13 +206,32 @@ function renderPrompt(o: RenderPromptOptions): string {
200
206
  ? `_No additional required fields beyond the harness defaults._`
201
207
  : o.requiredFields.map((f) => `- \`${f}\``).join("\n");
202
208
 
209
+ const validatorLines =
210
+ o.validators.length === 0
211
+ ? `_No validators required for this stage._`
212
+ : o.validators
213
+ .map((v) => `- \`${v.id}\` — ${v.description}`)
214
+ .join("\n");
215
+
216
+ sections.push(
217
+ [
218
+ `# VALIDATORS`,
219
+ ``,
220
+ `Cortex defines what must be validated; you (the agent) pick the concrete tooling and run it in this environment, then report back.`,
221
+ ``,
222
+ validatorLines,
223
+ ``,
224
+ `When you call \`cortex.workflow.advance\`, set \`validators_passed: [<id1>, <id2>, ...]\` listing every validator you successfully ran. The harness blocks the advance if a required validator is missing — unless you explicitly pass \`override\` with a reason and the list of skipped validator ids.`,
225
+ ].join("\n"),
226
+ );
227
+
203
228
  sections.push(
204
229
  [
205
230
  `# OUTPUT`,
206
231
  ``,
207
232
  `Produce a single markdown file named \`${o.expectedArtifact}\` with YAML frontmatter on top.`,
208
233
  ``,
209
- `Required frontmatter fields (in addition to \`stage\`, \`status\`, \`references\`, \`written_at\` which the harness manages):`,
234
+ `Required frontmatter fields (in addition to \`stage\`, \`status\`, \`references\`, \`validators_passed\`, \`written_at\` which the harness manages):`,
210
235
  ``,
211
236
  requiredLines,
212
237
  ``,
@@ -4,6 +4,7 @@ import { composeStageEnvelope } from "./envelope.js";
4
4
  import { DEFAULT_WORKFLOWS } from "./default-workflows.js";
5
5
  import { loadSyncedWorkflows } from "./synced-registry.js";
6
6
  import {
7
+ stageOverrideSchema,
7
8
  stageStatusSchema,
8
9
  type StageStatus,
9
10
  type WorkflowDefinition,
@@ -45,6 +46,19 @@ export const WorkflowAdvanceInput = z.object({
45
46
  status: stageStatusSchema.optional(),
46
47
  /** Optional structured outcome surfaced into state.json for fast lookup by later stages. */
47
48
  outcome: z.record(z.string(), z.unknown()).optional(),
49
+ /**
50
+ * Validators the agent reports having run for this stage. Compared
51
+ * against the stage definition's required validators on advance —
52
+ * missing entries block the call unless an override is supplied.
53
+ */
54
+ validators_passed: z.array(z.string().min(1)).default([]),
55
+ /**
56
+ * Process override. Required when validators_passed does not cover
57
+ * every validator the stage declares. Logged as a high-evidence
58
+ * audit event and stamped into the artifact's frontmatter so the
59
+ * deviation is visible in the evidence trail.
60
+ */
61
+ override: stageOverrideSchema.optional(),
48
62
  });
49
63
  export type WorkflowAdvanceInputT = z.infer<typeof WorkflowAdvanceInput>;
50
64
 
@@ -155,6 +169,8 @@ export function runWorkflowAdvance(
155
169
  body: input.body,
156
170
  outcome: input.outcome,
157
171
  status: finalStatus,
172
+ validatorsPassed: input.validators_passed,
173
+ override: input.override,
158
174
  });
159
175
 
160
176
  // If the run is still going, also return the next envelope so the caller
@@ -6,8 +6,10 @@ import {
6
6
  import {
7
7
  runStateSchema,
8
8
  stageArtifactFrontmatterSchema,
9
+ stageOverrideSchema,
9
10
  workflowDefinitionSchema,
10
11
  type RunState,
12
+ type StageOverride,
11
13
  type StageRecord,
12
14
  type StageStatus,
13
15
  type WorkflowDefinition,
@@ -36,6 +38,7 @@ export function createRun(options: CreateRunOptions): RunState {
36
38
  const stages: StageRecord[] = workflow.stages.map((stage) => ({
37
39
  name: stage.name,
38
40
  status: "pending" as StageStatus,
41
+ validators_passed: [],
39
42
  }));
40
43
 
41
44
  const state: RunState = {
@@ -80,6 +83,10 @@ export type AdvanceStageOptions = {
80
83
  outcome?: Record<string, unknown>;
81
84
  /** Final status to record for this stage. Defaults to "complete". */
82
85
  status?: StageStatus;
86
+ /** Validators the agent reports having run. Compared against stage.validators. */
87
+ validatorsPassed?: string[];
88
+ /** Process override; required when validators_passed doesn't cover stage.validators. */
89
+ override?: StageOverride;
83
90
  now?: () => Date;
84
91
  };
85
92
 
@@ -111,12 +118,52 @@ export function advanceStage(options: AdvanceStageOptions): RunState {
111
118
  );
112
119
  }
113
120
 
121
+ const stageIndex = workflow.stages.findIndex((s) => s.name === options.stageName);
122
+ const stageDef = workflow.stages[stageIndex];
123
+
124
+ // Validator coverage check: every validator the stage declares must
125
+ // appear in validators_passed unless the override explicitly skips it.
126
+ // Process is enforced here even though the validators themselves run
127
+ // in the agent's environment.
128
+ const validatorsPassed = options.validatorsPassed ?? [];
129
+ const override = options.override
130
+ ? stageOverrideSchema.parse(options.override)
131
+ : undefined;
132
+ const requiredValidators = stageDef.validators.map((v) => v.id);
133
+ const declaredSkipped = new Set(override?.skipped_validators ?? []);
134
+ const missingValidators = requiredValidators.filter(
135
+ (id) => !validatorsPassed.includes(id) && !declaredSkipped.has(id),
136
+ );
137
+ // blocked / failed stages are exempt from validator coverage — the
138
+ // stage is explicitly halting before completion, so the validators
139
+ // logically cannot have run.
140
+ const finalStatus = options.status ?? "complete";
141
+ const exemptStatus = finalStatus === "blocked" || finalStatus === "failed";
142
+ if (missingValidators.length > 0 && !exemptStatus) {
143
+ throw new Error(
144
+ `Stage ${options.stageName} requires validators ${requiredValidators.join(", ")} ` +
145
+ `but artifact reported only ${validatorsPassed.join(", ") || "<none>"}. ` +
146
+ `Missing: ${missingValidators.join(", ")}. ` +
147
+ `Pass override.skipped_validators with a reason to advance anyway.`,
148
+ );
149
+ }
150
+
114
151
  const now = (options.now ?? (() => new Date()))();
115
152
  const completedAt = now.toISOString();
116
153
 
117
154
  const frontmatter = stageArtifactFrontmatterSchema.parse({
118
155
  ...options.frontmatter,
119
156
  stage: options.stageName,
157
+ validators_passed: validatorsPassed,
158
+ ...(override
159
+ ? {
160
+ override: {
161
+ reason: override.reason,
162
+ skipped_validators: override.skipped_validators,
163
+ skipped_requirements: override.skipped_requirements,
164
+ },
165
+ }
166
+ : {}),
120
167
  written_at: options.frontmatter.written_at ?? completedAt,
121
168
  });
122
169
  writeStageArtifact(
@@ -127,9 +174,7 @@ export function advanceStage(options: AdvanceStageOptions): RunState {
127
174
  options.body,
128
175
  );
129
176
 
130
- const stageIndex = workflow.stages.findIndex((s) => s.name === options.stageName);
131
177
  const nextStage = workflow.stages[stageIndex + 1] ?? null;
132
- const finalStatus = options.status ?? "complete";
133
178
 
134
179
  const updatedStages: StageRecord[] = state.stages.map((record) => {
135
180
  if (record.name !== options.stageName) return record;
@@ -140,6 +185,8 @@ export function advanceStage(options: AdvanceStageOptions): RunState {
140
185
  started_at: record.started_at ?? state.started_at,
141
186
  completed_at: completedAt,
142
187
  outcome: options.outcome,
188
+ validators_passed: validatorsPassed,
189
+ override,
143
190
  };
144
191
  });
145
192
 
@@ -26,6 +26,23 @@ const slugSchema = z
26
26
  "Must be lowercase alphanumeric with hyphens (no leading/trailing hyphen)",
27
27
  );
28
28
 
29
+ /**
30
+ * Validator requirement declared by a workflow stage. Cortex enforces
31
+ * that the agent reports having run each declared validator (via the
32
+ * artifact's `validators_passed` frontmatter), but never executes the
33
+ * validator itself — the agent picks the concrete tooling.
34
+ *
35
+ * id is a stable identifier (e.g. "mutation-score") that the agent
36
+ * echoes back; description is human-readable context for the agent
37
+ * rendered into the stage envelope.
38
+ */
39
+ export const validatorRequirementSchema = z.object({
40
+ id: slugSchema,
41
+ description: z.string().min(1).max(500),
42
+ });
43
+
44
+ export type ValidatorRequirement = z.infer<typeof validatorRequirementSchema>;
45
+
29
46
  /**
30
47
  * Static definition of a single stage in a workflow. Authored at the
31
48
  * organization level (in cortex-web later) and synced down to projects.
@@ -37,6 +54,15 @@ export const stageDefinitionSchema = z.object({
37
54
  reads: z.array(slugSchema).default([]),
38
55
  /** Required frontmatter fields the produced artifact must populate. */
39
56
  required_fields: z.array(z.string().min(1)).default([]),
57
+ /**
58
+ * Validators the stage requires the agent to have run. The agent
59
+ * picks the actual tooling (e.g. stryker for mutation testing) and
60
+ * reports the result by listing each validator's id under
61
+ * `validators_passed` in the artifact frontmatter. Cortex enforces
62
+ * the list-coverage contract on advance unless the call carries an
63
+ * explicit override.
64
+ */
65
+ validators: z.array(validatorRequirementSchema).default([]),
40
66
  /** Capability key the stage runs under. References a separate capability registry. */
41
67
  capability: z.string().min(1).optional(),
42
68
  /** Human-readable summary surfaced in dashboards and audit. */
@@ -70,6 +96,21 @@ export const stageStatusSchema = z.enum([
70
96
 
71
97
  export type StageStatus = z.infer<typeof stageStatusSchema>;
72
98
 
99
+ /**
100
+ * Process-override record. When a stage advances despite missing or
101
+ * failed validators, the caller must pass an override with a free-text
102
+ * reason. The override is recorded on the StageRecord, stamped into
103
+ * the artifact's frontmatter, and emitted as a high-evidence audit
104
+ * event so reviewers can see the deviation in the evidence trail.
105
+ */
106
+ export const stageOverrideSchema = z.object({
107
+ reason: z.string().min(1).max(2000),
108
+ skipped_validators: z.array(z.string().min(1)).default([]),
109
+ skipped_requirements: z.array(z.string().min(1)).default([]),
110
+ });
111
+
112
+ export type StageOverride = z.infer<typeof stageOverrideSchema>;
113
+
73
114
  /**
74
115
  * Per-stage record inside state.json. Holds outcome metadata that the next
75
116
  * stage's envelope composer needs without re-parsing every artifact.
@@ -82,6 +123,10 @@ export const stageRecordSchema = z.object({
82
123
  completed_at: z.string().datetime().optional(),
83
124
  /** Frontmatter outcome surfaced for fast lookup (e.g. approved=true on review). */
84
125
  outcome: z.record(z.string(), z.unknown()).optional(),
126
+ /** Validators the agent reported having run for this stage. */
127
+ validators_passed: z.array(z.string().min(1)).default([]),
128
+ /** Override record if the stage advanced despite missing/failed requirements. */
129
+ override: stageOverrideSchema.optional(),
85
130
  });
86
131
 
87
132
  export type StageRecord = z.infer<typeof stageRecordSchema>;
@@ -14,6 +14,7 @@ import { pushAuditEvents, queueAuditEvent, setAuditPushContext } from "./audit/p
14
14
  import { PolicyStore } from "../core/policy/store.js";
15
15
  import { syncFromCloud, syncFromLocal } from "./policy/sync.js";
16
16
  import { registerEnterpriseTools } from "./tools/enterprise.js";
17
+ import { registerHarnessTools } from "./tools/harness.js";
17
18
  import { pushViolations, setViolationPushContext } from "./violations/push.js";
18
19
  import { pushReviewResults, setReviewPushContext } from "./reviews/push.js";
19
20
  import { setWorkflowPushContext } from "./workflow/push.js";
@@ -319,6 +320,10 @@ export async function register(server: McpServer): Promise<void> {
319
320
  }
320
321
 
321
322
  registerEnterpriseTools(server, collector, auditWriter, config, contextDir, policyStore, version);
323
+ // Cortex Harness MCP tools (cortex.workflow.*) — only registered for
324
+ // enterprise projects, since they depend on org-authored workflows
325
+ // synced from cortex-web (also enterprise-only).
326
+ registerHarnessTools(server);
322
327
 
323
328
  // v2.0.0: globalThis.__cortexContextToolHook bridge removed.
324
329
  // Enterprise is now in-process with cortex-mcp; tool events flow via
@@ -0,0 +1,98 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import {
3
+ WorkflowAdvanceInput,
4
+ WorkflowEnvelopeInput,
5
+ WorkflowStartInput,
6
+ WorkflowStatusInput,
7
+ resolveProjectRoot,
8
+ runWorkflowAdvance,
9
+ runWorkflowEnvelope,
10
+ runWorkflowStart,
11
+ runWorkflowStatus,
12
+ } from "../../core/workflow/mcp-tools.js";
13
+
14
+ /**
15
+ * Registers the cortex.workflow.* tools that drive the Cortex Harness.
16
+ * These are an enterprise-only feature: they're only registered when
17
+ * the enterprise plugin successfully loads (license + config valid).
18
+ *
19
+ * Community-mode MCP servers do not see these tools at all — the
20
+ * harness depends on org-authored workflows from cortex-web, which
21
+ * itself requires an enterprise plan.
22
+ *
23
+ * Pure runner functions live in core/workflow/mcp-tools.ts so they can
24
+ * be unit-tested without spinning up an MCP server. This module only
25
+ * wires them onto the server with the right tool names + input schemas.
26
+ */
27
+
28
+ type ToolPayload = Record<string, unknown>;
29
+
30
+ export function registerHarnessTools(server: McpServer): void {
31
+ server.registerTool(
32
+ "cortex.workflow.start",
33
+ {
34
+ description:
35
+ "Start a Cortex Harness workflow run for a task. Creates .agents/<task_id>/state.json and returns the first stage's envelope (the prompt the agent should answer). Enterprise-only.",
36
+ inputSchema: WorkflowStartInput,
37
+ },
38
+ async (input) => buildResult(
39
+ runWorkflowStart(WorkflowStartInput.parse(input ?? {}), {
40
+ cwd: resolveProjectRoot(),
41
+ }) as ToolPayload,
42
+ ),
43
+ );
44
+
45
+ server.registerTool(
46
+ "cortex.workflow.advance",
47
+ {
48
+ description:
49
+ "Complete the current stage of a workflow run by writing its artifact and advancing the run pointer. Returns the new run state plus the next stage's envelope (or null when the run is finished, blocked, or failed). Enterprise-only.",
50
+ inputSchema: WorkflowAdvanceInput,
51
+ },
52
+ async (input) => buildResult(
53
+ runWorkflowAdvance(WorkflowAdvanceInput.parse(input ?? {}), {
54
+ cwd: resolveProjectRoot(),
55
+ }) as ToolPayload,
56
+ ),
57
+ );
58
+
59
+ server.registerTool(
60
+ "cortex.workflow.status",
61
+ {
62
+ description:
63
+ "Read the current run state for a task (current stage, completed stages, outcome). Returns null state when no run exists for the given task_id. Enterprise-only.",
64
+ inputSchema: WorkflowStatusInput,
65
+ },
66
+ async (input) => buildResult(
67
+ runWorkflowStatus(WorkflowStatusInput.parse(input ?? {}), {
68
+ cwd: resolveProjectRoot(),
69
+ }) as ToolPayload,
70
+ ),
71
+ );
72
+
73
+ server.registerTool(
74
+ "cortex.workflow.envelope",
75
+ {
76
+ description:
77
+ "Compose the prompt envelope for a workflow stage without advancing the run. Defaults to the run's current_stage; pass `stage` to dry-run a different stage. Enterprise-only.",
78
+ inputSchema: WorkflowEnvelopeInput,
79
+ },
80
+ async (input) => buildResult(
81
+ runWorkflowEnvelope(WorkflowEnvelopeInput.parse(input ?? {}), {
82
+ cwd: resolveProjectRoot(),
83
+ }) as ToolPayload,
84
+ ),
85
+ );
86
+ }
87
+
88
+ function buildResult(data: ToolPayload) {
89
+ return {
90
+ content: [
91
+ {
92
+ type: "text" as const,
93
+ text: JSON.stringify(data, null, 2),
94
+ },
95
+ ],
96
+ structuredContent: data,
97
+ };
98
+ }
@@ -11,17 +11,6 @@ import {
11
11
  getSessionEventHook,
12
12
  loadPlugins,
13
13
  } from "./plugin.js";
14
- import {
15
- WorkflowStartInput,
16
- WorkflowAdvanceInput,
17
- WorkflowStatusInput,
18
- WorkflowEnvelopeInput,
19
- resolveProjectRoot,
20
- runWorkflowAdvance,
21
- runWorkflowEnvelope,
22
- runWorkflowStart,
23
- runWorkflowStatus,
24
- } from "./core/workflow/mcp-tools.js";
25
14
 
26
15
  type ToolPayload = Record<string, unknown>;
27
16
 
@@ -334,69 +323,10 @@ function registerTools(server: McpServer): void {
334
323
  })
335
324
  );
336
325
 
337
- server.registerTool(
338
- "cortex.workflow.start",
339
- {
340
- description:
341
- "Start a Cortex Harness workflow run for a task. Creates .agents/<task_id>/state.json and returns the first stage's envelope (the prompt the agent should answer).",
342
- inputSchema: WorkflowStartInput,
343
- },
344
- async (input) => executeInstrumentedTool(
345
- "cortex.workflow.start",
346
- input,
347
- async () => runWorkflowStart(WorkflowStartInput.parse(input ?? {}), {
348
- cwd: resolveProjectRoot(),
349
- }) as ToolPayload,
350
- ),
351
- );
352
-
353
- server.registerTool(
354
- "cortex.workflow.advance",
355
- {
356
- description:
357
- "Complete the current stage of a workflow run by writing its artifact and advancing the run pointer. Returns the new run state plus the next stage's envelope (or null when the run is finished, blocked, or failed).",
358
- inputSchema: WorkflowAdvanceInput,
359
- },
360
- async (input) => executeInstrumentedTool(
361
- "cortex.workflow.advance",
362
- input,
363
- async () => runWorkflowAdvance(WorkflowAdvanceInput.parse(input ?? {}), {
364
- cwd: resolveProjectRoot(),
365
- }) as ToolPayload,
366
- ),
367
- );
368
-
369
- server.registerTool(
370
- "cortex.workflow.status",
371
- {
372
- description:
373
- "Read the current run state for a task (current stage, completed stages, outcome). Returns null state when no run exists for the given task_id.",
374
- inputSchema: WorkflowStatusInput,
375
- },
376
- async (input) => executeInstrumentedTool(
377
- "cortex.workflow.status",
378
- input,
379
- async () => runWorkflowStatus(WorkflowStatusInput.parse(input ?? {}), {
380
- cwd: resolveProjectRoot(),
381
- }) as ToolPayload,
382
- ),
383
- );
384
-
385
- server.registerTool(
386
- "cortex.workflow.envelope",
387
- {
388
- description:
389
- "Compose the prompt envelope for a workflow stage without advancing the run. Defaults to the run's current_stage; pass `stage` to dry-run a different stage.",
390
- inputSchema: WorkflowEnvelopeInput,
391
- },
392
- async (input) => executeInstrumentedTool(
393
- "cortex.workflow.envelope",
394
- input,
395
- async () => runWorkflowEnvelope(WorkflowEnvelopeInput.parse(input ?? {}), {
396
- cwd: resolveProjectRoot(),
397
- }) as ToolPayload,
398
- ),
399
- );
326
+ // Note: cortex.workflow.* tools (the Cortex Harness) are enterprise-only
327
+ // and registered by enterprise/index.ts::register() once the license has
328
+ // verified. They intentionally do not appear here so community-mode MCP
329
+ // servers do not surface them at all.
400
330
  }
401
331
 
402
332
  let shutdownCalled = false;
@@ -9,6 +9,15 @@ import { runStageCommand } from "../dist/cli/stage.js";
9
9
  function makeWorkspace() {
10
10
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-stage-cli-"));
11
11
  process.env.CORTEX_PROJECT_ROOT = dir;
12
+ // cortex stage is enterprise-only; satisfy the gate by writing a
13
+ // minimal enterprise.yml. isEnterpriseProject only requires a
14
+ // non-empty enterprise.api_key field.
15
+ fs.mkdirSync(path.join(dir, ".context"), { recursive: true });
16
+ fs.writeFileSync(
17
+ path.join(dir, ".context", "enterprise.yml"),
18
+ "enterprise:\n api_key: test-key-for-cli-tests\n",
19
+ "utf8",
20
+ );
12
21
  return dir;
13
22
  }
14
23
 
@@ -289,5 +298,37 @@ test("stage help: prints help text and returns without throwing", async () => {
289
298
  });
290
299
 
291
300
  test("stage <unknown>: throws with help text", async () => {
301
+ makeWorkspace();
292
302
  await assert.rejects(runStageCommand(["frobnicate"]), /Unknown stage subcommand/);
293
303
  });
304
+
305
+ test("stage start: blocked in community mode (no enterprise.yml)", async () => {
306
+ // Bypass the helper that auto-writes enterprise.yml.
307
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-stage-cli-community-"));
308
+ process.env.CORTEX_PROJECT_ROOT = dir;
309
+ try {
310
+ await assert.rejects(
311
+ runStageCommand([
312
+ "start",
313
+ "--task-id",
314
+ "task-1",
315
+ "--description",
316
+ "x",
317
+ ]),
318
+ /Cortex Harness — an enterprise-only feature/,
319
+ );
320
+ } finally {
321
+ delete process.env.CORTEX_PROJECT_ROOT;
322
+ }
323
+ });
324
+
325
+ test("stage help: still prints in community mode (so users discover the feature)", async () => {
326
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-stage-cli-community-help-"));
327
+ process.env.CORTEX_PROJECT_ROOT = dir;
328
+ try {
329
+ const { captured } = await captureStdout(() => runStageCommand(["help"]));
330
+ assert.match(captured, /Usage:/);
331
+ } finally {
332
+ delete process.env.CORTEX_PROJECT_ROOT;
333
+ }
334
+ });
@@ -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
+ });