@danielblomma/cortex-mcp 2.0.5 → 2.0.7
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 +24 -0
- 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 +9 -0
- package/scaffold/mcp/src/core/workflow/mcp-tools.ts +215 -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/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/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-synced-registry.test.mjs +179 -0
- package/scaffold/mcp/tests/workflow.test.mjs +283 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
} from "node:fs";
|
|
6
|
+
import { hostname } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { loadEnterpriseConfig } from "../core/config.js";
|
|
9
|
+
import { workflowDefinitionSchema, type WorkflowDefinition } from "../core/workflow/schemas.js";
|
|
10
|
+
import { writeHostAuditEvent } from "./ungoverned-scanner.js";
|
|
11
|
+
import { daemonDir } from "./paths.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Org-workflow sync flow — daemon side.
|
|
15
|
+
*
|
|
16
|
+
* The daemon polls cortex-web /api/v1/govern/workflows/manifest each tick
|
|
17
|
+
* to learn what workflows the org has authored. It diffs against a local
|
|
18
|
+
* state file, fetches changed full definitions, and caches them locally.
|
|
19
|
+
* cortex.workflow.start (and the cortex stage CLI) read the cache via
|
|
20
|
+
* loadSyncedWorkflows() and merge with bundled DEFAULT_WORKFLOWS, with
|
|
21
|
+
* org definitions taking precedence on workflow_id collisions.
|
|
22
|
+
*
|
|
23
|
+
* Three audit outcomes per tick:
|
|
24
|
+
* - workflows_unchanged — manifest matches local state
|
|
25
|
+
* - workflows_synced — at least one workflow was added / changed /
|
|
26
|
+
* removed (metadata: counts)
|
|
27
|
+
* - workflows_sync_failed — network / auth / parse error
|
|
28
|
+
*
|
|
29
|
+
* Unlike skills, there is no on-disk artifact to write per workflow —
|
|
30
|
+
* the cached JSON is the only product. No "restart Claude Code"
|
|
31
|
+
* notification is needed because workflow lookup happens at run-start.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const STATE_FILENAME = "workflows.local.json";
|
|
35
|
+
|
|
36
|
+
type ManifestEntry = {
|
|
37
|
+
workflow_id: string;
|
|
38
|
+
version: number;
|
|
39
|
+
updated_at: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type FetchedWorkflow = {
|
|
43
|
+
workflow_id: string;
|
|
44
|
+
description: string;
|
|
45
|
+
version: number;
|
|
46
|
+
definition: WorkflowDefinition;
|
|
47
|
+
updated_at: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type LocalWorkflowRecord = {
|
|
51
|
+
workflow_id: string;
|
|
52
|
+
version: number;
|
|
53
|
+
updated_at: string;
|
|
54
|
+
definition: WorkflowDefinition;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type LocalWorkflowsState = {
|
|
58
|
+
workflows: Record<string, LocalWorkflowRecord>;
|
|
59
|
+
last_synced_at?: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type WorkflowSyncOutcome =
|
|
63
|
+
| { kind: "unchanged"; count: number }
|
|
64
|
+
| {
|
|
65
|
+
kind: "synced";
|
|
66
|
+
added: string[];
|
|
67
|
+
changed: string[];
|
|
68
|
+
removed: string[];
|
|
69
|
+
}
|
|
70
|
+
| { kind: "failed"; error: string };
|
|
71
|
+
|
|
72
|
+
function stateFilePath(): string {
|
|
73
|
+
return join(daemonDir(), STATE_FILENAME);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function readSyncedWorkflowsState(): LocalWorkflowsState {
|
|
77
|
+
const path = stateFilePath();
|
|
78
|
+
if (!existsSync(path)) return { workflows: {} };
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as LocalWorkflowsState;
|
|
81
|
+
return {
|
|
82
|
+
workflows: parsed.workflows ?? {},
|
|
83
|
+
last_synced_at: parsed.last_synced_at,
|
|
84
|
+
};
|
|
85
|
+
} catch {
|
|
86
|
+
return { workflows: {} };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function writeSyncedWorkflowsState(state: LocalWorkflowsState): void {
|
|
91
|
+
writeFileSync(
|
|
92
|
+
stateFilePath(),
|
|
93
|
+
JSON.stringify(state, null, 2) + "\n",
|
|
94
|
+
"utf8",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function fetchManifest(
|
|
99
|
+
baseUrl: string,
|
|
100
|
+
apiKey: string,
|
|
101
|
+
): Promise<ManifestEntry[]> {
|
|
102
|
+
const url = new URL(
|
|
103
|
+
baseUrl.replace(/\/$/, "") + "/api/v1/govern/workflows/manifest",
|
|
104
|
+
);
|
|
105
|
+
const res = await fetch(url, {
|
|
106
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
110
|
+
}
|
|
111
|
+
const body = (await res.json()) as { workflows?: ManifestEntry[] };
|
|
112
|
+
return body.workflows ?? [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function fetchWorkflow(
|
|
116
|
+
baseUrl: string,
|
|
117
|
+
apiKey: string,
|
|
118
|
+
workflowId: string,
|
|
119
|
+
): Promise<FetchedWorkflow> {
|
|
120
|
+
const url = new URL(
|
|
121
|
+
baseUrl.replace(/\/$/, "") +
|
|
122
|
+
"/api/v1/govern/workflows/" +
|
|
123
|
+
encodeURIComponent(workflowId),
|
|
124
|
+
);
|
|
125
|
+
const res = await fetch(url, {
|
|
126
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
127
|
+
});
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
130
|
+
}
|
|
131
|
+
const body = (await res.json()) as { workflow?: FetchedWorkflow };
|
|
132
|
+
if (!body.workflow) {
|
|
133
|
+
throw new Error(`Response for ${workflowId} missing 'workflow' field`);
|
|
134
|
+
}
|
|
135
|
+
return body.workflow;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function runWorkflowSyncOnce(
|
|
139
|
+
cwd: string,
|
|
140
|
+
): Promise<WorkflowSyncOutcome> {
|
|
141
|
+
const config = loadEnterpriseConfig(join(cwd, ".context"));
|
|
142
|
+
const apiKey = config.enterprise.api_key.trim();
|
|
143
|
+
const baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
|
|
144
|
+
if (!apiKey || !baseUrl) {
|
|
145
|
+
const outcome: WorkflowSyncOutcome = {
|
|
146
|
+
kind: "failed",
|
|
147
|
+
error: "enterprise not configured",
|
|
148
|
+
};
|
|
149
|
+
await writeAudit(cwd, outcome);
|
|
150
|
+
return outcome;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let manifest: ManifestEntry[];
|
|
154
|
+
try {
|
|
155
|
+
manifest = await fetchManifest(baseUrl, apiKey);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const outcome: WorkflowSyncOutcome = {
|
|
158
|
+
kind: "failed",
|
|
159
|
+
error: err instanceof Error ? err.message : String(err),
|
|
160
|
+
};
|
|
161
|
+
await writeAudit(cwd, outcome);
|
|
162
|
+
return outcome;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const state = readSyncedWorkflowsState();
|
|
166
|
+
const remoteByName = new Map(manifest.map((e) => [e.workflow_id, e]));
|
|
167
|
+
|
|
168
|
+
const added: string[] = [];
|
|
169
|
+
const changed: string[] = [];
|
|
170
|
+
const removed: string[] = [];
|
|
171
|
+
|
|
172
|
+
for (const entry of manifest) {
|
|
173
|
+
const local = state.workflows[entry.workflow_id];
|
|
174
|
+
const isNew = !local;
|
|
175
|
+
const isChanged =
|
|
176
|
+
Boolean(local) &&
|
|
177
|
+
(local.updated_at !== entry.updated_at || local.version !== entry.version);
|
|
178
|
+
if (!isNew && !isChanged) continue;
|
|
179
|
+
|
|
180
|
+
let fetched: FetchedWorkflow;
|
|
181
|
+
try {
|
|
182
|
+
fetched = await fetchWorkflow(baseUrl, apiKey, entry.workflow_id);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
const outcome: WorkflowSyncOutcome = {
|
|
185
|
+
kind: "failed",
|
|
186
|
+
error:
|
|
187
|
+
err instanceof Error
|
|
188
|
+
? `fetch ${entry.workflow_id}: ${err.message}`
|
|
189
|
+
: `fetch ${entry.workflow_id}: ${String(err)}`,
|
|
190
|
+
};
|
|
191
|
+
await writeAudit(cwd, outcome);
|
|
192
|
+
return outcome;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let validated: WorkflowDefinition;
|
|
196
|
+
try {
|
|
197
|
+
validated = workflowDefinitionSchema.parse(fetched.definition);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const outcome: WorkflowSyncOutcome = {
|
|
200
|
+
kind: "failed",
|
|
201
|
+
error:
|
|
202
|
+
err instanceof Error
|
|
203
|
+
? `validate ${entry.workflow_id}: ${err.message}`
|
|
204
|
+
: `validate ${entry.workflow_id}: ${String(err)}`,
|
|
205
|
+
};
|
|
206
|
+
await writeAudit(cwd, outcome);
|
|
207
|
+
return outcome;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
state.workflows[entry.workflow_id] = {
|
|
211
|
+
workflow_id: entry.workflow_id,
|
|
212
|
+
version: fetched.version,
|
|
213
|
+
updated_at: fetched.updated_at,
|
|
214
|
+
definition: validated,
|
|
215
|
+
};
|
|
216
|
+
(isNew ? added : changed).push(entry.workflow_id);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const name of Object.keys(state.workflows)) {
|
|
220
|
+
if (remoteByName.has(name)) continue;
|
|
221
|
+
delete state.workflows[name];
|
|
222
|
+
removed.push(name);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const totalChanged = added.length + changed.length + removed.length;
|
|
226
|
+
if (totalChanged === 0) {
|
|
227
|
+
const outcome: WorkflowSyncOutcome = {
|
|
228
|
+
kind: "unchanged",
|
|
229
|
+
count: manifest.length,
|
|
230
|
+
};
|
|
231
|
+
await writeAudit(cwd, outcome);
|
|
232
|
+
return outcome;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
state.last_synced_at = new Date().toISOString();
|
|
236
|
+
writeSyncedWorkflowsState(state);
|
|
237
|
+
const outcome: WorkflowSyncOutcome = {
|
|
238
|
+
kind: "synced",
|
|
239
|
+
added,
|
|
240
|
+
changed,
|
|
241
|
+
removed,
|
|
242
|
+
};
|
|
243
|
+
await writeAudit(cwd, outcome);
|
|
244
|
+
return outcome;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function writeAudit(cwd: string, outcome: WorkflowSyncOutcome): Promise<void> {
|
|
248
|
+
const eventBase = {
|
|
249
|
+
timestamp: new Date().toISOString(),
|
|
250
|
+
host_id: hostname(),
|
|
251
|
+
};
|
|
252
|
+
if (outcome.kind === "unchanged") {
|
|
253
|
+
await writeHostAuditEvent(cwd, {
|
|
254
|
+
...eventBase,
|
|
255
|
+
event_type: "workflows_unchanged",
|
|
256
|
+
count: outcome.count,
|
|
257
|
+
}).catch(() => undefined);
|
|
258
|
+
} else if (outcome.kind === "synced") {
|
|
259
|
+
await writeHostAuditEvent(cwd, {
|
|
260
|
+
...eventBase,
|
|
261
|
+
event_type: "workflows_synced",
|
|
262
|
+
added: outcome.added,
|
|
263
|
+
changed: outcome.changed,
|
|
264
|
+
removed: outcome.removed,
|
|
265
|
+
}).catch(() => undefined);
|
|
266
|
+
} else {
|
|
267
|
+
await writeHostAuditEvent(cwd, {
|
|
268
|
+
...eventBase,
|
|
269
|
+
event_type: "workflows_sync_failed",
|
|
270
|
+
error: outcome.error,
|
|
271
|
+
}).catch(() => undefined);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export type WorkflowSyncTimerHandle = {
|
|
276
|
+
stop(): void;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
export function startWorkflowSyncTimer(
|
|
280
|
+
cwd: string,
|
|
281
|
+
intervalMs: number,
|
|
282
|
+
): WorkflowSyncTimerHandle {
|
|
283
|
+
const tick = () => {
|
|
284
|
+
void runWorkflowSyncOnce(cwd).catch((err) => {
|
|
285
|
+
process.stderr.write(
|
|
286
|
+
`[cortex-daemon] workflow sync failed: ${
|
|
287
|
+
err instanceof Error ? err.message : String(err)
|
|
288
|
+
}\n`,
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
void Promise.resolve().then(tick);
|
|
294
|
+
const handle = setInterval(tick, intervalMs);
|
|
295
|
+
if (typeof handle.unref === "function") handle.unref();
|
|
296
|
+
return {
|
|
297
|
+
stop() {
|
|
298
|
+
clearInterval(handle);
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
resolveDaemonEntry,
|
|
9
9
|
sendHeartbeat,
|
|
10
10
|
} from "./shared.js";
|
|
11
|
+
import { evaluateToolCall } from "../core/workflow/enforcement.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* PreToolUse hook for Claude Code.
|
|
@@ -46,6 +47,35 @@ async function main(): Promise<void> {
|
|
|
46
47
|
});
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
// Workflow capability gate. Runs before policy.check so harness-level
|
|
51
|
+
// restrictions block before the daemon's general policy machinery sees
|
|
52
|
+
// the call. No-op when CORTEX_ACTIVE_TASK_ID is unset.
|
|
53
|
+
const activeTaskId = process.env.CORTEX_ACTIVE_TASK_ID?.trim();
|
|
54
|
+
if (activeTaskId) {
|
|
55
|
+
try {
|
|
56
|
+
const verdict = evaluateToolCall({
|
|
57
|
+
cwd,
|
|
58
|
+
taskId: activeTaskId,
|
|
59
|
+
call: { toolName: tool, toolInput: input.tool_input ?? {} },
|
|
60
|
+
});
|
|
61
|
+
if (!verdict.allowed) {
|
|
62
|
+
process.stderr.write(
|
|
63
|
+
`[cortex] Blocked by harness capability: ${verdict.reason}\n`,
|
|
64
|
+
);
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
// Capability evaluation should never crash the hook — if it does,
|
|
69
|
+
// log and fall through to the existing policy.check rather than
|
|
70
|
+
// accidentally blocking a legitimate tool.
|
|
71
|
+
process.stderr.write(
|
|
72
|
+
`[cortex] capability evaluation failed (${
|
|
73
|
+
err instanceof Error ? err.message : String(err)
|
|
74
|
+
}); deferring to policy.check\n`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
49
79
|
const payload: PolicyCheckPayload = {
|
|
50
80
|
tool,
|
|
51
81
|
cwd,
|
|
@@ -11,6 +11,17 @@ 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";
|
|
14
25
|
|
|
15
26
|
type ToolPayload = Record<string, unknown>;
|
|
16
27
|
|
|
@@ -322,6 +333,70 @@ function registerTools(server: McpServer): void {
|
|
|
322
333
|
return reloadContextGraph(parsed.force);
|
|
323
334
|
})
|
|
324
335
|
);
|
|
336
|
+
|
|
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
|
+
);
|
|
325
400
|
}
|
|
326
401
|
|
|
327
402
|
let shutdownCalled = false;
|