@gajae-code/coding-agent 0.4.3 → 0.4.4

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/dist/types/async/job-manager.d.ts +19 -1
  3. package/dist/types/cli/setup-cli.d.ts +14 -1
  4. package/dist/types/commands/coordinator.d.ts +19 -0
  5. package/dist/types/commands/mcp-serve.d.ts +24 -0
  6. package/dist/types/commands/setup.d.ts +41 -0
  7. package/dist/types/coordinator/contract.d.ts +4 -0
  8. package/dist/types/coordinator-mcp/policy.d.ts +24 -0
  9. package/dist/types/coordinator-mcp/safety.d.ts +26 -0
  10. package/dist/types/coordinator-mcp/server.d.ts +52 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +13 -0
  12. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  13. package/dist/types/modes/components/hook-selector.d.ts +11 -0
  14. package/dist/types/setup/hermes-setup.d.ts +71 -0
  15. package/dist/types/task/render.d.ts +7 -1
  16. package/dist/types/tools/subagent-render.d.ts +25 -0
  17. package/dist/types/tools/subagent.d.ts +5 -1
  18. package/package.json +7 -7
  19. package/src/async/job-manager.ts +43 -1
  20. package/src/cli/setup-cli.ts +86 -2
  21. package/src/cli.ts +2 -0
  22. package/src/commands/coordinator.ts +70 -0
  23. package/src/commands/mcp-serve.ts +62 -0
  24. package/src/commands/setup.ts +30 -1
  25. package/src/coordinator/contract.ts +20 -0
  26. package/src/coordinator-mcp/policy.ts +160 -0
  27. package/src/coordinator-mcp/safety.ts +80 -0
  28. package/src/coordinator-mcp/server.ts +1316 -0
  29. package/src/extensibility/extensions/types.ts +13 -0
  30. package/src/gjc-runtime/session-state-sidecar.ts +79 -0
  31. package/src/internal-urls/docs-index.generated.ts +3 -2
  32. package/src/modes/components/hook-selector.ts +109 -5
  33. package/src/modes/controllers/extension-ui-controller.ts +16 -1
  34. package/src/prompts/agents/architect.md +6 -0
  35. package/src/prompts/agents/critic.md +6 -0
  36. package/src/prompts/agents/planner.md +8 -1
  37. package/src/session/agent-session.ts +6 -0
  38. package/src/setup/hermes/templates/operator-instructions.v1.md +29 -0
  39. package/src/setup/hermes-setup.ts +429 -0
  40. package/src/task/index.ts +2 -0
  41. package/src/task/render.ts +14 -0
  42. package/src/tools/ask.ts +30 -10
  43. package/src/tools/renderers.ts +2 -0
  44. package/src/tools/subagent-render.ts +160 -0
  45. package/src/tools/subagent.ts +49 -7
@@ -14,6 +14,12 @@ import {
14
14
  readGjcManagedCodexHooksStatus,
15
15
  } from "../hooks/codex-native-hooks-config";
16
16
  import { theme } from "../modes/theme/theme";
