@danielblomma/cortex-mcp 2.0.4 → 2.0.6
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/bin/cortex.mjs +74 -25
- package/package.json +1 -1
- package/scaffold/mcp/package-lock.json +63 -4
- package/scaffold/mcp/package.json +4 -1
- package/scaffold/mcp/src/cli/stage.ts +325 -0
- package/scaffold/mcp/src/core/workflow/artifact-io.ts +156 -0
- package/scaffold/mcp/src/core/workflow/capabilities.ts +100 -0
- package/scaffold/mcp/src/core/workflow/default-workflows.ts +83 -0
- package/scaffold/mcp/src/core/workflow/enforcement.ts +206 -0
- package/scaffold/mcp/src/core/workflow/envelope.ts +220 -0
- package/scaffold/mcp/src/core/workflow/index.ts +8 -0
- package/scaffold/mcp/src/core/workflow/mcp-tools.ts +208 -0
- package/scaffold/mcp/src/core/workflow/run-lifecycle.ts +165 -0
- package/scaffold/mcp/src/core/workflow/schemas.ts +125 -0
- package/scaffold/mcp/src/hooks/pre-tool-use.ts +30 -0
- package/scaffold/mcp/src/server.ts +75 -0
- package/scaffold/mcp/tests/workflow-cli.test.mjs +293 -0
- package/scaffold/mcp/tests/workflow-enforcement.test.mjs +370 -0
- package/scaffold/mcp/tests/workflow-envelope.test.mjs +247 -0
- package/scaffold/mcp/tests/workflow-mcp-tools.test.mjs +293 -0
- package/scaffold/mcp/tests/workflow.test.mjs +283 -0
- package/scaffold/scripts/bootstrap.sh +1 -1
- package/scaffold/scripts/doctor.sh +6 -6
- package/scaffold/scripts/embed.sh +2 -2
- package/scaffold/scripts/load-ryu.sh +3 -3
- package/scaffold/scripts/memory-compile.mjs +1 -1
- package/scaffold/scripts/memory-lint.mjs +1 -1
- package/scaffold/scripts/watch.sh +2 -7
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { isAbsolute, relative } from "node:path";
|
|
2
|
+
import { minimatch } from "minimatch";
|
|
3
|
+
import { readRunState } from "./artifact-io.js";
|
|
4
|
+
import { DEFAULT_CAPABILITIES, type CapabilityDefinition } from "./capabilities.js";
|
|
5
|
+
import { workflowDefinitionSchema, type WorkflowDefinition } from "./schemas.js";
|
|
6
|
+
import { DEFAULT_WORKFLOWS } from "./default-workflows.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pre-tool-use enforcement for the harness. Pure function: takes the tool
|
|
10
|
+
* call shape Claude Code emits, looks up the active workflow stage's
|
|
11
|
+
* capability, returns allow/deny + reason. The hook wires this into the
|
|
12
|
+
* stdin/exit-code dance.
|
|
13
|
+
*
|
|
14
|
+
* "Active task" is identified by env var CORTEX_ACTIVE_TASK_ID. The
|
|
15
|
+
* harness sets this when invoking an agent for a stage; outside the
|
|
16
|
+
* harness, the env var is unset and this evaluator is a no-op (returns
|
|
17
|
+
* { allowed: true }).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ToolCall = {
|
|
21
|
+
toolName: string;
|
|
22
|
+
toolInput: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type EnforcementResult =
|
|
26
|
+
| { allowed: true; reason?: string }
|
|
27
|
+
| { allowed: false; reason: string };
|
|
28
|
+
|
|
29
|
+
export type EvaluateOptions = {
|
|
30
|
+
cwd: string;
|
|
31
|
+
taskId: string;
|
|
32
|
+
call: ToolCall;
|
|
33
|
+
workflows?: Record<string, WorkflowDefinition>;
|
|
34
|
+
capabilities?: Record<string, CapabilityDefinition>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tool names that are pure mutations of the file system. Edits and writes
|
|
39
|
+
* gate against `write_globs`. Bash is treated as a mutation by default
|
|
40
|
+
* because we cannot reliably extract paths from arbitrary shell — agents
|
|
41
|
+
* running in restricted-write capabilities lose Bash unless the
|
|
42
|
+
* capability explicitly allow-lists it.
|
|
43
|
+
*/
|
|
44
|
+
const MUTATING_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Tool names that read but do not mutate. Gate against `read_globs`.
|
|
48
|
+
*/
|
|
49
|
+
const READING_TOOLS = new Set(["Read", "Grep", "Glob", "NotebookRead"]);
|
|
50
|
+
|
|
51
|
+
export function evaluateToolCall(options: EvaluateOptions): EnforcementResult {
|
|
52
|
+
const state = readRunState(options.cwd, options.taskId);
|
|
53
|
+
if (!state) {
|
|
54
|
+
return { allowed: true, reason: "no run state — harness not active" };
|
|
55
|
+
}
|
|
56
|
+
if (state.outcome !== "in_progress" || !state.current_stage) {
|
|
57
|
+
return {
|
|
58
|
+
allowed: true,
|
|
59
|
+
reason: `run not in progress (outcome=${state.outcome}) — no capability gate to apply`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const workflows = options.workflows ?? DEFAULT_WORKFLOWS;
|
|
64
|
+
const workflow = workflows[state.workflow_id];
|
|
65
|
+
if (!workflow) {
|
|
66
|
+
return {
|
|
67
|
+
allowed: false,
|
|
68
|
+
reason: `unknown workflow_id ${state.workflow_id}; cannot resolve capability for current stage`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Validate so corrupt input doesn't slip through.
|
|
72
|
+
workflowDefinitionSchema.parse(workflow);
|
|
73
|
+
|
|
74
|
+
const stage = workflow.stages.find((s) => s.name === state.current_stage);
|
|
75
|
+
if (!stage) {
|
|
76
|
+
return {
|
|
77
|
+
allowed: false,
|
|
78
|
+
reason: `current stage ${state.current_stage} is not defined in workflow ${workflow.id}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!stage.capability) {
|
|
83
|
+
return { allowed: true, reason: "stage has no capability declared" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const capabilities = options.capabilities ?? DEFAULT_CAPABILITIES;
|
|
87
|
+
const capability = capabilities[stage.capability];
|
|
88
|
+
if (!capability) {
|
|
89
|
+
return {
|
|
90
|
+
allowed: false,
|
|
91
|
+
reason: `capability ${stage.capability} (referenced by stage ${stage.name}) is not in the registry`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return evaluateAgainstCapability(capability, options.call, options.cwd);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function evaluateAgainstCapability(
|
|
99
|
+
capability: CapabilityDefinition,
|
|
100
|
+
call: ToolCall,
|
|
101
|
+
cwd: string,
|
|
102
|
+
): EnforcementResult {
|
|
103
|
+
// 1. tools_allowed: empty = no restriction; otherwise tool must be in the list.
|
|
104
|
+
if (
|
|
105
|
+
capability.tools_allowed.length > 0 &&
|
|
106
|
+
!capability.tools_allowed.includes(call.toolName)
|
|
107
|
+
) {
|
|
108
|
+
return {
|
|
109
|
+
allowed: false,
|
|
110
|
+
reason: `capability ${capability.name} does not allow tool ${call.toolName}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const isMutation = MUTATING_TOOLS.has(call.toolName);
|
|
115
|
+
const isRead = READING_TOOLS.has(call.toolName);
|
|
116
|
+
|
|
117
|
+
// Bash is special: with restricted write_globs we have to assume the
|
|
118
|
+
// worst (since the shell can write anywhere). Block unless capability
|
|
119
|
+
// explicitly allow-lists Bash via tools_allowed.
|
|
120
|
+
if (call.toolName === "Bash") {
|
|
121
|
+
const isAllowedViaToolList = capability.tools_allowed.includes("Bash");
|
|
122
|
+
const writesUnrestricted = capability.write_globs.length === 0;
|
|
123
|
+
if (writesUnrestricted && !isAllowedViaToolList) {
|
|
124
|
+
return {
|
|
125
|
+
allowed: false,
|
|
126
|
+
reason: `capability ${capability.name} is read-only; Bash can mutate the filesystem and is not allow-listed`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { allowed: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isMutation) {
|
|
133
|
+
if (capability.write_globs.length === 0) {
|
|
134
|
+
return {
|
|
135
|
+
allowed: false,
|
|
136
|
+
reason: `capability ${capability.name} is read-only; ${call.toolName} cannot run`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const targetPath = extractFilePath(call.toolInput);
|
|
140
|
+
if (!targetPath) {
|
|
141
|
+
return {
|
|
142
|
+
allowed: false,
|
|
143
|
+
reason: `${call.toolName} did not include a file_path; cannot verify against capability ${capability.name}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const relPath = toRepoRelative(cwd, targetPath);
|
|
147
|
+
if (!matchesAnyGlob(relPath, capability.write_globs)) {
|
|
148
|
+
return {
|
|
149
|
+
allowed: false,
|
|
150
|
+
reason: `path ${relPath} is outside capability ${capability.name}'s write_globs (${capability.write_globs.join(", ")})`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { allowed: true };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (isRead) {
|
|
157
|
+
if (capability.read_globs.length === 0) {
|
|
158
|
+
// No reads allowed at all — only the human capability lands here.
|
|
159
|
+
return {
|
|
160
|
+
allowed: false,
|
|
161
|
+
reason: `capability ${capability.name} does not permit any read operations`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const targetPath = extractFilePath(call.toolInput);
|
|
165
|
+
if (!targetPath) {
|
|
166
|
+
// Some read tools (Grep, Glob) operate on the whole repo; allow
|
|
167
|
+
// through if the capability has any read access at all.
|
|
168
|
+
return { allowed: true };
|
|
169
|
+
}
|
|
170
|
+
const relPath = toRepoRelative(cwd, targetPath);
|
|
171
|
+
if (!matchesAnyGlob(relPath, capability.read_globs)) {
|
|
172
|
+
return {
|
|
173
|
+
allowed: false,
|
|
174
|
+
reason: `path ${relPath} is outside capability ${capability.name}'s read_globs (${capability.read_globs.join(", ")})`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return { allowed: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Unknown tool — fall through to allow if not explicitly restricted.
|
|
181
|
+
return { allowed: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function extractFilePath(toolInput: Record<string, unknown>): string | null {
|
|
185
|
+
const candidates = ["file_path", "path", "notebook_path"];
|
|
186
|
+
for (const key of candidates) {
|
|
187
|
+
const value = toolInput[key];
|
|
188
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toRepoRelative(cwd: string, targetPath: string): string {
|
|
194
|
+
if (!isAbsolute(targetPath)) return targetPath;
|
|
195
|
+
const rel = relative(cwd, targetPath);
|
|
196
|
+
// If the path is outside the repo, return the absolute form so glob
|
|
197
|
+
// matches (which expect repo-relative) reliably miss.
|
|
198
|
+
if (rel.startsWith("..")) return targetPath;
|
|
199
|
+
return rel;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function matchesAnyGlob(path: string, globs: string[]): boolean {
|
|
203
|
+
return globs.some((pattern) =>
|
|
204
|
+
minimatch(path, pattern, { dot: true, nocase: false }),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { artifactPath, readRunState } from "./artifact-io.js";
|
|
3
|
+
import { workflowDefinitionSchema, type WorkflowDefinition } from "./schemas.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Composes the prompt one stage's agent sees. Pure function over the
|
|
7
|
+
* persisted run state plus the workflow definition — no agent invocation,
|
|
8
|
+
* no MCP, no daemon. The harness later wraps this into an MCP call or a
|
|
9
|
+
* CLI invocation.
|
|
10
|
+
*
|
|
11
|
+
* Design: the agent gets four sections in a fixed order so it can anchor
|
|
12
|
+
* on them reliably:
|
|
13
|
+
*
|
|
14
|
+
* TASK — what the developer asked for, copied verbatim from RunState
|
|
15
|
+
* STAGE — what *this* stage is supposed to produce
|
|
16
|
+
* HANDOFFS — every prior-stage artifact the new stage declared in `reads`,
|
|
17
|
+
* inlined raw (frontmatter + body) so the agent sees structured
|
|
18
|
+
* outcomes alongside the reasoning
|
|
19
|
+
* OUTPUT — exact frontmatter contract the agent must satisfy plus the
|
|
20
|
+
* expected artifact filename
|
|
21
|
+
*
|
|
22
|
+
* Capability constraints (which files the agent may edit, which tools it
|
|
23
|
+
* may call) are NOT enforced by the prompt — they're enforced by hooks
|
|
24
|
+
* downstream. The capability key is surfaced in the prompt as a label so
|
|
25
|
+
* the agent knows under what role it's running, but the real gate is
|
|
26
|
+
* pre-tool-use.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type ComposedEnvelope = {
|
|
30
|
+
/** The full prompt the agent will receive. */
|
|
31
|
+
prompt: string;
|
|
32
|
+
/** Expected artifact filename the agent must produce. */
|
|
33
|
+
expectedArtifact: string;
|
|
34
|
+
/** Frontmatter keys the agent must populate (beyond stage/status/references). */
|
|
35
|
+
requiredFields: string[];
|
|
36
|
+
/** Capability key the stage runs under (informational). */
|
|
37
|
+
capability: string | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ComposeStageEnvelopeOptions = {
|
|
41
|
+
cwd: string;
|
|
42
|
+
taskId: string;
|
|
43
|
+
workflow: WorkflowDefinition;
|
|
44
|
+
/**
|
|
45
|
+
* Defaults to the run's current_stage. Pass an explicit stageName when
|
|
46
|
+
* dry-running an envelope without driving state forward.
|
|
47
|
+
*/
|
|
48
|
+
stageName?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function composeStageEnvelope(
|
|
52
|
+
options: ComposeStageEnvelopeOptions,
|
|
53
|
+
): ComposedEnvelope {
|
|
54
|
+
const workflow = workflowDefinitionSchema.parse(options.workflow);
|
|
55
|
+
const state = readRunState(options.cwd, options.taskId);
|
|
56
|
+
if (!state) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`No run state found for task ${options.taskId}. Call createRun() first.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (state.workflow_id !== workflow.id) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Workflow mismatch: run was started with ${state.workflow_id}, envelope was composed with ${workflow.id}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const stageName = options.stageName ?? state.current_stage;
|
|
68
|
+
if (!stageName) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Run ${options.taskId} is not at any stage (outcome=${state.outcome}). Cannot compose envelope.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const stage = workflow.stages.find((s) => s.name === stageName);
|
|
74
|
+
if (!stage) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Stage ${stageName} is not defined in workflow ${workflow.id}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const handoffs: string[] = [];
|
|
81
|
+
for (const readName of stage.reads) {
|
|
82
|
+
const priorStage = workflow.stages.find((s) => s.name === readName);
|
|
83
|
+
if (!priorStage) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Stage ${stageName} declares reads from unknown stage ${readName}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const priorRecord = state.stages.find((r) => r.name === readName);
|
|
89
|
+
if (!priorRecord || priorRecord.status === "pending" || !priorRecord.artifact) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Stage ${stageName} requires artifact from ${readName}, but it has not been produced yet`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const path = artifactPath(options.cwd, options.taskId, priorRecord.artifact);
|
|
95
|
+
let raw: string;
|
|
96
|
+
try {
|
|
97
|
+
raw = readFileSync(path, "utf8");
|
|
98
|
+
} catch (err) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Failed to read handoff artifact for ${readName} at ${path}: ${
|
|
101
|
+
err instanceof Error ? err.message : String(err)
|
|
102
|
+
}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
handoffs.push(renderHandoff(readName, priorRecord.artifact, raw));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const requiredFields = stage.required_fields;
|
|
109
|
+
const capability = stage.capability ?? null;
|
|
110
|
+
|
|
111
|
+
const prompt = renderPrompt({
|
|
112
|
+
taskDescription: state.task_description,
|
|
113
|
+
workflowId: workflow.id,
|
|
114
|
+
workflowDescription: workflow.description,
|
|
115
|
+
stageName: stage.name,
|
|
116
|
+
stageDescription: stage.description,
|
|
117
|
+
expectedArtifact: stage.artifact,
|
|
118
|
+
requiredFields,
|
|
119
|
+
capability,
|
|
120
|
+
handoffs,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
prompt,
|
|
125
|
+
expectedArtifact: stage.artifact,
|
|
126
|
+
requiredFields,
|
|
127
|
+
capability,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderHandoff(
|
|
132
|
+
stageName: string,
|
|
133
|
+
artifactName: string,
|
|
134
|
+
rawArtifact: string,
|
|
135
|
+
): string {
|
|
136
|
+
return [
|
|
137
|
+
`--- handoff:${stageName} (${artifactName}) ---`,
|
|
138
|
+
rawArtifact.trim(),
|
|
139
|
+
`--- end handoff:${stageName} ---`,
|
|
140
|
+
].join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type RenderPromptOptions = {
|
|
144
|
+
taskDescription: string;
|
|
145
|
+
workflowId: string;
|
|
146
|
+
workflowDescription: string;
|
|
147
|
+
stageName: string;
|
|
148
|
+
stageDescription: string;
|
|
149
|
+
expectedArtifact: string;
|
|
150
|
+
requiredFields: string[];
|
|
151
|
+
capability: string | null;
|
|
152
|
+
handoffs: string[];
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
function renderPrompt(o: RenderPromptOptions): string {
|
|
156
|
+
const sections: string[] = [];
|
|
157
|
+
|
|
158
|
+
sections.push(
|
|
159
|
+
[
|
|
160
|
+
`# TASK`,
|
|
161
|
+
``,
|
|
162
|
+
o.taskDescription.trim(),
|
|
163
|
+
``,
|
|
164
|
+
`Workflow: ${o.workflowId} — ${o.workflowDescription}`,
|
|
165
|
+
].join("\n"),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
sections.push(
|
|
169
|
+
[
|
|
170
|
+
`# STAGE: ${o.stageName}`,
|
|
171
|
+
``,
|
|
172
|
+
o.stageDescription.trim(),
|
|
173
|
+
``,
|
|
174
|
+
o.capability
|
|
175
|
+
? `Running under capability: \`${o.capability}\` (file and tool restrictions are enforced by Cortex hooks at tool-use time, not by you).`
|
|
176
|
+
: `No capability constraint declared for this stage.`,
|
|
177
|
+
].join("\n"),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
if (o.handoffs.length === 0) {
|
|
181
|
+
sections.push(
|
|
182
|
+
[`# HANDOFFS`, ``, `_No prior-stage artifacts; this is the first stage._`].join(
|
|
183
|
+
"\n",
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
} else {
|
|
187
|
+
sections.push(
|
|
188
|
+
[
|
|
189
|
+
`# HANDOFFS`,
|
|
190
|
+
``,
|
|
191
|
+
`The following stages have already run. Each artifact below is the complete file as it lives on disk; use the frontmatter for structured outcomes and the body for reasoning.`,
|
|
192
|
+
``,
|
|
193
|
+
...o.handoffs,
|
|
194
|
+
].join("\n"),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const requiredLines =
|
|
199
|
+
o.requiredFields.length === 0
|
|
200
|
+
? `_No additional required fields beyond the harness defaults._`
|
|
201
|
+
: o.requiredFields.map((f) => `- \`${f}\``).join("\n");
|
|
202
|
+
|
|
203
|
+
sections.push(
|
|
204
|
+
[
|
|
205
|
+
`# OUTPUT`,
|
|
206
|
+
``,
|
|
207
|
+
`Produce a single markdown file named \`${o.expectedArtifact}\` with YAML frontmatter on top.`,
|
|
208
|
+
``,
|
|
209
|
+
`Required frontmatter fields (in addition to \`stage\`, \`status\`, \`references\`, \`written_at\` which the harness manages):`,
|
|
210
|
+
``,
|
|
211
|
+
requiredLines,
|
|
212
|
+
``,
|
|
213
|
+
`Body: clear, well-structured markdown explaining your reasoning. Cite handoff artifacts by stage name when relevant.`,
|
|
214
|
+
``,
|
|
215
|
+
`If you cannot complete this stage (missing context, blocking concern, conflicting prior decisions), set \`status: blocked\` in frontmatter and explain why in the body — do not fabricate work.`,
|
|
216
|
+
].join("\n"),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return sections.join("\n\n");
|
|
220
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./schemas.js";
|
|
2
|
+
export * from "./artifact-io.js";
|
|
3
|
+
export * from "./run-lifecycle.js";
|
|
4
|
+
export * from "./envelope.js";
|
|
5
|
+
export * from "./default-workflows.js";
|
|
6
|
+
export * from "./mcp-tools.js";
|
|
7
|
+
export * from "./capabilities.js";
|
|
8
|
+
export * from "./enforcement.js";
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { advanceStage, createRun, getRunState } from "./run-lifecycle.js";
|
|
3
|
+
import { composeStageEnvelope } from "./envelope.js";
|
|
4
|
+
import { DEFAULT_WORKFLOWS } from "./default-workflows.js";
|
|
5
|
+
import {
|
|
6
|
+
stageStatusSchema,
|
|
7
|
+
type StageStatus,
|
|
8
|
+
type WorkflowDefinition,
|
|
9
|
+
} from "./schemas.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Pure runner functions that back the cortex.workflow.* MCP tools.
|
|
13
|
+
* Kept separate from server.ts so they can be unit-tested without spinning
|
|
14
|
+
* up an MCP server. server.ts is a thin shim that registers each runner
|
|
15
|
+
* under its tool name and serializes the result through buildToolResult.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const slugSchema = z
|
|
19
|
+
.string()
|
|
20
|
+
.min(1)
|
|
21
|
+
.max(80)
|
|
22
|
+
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/);
|
|
23
|
+
|
|
24
|
+
export const WorkflowStartInput = z.object({
|
|
25
|
+
task_id: slugSchema,
|
|
26
|
+
task_description: z.string().min(1).max(2000),
|
|
27
|
+
workflow_id: slugSchema.default("secure-build"),
|
|
28
|
+
});
|
|
29
|
+
export type WorkflowStartInputT = z.infer<typeof WorkflowStartInput>;
|
|
30
|
+
|
|
31
|
+
export const WorkflowAdvanceInput = z.object({
|
|
32
|
+
task_id: slugSchema,
|
|
33
|
+
/** Required for safety: must equal the run's current_stage. */
|
|
34
|
+
stage: slugSchema,
|
|
35
|
+
/**
|
|
36
|
+
* Stage frontmatter as a free-form object. Stage / status / references /
|
|
37
|
+
* written_at are managed by the harness and may be omitted (or, if set,
|
|
38
|
+
* are overridden). Stage-specific fields like `approved` or `score` are
|
|
39
|
+
* passed through.
|
|
40
|
+
*/
|
|
41
|
+
frontmatter: z.record(z.string(), z.unknown()).default({}),
|
|
42
|
+
body: z.string().min(1),
|
|
43
|
+
/** Final stage status. Defaults to "complete". Use "blocked" or "failed" to halt the run. */
|
|
44
|
+
status: stageStatusSchema.optional(),
|
|
45
|
+
/** Optional structured outcome surfaced into state.json for fast lookup by later stages. */
|
|
46
|
+
outcome: z.record(z.string(), z.unknown()).optional(),
|
|
47
|
+
});
|
|
48
|
+
export type WorkflowAdvanceInputT = z.infer<typeof WorkflowAdvanceInput>;
|
|
49
|
+
|
|
50
|
+
export const WorkflowStatusInput = z.object({
|
|
51
|
+
task_id: slugSchema,
|
|
52
|
+
});
|
|
53
|
+
export type WorkflowStatusInputT = z.infer<typeof WorkflowStatusInput>;
|
|
54
|
+
|
|
55
|
+
export const WorkflowEnvelopeInput = z.object({
|
|
56
|
+
task_id: slugSchema,
|
|
57
|
+
/** Defaults to the run's current stage. */
|
|
58
|
+
stage: slugSchema.optional(),
|
|
59
|
+
});
|
|
60
|
+
export type WorkflowEnvelopeInputT = z.infer<typeof WorkflowEnvelopeInput>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolves the project root. The MCP server is started with cwd =
|
|
64
|
+
* project root and CORTEX_PROJECT_ROOT set to the same value (see
|
|
65
|
+
* bin/cortex.mjs `mcp` command). Tests pass cwd explicitly.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveProjectRoot(): string {
|
|
68
|
+
const fromEnv = process.env.CORTEX_PROJECT_ROOT?.trim();
|
|
69
|
+
if (fromEnv) return fromEnv;
|
|
70
|
+
return process.cwd();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type WorkflowToolContext = {
|
|
74
|
+
cwd: string;
|
|
75
|
+
workflows?: Record<string, WorkflowDefinition>;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function resolveWorkflow(
|
|
79
|
+
workflowId: string,
|
|
80
|
+
registry: Record<string, WorkflowDefinition> | undefined,
|
|
81
|
+
): WorkflowDefinition {
|
|
82
|
+
const workflows = registry ?? DEFAULT_WORKFLOWS;
|
|
83
|
+
const workflow = workflows[workflowId];
|
|
84
|
+
if (!workflow) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Unknown workflow_id: ${workflowId}. Available: ${Object.keys(workflows).join(", ") || "<none>"}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return workflow;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function runWorkflowStart(
|
|
93
|
+
input: WorkflowStartInputT,
|
|
94
|
+
ctx: WorkflowToolContext,
|
|
95
|
+
) {
|
|
96
|
+
const workflow = resolveWorkflow(input.workflow_id, ctx.workflows);
|
|
97
|
+
const state = createRun({
|
|
98
|
+
cwd: ctx.cwd,
|
|
99
|
+
taskId: input.task_id,
|
|
100
|
+
workflow,
|
|
101
|
+
taskDescription: input.task_description,
|
|
102
|
+
});
|
|
103
|
+
const envelope = composeStageEnvelope({
|
|
104
|
+
cwd: ctx.cwd,
|
|
105
|
+
taskId: input.task_id,
|
|
106
|
+
workflow,
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
state,
|
|
110
|
+
envelope,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function runWorkflowAdvance(
|
|
115
|
+
input: WorkflowAdvanceInputT,
|
|
116
|
+
ctx: WorkflowToolContext,
|
|
117
|
+
) {
|
|
118
|
+
const state = getRunState(ctx.cwd, input.task_id);
|
|
119
|
+
if (!state) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`No run state found for task ${input.task_id}. Call cortex.workflow.start first.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const workflow = resolveWorkflow(state.workflow_id, ctx.workflows);
|
|
125
|
+
const stage = workflow.stages.find((s) => s.name === input.stage);
|
|
126
|
+
if (!stage) {
|
|
127
|
+
throw new Error(`Stage ${input.stage} is not defined in workflow ${workflow.id}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const finalStatus: StageStatus = input.status ?? "complete";
|
|
131
|
+
|
|
132
|
+
const nextState = advanceStage({
|
|
133
|
+
cwd: ctx.cwd,
|
|
134
|
+
taskId: input.task_id,
|
|
135
|
+
workflow,
|
|
136
|
+
stageName: input.stage,
|
|
137
|
+
artifactName: stage.artifact,
|
|
138
|
+
frontmatter: {
|
|
139
|
+
...input.frontmatter,
|
|
140
|
+
stage: input.stage,
|
|
141
|
+
status: finalStatus,
|
|
142
|
+
references:
|
|
143
|
+
(Array.isArray((input.frontmatter as Record<string, unknown>).references)
|
|
144
|
+
? ((input.frontmatter as Record<string, unknown>).references as unknown[])
|
|
145
|
+
.filter((v): v is string => typeof v === "string")
|
|
146
|
+
: null) ?? deriveReferencesFromReads(stage.reads, workflow),
|
|
147
|
+
},
|
|
148
|
+
body: input.body,
|
|
149
|
+
outcome: input.outcome,
|
|
150
|
+
status: finalStatus,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// If the run is still going, also return the next envelope so the caller
|
|
154
|
+
// can immediately know what comes next without a follow-up status round-trip.
|
|
155
|
+
let nextEnvelope: ReturnType<typeof composeStageEnvelope> | null = null;
|
|
156
|
+
if (nextState.outcome === "in_progress" && nextState.current_stage) {
|
|
157
|
+
nextEnvelope = composeStageEnvelope({
|
|
158
|
+
cwd: ctx.cwd,
|
|
159
|
+
taskId: input.task_id,
|
|
160
|
+
workflow,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
state: nextState,
|
|
166
|
+
next_envelope: nextEnvelope,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function deriveReferencesFromReads(
|
|
171
|
+
reads: string[],
|
|
172
|
+
workflow: WorkflowDefinition,
|
|
173
|
+
): string[] {
|
|
174
|
+
const refs: string[] = [];
|
|
175
|
+
for (const readName of reads) {
|
|
176
|
+
const stage = workflow.stages.find((s) => s.name === readName);
|
|
177
|
+
if (stage) refs.push(stage.artifact);
|
|
178
|
+
}
|
|
179
|
+
return refs;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function runWorkflowStatus(
|
|
183
|
+
input: WorkflowStatusInputT,
|
|
184
|
+
ctx: WorkflowToolContext,
|
|
185
|
+
) {
|
|
186
|
+
const state = getRunState(ctx.cwd, input.task_id);
|
|
187
|
+
return { state };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function runWorkflowEnvelope(
|
|
191
|
+
input: WorkflowEnvelopeInputT,
|
|
192
|
+
ctx: WorkflowToolContext,
|
|
193
|
+
) {
|
|
194
|
+
const state = getRunState(ctx.cwd, input.task_id);
|
|
195
|
+
if (!state) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`No run state found for task ${input.task_id}. Call cortex.workflow.start first.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const workflow = resolveWorkflow(state.workflow_id, ctx.workflows);
|
|
201
|
+
const envelope = composeStageEnvelope({
|
|
202
|
+
cwd: ctx.cwd,
|
|
203
|
+
taskId: input.task_id,
|
|
204
|
+
workflow,
|
|
205
|
+
stageName: input.stage,
|
|
206
|
+
});
|
|
207
|
+
return { envelope };
|
|
208
|
+
}
|