@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.
- package/CHANGELOG.md +42 -0
- package/dist/types/async/job-manager.d.ts +19 -1
- package/dist/types/cli/fast-help.d.ts +1 -0
- package/dist/types/cli/setup-cli.d.ts +16 -1
- package/dist/types/commands/coordinator.d.ts +19 -0
- package/dist/types/commands/harness.d.ts +3 -0
- package/dist/types/commands/mcp-serve.d.ts +24 -0
- package/dist/types/commands/setup.d.ts +47 -0
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/coordinator/contract.d.ts +4 -0
- package/dist/types/coordinator-mcp/policy.d.ts +24 -0
- package/dist/types/coordinator-mcp/safety.d.ts +26 -0
- package/dist/types/coordinator-mcp/server.d.ts +58 -0
- package/dist/types/extensibility/extensions/types.d.ts +13 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
- package/dist/types/harness-control-plane/finalize.d.ts +5 -0
- package/dist/types/harness-control-plane/phase-rollup.d.ts +23 -0
- package/dist/types/harness-control-plane/receipt-ingest.d.ts +19 -0
- package/dist/types/harness-control-plane/receipts.d.ts +46 -0
- package/dist/types/harness-control-plane/rpc-adapter.d.ts +3 -0
- package/dist/types/harness-control-plane/types.d.ts +9 -1
- package/dist/types/main.d.ts +2 -2
- package/dist/types/modes/components/hook-selector.d.ts +11 -0
- package/dist/types/modes/utils/abort-message.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +8 -0
- package/dist/types/setup/hermes-setup.d.ts +78 -0
- package/dist/types/task/fork-context-advisory.d.ts +13 -0
- package/dist/types/task/receipt.d.ts +1 -0
- package/dist/types/task/render.d.ts +7 -1
- package/dist/types/task/roi-reconciliation.d.ts +27 -0
- package/dist/types/task/types.d.ts +10 -0
- package/dist/types/tools/subagent-render.d.ts +25 -0
- package/dist/types/tools/subagent.d.ts +5 -1
- package/package.json +8 -7
- package/scripts/build-binary.ts +4 -0
- package/src/async/job-manager.ts +43 -1
- package/src/cli/fast-help.ts +80 -0
- package/src/cli/setup-cli.ts +95 -2
- package/src/cli.ts +109 -16
- package/src/commands/coordinator.ts +113 -0
- package/src/commands/harness.ts +92 -9
- package/src/commands/mcp-serve.ts +63 -0
- package/src/commands/setup.ts +34 -1
- package/src/config/models-config-schema.ts +1 -0
- package/src/coordinator/contract.ts +21 -0
- package/src/coordinator-mcp/policy.ts +160 -0
- package/src/coordinator-mcp/safety.ts +80 -0
- package/src/coordinator-mcp/server.ts +1519 -0
- package/src/cursor.ts +30 -2
- package/src/extensibility/extensions/types.ts +13 -0
- package/src/gjc-runtime/launch-worktree.ts +12 -1
- package/src/gjc-runtime/session-state-sidecar.ts +117 -0
- package/src/harness-control-plane/finalize.ts +39 -5
- package/src/harness-control-plane/owner.ts +9 -1
- package/src/harness-control-plane/phase-rollup.ts +96 -0
- package/src/harness-control-plane/receipt-ingest.ts +127 -0
- package/src/harness-control-plane/receipts.ts +229 -1
- package/src/harness-control-plane/rpc-adapter.ts +8 -0
- package/src/harness-control-plane/types.ts +29 -1
- package/src/internal-urls/docs-index.generated.ts +6 -4
- package/src/main.ts +7 -3
- package/src/modes/components/hook-selector.ts +109 -5
- package/src/modes/components/status-line.ts +6 -6
- package/src/modes/controllers/event-controller.ts +5 -4
- package/src/modes/controllers/extension-ui-controller.ts +16 -1
- package/src/modes/interactive-mode.ts +4 -5
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/utils/abort-message.ts +41 -0
- package/src/modes/utils/context-usage.ts +15 -8
- package/src/modes/utils/ui-helpers.ts +5 -6
- package/src/prompts/agents/architect.md +6 -0
- package/src/prompts/agents/critic.md +6 -0
- package/src/prompts/agents/planner.md +8 -1
- package/src/sdk.ts +9 -4
- package/src/session/agent-session.ts +22 -5
- package/src/session/session-manager.ts +20 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +30 -0
- package/src/setup/hermes-setup.ts +484 -0
- package/src/task/fork-context-advisory.ts +99 -0
- package/src/task/index.ts +33 -2
- package/src/task/receipt.ts +2 -0
- package/src/task/render.ts +14 -0
- package/src/task/roi-reconciliation.ts +90 -0
- package/src/task/types.ts +7 -0
- package/src/tools/ask.ts +30 -10
- package/src/tools/index.ts +2 -2
- package/src/tools/renderers.ts +2 -0
- package/src/tools/subagent-render.ts +169 -0
- package/src/tools/subagent.ts +49 -7
- 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()
|
|
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,
|
|
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 +=
|
|
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 +=
|
|
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
|
+
}
|