17
+ import {
18
+ formatHermesSetupResult,
19
+ type HermesSetupFlags,
20
+ hermesSetupExitCode,
21
+ runHermesSetup,
22
+ } from "../setup/hermes-setup";
17
23
  import {
18
24
  addApiCompatibleProvider,
19
25
  formatProviderPresetList,
@@ -21,7 +27,7 @@ import {
21
27
  parseProviderCompatibility,
22
28
  } from "../setup/provider-onboarding";
23
29
 
24
- export type SetupComponent = "defaults" | "hooks" | "provider" | "python" | "stt";
30
+ export type SetupComponent = "defaults" | "hermes" | "hooks" | "provider" | "python" | "stt";
25
31
 
26
32
  export interface SetupCommandArgs {
27
33
  component: SetupComponent;
@@ -36,10 +42,23 @@ export interface SetupCommandArgs {
36
42
  apiKeyEnv?: string;
37
43
  model?: string[];
38
44
  modelsPath?: string;
45
+ smoke?: boolean;
46
+ install?: boolean;
47
+ root?: string[];
48
+ repo?: string;
49
+ profile?: string;
50
+ sessionCommand?: string;
51
+ stateRoot?: string;
52
+ mutation?: string[];
53
+ artifactByteCap?: string;
54
+ serverKey?: string;
55
+ gjcCommand?: string;
56
+ target?: string;
57
+ profileDir?: string;
39
58
  };
40
59
  }
41
60
 
42
- const VALID_COMPONENTS: SetupComponent[] = ["defaults", "hooks", "provider", "python", "stt"];
61
+ const VALID_COMPONENTS: SetupComponent[] = ["defaults", "hermes", "hooks", "provider", "python", "stt"];
43
62
 
44
63
  function hasProviderSetupFlags(flags: SetupCommandArgs["flags"]): boolean {
45
64
  return (
@@ -88,6 +107,32 @@ export function parseSetupArgs(args: string[]): SetupCommandArgs | undefined {
88
107
  flags.check = true;
89
108
  } else if (arg === "--force" || arg === "-f") {
90
109
  flags.force = true;
110
+ } else if (arg === "--smoke") {
111
+ flags.smoke = true;
112
+ } else if (arg === "--install") {
113
+ flags.install = true;
114
+ } else if (arg === "--root") {
115
+ flags.root = [...(flags.root ?? []), args[++i] ?? ""];
116
+ } else if (arg === "--repo") {
117
+ flags.repo = args[++i];
118
+ } else if (arg === "--profile") {
119
+ flags.profile = args[++i];
120
+ } else if (arg === "--session-command") {
121
+ flags.sessionCommand = args[++i];
122
+ } else if (arg === "--state-root") {
123
+ flags.stateRoot = args[++i];
124
+ } else if (arg === "--mutation") {
125
+ flags.mutation = [...(flags.mutation ?? []), args[++i] ?? ""];
126
+ } else if (arg === "--artifact-byte-cap") {
127
+ flags.artifactByteCap = args[++i];
128
+ } else if (arg === "--server-key") {
129
+ flags.serverKey = args[++i];
130
+ } else if (arg === "--gjc-command") {
131
+ flags.gjcCommand = args[++i];
132
+ } else if (arg === "--target") {
133
+ flags.target = args[++i];
134
+ } else if (arg === "--profile-dir") {
135
+ flags.profileDir = args[++i];
91
136
  } else if (arg === "--compat") {
92
137
  flags.compat = args[++i];
93
138
  } else if (arg === "--preset") {
@@ -177,6 +222,9 @@ export async function runSetupCommand(cmd: SetupCommandArgs): Promise<void> {
177
222
  case "defaults":
178
223
  await handleDefaultsSetup(cmd.flags);
179
224
  break;
225
+ case "hermes":
226
+ await handleHermesSetup(cmd.flags);
227
+ break;
180
228
  case "hooks":
181
229
  await handleHooksSetup(cmd.flags);
182
230
  break;
@@ -192,6 +240,26 @@ export async function runSetupCommand(cmd: SetupCommandArgs): Promise<void> {
192
240
  }
193
241
  }
194
242
 
243
+ async function handleHermesSetup(flags: HermesSetupFlags): Promise<void> {
244
+ try {
245
+ const result = await runHermesSetup(flags);
246
+ if (flags.json) {
247
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
248
+ return;
249
+ }
250
+ process.stdout.write(`${chalk.green(`${theme.status.success} Hermes MCP setup ready`)}\n`);
251
+ process.stdout.write(`${chalk.dim(formatHermesSetupResult(result))}\n`);
252
+ } catch (error) {
253
+ const message = error instanceof Error ? error.message : String(error);
254
+ if (flags.json) {
255
+ process.stdout.write(`${JSON.stringify({ ok: false, error: message }, null, 2)}\n`);
256
+ } else {
257
+ process.stderr.write(`${chalk.red(`${theme.status.error} Hermes MCP setup failed`)}\n`);
258
+ process.stderr.write(`${chalk.dim(message)}\n`);
259
+ }
260
+ process.exit(hermesSetupExitCode(error));
261
+ }
262
+ }
195
263
  async function handleProviderSetup(flags: {
196
264
  json?: boolean;
197
265
  force?: boolean;
@@ -410,6 +478,7 @@ ${chalk.bold("Usage:")}
410
478
 
411
479
  ${chalk.bold("Components:")}
412
480
  defaults Install bundled GJC default workflow skills (default)
481
+ hermes Optional: render/install a Hermes MCP bridge setup package
413
482
  hooks Optional: install GJC native Codex UserPromptSubmit/Stop skill-state hooks
414
483
  provider Optional: add a preset, OpenAI-compatible, or Anthropic-compatible API provider
415
484
  python Optional: verify a Python 3 interpreter is reachable for code execution
@@ -421,6 +490,11 @@ ${chalk.bold("Provider example:")}
421
490
  ${APP_NAME} setup provider --preset glm
422
491
  MY_PROVIDER_KEY=sk-... ${APP_NAME} setup provider --compat openai --provider my-oai --base-url https://api.example.com/v1 --api-key-env MY_PROVIDER_KEY --model gpt-example
423
492
 
493
+ ${chalk.bold("Hermes example:")}
494
+ ${APP_NAME} setup hermes --root /path/to/repo
495
+ ${APP_NAME} setup hermes --root /path/to/repo --profile my-bot --repo gajae-code --profile-dir /path/to/hermes/profile --install
496
+ ${APP_NAME} setup hermes --root /path/to/repo --session-command "gjc --model <provider/model>"
497
+
424
498
  ${chalk.bold("Options:")}
425
499
  -c, --check Check if dependencies are installed without installing
426
500
  -f, --force Overwrite existing default workflow skill files
@@ -432,6 +506,15 @@ ${chalk.bold("Options:")}
432
506
  --api-key-env Read provider API key from this environment variable
433
507
  --model, --models Model id to add (repeat or comma-separate)
434
508
  --models-path Override models config path
509
+ --smoke Run Hermes MCP setup smoke checks
510
+ --install Install generated Hermes setup files
511
+ --root Allowed Hermes MCP workdir/artifact root (repeatable)
512
+ --profile Hermes MCP profile namespace
513
+ --repo Hermes MCP repo namespace
514
+ --session-command Explicit GJC session command; omitted by default
515
+ --mutation Hermes MCP mutation classes: sessions,questions,reports,all
516
+ --target Hermes config file target for config-only install
517
+ --profile-dir Hermes profile directory for full setup install
435
518
 
436
519
  ${chalk.bold("Examples:")}
437
520
  ${APP_NAME} setup Install bundled GJC default workflow skills
@@ -439,6 +522,7 @@ ${chalk.bold("Examples:")}
439
522
  ${APP_NAME} setup defaults --check Check bundled GJC default workflow skills are installed
440
523
  ${APP_NAME} setup hooks Install native Codex skill-state hooks
441
524
  ${APP_NAME} setup hooks --check Check native Codex skill-state hooks
525
+ ${APP_NAME} setup hermes Render a model-agnostic Hermes MCP setup preview
442
526
  ${APP_NAME} setup python Install Python execution dependencies
443
527
  ${APP_NAME} setup stt Install speech-to-text dependencies
444
528
  ${APP_NAME} setup stt --check Check if STT dependencies are available
package/src/cli.ts CHANGED
@@ -36,10 +36,12 @@ const commands: CommandEntry[] = [
36
36
  { name: "skills", load: () => import("./commands/skills").then(m => m.default) },
37
37
  { name: "session", load: () => import("./commands/session").then(m => m.default) },
38
38
  { name: "harness", load: () => import("./commands/harness").then(m => m.default) },
39
+ { name: "coordinator", load: () => import("./commands/coordinator").then(m => m.default) },
39
40
  { name: "team", load: () => import("./commands/team").then(m => m.default) },
40
41
  { name: "ultragoal", load: () => import("./commands/ultragoal").then(m => m.default) },
41
42
  { name: "ralplan", load: () => import("./commands/ralplan").then(m => m.default) },
42
43
  { name: "config", load: () => import("./commands/config").then(m => m.default) },
44
+ { name: "mcp-serve", load: () => import("./commands/mcp-serve").then(m => m.default) },
43
45
  {
44
46
  name: "contribute-pr",
45
47
  aliases: ["contribution-prep"],
@@ -0,0 +1,70 @@
1
+ import { Args, Command, Flags } from "@gajae-code/utils/cli";
2
+ import {
3
+ COORDINATOR_MCP_PROTOCOL_VERSION,
4
+ COORDINATOR_MCP_SERVER_NAME,
5
+ COORDINATOR_MCP_TOOL_NAMES,
6
+ } from "../coordinator/contract";
7
+
8
+ function writeJson(value: unknown): void {
9
+ process.stdout.write(`${JSON.stringify(value, null, 2)}
10
+ `);
11
+ }
12
+
13
+ function coordinatorContractPayload(): {
14
+ ok: true;
15
+ server: { name: string; protocolVersion: string };
16
+ readOnly: true;
17
+ tools: string[];
18
+ } {
19
+ return {
20
+ ok: true,
21
+ server: { name: COORDINATOR_MCP_SERVER_NAME, protocolVersion: COORDINATOR_MCP_PROTOCOL_VERSION },
22
+ readOnly: true,
23
+ tools: [...COORDINATOR_MCP_TOOL_NAMES],
24
+ };
25
+ }
26
+
27
+ export default class Coordinator extends Command {
28
+ static description = "Inspect GJC coordinator MCP bridge contracts";
29
+ static strict = false;
30
+
31
+ static args = {
32
+ action: Args.string({ description: "Action to run (check or tools)", required: false }),
33
+ };
34
+
35
+ static flags = {
36
+ json: Flags.boolean({ char: "j", description: "Emit machine-readable JSON", default: false }),
37
+ };
38
+
39
+ async run(): Promise<void> {
40
+ const { args, flags } = await this.parse(Coordinator);
41
+ const action = args.action ?? "check";
42
+ if (action !== "check" && action !== "tools") {
43
+ const payload = { ok: false, reason: "unknown_coordinator_subcommand", subcommand: action };
44
+ if (flags.json) writeJson(payload);
45
+ else
46
+ process.stderr.write(`unknown_coordinator_subcommand:${action}
47
+ `);
48
+ process.exit(1);
49
+ }
50
+
51
+ const payload = coordinatorContractPayload();
52
+ if (flags.json) {
53
+ writeJson(action === "tools" ? { ok: true, tools: payload.tools } : payload);
54
+ return;
55
+ }
56
+ if (action === "tools") {
57
+ for (const tool of payload.tools)
58
+ process.stdout.write(`${tool}
59
+ `);
60
+ return;
61
+ }
62
+ process.stdout.write(
63
+ `server: ${payload.server.name}
64
+ protocol: ${payload.server.protocolVersion}
65
+ readOnly: true
66
+ tools: ${payload.tools.length}
67
+ `,
68
+ );
69
+ }
70
+ }
@@ -0,0 +1,62 @@
1
+ import { Args, Command, Flags } from "@gajae-code/utils/cli";
2
+ import {
3
+ COORDINATOR_MCP_PROTOCOL_VERSION,
4
+ COORDINATOR_MCP_SERVER_NAME,
5
+ COORDINATOR_MCP_TOOL_NAMES,
6
+ } from "../coordinator/contract";
7
+ import { runCoordinatorMcpStdio } from "../coordinator-mcp/server";
8
+
9
+ function writeJson(value: unknown): void {
10
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
11
+ }
12
+
13
+ export function validateMcpServeSubcommandForTest(server: string | undefined): void {
14
+ if (server !== "coordinator") throw new Error(`unknown_mcp_serve_subcommand:${server ?? ""}`);
15
+ }
16
+
17
+ export default class McpServe extends Command {
18
+ static description = "Serve GJC MCP compatibility bridges";
19
+ static strict = false;
20
+
21
+ static args = {
22
+ server: Args.string({ description: "MCP server to run (coordinator)", required: false }),
23
+ };
24
+
25
+ static flags = {
26
+ json: Flags.boolean({ char: "j", description: "Emit machine-readable JSON", default: false }),
27
+ check: Flags.boolean({ description: "Validate server configuration and print a smoke summary", default: false }),
28
+ };
29
+
30
+ async run(): Promise<void> {
31
+ const { args, flags } = await this.parse(McpServe);
32
+ const server = args.server ?? "";
33
+ try {
34
+ validateMcpServeSubcommandForTest(server);
35
+ } catch (error) {
36
+ const subcommand = server;
37
+ if (flags.json) {
38
+ writeJson({ ok: false, reason: "unknown_mcp_serve_subcommand", subcommand });
39
+ } else {
40
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
41
+ }
42
+ return;
43
+ }
44
+
45
+ if (flags.check) {
46
+ const payload = {
47
+ ok: true,
48
+ server: { name: COORDINATOR_MCP_SERVER_NAME, protocolVersion: COORDINATOR_MCP_PROTOCOL_VERSION },
49
+ readOnly: true,
50
+ tools: [...COORDINATOR_MCP_TOOL_NAMES],
51
+ };
52
+ if (flags.json) writeJson(payload);
53
+ else
54
+ process.stdout.write(
55
+ `server: ${payload.server.name}\nprotocol: ${payload.server.protocolVersion}\ntools: ${payload.tools.length}\n`,
56
+ );
57
+ return;
58
+ }
59
+
60
+ await runCoordinatorMcpStdio();
61
+ }
62
+ }
@@ -5,7 +5,7 @@ import { Args, Command, Flags } from "@gajae-code/utils/cli";
5
5
  import { runSetupCommand, type SetupCommandArgs, type SetupComponent } from "../cli/setup-cli";
6
6
  import { initTheme } from "../modes/theme/theme";
7
7
 
8
- const COMPONENTS: SetupComponent[] = ["defaults", "hooks", "provider", "python", "stt"];
8
+ const COMPONENTS: SetupComponent[] = ["defaults", "hermes", "hooks", "provider", "python", "stt"];
9
9
 
10
10
  export default class Setup extends Command {
11
11
  static description = "Install GJC defaults or optional feature dependencies";
@@ -22,6 +22,22 @@ export default class Setup extends Command {
22
22
  check: Flags.boolean({ char: "c", description: "Check if dependencies are installed" }),
23
23
  force: Flags.boolean({ char: "f", description: "Overwrite existing default workflow skill files" }),
24
24
  json: Flags.boolean({ description: "Output status as JSON" }),
25
+ smoke: Flags.boolean({ description: "Run Hermes MCP setup smoke checks" }),
26
+ install: Flags.boolean({ description: "Install generated Hermes setup files" }),
27
+ root: Flags.string({ description: "Allowed Hermes MCP workdir/artifact root (repeatable)", multiple: true }),
28
+ repo: Flags.string({ description: "Hermes MCP repo namespace" }),
29
+ profile: Flags.string({ description: "Hermes MCP profile namespace" }),
30
+ "session-command": Flags.string({ description: "Explicit GJC session command for Hermes to launch" }),
31
+ "state-root": Flags.string({ description: "Hermes MCP coordination state root" }),
32
+ mutation: Flags.string({
33
+ description: "Hermes MCP mutation classes: sessions,questions,reports,all",
34
+ multiple: true,
35
+ }),
36
+ "artifact-byte-cap": Flags.string({ description: "Hermes MCP artifact read byte cap" }),
37
+ "server-key": Flags.string({ description: "Hermes MCP server key in coordinator config" }),
38
+ "gjc-command": Flags.string({ description: "Command used to start `gjc mcp-serve coordinator`" }),
39
+ target: Flags.string({ description: "Hermes config file target for config-only install" }),
40
+ "profile-dir": Flags.string({ description: "Hermes profile directory for full setup install" }),
25
41
  preset: Flags.string({ description: "Provider preset: minimax, minimax-cn, or glm" }),
26
42
  compat: Flags.string({ description: "Provider compatibility: openai or anthropic" }),
27
43
  provider: Flags.string({ description: "Provider id to add to models.yml" }),
@@ -46,6 +62,19 @@ export default class Setup extends Command {
46
62
  apiKeyEnv: flags["api-key-env"],
47
63
  model: flags.model,
48
64
  modelsPath: flags["models-path"],
65
+ smoke: flags.smoke,
66
+ install: flags.install,
67
+ root: flags.root,
68
+ repo: flags.repo,
69
+ profile: flags.profile,
70
+ sessionCommand: flags["session-command"],
71
+ stateRoot: flags["state-root"],
72
+ mutation: flags.mutation,
73
+ artifactByteCap: flags["artifact-byte-cap"],
74
+ serverKey: flags["server-key"],
75
+ gjcCommand: flags["gjc-command"],
76
+ target: flags.target,
77
+ profileDir: flags["profile-dir"],
49
78
  },
50
79
  };
51
80
  await initTheme();
@@ -0,0 +1,20 @@
1
+ export const COORDINATOR_MCP_PROTOCOL_VERSION = "2024-11-05";
2
+ export const COORDINATOR_MCP_SERVER_NAME = "gjc-coordinator-mcp";
3
+
4
+ export const COORDINATOR_MCP_TOOL_NAMES = [
5
+ "gjc_coordinator_list_sessions",
6
+ "gjc_coordinator_read_status",
7
+ "gjc_coordinator_read_tail",
8
+ "gjc_coordinator_list_questions",
9
+ "gjc_coordinator_list_artifacts",
10
+ "gjc_coordinator_read_artifact",
11
+ "gjc_coordinator_read_coordination_status",
12
+ "gjc_coordinator_start_session",
13
+ "gjc_coordinator_send_prompt",
14
+ "gjc_coordinator_submit_question_answer",
15
+ "gjc_coordinator_read_turn",
16
+ "gjc_coordinator_await_turn",
17
+ "gjc_coordinator_report_status",
18
+ ] as const;
19
+
20
+ export type CoordinatorToolName = (typeof COORDINATOR_MCP_TOOL_NAMES)[number];
@@ -0,0 +1,160 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ export type CoordinatorMutationClass = "sessions" | "questions" | "reports";
5
+
6
+ export interface CoordinatorNamespace {
7
+ profile: string | null;
8
+ repo: string | null;
9
+ }
10
+
11
+ export interface CoordinatorMcpConfig {
12
+ allowedRoots: string[];
13
+ mutationClasses: Set<CoordinatorMutationClass>;
14
+ artifactByteCap: number;
15
+ namespace: CoordinatorNamespace;
16
+ stateRoot: string;
17
+ sessionCommand: string | null;
18
+ }
19
+
20
+ export interface CoordinatorMutationRequest {
21
+ allow_mutation?: boolean;
22
+ }
23
+
24
+ const DEFAULT_ARTIFACT_BYTE_CAP = 64 * 1024;
25
+ const MAX_ARTIFACT_BYTE_CAP = 1024 * 1024;
26
+ const MUTATION_CLASSES = new Set<CoordinatorMutationClass>(["sessions", "questions", "reports"]);
27
+ const LEGACY_MUTATION_CLASS_ALIASES = new Map<string, CoordinatorMutationClass>([
28
+ ["session", "sessions"],
29
+ ["prompt", "sessions"],
30
+ ["question", "questions"],
31
+ ["report", "reports"],
32
+ ]);
33
+
34
+ function parseList(value: string | undefined): string[] {
35
+ return (value ?? "")
36
+ .split(/[\n,;:]+/)
37
+ .map(part => part.trim())
38
+ .filter(Boolean);
39
+ }
40
+
41
+ function parseRootList(value: string | undefined): string[] {
42
+ const normalized = (value ?? "").replace(/[\n,;]+/g, path.delimiter);
43
+ return normalized
44
+ .split(path.delimiter)
45
+ .map(part => part.trim())
46
+ .filter(Boolean);
47
+ }
48
+
49
+ function parseMutationClasses(value: string | undefined): Set<CoordinatorMutationClass> {
50
+ const classes = new Set<CoordinatorMutationClass>();
51
+ for (const raw of parseList(value)) {
52
+ const normalized = raw.toLowerCase();
53
+ if (normalized === "all") {
54
+ for (const mutationClass of MUTATION_CLASSES) classes.add(mutationClass);
55
+ continue;
56
+ }
57
+ const mutationClass = LEGACY_MUTATION_CLASS_ALIASES.get(normalized) ?? normalized;
58
+ if (MUTATION_CLASSES.has(mutationClass as CoordinatorMutationClass))
59
+ classes.add(mutationClass as CoordinatorMutationClass);
60
+ }
61
+ return classes;
62
+ }
63
+
64
+ function parseByteCap(value: string | undefined): number {
65
+ const parsed = Number.parseInt(value ?? "", 10);
66
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_ARTIFACT_BYTE_CAP;
67
+ return Math.min(parsed, MAX_ARTIFACT_BYTE_CAP);
68
+ }
69
+
70
+ function cleanScope(value: string | undefined): string | null {
71
+ const trimmed = value?.trim();
72
+ if (!trimmed) return null;
73
+ return trimmed.replace(/[^a-zA-Z0-9_.-]+/g, "-").slice(0, 100) || null;
74
+ }
75
+
76
+ export function buildCoordinatorMcpConfig(env: NodeJS.ProcessEnv = process.env): CoordinatorMcpConfig {
77
+ const stateRoot =
78
+ env.GJC_COORDINATOR_MCP_STATE_ROOT?.trim() || path.join(process.cwd(), ".gjc", "state", "coordinator-mcp");
79
+ return {
80
+ allowedRoots: parseRootList(env.GJC_COORDINATOR_MCP_WORKDIR_ROOTS).map(root => path.resolve(root)),
81
+ mutationClasses: parseMutationClasses(
82
+ env.GJC_COORDINATOR_MCP_MUTATIONS ?? env.GJC_COORDINATOR_MCP_ENABLE_MUTATION_CLASSES,
83
+ ),
84
+ artifactByteCap: parseByteCap(
85
+ env.GJC_COORDINATOR_MCP_ARTIFACT_BYTE_CAP ?? env.GJC_COORDINATOR_MCP_ARTIFACT_MAX_BYTES,
86
+ ),
87
+ namespace: {
88
+ profile: cleanScope(env.GJC_COORDINATOR_MCP_PROFILE),
89
+ repo: cleanScope(env.GJC_COORDINATOR_MCP_REPO),
90
+ },
91
+ stateRoot: path.resolve(stateRoot),
92
+ sessionCommand: env.GJC_COORDINATOR_MCP_SESSION_COMMAND?.trim() || null,
93
+ };
94
+ }
95
+
96
+ async function realpathIfExists(value: string): Promise<string> {
97
+ try {
98
+ return await fs.realpath(value);
99
+ } catch (error) {
100
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
101
+ const parent = await fs.realpath(path.dirname(value));
102
+ return path.join(parent, path.basename(value));
103
+ }
104
+ }
105
+
106
+ function isInside(candidate: string, root: string): boolean {
107
+ const relative = path.relative(root, candidate);
108
+ return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
109
+ }
110
+
111
+ async function canonicalAllowedRoots(config: CoordinatorMcpConfig): Promise<string[]> {
112
+ const roots = await Promise.all(config.allowedRoots.map(root => realpathIfExists(root)));
113
+ return roots.map(root => path.resolve(root));
114
+ }
115
+
116
+ export async function assertCoordinatorWorkdir(config: CoordinatorMcpConfig, cwd: unknown): Promise<string> {
117
+ if (typeof cwd !== "string" || cwd.trim().length === 0) throw new Error("coordinator_workdir_required");
118
+ if (config.allowedRoots.length === 0) throw new Error("coordinator_workdir_roots_required");
119
+ const requested = path.resolve(cwd);
120
+ const canonicalRequested = await realpathIfExists(requested);
121
+ const roots = await canonicalAllowedRoots(config);
122
+ if (!roots.some(root => isInside(canonicalRequested, root))) {
123
+ throw new Error(`coordinator_workdir_outside_allowed_roots:${requested}`);
124
+ }
125
+ return requested;
126
+ }
127
+
128
+ export async function assertCoordinatorArtifactPath(
129
+ config: CoordinatorMcpConfig,
130
+ artifactPath: unknown,
131
+ ): Promise<{ path: string; byteCap: number }> {
132
+ if (typeof artifactPath !== "string" || artifactPath.trim().length === 0)
133
+ throw new Error("coordinator_artifact_path_required");
134
+ if (config.allowedRoots.length === 0) throw new Error("coordinator_artifact_roots_required");
135
+ const requested = path.resolve(artifactPath);
136
+ const canonicalRequested = await realpathIfExists(requested);
137
+ const roots = await canonicalAllowedRoots(config);
138
+ if (!roots.some(root => isInside(canonicalRequested, root))) {
139
+ throw new Error(`coordinator_artifact_outside_allowed_roots:${requested}`);
140
+ }
141
+ return { path: requested, byteCap: config.artifactByteCap };
142
+ }
143
+
144
+ export function requireCoordinatorMutation(
145
+ config: CoordinatorMcpConfig,
146
+ mutationClass: CoordinatorMutationClass,
147
+ request: CoordinatorMutationRequest,
148
+ ): void {
149
+ if (!config.mutationClasses.has(mutationClass))
150
+ throw new Error(`coordinator_mutation_class_disabled:${mutationClass}`);
151
+ if (request.allow_mutation !== true) throw new Error(`coordinator_mutation_call_not_allowed:${mutationClass}`);
152
+ }
153
+
154
+ export function coordinatorNamespacePath(config: CoordinatorMcpConfig): string {
155
+ return path.join(
156
+ config.stateRoot,
157
+ config.namespace.profile ?? "unscoped-profile",
158
+ config.namespace.repo ?? "unscoped-repo",
159
+ );
160
+ }
@@ -0,0 +1,80 @@
1
+ import {
2
+ assertCoordinatorArtifactPath,
3
+ assertCoordinatorWorkdir,
4
+ buildCoordinatorMcpConfig,
5
+ type CoordinatorMcpConfig,
6
+ type CoordinatorMutationClass,
7
+ requireCoordinatorMutation,
8
+ } from "./policy";
9
+
10
+ export const COORDINATOR_MUTATION_CLASSES = ["sessions", "questions", "reports"] as const;
11
+
12
+ export type { CoordinatorMutationClass };
13
+
14
+ export interface CoordinatorSafetyConfig {
15
+ allowedRoots: string[];
16
+ artifactMaxBytes: number;
17
+ enabledMutationClasses: Set<CoordinatorMutationClass>;
18
+ repo?: string;
19
+ profile?: string;
20
+ }
21
+
22
+ export interface CoordinatorSafetyPolicy {
23
+ config: CoordinatorSafetyConfig;
24
+ resolveWorkdir(input: unknown): Promise<string>;
25
+ resolveArtifactPath(input: unknown): Promise<string>;
26
+ assertMutationAllowed(
27
+ mutationClass: CoordinatorMutationClass,
28
+ args: Record<string, unknown>,
29
+ ): { ok: true } | CoordinatorFailure;
30
+ }
31
+
32
+ export interface CoordinatorFailure {
33
+ ok: false;
34
+ reason: string;
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ function toSafetyConfig(config: CoordinatorMcpConfig): CoordinatorSafetyConfig {
39
+ return {
40
+ allowedRoots: config.allowedRoots,
41
+ artifactMaxBytes: config.artifactByteCap,
42
+ enabledMutationClasses: config.mutationClasses,
43
+ repo: config.namespace.repo ?? undefined,
44
+ profile: config.namespace.profile ?? undefined,
45
+ };
46
+ }
47
+
48
+ function toFailure(error: unknown): CoordinatorFailure {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ const [rawReason, detail] = message.split(":", 2);
51
+ const reason = rawReason.replace(/^coordinator_/, "");
52
+ return detail === undefined ? { ok: false, reason } : { ok: false, reason, detail };
53
+ }
54
+
55
+ export async function createCoordinatorSafetyPolicy(
56
+ options: { env?: NodeJS.ProcessEnv } = {},
57
+ ): Promise<CoordinatorSafetyPolicy> {
58
+ const canonicalConfig = buildCoordinatorMcpConfig(options.env ?? process.env);
59
+ const config = toSafetyConfig(canonicalConfig);
60
+ return {
61
+ config,
62
+ resolveWorkdir(input: unknown): Promise<string> {
63
+ return assertCoordinatorWorkdir(canonicalConfig, input);
64
+ },
65
+ async resolveArtifactPath(input: unknown): Promise<string> {
66
+ return (await assertCoordinatorArtifactPath(canonicalConfig, input)).path;
67
+ },
68
+ assertMutationAllowed(
69
+ mutationClass: CoordinatorMutationClass,
70
+ args: Record<string, unknown>,
71
+ ): { ok: true } | CoordinatorFailure {
72
+ try {
73
+ requireCoordinatorMutation(canonicalConfig, mutationClass, args);
74
+ return { ok: true };
75
+ } catch (error) {
76
+ return toFailure(error);
77
+ }
78
+ },
79
+ };
80
+ }