@danielblomma/cortex-mcp 2.0.5 → 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 CHANGED
@@ -89,6 +89,13 @@ function printHelp() {
89
89
  console.log(helpRow("hooks [install|uninstall|status] [--project]", "Claude Code hooks"));
90
90
  console.log(helpRow("telemetry test", "Smoke-test the push pipeline"));
91
91
 
92
+ console.log(helpSection("HARNESS"));
93
+ console.log(helpRow("stage start --task-id <id> --description \"...\"", "Start a workflow run for a task"));
94
+ console.log(helpRow("stage status --task-id <id>", "Print run state JSON"));
95
+ console.log(helpRow("stage envelope --task-id <id> [--stage <name>]", "Compose stage prompt envelope"));
96
+ console.log(helpRow("stage advance --task-id <id> --stage <name> --body-file <path>", "Write artifact, advance run"));
97
+ console.log(helpRow("stage run --task-id <id> -- <command>", "Exec a command with CORTEX_ACTIVE_TASK_ID set"));
98
+
92
99
  console.log(helpSection("MISC"));
93
100
  console.log(helpRow("mcp", "Run the MCP stdio server for the current project"));
94
101
  console.log(helpRow("version", "Print CLI version"));
@@ -1005,6 +1012,10 @@ async function run() {
1005
1012
  return runRunCommand(rest);
1006
1013
  }
1007
1014
 
1015
+ if (command === "stage") {
1016
+ return runStageCommandShim(rest);
1017
+ }
1018
+
1008
1019
  const passthrough = new Set([
1009
1020
  "bootstrap",
1010
1021
  "update",
@@ -1527,6 +1538,19 @@ async function runRunCommand(args) {
1527
1538
  process.exit(exitCode);
1528
1539
  }
1529
1540
 
1541
+ async function runStageCommandShim(args) {
1542
+ const target = process.env.CORTEX_PROJECT_ROOT?.trim() || process.cwd();
1543
+ process.env.CORTEX_PROJECT_ROOT = path.resolve(target);
1544
+ const entry = resolveCliEntry("stage");
1545
+ if (!fs.existsSync(entry)) {
1546
+ throw new Error(
1547
+ `Build the project's MCP first (missing ${entry}). Run 'cortex bootstrap' in the project root.`,
1548
+ );
1549
+ }
1550
+ const mod = await import(pathToFileURL(entry).href);
1551
+ await mod.runStageCommand(args);
1552
+ }
1553
+
1530
1554
  async function runTelemetryCommand(args) {
1531
1555
  const sub = args[0] || "help";
1532
1556
  if (sub === "test") {
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.5",
4
+ "version": "2.0.6",
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",
@@ -10,6 +10,9 @@
10
10
  "dependencies": {
11
11
  "@huggingface/transformers": "^4.1.0",
12
12
  "@modelcontextprotocol/sdk": "^1.27.1",
13
+ "@types/js-yaml": "^4.0.9",
14
+ "js-yaml": "^4.1.1",
15
+ "minimatch": "^10.2.5",
13
16
  "ryugraph": "^25.9.1",
14
17
  "zod": "^3.24.1"
15
18
  },
@@ -842,12 +845,17 @@
842
845
  "dev": true,
843
846
  "license": "MIT"
844
847
  },
848
+ "node_modules/@types/js-yaml": {
849
+ "version": "4.0.9",
850
+ "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
851
+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
852
+ "license": "MIT"
853
+ },
845
854
  "node_modules/@types/node": {
846
855
  "version": "22.19.13",
847
856
  "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
848
857
  "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==",
849
858
  "license": "MIT",
850
- "peer": true,
851
859
  "dependencies": {
852
860
  "undici-types": "~6.21.0"
853
861
  }
@@ -989,6 +997,21 @@
989
997
  "dev": true,
990
998
  "license": "MIT"
991
999
  },
1000
+ "node_modules/argparse": {
1001
+ "version": "2.0.1",
1002
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
1003
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
1004
+ "license": "Python-2.0"
1005
+ },
1006
+ "node_modules/balanced-match": {
1007
+ "version": "4.0.4",
1008
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
1009
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
1010
+ "license": "MIT",
1011
+ "engines": {
1012
+ "node": "18 || 20 || >=22"
1013
+ }
1014
+ },
992
1015
  "node_modules/body-parser": {
993
1016
  "version": "2.2.2",
994
1017
  "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -1020,6 +1043,18 @@
1020
1043
  "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
1021
1044
  "license": "MIT"
1022
1045
  },
1046
+ "node_modules/brace-expansion": {
1047
+ "version": "5.0.5",
1048
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
1049
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
1050
+ "license": "MIT",
1051
+ "dependencies": {
1052
+ "balanced-match": "^4.0.2"
1053
+ },
1054
+ "engines": {
1055
+ "node": "18 || 20 || >=22"
1056
+ }
1057
+ },
1023
1058
  "node_modules/bytes": {
1024
1059
  "version": "3.1.2",
1025
1060
  "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1760,7 +1795,6 @@
1760
1795
  "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
1761
1796
  "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
1762
1797
  "license": "MIT",
1763
- "peer": true,
1764
1798
  "engines": {
1765
1799
  "node": ">=16.9.0"
1766
1800
  }
@@ -1861,6 +1895,18 @@
1861
1895
  "url": "https://github.com/sponsors/panva"
1862
1896
  }
1863
1897
  },
1898
+ "node_modules/js-yaml": {
1899
+ "version": "4.1.1",
1900
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
1901
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
1902
+ "license": "MIT",
1903
+ "dependencies": {
1904
+ "argparse": "^2.0.1"
1905
+ },
1906
+ "bin": {
1907
+ "js-yaml": "bin/js-yaml.js"
1908
+ }
1909
+ },
1864
1910
  "node_modules/json-schema-traverse": {
1865
1911
  "version": "1.0.0",
1866
1912
  "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -1940,6 +1986,21 @@
1940
1986
  "url": "https://github.com/sponsors/sindresorhus"
1941
1987
  }
1942
1988
  },
1989
+ "node_modules/minimatch": {
1990
+ "version": "10.2.5",
1991
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
1992
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
1993
+ "license": "BlueOak-1.0.0",
1994
+ "dependencies": {
1995
+ "brace-expansion": "^5.0.5"
1996
+ },
1997
+ "engines": {
1998
+ "node": "18 || 20 || >=22"
1999
+ },
2000
+ "funding": {
2001
+ "url": "https://github.com/sponsors/isaacs"
2002
+ }
2003
+ },
1943
2004
  "node_modules/minimist": {
1944
2005
  "version": "1.2.8",
1945
2006
  "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -2611,7 +2672,6 @@
2611
2672
  "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
2612
2673
  "dev": true,
2613
2674
  "license": "Apache-2.0",
2614
- "peer": true,
2615
2675
  "bin": {
2616
2676
  "tsc": "bin/tsc",
2617
2677
  "tsserver": "bin/tsserver"
@@ -2764,7 +2824,6 @@
2764
2824
  "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
2765
2825
  "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
2766
2826
  "license": "MIT",
2767
- "peer": true,
2768
2827
  "funding": {
2769
2828
  "url": "https://github.com/sponsors/colinhacks"
2770
2829
  }
@@ -12,8 +12,11 @@
12
12
  "test": "npm run build --silent && node --test tests/*.test.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@modelcontextprotocol/sdk": "^1.27.1",
16
15
  "@huggingface/transformers": "^4.1.0",
16
+ "@modelcontextprotocol/sdk": "^1.27.1",
17
+ "@types/js-yaml": "^4.0.9",
18
+ "js-yaml": "^4.1.1",
19
+ "minimatch": "^10.2.5",
17
20
  "ryugraph": "^25.9.1",
18
21
  "zod": "^3.24.1"
19
22
  },
@@ -0,0 +1,325 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import {
4
+ composeStageEnvelope,
5
+ createRun,
6
+ advanceStage,
7
+ getRunState,
8
+ type StageStatus,
9
+ type WorkflowDefinition,
10
+ } from "../core/workflow/index.js";
11
+ import { DEFAULT_WORKFLOWS } from "../core/workflow/default-workflows.js";
12
+
13
+ /**
14
+ * `cortex stage` CLI surface. Each subcommand is a thin shell wrapper
15
+ * around the workflow primitives, mirroring the cortex.workflow.* MCP
16
+ * tools so shell scripts and CI can drive the harness without an MCP
17
+ * client.
18
+ *
19
+ * Subcommands:
20
+ * start --task-id <id> --description "..." [--workflow <id>]
21
+ * status --task-id <id>
22
+ * envelope --task-id <id> [--stage <name>]
23
+ * advance --task-id <id> --stage <name> --body-file <path>
24
+ * [--frontmatter-file <path>] [--status <complete|blocked|failed>]
25
+ * run --task-id <id> -- <command> [args...]
26
+ * Sets CORTEX_ACTIVE_TASK_ID and execs the command. Use this to
27
+ * spawn an agent that runs under the harness's pre-tool-use gate.
28
+ *
29
+ * All commands resolve cwd from CORTEX_PROJECT_ROOT (preferred) or
30
+ * process.cwd() so they work both inside an MCP server context and as
31
+ * standalone shell calls.
32
+ */
33
+
34
+ export async function runStageCommand(args: string[]): Promise<void> {
35
+ const sub = args[0] ?? "help";
36
+ const rest = args.slice(1);
37
+
38
+ switch (sub) {
39
+ case "start":
40
+ return runStart(rest);
41
+ case "status":
42
+ return runStatus(rest);
43
+ case "envelope":
44
+ return runEnvelope(rest);
45
+ case "advance":
46
+ return runAdvance(rest);
47
+ case "run":
48
+ return runRun(rest);
49
+ case "help":
50
+ case "--help":
51
+ case "-h":
52
+ printHelp();
53
+ return;
54
+ default:
55
+ printHelp();
56
+ throw new Error(`Unknown stage subcommand: ${sub}`);
57
+ }
58
+ }
59
+
60
+ function printHelp(): void {
61
+ const lines = [
62
+ "Usage:",
63
+ " cortex stage start --task-id <id> --description \"...\" [--workflow <id>]",
64
+ " cortex stage status --task-id <id>",
65
+ " cortex stage envelope --task-id <id> [--stage <name>]",
66
+ " cortex stage advance --task-id <id> --stage <name> --body-file <path>",
67
+ " [--frontmatter-file <path>] [--status <s>] [--outcome-file <path>]",
68
+ " cortex stage run --task-id <id> -- <command> [args...]",
69
+ "",
70
+ "Status values: complete (default) | blocked | failed",
71
+ "All commands operate on .agents/<task-id>/ in the current project root.",
72
+ ];
73
+ process.stdout.write(lines.join("\n") + "\n");
74
+ }
75
+
76
+ function projectRoot(): string {
77
+ return process.env.CORTEX_PROJECT_ROOT?.trim() || process.cwd();
78
+ }
79
+
80
+ function resolveWorkflow(workflowId: string): WorkflowDefinition {
81
+ const wf = DEFAULT_WORKFLOWS[workflowId];
82
+ if (!wf) {
83
+ throw new Error(
84
+ `Unknown workflow_id: ${workflowId}. Available: ${
85
+ Object.keys(DEFAULT_WORKFLOWS).join(", ") || "<none>"
86
+ }`,
87
+ );
88
+ }
89
+ return wf;
90
+ }
91
+
92
+ type Flags = Record<string, string | boolean>;
93
+
94
+ function parseFlags(args: string[]): { flags: Flags; rest: string[] } {
95
+ const flags: Flags = {};
96
+ const rest: string[] = [];
97
+ let i = 0;
98
+ while (i < args.length) {
99
+ const arg = args[i];
100
+ if (arg === "--") {
101
+ rest.push(...args.slice(i + 1));
102
+ break;
103
+ }
104
+ if (arg.startsWith("--")) {
105
+ const next = args[i + 1];
106
+ if (next === undefined || next.startsWith("--")) {
107
+ flags[arg.slice(2)] = true;
108
+ i += 1;
109
+ } else {
110
+ flags[arg.slice(2)] = next;
111
+ i += 2;
112
+ }
113
+ continue;
114
+ }
115
+ rest.push(arg);
116
+ i += 1;
117
+ }
118
+ return { flags, rest };
119
+ }
120
+
121
+ function requireFlag(flags: Flags, name: string): string {
122
+ const value = flags[name];
123
+ if (typeof value !== "string" || value.length === 0) {
124
+ throw new Error(`Missing required flag: --${name}`);
125
+ }
126
+ return value;
127
+ }
128
+
129
+ function emitJson(data: unknown): void {
130
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
131
+ }
132
+
133
+ async function runStart(args: string[]): Promise<void> {
134
+ const { flags } = parseFlags(args);
135
+ const taskId = requireFlag(flags, "task-id");
136
+ const description = requireFlag(flags, "description");
137
+ const workflowId =
138
+ typeof flags.workflow === "string" ? flags.workflow : "secure-build";
139
+
140
+ const workflow = resolveWorkflow(workflowId);
141
+ const state = createRun({
142
+ cwd: projectRoot(),
143
+ taskId,
144
+ workflow,
145
+ taskDescription: description,
146
+ });
147
+ const envelope = composeStageEnvelope({
148
+ cwd: projectRoot(),
149
+ taskId,
150
+ workflow,
151
+ });
152
+ emitJson({ state, envelope });
153
+ }
154
+
155
+ async function runStatus(args: string[]): Promise<void> {
156
+ const { flags } = parseFlags(args);
157
+ const taskId = requireFlag(flags, "task-id");
158
+ const state = getRunState(projectRoot(), taskId);
159
+ emitJson({ state });
160
+ }
161
+
162
+ async function runEnvelope(args: string[]): Promise<void> {
163
+ const { flags } = parseFlags(args);
164
+ const taskId = requireFlag(flags, "task-id");
165
+ const stageName = typeof flags.stage === "string" ? flags.stage : undefined;
166
+
167
+ const state = getRunState(projectRoot(), taskId);
168
+ if (!state) {
169
+ throw new Error(
170
+ `No run state for task ${taskId}. Start one with 'cortex stage start'.`,
171
+ );
172
+ }
173
+ const workflow = resolveWorkflow(state.workflow_id);
174
+ const envelope = composeStageEnvelope({
175
+ cwd: projectRoot(),
176
+ taskId,
177
+ workflow,
178
+ stageName,
179
+ });
180
+ emitJson({ envelope });
181
+ }
182
+
183
+ async function runAdvance(args: string[]): Promise<void> {
184
+ const { flags } = parseFlags(args);
185
+ const taskId = requireFlag(flags, "task-id");
186
+ const stageName = requireFlag(flags, "stage");
187
+ const bodyPath = requireFlag(flags, "body-file");
188
+ const frontmatterPath =
189
+ typeof flags["frontmatter-file"] === "string"
190
+ ? flags["frontmatter-file"]
191
+ : null;
192
+ const outcomePath =
193
+ typeof flags["outcome-file"] === "string" ? flags["outcome-file"] : null;
194
+ const statusFlag =
195
+ typeof flags.status === "string" ? (flags.status as StageStatus) : undefined;
196
+
197
+ const body = readFileSync(bodyPath, "utf8");
198
+ const frontmatter: Record<string, unknown> = frontmatterPath
199
+ ? parseJsonObject(frontmatterPath)
200
+ : {};
201
+ const outcome = outcomePath ? parseJsonObject(outcomePath) : undefined;
202
+
203
+ const state = getRunState(projectRoot(), taskId);
204
+ if (!state) {
205
+ throw new Error(
206
+ `No run state for task ${taskId}. Start one with 'cortex stage start'.`,
207
+ );
208
+ }
209
+ const workflow = resolveWorkflow(state.workflow_id);
210
+ const stage = workflow.stages.find((s) => s.name === stageName);
211
+ if (!stage) {
212
+ throw new Error(
213
+ `Stage ${stageName} is not defined in workflow ${workflow.id}`,
214
+ );
215
+ }
216
+
217
+ const finalStatus: StageStatus = statusFlag ?? "complete";
218
+ const next = advanceStage({
219
+ cwd: projectRoot(),
220
+ taskId,
221
+ workflow,
222
+ stageName,
223
+ artifactName: stage.artifact,
224
+ frontmatter: {
225
+ ...frontmatter,
226
+ stage: stageName,
227
+ status: finalStatus,
228
+ references:
229
+ (Array.isArray((frontmatter as Record<string, unknown>).references)
230
+ ? ((frontmatter as Record<string, unknown>).references as unknown[])
231
+ .filter((v): v is string => typeof v === "string")
232
+ : null) ?? deriveReferencesFromReads(stage.reads, workflow),
233
+ },
234
+ body,
235
+ status: finalStatus,
236
+ outcome,
237
+ });
238
+
239
+ let nextEnvelope = null;
240
+ if (next.outcome === "in_progress" && next.current_stage) {
241
+ nextEnvelope = composeStageEnvelope({
242
+ cwd: projectRoot(),
243
+ taskId,
244
+ workflow,
245
+ });
246
+ }
247
+ emitJson({ state: next, next_envelope: nextEnvelope });
248
+ }
249
+
250
+ function parseJsonObject(filePath: string): Record<string, unknown> {
251
+ const raw = readFileSync(filePath, "utf8");
252
+ let value: unknown;
253
+ try {
254
+ value = JSON.parse(raw);
255
+ } catch (err) {
256
+ throw new Error(
257
+ `Failed to parse JSON in ${filePath}: ${
258
+ err instanceof Error ? err.message : String(err)
259
+ }`,
260
+ );
261
+ }
262
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
263
+ throw new Error(`Expected JSON object in ${filePath}, got ${typeof value}`);
264
+ }
265
+ return value as Record<string, unknown>;
266
+ }
267
+
268
+ function deriveReferencesFromReads(
269
+ reads: string[],
270
+ workflow: WorkflowDefinition,
271
+ ): string[] {
272
+ const refs: string[] = [];
273
+ for (const readName of reads) {
274
+ const stage = workflow.stages.find((s) => s.name === readName);
275
+ if (stage) refs.push(stage.artifact);
276
+ }
277
+ return refs;
278
+ }
279
+
280
+ async function runRun(args: string[]): Promise<void> {
281
+ const { flags, rest } = parseFlags(args);
282
+ const taskId = requireFlag(flags, "task-id");
283
+ if (rest.length === 0) {
284
+ throw new Error(
285
+ "cortex stage run requires a command after --, e.g. 'cortex stage run --task-id task-1 -- claude'",
286
+ );
287
+ }
288
+
289
+ const state = getRunState(projectRoot(), taskId);
290
+ if (!state) {
291
+ throw new Error(
292
+ `No run state for task ${taskId}. Start one with 'cortex stage start'.`,
293
+ );
294
+ }
295
+ if (state.outcome !== "in_progress" || !state.current_stage) {
296
+ throw new Error(
297
+ `Run ${taskId} is not in progress (outcome=${state.outcome}). Cannot spawn agent.`,
298
+ );
299
+ }
300
+
301
+ const [command, ...commandArgs] = rest;
302
+
303
+ const child = spawn(command, commandArgs, {
304
+ stdio: "inherit",
305
+ env: {
306
+ ...process.env,
307
+ CORTEX_ACTIVE_TASK_ID: taskId,
308
+ },
309
+ });
310
+
311
+ await new Promise<void>((resolve, reject) => {
312
+ child.on("error", reject);
313
+ child.on("exit", (code, signal) => {
314
+ if (signal) {
315
+ reject(new Error(`spawned process terminated by signal ${signal}`));
316
+ return;
317
+ }
318
+ if (code !== 0) {
319
+ reject(new Error(`spawned process exited with code ${code}`));
320
+ return;
321
+ }
322
+ resolve();
323
+ });
324
+ });
325
+ }
@@ -0,0 +1,156 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ } from "node:fs";
7
+ import { join } from "node:path";
8
+ import * as yaml from "js-yaml";
9
+ import {
10
+ runStateSchema,
11
+ stageArtifactFrontmatterSchema,
12
+ type RunState,
13
+ type StageArtifactFrontmatter,
14
+ } from "./schemas.js";
15
+
16
+ /**
17
+ * Filesystem layout for one workflow run:
18
+ *
19
+ * <cwd>/.agents/<task-id>/
20
+ * plan.md
21
+ * review.md
22
+ * ...
23
+ * state.json
24
+ *
25
+ * All paths in this module are relative to the project's <cwd>. The caller
26
+ * is responsible for choosing cwd; we never assume process.cwd() here so
27
+ * tests can target a tmp directory.
28
+ */
29
+
30
+ export const AGENTS_DIR = ".agents";
31
+ export const STATE_FILENAME = "state.json";
32
+
33
+ export function runDir(cwd: string, taskId: string): string {
34
+ return join(cwd, AGENTS_DIR, taskId);
35
+ }
36
+
37
+ export function stateFilePath(cwd: string, taskId: string): string {
38
+ return join(runDir(cwd, taskId), STATE_FILENAME);
39
+ }
40
+
41
+ export function artifactPath(
42
+ cwd: string,
43
+ taskId: string,
44
+ artifactName: string,
45
+ ): string {
46
+ return join(runDir(cwd, taskId), artifactName);
47
+ }
48
+
49
+ const FRONTMATTER_OPEN = /^---\s*\r?\n/;
50
+ const FRONTMATTER_CLOSE = /\r?\n---\s*\r?\n/;
51
+
52
+ export type ParsedArtifact = {
53
+ frontmatter: StageArtifactFrontmatter;
54
+ body: string;
55
+ };
56
+
57
+ export function parseStageArtifact(text: string): ParsedArtifact {
58
+ if (!FRONTMATTER_OPEN.test(text)) {
59
+ throw new Error("Stage artifact is missing YAML frontmatter (--- ... ---)");
60
+ }
61
+ const afterOpen = text.replace(FRONTMATTER_OPEN, "");
62
+ const closeMatch = afterOpen.match(FRONTMATTER_CLOSE);
63
+ if (!closeMatch || closeMatch.index === undefined) {
64
+ throw new Error("Stage artifact frontmatter is not terminated (--- ... ---)");
65
+ }
66
+ const yamlText = afterOpen.slice(0, closeMatch.index);
67
+ const body = afterOpen
68
+ .slice(closeMatch.index + closeMatch[0].length)
69
+ .replace(/^\s+/, "")
70
+ .replace(/\s+$/, "");
71
+
72
+ let raw: unknown;
73
+ try {
74
+ raw = yaml.load(yamlText);
75
+ } catch (err) {
76
+ throw new Error(
77
+ `Failed to parse stage artifact frontmatter as YAML: ${
78
+ err instanceof Error ? err.message : String(err)
79
+ }`,
80
+ );
81
+ }
82
+
83
+ const parsed = stageArtifactFrontmatterSchema.safeParse(raw);
84
+ if (!parsed.success) {
85
+ throw new Error(
86
+ `Stage artifact frontmatter does not match schema: ${parsed.error.message}`,
87
+ );
88
+ }
89
+ return { frontmatter: parsed.data, body };
90
+ }
91
+
92
+ export function readStageArtifact(
93
+ cwd: string,
94
+ taskId: string,
95
+ artifactName: string,
96
+ ): ParsedArtifact {
97
+ const path = artifactPath(cwd, taskId, artifactName);
98
+ const text = readFileSync(path, "utf8");
99
+ return parseStageArtifact(text);
100
+ }
101
+
102
+ export function renderStageArtifact(
103
+ frontmatter: StageArtifactFrontmatter,
104
+ body: string,
105
+ ): string {
106
+ const yamlText = yaml.dump(frontmatter, {
107
+ lineWidth: 100,
108
+ noRefs: true,
109
+ sortKeys: false,
110
+ });
111
+ const trimmedBody = body.trim();
112
+ return `---\n${yamlText}---\n\n${trimmedBody}\n`;
113
+ }
114
+
115
+ export function writeStageArtifact(
116
+ cwd: string,
117
+ taskId: string,
118
+ artifactName: string,
119
+ frontmatter: StageArtifactFrontmatter,
120
+ body: string,
121
+ ): string {
122
+ const dir = runDir(cwd, taskId);
123
+ mkdirSync(dir, { recursive: true });
124
+ const path = artifactPath(cwd, taskId, artifactName);
125
+ writeFileSync(path, renderStageArtifact(frontmatter, body), "utf8");
126
+ return path;
127
+ }
128
+
129
+ export function readRunState(cwd: string, taskId: string): RunState | null {
130
+ const path = stateFilePath(cwd, taskId);
131
+ if (!existsSync(path)) return null;
132
+ let raw: unknown;
133
+ try {
134
+ raw = JSON.parse(readFileSync(path, "utf8"));
135
+ } catch (err) {
136
+ throw new Error(
137
+ `Failed to parse run state at ${path}: ${
138
+ err instanceof Error ? err.message : String(err)
139
+ }`,
140
+ );
141
+ }
142
+ const parsed = runStateSchema.safeParse(raw);
143
+ if (!parsed.success) {
144
+ throw new Error(`Run state at ${path} does not match schema: ${parsed.error.message}`);
145
+ }
146
+ return parsed.data;
147
+ }
148
+
149
+ export function writeRunState(cwd: string, state: RunState): string {
150
+ const validated = runStateSchema.parse(state);
151
+ const dir = runDir(cwd, validated.task_id);
152
+ mkdirSync(dir, { recursive: true });
153
+ const path = stateFilePath(cwd, validated.task_id);
154
+ writeFileSync(path, JSON.stringify(validated, null, 2) + "\n", "utf8");
155
+ return path;
156
+ }