@gajae-code/coding-agent 0.4.3 → 0.4.5

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 (92) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/async/job-manager.d.ts +19 -1
  3. package/dist/types/cli/fast-help.d.ts +1 -0
  4. package/dist/types/cli/setup-cli.d.ts +16 -1
  5. package/dist/types/commands/coordinator.d.ts +19 -0
  6. package/dist/types/commands/harness.d.ts +3 -0
  7. package/dist/types/commands/mcp-serve.d.ts +24 -0
  8. package/dist/types/commands/setup.d.ts +47 -0
  9. package/dist/types/config/model-registry.d.ts +3 -0
  10. package/dist/types/config/models-config-schema.d.ts +5 -0
  11. package/dist/types/coordinator/contract.d.ts +4 -0
  12. package/dist/types/coordinator-mcp/policy.d.ts +24 -0
  13. package/dist/types/coordinator-mcp/safety.d.ts +26 -0
  14. package/dist/types/coordinator-mcp/server.d.ts +58 -0
  15. package/dist/types/extensibility/extensions/types.d.ts +13 -0
  16. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  17. package/dist/types/harness-control-plane/finalize.d.ts +5 -0
  18. package/dist/types/harness-control-plane/phase-rollup.d.ts +23 -0
  19. package/dist/types/harness-control-plane/receipt-ingest.d.ts +19 -0
  20. package/dist/types/harness-control-plane/receipts.d.ts +46 -0
  21. package/dist/types/harness-control-plane/rpc-adapter.d.ts +3 -0
  22. package/dist/types/harness-control-plane/types.d.ts +9 -1
  23. package/dist/types/main.d.ts +2 -2
  24. package/dist/types/modes/components/hook-selector.d.ts +11 -0
  25. package/dist/types/modes/utils/abort-message.d.ts +4 -0
  26. package/dist/types/session/session-manager.d.ts +8 -0
  27. package/dist/types/setup/hermes-setup.d.ts +78 -0
  28. package/dist/types/task/fork-context-advisory.d.ts +13 -0
  29. package/dist/types/task/receipt.d.ts +1 -0
  30. package/dist/types/task/render.d.ts +7 -1
  31. package/dist/types/task/roi-reconciliation.d.ts +27 -0
  32. package/dist/types/task/types.d.ts +10 -0
  33. package/dist/types/tools/subagent-render.d.ts +25 -0
  34. package/dist/types/tools/subagent.d.ts +5 -1
  35. package/package.json +8 -7
  36. package/scripts/build-binary.ts +4 -0
  37. package/src/async/job-manager.ts +43 -1
  38. package/src/cli/fast-help.ts +80 -0
  39. package/src/cli/setup-cli.ts +95 -2
  40. package/src/cli.ts +109 -16
  41. package/src/commands/coordinator.ts +113 -0
  42. package/src/commands/harness.ts +92 -9
  43. package/src/commands/mcp-serve.ts +63 -0
  44. package/src/commands/setup.ts +34 -1
  45. package/src/config/models-config-schema.ts +1 -0
  46. package/src/coordinator/contract.ts +21 -0
  47. package/src/coordinator-mcp/policy.ts +160 -0
  48. package/src/coordinator-mcp/safety.ts +80 -0
  49. package/src/coordinator-mcp/server.ts +1519 -0
  50. package/src/cursor.ts +30 -2
  51. package/src/extensibility/extensions/types.ts +13 -0
  52. package/src/gjc-runtime/launch-worktree.ts +12 -1
  53. package/src/gjc-runtime/session-state-sidecar.ts +117 -0
  54. package/src/harness-control-plane/finalize.ts +39 -5
  55. package/src/harness-control-plane/owner.ts +9 -1
  56. package/src/harness-control-plane/phase-rollup.ts +96 -0
  57. package/src/harness-control-plane/receipt-ingest.ts +127 -0
  58. package/src/harness-control-plane/receipts.ts +229 -1
  59. package/src/harness-control-plane/rpc-adapter.ts +8 -0
  60. package/src/harness-control-plane/types.ts +29 -1
  61. package/src/internal-urls/docs-index.generated.ts +6 -4
  62. package/src/main.ts +7 -3
  63. package/src/modes/components/hook-selector.ts +109 -5
  64. package/src/modes/components/status-line.ts +6 -6
  65. package/src/modes/controllers/event-controller.ts +5 -4
  66. package/src/modes/controllers/extension-ui-controller.ts +16 -1
  67. package/src/modes/interactive-mode.ts +4 -5
  68. package/src/modes/print-mode.ts +1 -1
  69. package/src/modes/theme/theme.ts +2 -2
  70. package/src/modes/utils/abort-message.ts +41 -0
  71. package/src/modes/utils/context-usage.ts +15 -8
  72. package/src/modes/utils/ui-helpers.ts +5 -6
  73. package/src/prompts/agents/architect.md +6 -0
  74. package/src/prompts/agents/critic.md +6 -0
  75. package/src/prompts/agents/planner.md +8 -1
  76. package/src/sdk.ts +9 -4
  77. package/src/session/agent-session.ts +22 -5
  78. package/src/session/session-manager.ts +20 -0
  79. package/src/setup/hermes/templates/operator-instructions.v1.md +30 -0
  80. package/src/setup/hermes-setup.ts +484 -0
  81. package/src/task/fork-context-advisory.ts +99 -0
  82. package/src/task/index.ts +33 -2
  83. package/src/task/receipt.ts +2 -0
  84. package/src/task/render.ts +14 -0
  85. package/src/task/roi-reconciliation.ts +90 -0
  86. package/src/task/types.ts +7 -0
  87. package/src/tools/ask.ts +30 -10
  88. package/src/tools/index.ts +2 -2
  89. package/src/tools/renderers.ts +2 -0
  90. package/src/tools/subagent-render.ts +169 -0
  91. package/src/tools/subagent.ts +49 -7
  92. package/src/utils/title-generator.ts +16 -2
