@gajae-code/coding-agent 0.11.7 → 0.11.9
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 +73 -1
- package/dist/types/cli/args.d.ts +0 -2
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/config/keybindings.d.ts +33 -3
- package/dist/types/config/model-profile-activation.d.ts +4 -3
- package/dist/types/config/model-profile-contract.d.ts +38 -0
- package/dist/types/config/model-profiles.d.ts +2 -3
- package/dist/types/config/settings-schema.d.ts +15 -2
- package/dist/types/edit/modes/patch.d.ts +13 -0
- package/dist/types/edit/path-mutation-lock.d.ts +15 -0
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/eval/py/tool-bridge.d.ts +1 -2
- package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
- package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
- package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
- package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
- package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +44 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
- package/dist/types/main.d.ts +8 -0
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
- package/dist/types/modes/components/tool-status-header.d.ts +3 -2
- package/dist/types/modes/components/welcome.d.ts +4 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +5 -2
- package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
- package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
- package/dist/types/modes/types.d.ts +3 -0
- package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
- package/dist/types/runtime-mcp/manager.d.ts +1 -1
- package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
- package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
- package/dist/types/runtime-mcp/redaction.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
- package/dist/types/sdk/bus/config-commands.d.ts +2 -2
- package/dist/types/sdk/bus/config.d.ts +2 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +25 -0
- package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +15 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
- package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +1 -1
- package/dist/types/sdk/host/query/handlers.d.ts +9 -0
- package/dist/types/sdk/index.d.ts +2 -0
- package/dist/types/sdk/prompt-status.d.ts +88 -0
- package/dist/types/sdk/session-directory.d.ts +1 -1
- package/dist/types/sdk/startup-capability.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +22 -3
- package/dist/types/session/internal/managed-session-storage.d.ts +24 -4
- package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
- package/dist/types/session/session-manager.d.ts +5 -1
- package/dist/types/session/session-storage.d.ts +31 -0
- package/dist/types/skill-state/active-state.d.ts +4 -0
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/task/types.d.ts +94 -0
- package/dist/types/tools/ask.d.ts +454 -174
- package/dist/types/tools/image-gen.d.ts +47 -1
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/utils/prompt-suggestion.d.ts +33 -0
- package/package.json +8 -8
- package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
- package/scripts/dogfood-repository-binding.ts +209 -0
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/scripts/run-test-manifest.ts +4 -4
- package/src/cli/args.ts +2 -92
- package/src/cli/mcp-cli.ts +4 -21
- package/src/cli/notify-cli.ts +5 -38
- package/src/cli.ts +50 -0
- package/src/commands/deep-interview.ts +9 -4
- package/src/commands/sdk.ts +2 -2
- package/src/commit/agentic/index.ts +3 -3
- package/src/commit/map-reduce/index.ts +2 -2
- package/src/config/keybindings.ts +157 -24
- package/src/config/model-profile-activation.ts +7 -18
- package/src/config/model-profile-contract.ts +161 -0
- package/src/config/model-profiles.ts +2 -3
- package/src/config/model-registry.ts +11 -1
- package/src/config/settings-schema.ts +17 -2
- package/src/coordinator-mcp/model-preset.ts +21 -74
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +80 -165
- package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
- package/src/edit/index.ts +3 -6
- package/src/edit/modes/patch.ts +38 -1
- package/src/edit/modes/replace.ts +42 -0
- package/src/edit/path-mutation-lock.ts +112 -0
- package/src/eval/py/executor.ts +30 -11
- package/src/eval/py/prelude.py +2 -2
- package/src/eval/py/tool-bridge.ts +34 -18
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
- package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
- package/src/gjc-runtime/deep-interview-draft.ts +842 -0
- package/src/gjc-runtime/deep-interview-payload.ts +262 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +294 -226
- package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
- package/src/gjc-runtime/deep-interview-state.ts +916 -4
- package/src/gjc-runtime/launch-tmux.ts +1 -1
- package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
- package/src/gjc-runtime/ralplan-runtime.ts +78 -4
- package/src/gjc-runtime/repository-binding.ts +267 -0
- package/src/gjc-runtime/session-state-sidecar.ts +25 -9
- package/src/gjc-runtime/state-writer.ts +166 -2
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
- package/src/gjc-runtime/tmux-sessions.ts +45 -6
- package/src/gjc-runtime/ultragoal-guard.ts +5 -5
- package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
- package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
- package/src/gjc-runtime/workflow-manifest.ts +191 -5
- package/src/internal-urls/docs-index.generated.ts +8 -6
- package/src/internal-urls/local-protocol.ts +33 -16
- package/src/internal-urls/mcp-protocol.ts +73 -11
- package/src/main.ts +25 -10
- package/src/modes/DESIGN.md +5 -0
- package/src/modes/acp/acp-agent.ts +50 -16
- package/src/modes/components/model-selector.ts +42 -8
- package/src/modes/components/plan-preview-overlay.ts +1 -1
- package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
- package/src/modes/components/tool-status-header.ts +20 -4
- package/src/modes/components/welcome.ts +32 -20
- package/src/modes/controllers/command-controller.ts +97 -54
- package/src/modes/controllers/event-controller.ts +10 -0
- package/src/modes/controllers/input-controller.ts +7 -2
- package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
- package/src/modes/controllers/selector-controller.ts +46 -54
- package/src/modes/interactive-mode.ts +70 -36
- package/src/modes/prompt-action-autocomplete.ts +32 -13
- package/src/modes/prompt-suggestion-controller.ts +96 -0
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/hotkeys-markdown.ts +64 -36
- package/src/modes/utils/ui-helpers.ts +5 -3
- package/src/prompts/agents/architect.md +0 -1
- package/src/prompts/agents/critic.md +0 -1
- package/src/prompts/system/prompt-suggestion-system.md +32 -0
- package/src/prompts/system/system-prompt.md +7 -4
- package/src/prompts/tools/cron.md +3 -1
- package/src/runtime-mcp/json-rpc.ts +7 -4
- package/src/runtime-mcp/manager.ts +51 -53
- package/src/runtime-mcp/oauth-flow.ts +0 -72
- package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
- package/src/runtime-mcp/redaction.ts +30 -0
- package/src/runtime-mcp/transports/http.ts +13 -6
- package/src/sdk/broker/broker.ts +34 -20
- package/src/sdk/broker/lifecycle.ts +185 -36
- package/src/sdk/broker/session-index.ts +11 -1
- package/src/sdk/bus/chat-daemon-control.ts +20 -5
- package/src/sdk/bus/config-commands.ts +2 -2
- package/src/sdk/bus/config.ts +9 -1
- package/src/sdk/bus/conversation-store.ts +40 -6
- package/src/sdk/bus/index.ts +240 -65
- package/src/sdk/bus/lifecycle-commands.ts +47 -3
- package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
- package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
- package/src/sdk/bus/notification-orchestration.ts +12 -0
- package/src/sdk/bus/notification-service.ts +27 -2
- package/src/sdk/bus/prompt-reconciliation.ts +239 -0
- package/src/sdk/bus/recent-activity.ts +90 -26
- package/src/sdk/bus/telegram-daemon-contract.ts +15 -3
- package/src/sdk/bus/telegram-daemon-control.ts +145 -33
- package/src/sdk/bus/telegram-daemon.ts +853 -112
- package/src/sdk/bus/telegram-reference.ts +20 -2
- package/src/sdk/cli/session-cli.ts +15 -2
- package/src/sdk/host/control/dispatch.ts +1 -1
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/host.ts +2 -6
- package/src/sdk/host/query/handlers.ts +70 -5
- package/src/sdk/index.ts +12 -0
- package/src/sdk/prompt-status.ts +97 -0
- package/src/sdk/protocol/operation-inventory.generated.json +47 -0
- package/src/sdk/protocol/operation-registry.ts +18 -4
- package/src/sdk/session-directory.ts +5 -1
- package/src/sdk/session.ts +2 -0
- package/src/sdk/startup-capability.ts +9 -1
- package/src/session/agent-session.ts +138 -5
- package/src/session/blob-store.ts +7 -2
- package/src/session/internal/managed-session-scope.ts +998 -133
- package/src/session/internal/managed-session-storage.ts +268 -203
- package/src/session/internal/native-publish-outcome.ts +266 -0
- package/src/session/session-manager.ts +406 -360
- package/src/session/session-storage.ts +151 -11
- package/src/skill-state/active-state.ts +10 -0
- package/src/task/executor.ts +13 -5
- package/src/task/gjc-command.ts +2 -2
- package/src/task/index.ts +121 -15
- package/src/task/receipt.ts +3 -0
- package/src/task/types.ts +34 -3
- package/src/tools/ask.ts +199 -88
- package/src/tools/ast-edit.ts +2 -2
- package/src/tools/bash-pty-selection.ts +2 -2
- package/src/tools/browser/screenshot-format.ts +6 -1
- package/src/tools/cron.ts +1 -1
- package/src/tools/image-gen.ts +178 -6
- package/src/tools/index.ts +4 -0
- package/src/tools/render-utils.ts +7 -1
- package/src/utils/edit-mode.ts +2 -2
- package/src/utils/prompt-suggestion.ts +265 -0
- package/src/utils/sixel.ts +3 -3
- package/src/web/insane/url-guard.ts +3 -1
- package/src/web/scrapers/docs-rs.ts +30 -5
|
@@ -705,7 +705,7 @@ function applyGjcTmuxRootTerminalTitleProfile(context: {
|
|
|
705
705
|
}
|
|
706
706
|
|
|
707
707
|
function shouldSetGjcTmuxRootTerminalTitle(parsed: Args, env: NodeJS.ProcessEnv): boolean {
|
|
708
|
-
return !parsed.noTitle && !env.PI_NO_TITLE;
|
|
708
|
+
return !parsed.noTitle && !(env.GJC_NO_TITLE || env.PI_NO_TITLE);
|
|
709
709
|
}
|
|
710
710
|
|
|
711
711
|
function buildTmuxRenameWindowArgs(title: string, target?: string): string[] {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
|
+
import * as fsSync from "node:fs";
|
|
2
3
|
import * as fs from "node:fs/promises";
|
|
3
4
|
import * as path from "node:path";
|
|
5
|
+
import { Process } from "@gajae-code/natives";
|
|
4
6
|
import { readLinuxProcStartTime } from "./linux-proc";
|
|
5
7
|
import { assertSafePathComponent } from "./session-layout";
|
|
6
|
-
import { lifecyclePaths } from "./tmux-owner-isolation";
|
|
8
|
+
import { lifecyclePaths, type OwnerIntent, observeOwnerTerminal } from "./tmux-owner-isolation";
|
|
7
9
|
|
|
8
10
|
export const MANAGED_OWNER_SUPERVISOR_ARG = "--internal-managed-owner-supervisor";
|
|
9
11
|
export const MANAGED_OWNER_CHILD_TOKEN_ENV = "GJC_MANAGED_OWNER_CHILD_TOKEN";
|
|
@@ -14,6 +16,15 @@ export const MANAGED_OWNER_STATE_DIR_ENV = "GJC_TMUX_OWNER_STATE_DIR";
|
|
|
14
16
|
export const MANAGED_OWNER_RUN_ID_ENV = "GJC_MANAGED_OWNER_RUN_ID";
|
|
15
17
|
export const MANAGED_OWNER_INCARNATION_ENV = "GJC_MANAGED_OWNER_INCARNATION";
|
|
16
18
|
|
|
19
|
+
let bootstrapSigtermPending = false;
|
|
20
|
+
const captureBootstrapSigterm = () => {
|
|
21
|
+
bootstrapSigtermPending = true;
|
|
22
|
+
};
|
|
23
|
+
if (process.argv.includes(MANAGED_OWNER_SUPERVISOR_ARG)) {
|
|
24
|
+
process.removeAllListeners("SIGTERM");
|
|
25
|
+
process.on("SIGTERM", captureBootstrapSigterm);
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
export interface ManagedOwnerBinding {
|
|
18
29
|
schema_version: 2;
|
|
19
30
|
generation: string;
|
|
@@ -52,7 +63,14 @@ function requiredEnvironment(name: string): string {
|
|
|
52
63
|
return value;
|
|
53
64
|
}
|
|
54
65
|
|
|
55
|
-
function lifecycleRoot(): {
|
|
66
|
+
function lifecycleRoot(): {
|
|
67
|
+
root: string;
|
|
68
|
+
stateDir: string;
|
|
69
|
+
generation: string;
|
|
70
|
+
sessionId: string;
|
|
71
|
+
runId: string;
|
|
72
|
+
incarnation: string;
|
|
73
|
+
} {
|
|
56
74
|
const stateDir = requiredEnvironment(MANAGED_OWNER_STATE_DIR_ENV);
|
|
57
75
|
const sessionId = requiredEnvironment(MANAGED_OWNER_SESSION_ID_ENV);
|
|
58
76
|
const generation = requiredEnvironment(MANAGED_OWNER_GENERATION_ENV);
|
|
@@ -68,7 +86,7 @@ function lifecycleRoot(): { root: string; generation: string; sessionId: string;
|
|
|
68
86
|
if (!path.isAbsolute(stateDir)) throw new Error("managed_owner_lifecycle_path_unsafe");
|
|
69
87
|
const root = lifecyclePaths(stateDir, sessionId, generation).root;
|
|
70
88
|
if (!root.startsWith(`${path.resolve(stateDir)}${path.sep}`)) throw new Error("managed_owner_lifecycle_path_unsafe");
|
|
71
|
-
return { root, generation, sessionId, runId, incarnation };
|
|
89
|
+
return { root, stateDir, generation, sessionId, runId, incarnation };
|
|
72
90
|
}
|
|
73
91
|
|
|
74
92
|
function commandDigest(command: readonly string[]): string {
|
|
@@ -106,8 +124,14 @@ export function isManagedOwnerSupervisorArgv(args: readonly string[]): boolean {
|
|
|
106
124
|
|
|
107
125
|
/** Runs one exact child and publishes authority only for a directly observed Linux signal 6. */
|
|
108
126
|
export async function runManagedOwnerSupervisor(): Promise<void> {
|
|
109
|
-
const { root, generation, sessionId, runId, incarnation } = lifecycleRoot();
|
|
127
|
+
const { root, stateDir, generation, sessionId, runId, incarnation } = lifecycleRoot();
|
|
110
128
|
const command = childCommand();
|
|
129
|
+
let sigtermPending = bootstrapSigtermPending;
|
|
130
|
+
const captureEarlySigterm = () => {
|
|
131
|
+
sigtermPending = true;
|
|
132
|
+
};
|
|
133
|
+
process.removeAllListeners("SIGTERM");
|
|
134
|
+
process.on("SIGTERM", captureEarlySigterm);
|
|
111
135
|
const supervisorStartTime = await readLinuxProcStartTime(process.pid);
|
|
112
136
|
if (!supervisorStartTime) throw new Error("managed_owner_supervisor_start_time_unavailable");
|
|
113
137
|
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
@@ -135,7 +159,62 @@ export async function runManagedOwnerSupervisor(): Promise<void> {
|
|
|
135
159
|
});
|
|
136
160
|
const childStartTime = await readLinuxProcStartTime(child.pid);
|
|
137
161
|
if (!childStartTime) throw new Error("managed_owner_child_start_time_unavailable");
|
|
162
|
+
const childProcess = Process.fromPid(child.pid);
|
|
163
|
+
if (!childProcess) throw new Error("managed_owner_child_reference_unavailable");
|
|
164
|
+
if (process.platform === "linux" && childProcess.incarnation !== `linux:${childStartTime}`)
|
|
165
|
+
throw new Error("managed_owner_child_incarnation_mismatch");
|
|
166
|
+
let childExited = false;
|
|
167
|
+
let sigtermRelayed = false;
|
|
168
|
+
let relayedIntent: OwnerIntent | null = null;
|
|
169
|
+
let relayedAt: string | null = null;
|
|
170
|
+
const relaySigterm = () => {
|
|
171
|
+
if (childExited || sigtermRelayed) return;
|
|
172
|
+
let candidateIntent: OwnerIntent | null = null;
|
|
173
|
+
try {
|
|
174
|
+
const candidate = JSON.parse(
|
|
175
|
+
fsSync.readFileSync(lifecyclePaths(stateDir, sessionId, generation).intentFile, "utf8"),
|
|
176
|
+
) as Partial<OwnerIntent>;
|
|
177
|
+
candidateIntent = typeof candidate.dispatch_id === "string" ? (candidate as OwnerIntent) : null;
|
|
178
|
+
} catch {
|
|
179
|
+
candidateIntent = null;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
if (!childProcess.signalRoot(15)) return;
|
|
183
|
+
} catch {
|
|
184
|
+
// The child exited between intent capture and delivery.
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
sigtermRelayed = true;
|
|
188
|
+
relayedAt = new Date().toISOString();
|
|
189
|
+
relayedIntent = candidateIntent;
|
|
190
|
+
};
|
|
191
|
+
sigtermPending ||= bootstrapSigtermPending;
|
|
192
|
+
process.removeListener("SIGTERM", captureBootstrapSigterm);
|
|
193
|
+
process.removeListener("SIGTERM", captureEarlySigterm);
|
|
194
|
+
process.on("SIGTERM", relaySigterm);
|
|
195
|
+
if (sigtermPending) relaySigterm();
|
|
138
196
|
const exitCode = await child.exited;
|
|
197
|
+
childExited = true;
|
|
198
|
+
process.removeListener("SIGTERM", relaySigterm);
|
|
199
|
+
const terminalIntent = relayedIntent as OwnerIntent | null;
|
|
200
|
+
const terminalObservedAt = relayedAt as string | null;
|
|
201
|
+
if (sigtermRelayed && terminalObservedAt && terminalIntent) {
|
|
202
|
+
await observeOwnerTerminal({
|
|
203
|
+
schema_version: 1,
|
|
204
|
+
op: "observe_terminal",
|
|
205
|
+
session_id: sessionId,
|
|
206
|
+
owner_generation: generation,
|
|
207
|
+
state_dir: stateDir,
|
|
208
|
+
socket_key: process.env.GJC_TMUX_OWNER_SERVER_KEY ?? "",
|
|
209
|
+
observer: "raw_monitor",
|
|
210
|
+
observed_at: terminalObservedAt,
|
|
211
|
+
signal: "SIGTERM",
|
|
212
|
+
exit_code: exitCode,
|
|
213
|
+
exit_kind: "supervisor_child_exit",
|
|
214
|
+
reason: "managed_owner_supervisor_exit",
|
|
215
|
+
operator_dispatch_id: terminalIntent.dispatch_id,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
139
218
|
if (child.signalCode === "SIGABRT") {
|
|
140
219
|
const receipt: ManagedOwnerSigabrtReceipt = {
|
|
141
220
|
schema_version: 2,
|
|
@@ -11,6 +11,15 @@ import {
|
|
|
11
11
|
type RalplanIndexRow,
|
|
12
12
|
summarizeRalplanIndex,
|
|
13
13
|
} from "./ledger-event-renderer";
|
|
14
|
+
import {
|
|
15
|
+
assertCwdMatchesRepositoryBinding,
|
|
16
|
+
assertPathUnderRepositoryBinding,
|
|
17
|
+
captureRepositoryBinding,
|
|
18
|
+
parseRepositoryBinding,
|
|
19
|
+
publicRepositoryBinding,
|
|
20
|
+
type RepositoryBinding,
|
|
21
|
+
RepositoryBindingError,
|
|
22
|
+
} from "./repository-binding";
|
|
14
23
|
import { GJC_RALPLAN_ARTIFACT_ENV, isRestrictedRoleAgentBash } from "./restricted-role-agent-bash";
|
|
15
24
|
import { modeStatePath, sessionPlansDir } from "./session-layout";
|
|
16
25
|
import { resolveGjcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
|
|
@@ -179,6 +188,43 @@ async function readActiveRunId(cwd: string, sessionId: string): Promise<string |
|
|
|
179
188
|
return candidate;
|
|
180
189
|
}
|
|
181
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Read the authoritative repository binding from ralplan run state and fail closed
|
|
193
|
+
* when the active cwd no longer matches (handoff / QA / stage write) (#2901).
|
|
194
|
+
*/
|
|
195
|
+
async function enforceRalplanRepositoryBinding(cwd: string, sessionId: string): Promise<RepositoryBinding> {
|
|
196
|
+
const statePath = ralplanStatePath(cwd, sessionId);
|
|
197
|
+
const existingRead = await readExistingStateForMutation(statePath);
|
|
198
|
+
if (existingRead.kind === "corrupt") {
|
|
199
|
+
throw new RalplanCommandError(
|
|
200
|
+
2,
|
|
201
|
+
`existing ralplan state is corrupt or tampered (${existingRead.error}); refusing to proceed at ${statePath}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (existingRead.kind === "absent") {
|
|
205
|
+
// No prior seed — capture live authority so subsequent handoffs still have a stamp.
|
|
206
|
+
return publicRepositoryBinding(await captureRepositoryBinding(cwd, { displayPath: cwd }));
|
|
207
|
+
}
|
|
208
|
+
const raw = existingRead.value.repository_binding ?? existingRead.value.repositoryBinding;
|
|
209
|
+
try {
|
|
210
|
+
if (raw === undefined) {
|
|
211
|
+
// Legacy seeds without a field: stamp current cwd and require future writes match it.
|
|
212
|
+
return publicRepositoryBinding(await captureRepositoryBinding(cwd, { displayPath: cwd }));
|
|
213
|
+
}
|
|
214
|
+
const binding = parseRepositoryBinding(raw);
|
|
215
|
+
await assertCwdMatchesRepositoryBinding(cwd, binding);
|
|
216
|
+
return publicRepositoryBinding(binding);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error instanceof RepositoryBindingError) {
|
|
219
|
+
throw new RalplanCommandError(2, `ralplan repository binding rejected: ${error.message}`);
|
|
220
|
+
}
|
|
221
|
+
throw new RalplanCommandError(
|
|
222
|
+
2,
|
|
223
|
+
`ralplan repository binding rejected: ${error instanceof Error ? error.message : String(error)}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
182
228
|
/**
|
|
183
229
|
* Run-state phases that an artifact write must never reopen. Once ralplan has
|
|
184
230
|
* reached a terminal/handed-off phase, a stray `--write` must not regress
|
|
@@ -651,6 +697,24 @@ async function buildRalplanHud(options: {
|
|
|
651
697
|
async function handleArtifactWrite(args: readonly string[], cwd: string): Promise<RalplanCommandResult> {
|
|
652
698
|
const plannerState = parsePlannerStateArgs(args);
|
|
653
699
|
const resolved = await resolveArtifactArgs(args, cwd);
|
|
700
|
+
// Fail closed before stage persistence / path writes when cwd drifted to a sibling repo.
|
|
701
|
+
const repositoryBinding = await enforceRalplanRepositoryBinding(cwd, resolved.sessionId);
|
|
702
|
+
// Artifact file paths (when --artifact points at a file) must stay under the bound root.
|
|
703
|
+
const rawArtifact = flagValue(args, "--artifact");
|
|
704
|
+
if (rawArtifact && !isRestrictedRoleAgentBash()) {
|
|
705
|
+
const candidate = path.isAbsolute(rawArtifact) ? rawArtifact : path.resolve(cwd, rawArtifact);
|
|
706
|
+
try {
|
|
707
|
+
const stat = await fs.stat(candidate);
|
|
708
|
+
if (stat.isFile()) {
|
|
709
|
+
assertPathUnderRepositoryBinding(repositoryBinding, candidate);
|
|
710
|
+
}
|
|
711
|
+
} catch (error) {
|
|
712
|
+
if (error instanceof RepositoryBindingError) {
|
|
713
|
+
throw new RalplanCommandError(2, `ralplan repository binding rejected: ${error.message}`);
|
|
714
|
+
}
|
|
715
|
+
// Non-file / missing path is inline content; resolveArtifactContent already handled it.
|
|
716
|
+
}
|
|
717
|
+
}
|
|
654
718
|
const content = resolved.artifact.endsWith("\n") ? resolved.artifact : `${resolved.artifact}\n`;
|
|
655
719
|
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
656
720
|
|
|
@@ -671,7 +735,7 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
|
|
|
671
735
|
`refusing to overwrite ralplan ${resolved.stage} stage ${resolved.stageN} at ${existingArtifact.path}: an artifact with different content already exists (existing sha256=${existingArtifact.sha256}, new sha256=${sha256}). Use a new --stage_n to record another pass.`,
|
|
672
736
|
);
|
|
673
737
|
}
|
|
674
|
-
return buildDeduplicatedResult(resolved, existingArtifact, sha256, cwd);
|
|
738
|
+
return buildDeduplicatedResult(resolved, existingArtifact, sha256, cwd, repositoryBinding);
|
|
675
739
|
}
|
|
676
740
|
|
|
677
741
|
// Keep run-state `current_phase` coherent with the stage being persisted.
|
|
@@ -696,6 +760,7 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
|
|
|
696
760
|
stage: persisted.stage,
|
|
697
761
|
stage_n: persisted.stageN,
|
|
698
762
|
sha256: persisted.sha256,
|
|
763
|
+
repository_binding: repositoryBinding,
|
|
699
764
|
created_at: persisted.createdAt,
|
|
700
765
|
};
|
|
701
766
|
if (persisted.pendingApprovalPath) payload.pending_approval_path = persisted.pendingApprovalPath;
|
|
@@ -716,6 +781,7 @@ function buildDeduplicatedResult(
|
|
|
716
781
|
existing: ExistingStageArtifact,
|
|
717
782
|
sha256: string,
|
|
718
783
|
cwd: string,
|
|
784
|
+
repositoryBinding: RepositoryBinding,
|
|
719
785
|
): RalplanCommandResult {
|
|
720
786
|
const payload: Record<string, unknown> = {
|
|
721
787
|
run_id: resolved.runId,
|
|
@@ -723,6 +789,7 @@ function buildDeduplicatedResult(
|
|
|
723
789
|
stage: resolved.stage,
|
|
724
790
|
stage_n: resolved.stageN,
|
|
725
791
|
sha256,
|
|
792
|
+
repository_binding: repositoryBinding,
|
|
726
793
|
created_at: existing.createdAt,
|
|
727
794
|
deduplicated: true,
|
|
728
795
|
};
|
|
@@ -809,7 +876,7 @@ function resolveConsensusArgs(args: readonly string[], cwd: string): ConsensusHa
|
|
|
809
876
|
async function seedRalplanState(
|
|
810
877
|
cwd: string,
|
|
811
878
|
resolved: ConsensusHandoffArgs,
|
|
812
|
-
): Promise<{ statePath: string; runId: string }> {
|
|
879
|
+
): Promise<{ statePath: string; runId: string; repositoryBinding: RepositoryBinding }> {
|
|
813
880
|
const statePath = ralplanStatePath(cwd, resolved.sessionId);
|
|
814
881
|
// Reuse an existing run id when present so a re-invocation of `gjc ralplan "task"` doesn't
|
|
815
882
|
// orphan in-progress artifacts under a fresh run id.
|
|
@@ -817,6 +884,11 @@ async function seedRalplanState(
|
|
|
817
884
|
const runId = existingRunId ?? resolved.sessionId ?? defaultRunId();
|
|
818
885
|
assertSafePathComponent(runId, "run-id");
|
|
819
886
|
const now = new Date().toISOString();
|
|
887
|
+
// When an active seed already carries authority, re-entry must match it (fail closed).
|
|
888
|
+
// Otherwise stamp the current cwd as the durable binding for this run.
|
|
889
|
+
const repositoryBinding = existingRunId
|
|
890
|
+
? await enforceRalplanRepositoryBinding(cwd, resolved.sessionId)
|
|
891
|
+
: publicRepositoryBinding(await captureRepositoryBinding(cwd, { displayPath: cwd }));
|
|
820
892
|
const payload: Record<string, unknown> = {
|
|
821
893
|
active: true,
|
|
822
894
|
current_phase: "planner",
|
|
@@ -827,6 +899,7 @@ async function seedRalplanState(
|
|
|
827
899
|
task: resolved.task,
|
|
828
900
|
run_id: runId,
|
|
829
901
|
updated_at: now,
|
|
902
|
+
repository_binding: repositoryBinding,
|
|
830
903
|
};
|
|
831
904
|
if (resolved.architectKind) payload.architect_kind = resolved.architectKind;
|
|
832
905
|
if (resolved.criticKind) payload.critic_kind = resolved.criticKind;
|
|
@@ -849,7 +922,7 @@ async function seedRalplanState(
|
|
|
849
922
|
},
|
|
850
923
|
});
|
|
851
924
|
await writeSessionActivityMarker(cwd, resolved.sessionId, { writer: "ralplan-runtime", path: statePath });
|
|
852
|
-
return { statePath, runId };
|
|
925
|
+
return { statePath, runId, repositoryBinding };
|
|
853
926
|
}
|
|
854
927
|
|
|
855
928
|
async function handleConsensusHandoff(args: readonly string[], cwd: string): Promise<RalplanCommandResult> {
|
|
@@ -857,7 +930,7 @@ async function handleConsensusHandoff(args: readonly string[], cwd: string): Pro
|
|
|
857
930
|
if (!resolved.task) {
|
|
858
931
|
throw new RalplanCommandError(2, 'gjc ralplan requires a task description, e.g. `gjc ralplan "<task>"`.');
|
|
859
932
|
}
|
|
860
|
-
const { statePath, runId } = await seedRalplanState(cwd, resolved);
|
|
933
|
+
const { statePath, runId, repositoryBinding } = await seedRalplanState(cwd, resolved);
|
|
861
934
|
const mode = resolved.deliberate ? "deliberate" : "short";
|
|
862
935
|
await syncRalplanHud({
|
|
863
936
|
cwd,
|
|
@@ -875,6 +948,7 @@ async function handleConsensusHandoff(args: readonly string[], cwd: string): Pro
|
|
|
875
948
|
state_path: statePath,
|
|
876
949
|
run_id: runId,
|
|
877
950
|
handoff: "/skill:ralplan",
|
|
951
|
+
repository_binding: repositoryBinding,
|
|
878
952
|
};
|
|
879
953
|
const stdout = resolved.json
|
|
880
954
|
? renderCliWriteReceipt({ ok: true, ...summary })
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authoritative repository/worktree binding for plans and delegated tasks (#2901).
|
|
3
|
+
*
|
|
4
|
+
* Multi-repo parent directories must not let QA/review lanes infer a sibling repo
|
|
5
|
+
* from prose. Bindings carry a validated worktree root + git common-dir identity
|
|
6
|
+
* so spawn/handoff can fail closed on mismatch.
|
|
7
|
+
*/
|
|
8
|
+
import * as fssync from "node:fs";
|
|
9
|
+
import * as fs from "node:fs/promises";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { type GitRepository, head, repo } from "../utils/git";
|
|
12
|
+
|
|
13
|
+
export const REPOSITORY_BINDING_SCHEMA = "gjc.repository_binding.v1" as const;
|
|
14
|
+
|
|
15
|
+
export interface RepositoryBinding {
|
|
16
|
+
schema: typeof REPOSITORY_BINDING_SCHEMA;
|
|
17
|
+
/** Canonical realpath of the git worktree root (or resolved cwd root). */
|
|
18
|
+
worktreeRoot: string;
|
|
19
|
+
/** Git common dir realpath when available; null outside a git checkout. */
|
|
20
|
+
commonDir: string | null;
|
|
21
|
+
/** Optional repo-relative subdirectory the lane should operate under. */
|
|
22
|
+
relativeSubdir?: string;
|
|
23
|
+
/** Optional display path (may be non-canonical); never used for authority. */
|
|
24
|
+
displayPath?: string;
|
|
25
|
+
/** Optional baseline HEAD at capture time. */
|
|
26
|
+
head?: string;
|
|
27
|
+
/** Optional branch name at capture time (when not detached). */
|
|
28
|
+
branch?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type RepositoryBindingErrorCode =
|
|
32
|
+
| "not_a_repository"
|
|
33
|
+
| "identity_mismatch"
|
|
34
|
+
| "path_outside_root"
|
|
35
|
+
| "invalid_binding";
|
|
36
|
+
|
|
37
|
+
export class RepositoryBindingError extends Error {
|
|
38
|
+
readonly code: RepositoryBindingErrorCode;
|
|
39
|
+
|
|
40
|
+
constructor(code: RepositoryBindingErrorCode, message: string) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "RepositoryBindingError";
|
|
43
|
+
this.code = code;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function realpathOrResolve(target: string): Promise<string> {
|
|
48
|
+
try {
|
|
49
|
+
return await fs.realpath(target);
|
|
50
|
+
} catch {
|
|
51
|
+
return path.resolve(target);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Sync realpath for path-under-root checks (handles macOS /var → /private/var). */
|
|
56
|
+
function realpathSyncOrResolve(target: string): string {
|
|
57
|
+
try {
|
|
58
|
+
return fssync.realpathSync(target);
|
|
59
|
+
} catch {
|
|
60
|
+
return path.resolve(target);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
69
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Capture a durable binding from the active cwd/worktree. */
|
|
73
|
+
export async function captureRepositoryBinding(
|
|
74
|
+
cwd: string,
|
|
75
|
+
options: { relativeSubdir?: string; displayPath?: string } = {},
|
|
76
|
+
): Promise<RepositoryBinding> {
|
|
77
|
+
const resolvedCwd = await realpathOrResolve(cwd);
|
|
78
|
+
const repository = await repo.resolve(resolvedCwd);
|
|
79
|
+
const worktreeRoot = repository ? await realpathOrResolve(repository.repoRoot) : resolvedCwd;
|
|
80
|
+
const commonDir = repository ? await realpathOrResolve(repository.commonDir) : null;
|
|
81
|
+
|
|
82
|
+
let headSha: string | undefined;
|
|
83
|
+
let branch: string | undefined;
|
|
84
|
+
if (repository) {
|
|
85
|
+
const headState = await head.resolve(resolvedCwd);
|
|
86
|
+
if (headState) {
|
|
87
|
+
headSha = headState.commit || undefined;
|
|
88
|
+
branch = headState.kind === "ref" ? headState.branchName || undefined : undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const binding: RepositoryBinding = {
|
|
93
|
+
schema: REPOSITORY_BINDING_SCHEMA,
|
|
94
|
+
worktreeRoot,
|
|
95
|
+
commonDir,
|
|
96
|
+
...(options.relativeSubdir ? { relativeSubdir: normalizeRelativeSubdir(options.relativeSubdir) } : {}),
|
|
97
|
+
...(options.displayPath ? { displayPath: options.displayPath } : {}),
|
|
98
|
+
...(headSha ? { head: headSha } : {}),
|
|
99
|
+
...(branch ? { branch } : {}),
|
|
100
|
+
};
|
|
101
|
+
return binding;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeRelativeSubdir(relative: string): string {
|
|
105
|
+
const normalized = relative
|
|
106
|
+
.replaceAll("\\", "/")
|
|
107
|
+
.replace(/^\.\/+/u, "")
|
|
108
|
+
.replace(/\/+$/u, "");
|
|
109
|
+
if (normalized === "" || normalized === ".") {
|
|
110
|
+
throw new RepositoryBindingError("invalid_binding", "relativeSubdir must be a non-empty relative path");
|
|
111
|
+
}
|
|
112
|
+
if (path.isAbsolute(normalized) || normalized.split("/").includes("..")) {
|
|
113
|
+
throw new RepositoryBindingError("invalid_binding", "relativeSubdir must be repo-relative without '..' segments");
|
|
114
|
+
}
|
|
115
|
+
return normalized;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Parse a binding from JSON/plan payload; fail closed on malformed shapes. */
|
|
119
|
+
export function parseRepositoryBinding(value: unknown): RepositoryBinding {
|
|
120
|
+
if (!isObject(value)) {
|
|
121
|
+
throw new RepositoryBindingError("invalid_binding", "repository binding must be an object");
|
|
122
|
+
}
|
|
123
|
+
const schema = nonEmptyString(value.schema);
|
|
124
|
+
if (schema !== REPOSITORY_BINDING_SCHEMA) {
|
|
125
|
+
throw new RepositoryBindingError(
|
|
126
|
+
"invalid_binding",
|
|
127
|
+
`repository binding schema must be ${REPOSITORY_BINDING_SCHEMA}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const worktreeRoot = nonEmptyString(value.worktreeRoot ?? value.worktree_root);
|
|
131
|
+
if (!worktreeRoot) {
|
|
132
|
+
throw new RepositoryBindingError("invalid_binding", "repository binding requires worktreeRoot");
|
|
133
|
+
}
|
|
134
|
+
const commonDirRaw = value.commonDir ?? value.common_dir;
|
|
135
|
+
const commonDir =
|
|
136
|
+
commonDirRaw === null || commonDirRaw === undefined
|
|
137
|
+
? null
|
|
138
|
+
: (nonEmptyString(commonDirRaw) ??
|
|
139
|
+
(() => {
|
|
140
|
+
throw new RepositoryBindingError("invalid_binding", "commonDir must be a string or null");
|
|
141
|
+
})());
|
|
142
|
+
const relativeSubdirRaw = value.relativeSubdir ?? value.relative_subdir;
|
|
143
|
+
const relativeSubdir =
|
|
144
|
+
relativeSubdirRaw === undefined ? undefined : normalizeRelativeSubdir(String(relativeSubdirRaw));
|
|
145
|
+
const displayPath = nonEmptyString(value.displayPath ?? value.display_path);
|
|
146
|
+
const head = nonEmptyString(value.head);
|
|
147
|
+
const branch = nonEmptyString(value.branch);
|
|
148
|
+
return {
|
|
149
|
+
schema: REPOSITORY_BINDING_SCHEMA,
|
|
150
|
+
worktreeRoot: path.resolve(worktreeRoot),
|
|
151
|
+
commonDir: commonDir === null ? null : path.resolve(commonDir),
|
|
152
|
+
...(relativeSubdir ? { relativeSubdir } : {}),
|
|
153
|
+
...(displayPath ? { displayPath } : {}),
|
|
154
|
+
...(head ? { head } : {}),
|
|
155
|
+
...(branch ? { branch } : {}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** True when two bindings refer to the same repository identity (linked worktrees ok). */
|
|
160
|
+
export function repositoryBindingsMatch(left: RepositoryBinding, right: RepositoryBinding): boolean {
|
|
161
|
+
if (left.commonDir && right.commonDir) {
|
|
162
|
+
return path.resolve(left.commonDir) === path.resolve(right.commonDir);
|
|
163
|
+
}
|
|
164
|
+
// Non-git workspaces: require exact worktree root match.
|
|
165
|
+
return path.resolve(left.worktreeRoot) === path.resolve(right.worktreeRoot);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Ensure `cwd` is inside the bound repository (or the same linked worktree family).
|
|
170
|
+
* Fails closed on sibling-repo drift.
|
|
171
|
+
*/
|
|
172
|
+
export async function assertCwdMatchesRepositoryBinding(
|
|
173
|
+
cwd: string,
|
|
174
|
+
binding: RepositoryBinding,
|
|
175
|
+
): Promise<RepositoryBinding> {
|
|
176
|
+
const active = await captureRepositoryBinding(cwd);
|
|
177
|
+
if (!repositoryBindingsMatch(active, binding)) {
|
|
178
|
+
throw new RepositoryBindingError(
|
|
179
|
+
"identity_mismatch",
|
|
180
|
+
`Active worktree does not match plan/task repository binding. active=${active.worktreeRoot} (commonDir=${active.commonDir ?? "none"}) bound=${binding.worktreeRoot} (commonDir=${binding.commonDir ?? "none"}).`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return active;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Ensure a declared target path resolves under the bound worktree root. */
|
|
187
|
+
export function assertPathUnderRepositoryBinding(binding: RepositoryBinding, targetPath: string): string {
|
|
188
|
+
const root = realpathSyncOrResolve(binding.worktreeRoot);
|
|
189
|
+
const base = binding.relativeSubdir ? realpathSyncOrResolve(path.resolve(root, binding.relativeSubdir)) : root;
|
|
190
|
+
const candidate = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(base, targetPath);
|
|
191
|
+
// Prefer realpath when the path exists so macOS /var ↔ /private/var aliases match.
|
|
192
|
+
const resolved = realpathSyncOrResolve(candidate);
|
|
193
|
+
const relative = path.relative(root, resolved);
|
|
194
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
195
|
+
throw new RepositoryBindingError(
|
|
196
|
+
"path_outside_root",
|
|
197
|
+
`Path escapes bound repository root: ${targetPath} (root=${root})`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return resolved;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Public identity snapshot for receipts/handoffs (no display-only fields required).
|
|
205
|
+
* Always includes the schema + durable roots so downstream lanes can re-verify.
|
|
206
|
+
*/
|
|
207
|
+
export function publicRepositoryBinding(binding: RepositoryBinding): RepositoryBinding {
|
|
208
|
+
return {
|
|
209
|
+
schema: REPOSITORY_BINDING_SCHEMA,
|
|
210
|
+
worktreeRoot: path.resolve(binding.worktreeRoot),
|
|
211
|
+
commonDir: binding.commonDir === null ? null : path.resolve(binding.commonDir),
|
|
212
|
+
...(binding.relativeSubdir ? { relativeSubdir: binding.relativeSubdir } : {}),
|
|
213
|
+
...(binding.displayPath ? { displayPath: binding.displayPath } : {}),
|
|
214
|
+
...(binding.head ? { head: binding.head } : {}),
|
|
215
|
+
...(binding.branch ? { branch: binding.branch } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve the authoritative binding for a delegated task before discovery/spawn.
|
|
221
|
+
*
|
|
222
|
+
* - Missing declaration → stamp from session cwd (never leave authority implicit).
|
|
223
|
+
* - Declared binding → parse + fail closed unless it matches the active session worktree.
|
|
224
|
+
* - relativeSubdir (when present) must resolve under the bound root.
|
|
225
|
+
*/
|
|
226
|
+
export async function resolveTaskRepositoryBinding(
|
|
227
|
+
sessionCwd: string,
|
|
228
|
+
declared: unknown | undefined,
|
|
229
|
+
): Promise<RepositoryBinding> {
|
|
230
|
+
const sessionBinding = await captureRepositoryBinding(sessionCwd, { displayPath: sessionCwd });
|
|
231
|
+
if (declared === undefined || declared === null) {
|
|
232
|
+
return publicRepositoryBinding(sessionBinding);
|
|
233
|
+
}
|
|
234
|
+
const taskBinding = parseRepositoryBinding(declared);
|
|
235
|
+
await assertCwdMatchesRepositoryBinding(sessionCwd, taskBinding);
|
|
236
|
+
if (taskBinding.relativeSubdir) {
|
|
237
|
+
assertPathUnderRepositoryBinding(taskBinding, ".");
|
|
238
|
+
}
|
|
239
|
+
return publicRepositoryBinding(taskBinding);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Ensure an execution/isolation root (cwd or worktree) still matches the bound identity.
|
|
244
|
+
* Used after isolation workspace creation so linked worktrees keep the source repository.
|
|
245
|
+
*/
|
|
246
|
+
export async function assertExecutionRootMatchesRepositoryBinding(
|
|
247
|
+
executionRoot: string,
|
|
248
|
+
binding: RepositoryBinding,
|
|
249
|
+
): Promise<RepositoryBinding> {
|
|
250
|
+
return await assertCwdMatchesRepositoryBinding(executionRoot, binding);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Optional helper for tests and diagnostics. */
|
|
254
|
+
export function bindingFromGitRepository(
|
|
255
|
+
repository: GitRepository,
|
|
256
|
+
options: { relativeSubdir?: string; displayPath?: string; head?: string; branch?: string } = {},
|
|
257
|
+
): RepositoryBinding {
|
|
258
|
+
return {
|
|
259
|
+
schema: REPOSITORY_BINDING_SCHEMA,
|
|
260
|
+
worktreeRoot: path.resolve(repository.repoRoot),
|
|
261
|
+
commonDir: path.resolve(repository.commonDir),
|
|
262
|
+
...(options.relativeSubdir ? { relativeSubdir: normalizeRelativeSubdir(options.relativeSubdir) } : {}),
|
|
263
|
+
...(options.displayPath ? { displayPath: options.displayPath } : {}),
|
|
264
|
+
...(options.head ? { head: options.head } : {}),
|
|
265
|
+
...(options.branch ? { branch: options.branch } : {}),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -911,15 +911,11 @@ async function operatorDispatchIdForOwner(
|
|
|
911
911
|
}
|
|
912
912
|
}
|
|
913
913
|
|
|
914
|
-
async function
|
|
914
|
+
async function observeOwnerTerminalPostmortem(
|
|
915
915
|
reason: postmortem.Reason,
|
|
916
|
-
|
|
917
|
-
stateFile: string,
|
|
916
|
+
owner: OwnerTerminalContext,
|
|
918
917
|
sessionId: string,
|
|
919
|
-
|
|
920
|
-
): Promise<void> {
|
|
921
|
-
const owner = context.ownerTerminal;
|
|
922
|
-
if (!owner) return;
|
|
918
|
+
): Promise<OwnerVerdict | null> {
|
|
923
919
|
try {
|
|
924
920
|
const now = new Date().toISOString();
|
|
925
921
|
const observation: Omit<ObserveTerminalRequest, "operator_dispatch_id"> = {
|
|
@@ -937,10 +933,27 @@ async function persistCoordinatorRuntimeStateFromOwnerTerminalPostmortem(
|
|
|
937
933
|
reason: "process_postmortem",
|
|
938
934
|
};
|
|
939
935
|
const operatorDispatchId = await operatorDispatchIdForOwner(owner, observation);
|
|
940
|
-
|
|
936
|
+
return await observeOwnerTerminal({
|
|
941
937
|
...observation,
|
|
942
938
|
...(operatorDispatchId ? { operator_dispatch_id: operatorDispatchId } : {}),
|
|
943
939
|
});
|
|
940
|
+
} catch {
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
async function persistCoordinatorRuntimeStateFromOwnerTerminalPostmortem(
|
|
946
|
+
context: RuntimeStateContext,
|
|
947
|
+
stateFile: string,
|
|
948
|
+
sessionId: string,
|
|
949
|
+
previous: Record<string, unknown>,
|
|
950
|
+
verdict: OwnerVerdict | null,
|
|
951
|
+
): Promise<void> {
|
|
952
|
+
const owner = context.ownerTerminal;
|
|
953
|
+
if (!owner) return;
|
|
954
|
+
try {
|
|
955
|
+
if (!verdict) throw new Error("owner terminal verdict unavailable");
|
|
956
|
+
const now = new Date().toISOString();
|
|
944
957
|
const expected = verdict.classification === "expected_operator_shutdown";
|
|
945
958
|
const state: RuntimeState = expected ? "completed" : "errored";
|
|
946
959
|
const payload = {
|
|
@@ -1009,6 +1022,9 @@ export async function persistCoordinatorRuntimeStateFromPostmortem(
|
|
|
1009
1022
|
const stateFile = runtimeStateFileForContext(context);
|
|
1010
1023
|
if (!stateFile) return;
|
|
1011
1024
|
const identity = normalizedIdentity(context);
|
|
1025
|
+
const ownerTerminalVerdict = context.ownerTerminal
|
|
1026
|
+
? await observeOwnerTerminalPostmortem(reason, context.ownerTerminal, identity.sessionId)
|
|
1027
|
+
: null;
|
|
1012
1028
|
await serializeStateFileWrite(
|
|
1013
1029
|
stateFile,
|
|
1014
1030
|
async () =>
|
|
@@ -1033,11 +1049,11 @@ export async function persistCoordinatorRuntimeStateFromPostmortem(
|
|
|
1033
1049
|
}
|
|
1034
1050
|
if (context.ownerTerminal) {
|
|
1035
1051
|
await persistCoordinatorRuntimeStateFromOwnerTerminalPostmortem(
|
|
1036
|
-
reason,
|
|
1037
1052
|
context,
|
|
1038
1053
|
stateFile,
|
|
1039
1054
|
identity.sessionId,
|
|
1040
1055
|
previous,
|
|
1056
|
+
ownerTerminalVerdict,
|
|
1041
1057
|
);
|
|
1042
1058
|
return;
|
|
1043
1059
|
}
|