@danielblomma/cortex-mcp 2.0.6 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scaffold/mcp/src/cli/stage.ts +42 -0
- package/scaffold/mcp/src/core/workflow/default-workflows.ts +35 -0
- package/scaffold/mcp/src/core/workflow/envelope.ts +26 -1
- package/scaffold/mcp/src/core/workflow/index.ts +1 -0
- package/scaffold/mcp/src/core/workflow/mcp-tools.ts +24 -1
- package/scaffold/mcp/src/core/workflow/run-lifecycle.ts +49 -2
- package/scaffold/mcp/src/core/workflow/schemas.ts +45 -0
- package/scaffold/mcp/src/core/workflow/synced-registry.ts +64 -0
- package/scaffold/mcp/src/daemon/main.ts +15 -0
- package/scaffold/mcp/src/daemon/workflow-sync-checker.ts +301 -0
- package/scaffold/mcp/src/enterprise/index.ts +5 -0
- package/scaffold/mcp/src/enterprise/tools/harness.ts +98 -0
- package/scaffold/mcp/src/server.ts +4 -74
- package/scaffold/mcp/tests/workflow-cli.test.mjs +41 -0
- package/scaffold/mcp/tests/workflow-synced-registry.test.mjs +179 -0
- package/scaffold/mcp/tests/workflow-validators-override.test.mjs +272 -0
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.
|
|
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
|
``,
|
|
@@ -2,7 +2,9 @@ import { z } from "zod";
|
|
|
2
2
|
import { advanceStage, createRun, getRunState } from "./run-lifecycle.js";
|
|
3
3
|
import { composeStageEnvelope } from "./envelope.js";
|
|
4
4
|
import { DEFAULT_WORKFLOWS } from "./default-workflows.js";
|
|
5
|
+
import { loadSyncedWorkflows } from "./synced-registry.js";
|
|
5
6
|
import {
|
|
7
|
+
stageOverrideSchema,
|
|
6
8
|
stageStatusSchema,
|
|
7
9
|
type StageStatus,
|
|
8
10
|
type WorkflowDefinition,
|
|
@@ -44,6 +46,19 @@ export const WorkflowAdvanceInput = z.object({
|
|
|
44
46
|
status: stageStatusSchema.optional(),
|
|
45
47
|
/** Optional structured outcome surfaced into state.json for fast lookup by later stages. */
|
|
46
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(),
|
|
47
62
|
});
|
|
48
63
|
export type WorkflowAdvanceInputT = z.infer<typeof WorkflowAdvanceInput>;
|
|
49
64
|
|
|
@@ -79,7 +94,13 @@ function resolveWorkflow(
|
|
|
79
94
|
workflowId: string,
|
|
80
95
|
registry: Record<string, WorkflowDefinition> | undefined,
|
|
81
96
|
): WorkflowDefinition {
|
|
82
|
-
|
|
97
|
+
// When the caller passes an explicit registry, it wins outright (used
|
|
98
|
+
// by tests). Otherwise we merge bundled defaults with the org-authored
|
|
99
|
+
// workflows the daemon has synced into ~/.cortex/workflows.local.json,
|
|
100
|
+
// with the synced ones taking precedence on workflow_id collisions so
|
|
101
|
+
// org overrides actually override.
|
|
102
|
+
const workflows =
|
|
103
|
+
registry ?? { ...DEFAULT_WORKFLOWS, ...loadSyncedWorkflows() };
|
|
83
104
|
const workflow = workflows[workflowId];
|
|
84
105
|
if (!workflow) {
|
|
85
106
|
throw new Error(
|
|
@@ -148,6 +169,8 @@ export function runWorkflowAdvance(
|
|
|
148
169
|
body: input.body,
|
|
149
170
|
outcome: input.outcome,
|
|
150
171
|
status: finalStatus,
|
|
172
|
+
validatorsPassed: input.validators_passed,
|
|
173
|
+
override: input.override,
|
|
151
174
|
});
|
|
152
175
|
|
|
153
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>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { workflowDefinitionSchema, type WorkflowDefinition } from "./schemas.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read side of the org-workflow sync cache. The daemon's
|
|
8
|
+
* workflow-sync-checker writes ~/.cortex/workflows.local.json; this
|
|
9
|
+
* module reads it. Kept in core/workflow/ rather than daemon/ so
|
|
10
|
+
* mcp-tools.ts can consult the cache without depending on daemon code.
|
|
11
|
+
*
|
|
12
|
+
* Each entry is validated against workflowDefinitionSchema before being
|
|
13
|
+
* surfaced — if the cache file is corrupt or contains stale shapes from
|
|
14
|
+
* an older daemon, those entries are silently dropped rather than
|
|
15
|
+
* crashing the read.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const SYNCED_WORKFLOWS_FILENAME = "workflows.local.json";
|
|
19
|
+
|
|
20
|
+
type LocalWorkflowRecord = {
|
|
21
|
+
workflow_id: string;
|
|
22
|
+
version: number;
|
|
23
|
+
updated_at: string;
|
|
24
|
+
definition: unknown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type LocalWorkflowsState = {
|
|
28
|
+
workflows?: Record<string, LocalWorkflowRecord>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function syncedWorkflowsCachePath(dir?: string): string {
|
|
32
|
+
return join(dir ?? join(homedir(), ".cortex"), SYNCED_WORKFLOWS_FILENAME);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns the synced org-authored workflows keyed by `workflow_id`.
|
|
37
|
+
* Empty object when the cache is missing, unreadable, malformed, or
|
|
38
|
+
* contains no valid entries. The optional `dir` argument is for tests;
|
|
39
|
+
* production callers leave it unset.
|
|
40
|
+
*/
|
|
41
|
+
export function loadSyncedWorkflows(
|
|
42
|
+
dir?: string,
|
|
43
|
+
): Record<string, WorkflowDefinition> {
|
|
44
|
+
const path = syncedWorkflowsCachePath(dir);
|
|
45
|
+
if (!existsSync(path)) return {};
|
|
46
|
+
|
|
47
|
+
let parsed: LocalWorkflowsState;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(readFileSync(path, "utf8")) as LocalWorkflowsState;
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
const records = parsed.workflows;
|
|
54
|
+
if (!records || typeof records !== "object") return {};
|
|
55
|
+
|
|
56
|
+
const out: Record<string, WorkflowDefinition> = {};
|
|
57
|
+
for (const [id, record] of Object.entries(records)) {
|
|
58
|
+
if (!record || typeof record !== "object") continue;
|
|
59
|
+
const result = workflowDefinitionSchema.safeParse(record.definition);
|
|
60
|
+
if (!result.success) continue;
|
|
61
|
+
out[id] = result.data;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "./heartbeat-tracker.js";
|
|
29
29
|
import { startSyncTimer } from "./sync-checker.js";
|
|
30
30
|
import { startSkillSyncTimer } from "./skill-sync-checker.js";
|
|
31
|
+
import { startWorkflowSyncTimer } from "./workflow-sync-checker.js";
|
|
31
32
|
import { startHostEventsPusher } from "./host-events-pusher.js";
|
|
32
33
|
import { startEgressProxy } from "./egress-proxy.js";
|
|
33
34
|
import { startHeartbeatPusher } from "./heartbeat-pusher.js";
|
|
@@ -357,6 +358,20 @@ async function main(): Promise<void> {
|
|
|
357
358
|
startSkillSyncTimer(process.cwd(), skillSyncMs);
|
|
358
359
|
}
|
|
359
360
|
|
|
361
|
+
// Harness Phase 2: poll cortex-web for org-authored workflows, cache
|
|
362
|
+
// their definitions locally so cortex.workflow.start can resolve
|
|
363
|
+
// org-specific workflow_ids ahead of bundled defaults. Same cadence
|
|
364
|
+
// as the skill sync by default; independently configurable via
|
|
365
|
+
// CORTEX_WORKFLOW_SYNC_MS / CORTEX_DISABLE_WORKFLOW_SYNC.
|
|
366
|
+
const workflowSyncRaw = parseInt(process.env.CORTEX_WORKFLOW_SYNC_MS ?? "", 10);
|
|
367
|
+
const workflowSyncMs =
|
|
368
|
+
Number.isFinite(workflowSyncRaw) && workflowSyncRaw > 0
|
|
369
|
+
? workflowSyncRaw
|
|
370
|
+
: skillSyncMs;
|
|
371
|
+
if (process.env.CORTEX_DISABLE_WORKFLOW_SYNC !== "1") {
|
|
372
|
+
startWorkflowSyncTimer(process.cwd(), workflowSyncMs);
|
|
373
|
+
}
|
|
374
|
+
|
|
360
375
|
// Govern host heartbeat — fills host_enrollment on cortex-web so the
|
|
361
376
|
// dashboard at /dashboard/govern actually shows this host.
|
|
362
377
|
const heartbeatRaw = parseInt(process.env.CORTEX_HEARTBEAT_PUSH_MS ?? "", 10);
|