@@ -41,6 +41,7 @@ import {
41
41
  calculatePromptTokens,
42
42
  collectEntriesForBranchSummary,
43
43
  compact,
44
+ estimateMessageTokensHeuristic,
44
45
  estimateTokens,
45
46
  generateBranchSummary,
46
47
  generateHandoff,
@@ -180,6 +181,7 @@ import type { HookCommandContext } from "../extensibility/hooks/types";
180
181
  import type { Skill, SkillWarning } from "../extensibility/skills";
181
182
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
182
183
  import { buildGjcRuntimeSessionEnv, consumePendingGoalModeRequest } from "../gjc-runtime/goal-mode-request";
184
+ import { persistCoordinatorRuntimeStateFromEvent } from "../gjc-runtime/session-state-sidecar";
183
185
  import { writeArtifact } from "../gjc-runtime/state-writer";
184
186
  import { requestGjcWorkerIntegrationAttempt } from "../gjc-runtime/team-runtime";
185
187
  import { GoalRuntime } from "../goals/runtime";
@@ -1626,6 +1628,11 @@ export class AgentSession {
1626
1628
  }
1627
1629
 
1628
1630
  async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
1631
+ await persistCoordinatorRuntimeStateFromEvent(event, {
1632
+ sessionId: this.sessionId,
1633
+ cwd: this.sessionManager.getCwd(),
1634
+ sessionFile: this.sessionManager.getSessionFile(),
1635
+ });
1629
1636
  if (event.type === "message_update") {
1630
1637
  this.#emit(event);
1631
1638
  void this.#queueExtensionEvent(event);
@@ -4382,7 +4389,7 @@ export class AgentSession {
4382
4389
  return false;
4383
4390
  }
4384
4391
 
4385
- const previousTools = this.getActiveToolNames().filter(name => name !== "goal");
4392
+ const previousTools = this.getActiveToolNames();
4386
4393
  const goalTools = [...new Set([...previousTools, "goal"])];
4387
4394
  await this.#goalRuntime.createGoal({ objective: pendingGoal.objective });
4388
4395
  await this.setActiveToolsByName(goalTools);
@@ -6057,6 +6064,9 @@ export class AgentSession {
6057
6064
  return undefined;
6058
6065
  }
6059
6066
 
6067
+ // getBranch() returns materialized copies for blob-externalized entries, so
6068
+ // the pruning mutations must be written back into the canonical store.
6069
+ this.sessionManager.applyEntryMessageUpdates(result.prunedEntries);
6060
6070
  await this.sessionManager.rewriteEntries();
6061
6071
  const sessionContext = this.buildDisplaySessionContext();
6062
6072
  this.agent.replaceMessages(sessionContext.messages);
@@ -6501,12 +6511,18 @@ export class AgentSession {
6501
6511
  // Case 2: Threshold - turn succeeded but context is getting large
6502
6512
  // Skip if this was an error (non-overflow errors don't have usage data)
6503
6513
  if (assistantMessage.stopReason === "error") return;
6504
- const pruneResult = await this.#pruneToolOutputs();
6505
6514
  let contextTokens = calculateContextTokens(assistantMessage.usage);
6515
+ const maxOutputTokens = this.model?.maxTokens ?? 0;
6516
+ // Cache-epoch invariant: pruning rewrites already-sent toolResult history,
6517
+ // which breaks the provider prompt-cache prefix mid-epoch. Only prune at a
6518
+ // sanctioned maintenance boundary, i.e. when the un-pruned context already
6519
+ // crosses the compaction threshold. Pruning may then avert full compaction.
6520
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) return;
6521
+ const pruneResult = await this.#pruneToolOutputs();
6506
6522
  if (pruneResult) {
6507
6523
  contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
6508
6524
  }
6509
- if (shouldCompact(contextTokens, contextWindow, compactionSettings, this.model?.maxTokens ?? 0)) {
6525
+ if (shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) {
6510
6526
  // Try promotion first — if a larger model is available, switch instead of compacting
6511
6527
  const promoted = await this.#tryContextPromotion(assistantMessage);
6512
6528
  if (!promoted) {
@@ -8332,6 +8348,7 @@ export class AgentSession {
8332
8348
  onChunk,
8333
8349
  signal: abortController.signal,
8334
8350
  sessionKey: this.sessionId,
8351
+ cwd,
8335
8352
  timeout: clampTimeout("bash") * 1000,
8336
8353
  env: buildGjcRuntimeSessionEnv({
8337
8354
  sessionFile: this.sessionManager.getSessionFile(),
@@ -9521,7 +9538,7 @@ export class AgentSession {
9521
9538
  // No usage data - estimate all messages
9522
9539
  let estimated = 0;
9523
9540
  for (const message of messages) {
9524
- estimated += estimateTokens(message);
9541
+ estimated += estimateMessageTokensHeuristic(message);
9525
9542
  }
9526
9543
  return {
9527
9544
  tokens: estimated,
@@ -9531,7 +9548,7 @@ export class AgentSession {
9531
9548
  const usageTokens = calculatePromptTokens(lastUsage);
9532
9549
  let trailingTokens = 0;
9533
9550
  for (let i = lastUsageIndex + 1; i < messages.length; i++) {
9534
- trailingTokens += estimateTokens(messages[i]);
9551
+ trailingTokens += estimateMessageTokensHeuristic(messages[i]);
9535
9552
  }
9536
9553
 
9537
9554
  return {
@@ -3125,6 +3125,26 @@ export class SessionManager {
3125
3125
  return entry.id;
3126
3126
  }
3127
3127
 
3128
+ /**
3129
+ * Write mutated message entries back into the canonical entry store by id.
3130
+ *
3131
+ * `getBranch()` materializes resident-blob entries into copies, so in-place
3132
+ * mutation of returned entries (e.g. pruning tool outputs) does not affect
3133
+ * the canonical store. This applies such mutations for real.
3134
+ */
3135
+ applyEntryMessageUpdates(entries: readonly SessionMessageEntry[]): void {
3136
+ for (const updated of entries) {
3137
+ const canonical = this.#byId.get(updated.id);
3138
+ if (canonical?.type !== "message") continue;
3139
+ const residentEntry = prepareEntryForResidentSync(
3140
+ { ...canonical, message: updated.message },
3141
+ this.#residentBlobStore,
3142
+ ) as SessionMessageEntry;
3143
+ canonical.message = residentEntry.message;
3144
+ }
3145
+ this.#needsFullRewriteOnNextPersist = true;
3146
+ }
3147
+
3128
3148
  /**
3129
3149
  * Rewrite the session file after in-place entry updates.
3130
3150
  * Use sparingly (e.g., pruning old tool outputs).
@@ -0,0 +1,30 @@
1
+ # GJC Hermes operator instructions v{{TEMPLATE_VERSION}}
2
+
3
+ Server key: {{SERVER_KEY}}
4
+
5
+ These instructions teach a Hermes-style coordinator how to operate GJC through the `{{TOOL_PREFIX}}_*` MCP tools. They are setup guidance, not a GJC workflow skill.
6
+
7
+ ## Core loop
8
+
9
+ 1. Use `{{TOOL_PREFIX}}_list_sessions` to find an existing session, or `{{TOOL_PREFIX}}_start_session` when a new session is required and mutation is enabled.
10
+ 2. Send exactly one bounded task prompt with `{{TOOL_PREFIX}}_send_prompt`.
11
+ 3. Store the returned `turn_id`.
12
+ 4. Poll `{{TOOL_PREFIX}}_read_turn` or `{{TOOL_PREFIX}}_await_turn` for that `turn_id` until the turn is terminal.
13
+ If a second task is needed while one turn is active, pass `queue: true`; the next queued turn is promoted after the active turn is reported terminal.
14
+ 5. If GJC asks a structured question, use `{{TOOL_PREFIX}}_list_questions` and answer with `{{TOOL_PREFIX}}_submit_question_answer`.
15
+ 6. Use `{{TOOL_PREFIX}}_report_status` for coordinator-visible status and final reports.
16
+ 7. Use `{{TOOL_PREFIX}}_read_tail` only as advisory debug output when structured turn state is insufficient.
17
+
18
+ Do not report completion to the user until the GJC turn is terminal. Do not infer completion from terminal scrollback alone.
19
+
20
+ ## Worktree, model, and provider policy
21
+
22
+ The Hermes bridge does not choose a model/provider. Generated setup configures `GJC_COORDINATOR_MCP_SESSION_COMMAND` to `gjc --worktree` by default, so GJC creates and tracks the worktree while still using normal local model/provider resolution. Keep worktree creation inside GJC rather than creating unmanaged Hermes-side git worktrees; this preserves the original project identity for session listing and resume. If the operator config supplies a different `GJC_COORDINATOR_MCP_SESSION_COMMAND`, preserve it as explicit user intent.
23
+
24
+ Provider-specific commands are examples only, never product defaults.
25
+
26
+ ## Safety
27
+
28
+ - Mutating tools require bridge startup mutation classes and per-call consent.
29
+ - Allowed roots restrict workdir and artifact paths.
30
+ - Artifact reads are bounded and should be treated as evidence, not unlimited filesystem access.
@@ -0,0 +1,484 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { YAML } from "bun";
6
+ import {
7
+ COORDINATOR_MCP_PROTOCOL_VERSION,
8
+ COORDINATOR_MCP_SERVER_NAME,
9
+ COORDINATOR_MCP_TOOL_NAMES,
10
+ } from "../coordinator/contract";
11
+ import { createCoordinatorMcpServer } from "../coordinator-mcp/server";
12
+ import operatorInstructionsTemplate from "./hermes/templates/operator-instructions.v1.md" with { type: "text" };
13
+
14
+ export type HermesMutationClass = "sessions" | "questions" | "reports";
15
+ export type HermesSetupMode = "render" | "install" | "check" | "smoke";
16
+
17
+ export interface HermesSetupFlags {
18
+ json?: boolean;
19
+ check?: boolean;
20
+ smoke?: boolean;
21
+ install?: boolean;
22
+ force?: boolean;
23
+ root?: string[];
24
+ repo?: string;
25
+ profile?: string;
26
+ sessionCommand?: string;
27
+ noWorktree?: boolean;
28
+ worktreeName?: string;
29
+ stateRoot?: string;
30
+ mutation?: string[];
31
+ artifactByteCap?: string;
32
+ serverKey?: string;
33
+ gjcCommand?: string;
34
+ target?: string;
35
+ profileDir?: string;
36
+ }
37
+
38
+ export interface CoordinatorSetupSpec {
39
+ schemaVersion: 1;
40
+ coordinator: "hermes";
41
+ serverKey: string;
42
+ serverName: typeof COORDINATOR_MCP_SERVER_NAME;
43
+ protocolVersion: typeof COORDINATOR_MCP_PROTOCOL_VERSION;
44
+ gjcCommand: string;
45
+ args: ["mcp-serve", "coordinator"];
46
+ roots: string[];
47
+ namespace: {
48
+ profile?: string;
49
+ repo?: string;
50
+ };
51
+ sessionCommand?: string;
52
+ sessionCommandSource: "default" | "explicit";
53
+ worktree: {
54
+ enabled: boolean;
55
+ name?: string;
56
+ };
57
+ stateRoot?: string;
58
+ mutationPolicy: {
59
+ classes: HermesMutationClass[];
60
+ perCallConsentRequired: true;
61
+ };
62
+ artifactByteCap?: number;
63
+ installTarget?: {
64
+ kind: "profile-dir" | "config-file";
65
+ path: string;
66
+ };
67
+ operatorTemplateVersion: 1;
68
+ contractDocVersion: 1;
69
+ }
70
+
71
+ export interface HermesSetupResult {
72
+ ok: boolean;
73
+ mode: HermesSetupMode;
74
+ files_written: string[];
75
+ previews: Array<{ path: string; content: string }>;
76
+ warnings: string[];
77
+ smoke: null | {
78
+ ok: boolean;
79
+ protocolVersion: string;
80
+ serverName: string;
81
+ requiredTools: string[];
82
+ missingTools: string[];
83
+ };
84
+ }
85
+
86
+ class HermesSetupError extends Error {
87
+ readonly exitCode: number;
88
+ constructor(message: string, exitCode: number) {
89
+ super(message);
90
+ this.name = "HermesSetupError";
91
+ this.exitCode = exitCode;
92
+ }
93
+ }
94
+
95
+ const MUTATION_CLASSES: HermesMutationClass[] = ["sessions", "questions", "reports"];
96
+ const MANAGED_BY = "gjc";
97
+ const SETUP_SCHEMA_VERSION = "1";
98
+ const DEFAULT_SERVER_KEY = "gjc_coordinator";
99
+ const DEFAULT_GJC_COMMAND = "gjc";
100
+ const DEFAULT_TIMEOUT = 180;
101
+ const DEFAULT_CONNECT_TIMEOUT = 60;
102
+
103
+ function isRecord(value: unknown): value is Record<string, unknown> {
104
+ return typeof value === "object" && value !== null && !Array.isArray(value);
105
+ }
106
+
107
+ function optionalTrim(value: string | undefined): string | undefined {
108
+ const trimmed = value?.trim();
109
+ return trimmed ? trimmed : undefined;
110
+ }
111
+
112
+ function normalizeRoots(roots: string[] | undefined): string[] {
113
+ if (!roots || roots.length === 0) {
114
+ throw new HermesSetupError("Hermes setup requires at least one --root <path>.", 2);
115
+ }
116
+ const seen = new Set<string>();
117
+ const normalized: string[] = [];
118
+ const home = path.resolve(os.homedir());
119
+ for (const root of roots) {
120
+ const trimmed = root.trim();
121
+ if (!trimmed) {
122
+ throw new HermesSetupError("Hermes setup root entries must not be empty.", 2);
123
+ }
124
+ const resolved = path.resolve(trimmed);
125
+ if (resolved === path.parse(resolved).root || resolved === path.resolve("/home") || resolved === home) {
126
+ throw new HermesSetupError(`Refusing broad Hermes MCP root: ${resolved}`, 2);
127
+ }
128
+ if (!seen.has(resolved)) {
129
+ seen.add(resolved);
130
+ normalized.push(resolved);
131
+ }
132
+ }
133
+ return normalized;
134
+ }
135
+
136
+ function parseMutationClasses(values: string[] | undefined): HermesMutationClass[] {
137
+ if (!values || values.length === 0) return [];
138
+ const classes: HermesMutationClass[] = [];
139
+ for (const raw of values) {
140
+ for (const part of raw.split(",")) {
141
+ const value = part.trim();
142
+ if (!value) continue;
143
+ if (value === "all") {
144
+ for (const cls of MUTATION_CLASSES) {
145
+ if (!classes.includes(cls)) classes.push(cls);
146
+ }
147
+ continue;
148
+ }
149
+ if (!MUTATION_CLASSES.includes(value as HermesMutationClass)) {
150
+ throw new HermesSetupError(`Invalid Hermes mutation class: ${value}`, 2);
151
+ }
152
+ if (!classes.includes(value as HermesMutationClass)) classes.push(value as HermesMutationClass);
153
+ }
154
+ }
155
+ return classes;
156
+ }
157
+
158
+ function parseByteCap(value: string | undefined): number | undefined {
159
+ if (value === undefined) return undefined;
160
+ const parsed = Number(value);
161
+ if (!Number.isInteger(parsed) || parsed <= 0) {
162
+ throw new HermesSetupError("--artifact-byte-cap must be a positive integer.", 2);
163
+ }
164
+ return parsed;
165
+ }
166
+
167
+ function normalizeWorktreeName(value: string | undefined): string | undefined {
168
+ const trimmed = optionalTrim(value);
169
+ if (!trimmed) return undefined;
170
+ if (trimmed.startsWith("-") || !/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,127}$/.test(trimmed)) {
171
+ throw new HermesSetupError(`Invalid Hermes worktree name: ${trimmed}`, 2);
172
+ }
173
+ return trimmed;
174
+ }
175
+
176
+ function resolveHermesWorktree(flags: HermesSetupFlags): CoordinatorSetupSpec["worktree"] {
177
+ if (flags.noWorktree && flags.worktreeName) {
178
+ throw new HermesSetupError("Use either --no-worktree or --worktree-name, not both.", 2);
179
+ }
180
+ const name = normalizeWorktreeName(flags.worktreeName);
181
+ return flags.noWorktree ? { enabled: false } : { enabled: true, ...(name ? { name } : {}) };
182
+ }
183
+
184
+ function resolveHermesSessionCommand(gjcCommand: string, flags: HermesSetupFlags): string {
185
+ const explicit = optionalTrim(flags.sessionCommand);
186
+ if (explicit) {
187
+ if (flags.noWorktree || flags.worktreeName) {
188
+ throw new HermesSetupError(
189
+ "Use either --session-command or Hermes worktree flags; explicit session commands are preserved exactly.",
190
+ 2,
191
+ );
192
+ }
193
+ return explicit;
194
+ }
195
+ const worktree = resolveHermesWorktree(flags);
196
+ if (!worktree.enabled) return gjcCommand;
197
+ return worktree.name ? `${gjcCommand} --worktree ${worktree.name}` : `${gjcCommand} --worktree`;
198
+ }
199
+
200
+ function normalizeInstallTarget(flags: HermesSetupFlags): CoordinatorSetupSpec["installTarget"] {
201
+ if (flags.target && flags.profileDir) {
202
+ throw new HermesSetupError("Use exactly one of --target or --profile-dir for Hermes setup install targets.", 2);
203
+ }
204
+ if (!flags.target && !flags.profileDir) return undefined;
205
+ return flags.profileDir
206
+ ? { kind: "profile-dir", path: path.resolve(flags.profileDir) }
207
+ : { kind: "config-file", path: path.resolve(flags.target!) };
208
+ }
209
+
210
+ export function buildHermesSetupSpec(flags: HermesSetupFlags): CoordinatorSetupSpec {
211
+ const roots = normalizeRoots(flags.root);
212
+ const gjcCommand = optionalTrim(flags.gjcCommand) ?? DEFAULT_GJC_COMMAND;
213
+ const sessionCommand = resolveHermesSessionCommand(gjcCommand, flags);
214
+ return {
215
+ schemaVersion: 1,
216
+ coordinator: "hermes",
217
+ serverKey: optionalTrim(flags.serverKey) ?? DEFAULT_SERVER_KEY,
218
+ serverName: COORDINATOR_MCP_SERVER_NAME,
219
+ protocolVersion: COORDINATOR_MCP_PROTOCOL_VERSION,
220
+ gjcCommand,
221
+ args: ["mcp-serve", "coordinator"],
222
+ roots,
223
+ namespace: {
224
+ ...(optionalTrim(flags.profile) ? { profile: optionalTrim(flags.profile) } : {}),
225
+ ...(optionalTrim(flags.repo) ? { repo: optionalTrim(flags.repo) } : {}),
226
+ },
227
+ worktree: resolveHermesWorktree(flags),
228
+ sessionCommandSource: optionalTrim(flags.sessionCommand) ? "explicit" : "default",
229
+ sessionCommand,
230
+ ...(optionalTrim(flags.stateRoot) ? { stateRoot: path.resolve(flags.stateRoot!) } : {}),
231
+ mutationPolicy: {
232
+ classes: parseMutationClasses(flags.mutation),
233
+ perCallConsentRequired: true,
234
+ },
235
+ ...(parseByteCap(flags.artifactByteCap) ? { artifactByteCap: parseByteCap(flags.artifactByteCap) } : {}),
236
+ ...(normalizeInstallTarget(flags) ? { installTarget: normalizeInstallTarget(flags) } : {}),
237
+ operatorTemplateVersion: 1,
238
+ contractDocVersion: 1,
239
+ };
240
+ }
241
+
242
+ function canonicalize(value: unknown): unknown {
243
+ if (Array.isArray(value)) return value.map(item => canonicalize(item));
244
+ if (!isRecord(value)) return value;
245
+ const output: Record<string, unknown> = {};
246
+ for (const key of Object.keys(value).sort()) {
247
+ const item = value[key];
248
+ if (item !== undefined) output[key] = canonicalize(item);
249
+ }
250
+ return output;
251
+ }
252
+
253
+ function signaturePayload(spec: CoordinatorSetupSpec): Record<string, unknown> {
254
+ return {
255
+ args: spec.args,
256
+ artifactByteCap: spec.artifactByteCap,
257
+ command: spec.gjcCommand,
258
+ contractDocVersion: spec.contractDocVersion,
259
+ coordinator: spec.coordinator,
260
+ mutationClasses: spec.mutationPolicy.classes,
261
+ worktree: spec.worktree,
262
+ sessionCommandSource: spec.sessionCommandSource,
263
+ namespace: spec.namespace,
264
+ operatorTemplateVersion: spec.operatorTemplateVersion,
265
+ roots: spec.roots,
266
+ schemaVersion: spec.schemaVersion,
267
+ serverKey: spec.serverKey,
268
+ sessionCommand: spec.sessionCommand,
269
+ stateRoot: spec.stateRoot,
270
+ };
271
+ }
272
+
273
+ export function computeHermesSetupSignature(spec: CoordinatorSetupSpec): string {
274
+ const canonical = JSON.stringify(canonicalize(signaturePayload(spec)));
275
+ return crypto.createHash("sha256").update(canonical).digest("hex");
276
+ }
277
+
278
+ export function renderHermesServerBlock(spec: CoordinatorSetupSpec): Record<string, unknown> {
279
+ const env: Record<string, string> = {
280
+ GJC_COORDINATOR_MCP_WORKDIR_ROOTS: spec.roots.join(path.delimiter),
281
+ GJC_COORDINATOR_MCP_SETUP_MANAGED_BY: MANAGED_BY,
282
+ GJC_COORDINATOR_MCP_SETUP_SCHEMA_VERSION: SETUP_SCHEMA_VERSION,
283
+ GJC_COORDINATOR_MCP_SETUP_SIGNATURE: computeHermesSetupSignature(spec),
284
+ };
285
+ if (spec.namespace.profile) env.GJC_COORDINATOR_MCP_PROFILE = spec.namespace.profile;
286
+ if (spec.namespace.repo) env.GJC_COORDINATOR_MCP_REPO = spec.namespace.repo;
287
+ if (spec.stateRoot) env.GJC_COORDINATOR_MCP_STATE_ROOT = spec.stateRoot;
288
+ if (spec.mutationPolicy.classes.length > 0)
289
+ env.GJC_COORDINATOR_MCP_MUTATIONS = spec.mutationPolicy.classes.join(",");
290
+ if (spec.artifactByteCap !== undefined) env.GJC_COORDINATOR_MCP_ARTIFACT_BYTE_CAP = String(spec.artifactByteCap);
291
+ if (spec.sessionCommand) env.GJC_COORDINATOR_MCP_SESSION_COMMAND = spec.sessionCommand;
292
+ return {
293
+ command: spec.gjcCommand,
294
+ args: spec.args,
295
+ env,
296
+ timeout: DEFAULT_TIMEOUT,
297
+ connect_timeout: DEFAULT_CONNECT_TIMEOUT,
298
+ enabled: true,
299
+ };
300
+ }
301
+
302
+ function renderConfigYaml(spec: CoordinatorSetupSpec): string {
303
+ return YAML.stringify({ mcp_servers: { [spec.serverKey]: renderHermesServerBlock(spec) } }, null, 2);
304
+ }
305
+
306
+ function renderOperatorTemplate(spec: CoordinatorSetupSpec): string {
307
+ return operatorInstructionsTemplate
308
+ .replaceAll("{{SERVER_KEY}}", spec.serverKey)
309
+ .replaceAll("{{TOOL_PREFIX}}", "gjc_coordinator")
310
+ .replaceAll("{{TEMPLATE_VERSION}}", String(spec.operatorTemplateVersion));
311
+ }
312
+
313
+ function serverBlockIsManaged(block: unknown): boolean {
314
+ if (!isRecord(block)) return false;
315
+ const env = block.env;
316
+ return (
317
+ isRecord(env) &&
318
+ env.GJC_COORDINATOR_MCP_SETUP_MANAGED_BY === MANAGED_BY &&
319
+ env.GJC_COORDINATOR_MCP_SETUP_SCHEMA_VERSION === SETUP_SCHEMA_VERSION &&
320
+ typeof env.GJC_COORDINATOR_MCP_SETUP_SIGNATURE === "string"
321
+ );
322
+ }
323
+
324
+ async function readYamlConfig(configPath: string): Promise<Record<string, unknown>> {
325
+ const exists = await Bun.file(configPath).exists();
326
+ if (!exists) return {};
327
+ const content = await Bun.file(configPath).text();
328
+ if (!content.trim()) return {};
329
+ const parsed = YAML.parse(content);
330
+ if (!isRecord(parsed)) {
331
+ throw new HermesSetupError(`Hermes config must be a YAML object: ${configPath}`, 2);
332
+ }
333
+ return parsed;
334
+ }
335
+
336
+ async function backupFile(filePath: string): Promise<string | null> {
337
+ if (!(await Bun.file(filePath).exists())) return null;
338
+ const stamp = new Date().toISOString().replaceAll(":", "").replaceAll(".", "");
339
+ const backupPath = `${filePath}.bak.${stamp}`;
340
+ await Bun.write(backupPath, Bun.file(filePath));
341
+ return backupPath;
342
+ }
343
+
344
+ function mergeHermesConfig(
345
+ existing: Record<string, unknown>,
346
+ spec: CoordinatorSetupSpec,
347
+ force: boolean,
348
+ ): Record<string, unknown> {
349
+ const currentServers = isRecord(existing.mcp_servers) ? existing.mcp_servers : {};
350
+ const existingBlock = currentServers[spec.serverKey];
351
+ if (existingBlock !== undefined && !serverBlockIsManaged(existingBlock) && !force) {
352
+ throw new HermesSetupError(`Hermes MCP server '${spec.serverKey}' already exists and is not managed by GJC.`, 3);
353
+ }
354
+ return {
355
+ ...existing,
356
+ mcp_servers: {
357
+ ...currentServers,
358
+ [spec.serverKey]: renderHermesServerBlock(spec),
359
+ },
360
+ };
361
+ }
362
+
363
+ function configPathForTarget(spec: CoordinatorSetupSpec): string | null {
364
+ if (!spec.installTarget) return null;
365
+ if (spec.installTarget.kind === "config-file") return spec.installTarget.path;
366
+ return path.join(spec.installTarget.path, "config.yaml");
367
+ }
368
+
369
+ function operatorPathForTarget(spec: CoordinatorSetupSpec): string | null {
370
+ if (spec.installTarget?.kind !== "profile-dir") return null;
371
+ return path.join(spec.installTarget.path, "skills", "autonomous-ai-agents", "gajae-code", "SKILL.md");
372
+ }
373
+
374
+ async function installConfig(spec: CoordinatorSetupSpec, force: boolean): Promise<string[]> {
375
+ const configPath = configPathForTarget(spec);
376
+ if (!configPath) return [];
377
+ const existing = await readYamlConfig(configPath);
378
+ const merged = mergeHermesConfig(existing, spec, force);
379
+ if (force) await backupFile(configPath);
380
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
381
+ await Bun.write(configPath, YAML.stringify(merged, null, 2));
382
+ const written = [configPath];
383
+ const operatorPath = operatorPathForTarget(spec);
384
+ if (operatorPath) {
385
+ if ((await Bun.file(operatorPath).exists()) && !force) {
386
+ const current = await Bun.file(operatorPath).text();
387
+ if (
388
+ !current.includes("GJC Hermes operator instructions") ||
389
+ !current.includes(`Server key: ${spec.serverKey}`)
390
+ ) {
391
+ throw new HermesSetupError(
392
+ `Operator instruction target already exists and is not managed by GJC: ${operatorPath}`,
393
+ 3,
394
+ );
395
+ }
396
+ }
397
+ if (force) await backupFile(operatorPath);
398
+ await fs.mkdir(path.dirname(operatorPath), { recursive: true });
399
+ await Bun.write(operatorPath, renderOperatorTemplate(spec));
400
+ written.push(operatorPath);
401
+ }
402
+ return written;
403
+ }
404
+
405
+ async function runSmoke(spec: CoordinatorSetupSpec): Promise<HermesSetupResult["smoke"]> {
406
+ const requiredTools = [...COORDINATOR_MCP_TOOL_NAMES];
407
+ const server = createCoordinatorMcpServer({ env: {} });
408
+ const listed = await server.handleJsonRpc({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} });
409
+ const listedResult = isRecord(listed.result) ? listed.result : {};
410
+ const tools = Array.isArray(listedResult.tools) ? listedResult.tools : [];
411
+ const advertised = new Set(tools.map(tool => (isRecord(tool) ? String(tool.name) : "")));
412
+ const missingTools = requiredTools.filter(tool => !advertised.has(tool));
413
+ return {
414
+ ok: missingTools.length === 0,
415
+ protocolVersion: spec.protocolVersion,
416
+ serverName: spec.serverName,
417
+ requiredTools,
418
+ missingTools,
419
+ };
420
+ }
421
+
422
+ export async function runHermesSetup(flags: HermesSetupFlags): Promise<HermesSetupResult> {
423
+ const spec = buildHermesSetupSpec(flags);
424
+ if (flags.install && !spec.installTarget) {
425
+ throw new HermesSetupError("Hermes setup --install requires --target or --profile-dir.", 2);
426
+ }
427
+ if (!flags.install && spec.installTarget && !flags.check && !flags.smoke) {
428
+ throw new HermesSetupError(
429
+ "Hermes setup target/profile-dir writes require --install; omit the target for render-only output.",
430
+ 2,
431
+ );
432
+ }
433
+ const mode: HermesSetupMode = flags.smoke ? "smoke" : flags.check ? "check" : flags.install ? "install" : "render";
434
+ const configPath = configPathForTarget(spec) ?? "hermes-config.yaml";
435
+ const previews = [
436
+ { path: configPath, content: renderConfigYaml(spec) },
437
+ { path: operatorPathForTarget(spec) ?? "operator-instructions.v1.md", content: renderOperatorTemplate(spec) },
438
+ ];
439
+ const files_written = flags.install ? await installConfig(spec, Boolean(flags.force)) : [];
440
+ const smoke = flags.smoke ? await runSmoke(spec) : null;
441
+ if (smoke && !smoke.ok) {
442
+ throw new HermesSetupError(`Hermes MCP smoke failed; missing tools: ${smoke.missingTools.join(", ")}`, 4);
443
+ }
444
+ return {
445
+ ok: true,
446
+ mode,
447
+ files_written,
448
+ previews,
449
+ warnings:
450
+ spec.sessionCommandSource === "explicit"
451
+ ? [
452
+ "Using explicit GJC_COORDINATOR_MCP_SESSION_COMMAND exactly as supplied; provider/model/worktree validation is not performed.",
453
+ ]
454
+ : spec.worktree.enabled
455
+ ? [
456
+ `GJC_COORDINATOR_MCP_SESSION_COMMAND defaults to '${spec.sessionCommand}' so GJC owns worktree creation and resume identity.`,
457
+ ]
458
+ : [
459
+ "GJC_COORDINATOR_MCP_SESSION_COMMAND defaults to the configured gjc command with worktree isolation disabled by user request.",
460
+ ],
461
+ smoke,
462
+ };
463
+ }
464
+
465
+ export function formatHermesSetupResult(result: HermesSetupResult): string {
466
+ const lines = [`Hermes setup ${result.mode} complete.`];
467
+ if (result.files_written.length > 0) {
468
+ lines.push("Written:");
469
+ for (const file of result.files_written) lines.push(`- ${file}`);
470
+ }
471
+ if (result.files_written.length === 0) {
472
+ lines.push("No files written. Use --install with --target or --profile-dir to apply.");
473
+ for (const preview of result.previews) lines.push(`Preview: ${preview.path}`);
474
+ }
475
+ for (const warning of result.warnings) lines.push(`Warning: ${warning}`);
476
+ if (result.smoke) {
477
+ lines.push(`Smoke: ${result.smoke.ok ? "passed" : "failed"} (${result.smoke.requiredTools.length} tools)`);
478
+ }
479
+ return lines.join("\n");
480
+ }
481
+
482
+ export function hermesSetupExitCode(error: unknown): number {
483
+ return error instanceof HermesSetupError ? error.exitCode : 1;
484
+ }