agent-relay-server 0.122.0 → 0.122.1

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 (52) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -6
  3. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  4. package/runner/src/adapters/claude-delivery.ts +127 -0
  5. package/runner/src/adapters/claude-quota-harvest.ts +92 -0
  6. package/runner/src/adapters/claude-session-probe.ts +225 -0
  7. package/runner/src/adapters/claude-status-detectors.ts +147 -0
  8. package/runner/src/adapters/claude-transcript-tail.ts +128 -0
  9. package/runner/src/adapters/claude-transcript.ts +414 -0
  10. package/runner/src/adapters/claude.ts +672 -0
  11. package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
  12. package/runner/src/adapters/codex-client.ts +279 -0
  13. package/runner/src/adapters/codex.ts +1345 -0
  14. package/runner/src/attachment-cache.ts +189 -0
  15. package/runner/src/busy-reconciler.ts +102 -0
  16. package/runner/src/claim-tracker.ts +140 -0
  17. package/runner/src/claude-prompt-gates.ts +268 -0
  18. package/runner/src/context-probe-state.ts +6 -0
  19. package/runner/src/continuation-archive.ts +179 -0
  20. package/runner/src/control-server.ts +639 -0
  21. package/runner/src/index.ts +396 -0
  22. package/runner/src/launch-assembly.ts +508 -0
  23. package/runner/src/liveness.ts +50 -0
  24. package/runner/src/logger.ts +99 -0
  25. package/runner/src/mcp-outbox.ts +120 -0
  26. package/runner/src/message-body-config/config.ts +3 -0
  27. package/runner/src/message-body-config.ts +1 -0
  28. package/runner/src/native-self-resume.ts +202 -0
  29. package/runner/src/outbox.ts +342 -0
  30. package/runner/src/process-meta.ts +9 -0
  31. package/runner/src/profile-home.ts +260 -0
  32. package/runner/src/profile-projection.ts +305 -0
  33. package/runner/src/providers.ts +20 -0
  34. package/runner/src/provisioning.ts +314 -0
  35. package/runner/src/quota.ts +40 -0
  36. package/runner/src/rate-limit.ts +189 -0
  37. package/runner/src/relay-injection-events.ts +102 -0
  38. package/runner/src/relay-instructions.ts +60 -0
  39. package/runner/src/relay-mcp-proxy.ts +383 -0
  40. package/runner/src/relay-mcp.ts +156 -0
  41. package/runner/src/reply-obligation-cache.ts +136 -0
  42. package/runner/src/response-capture-report.ts +63 -0
  43. package/runner/src/runner-core.ts +2409 -0
  44. package/runner/src/runner-helpers.ts +435 -0
  45. package/runner/src/runner-insights.ts +105 -0
  46. package/runner/src/runner.ts +14 -0
  47. package/runner/src/session-destroy.ts +22 -0
  48. package/runner/src/session-insights.ts +118 -0
  49. package/runner/src/session-scratch.ts +271 -0
  50. package/runner/src/template-resolver.ts +29 -0
  51. package/runner/src/version.ts +43 -0
  52. package/src/gate-resolver.ts +7 -12
@@ -0,0 +1,118 @@
1
+ // Provider-agnostic core for the #184 context-gathering signal (epic #183).
2
+ //
3
+ // The transcript *format* is provider-specific (Claude JSONL, Codex app-server items,
4
+ // future providers), so each adapter normalizes its session into the same `SessionEvent`
5
+ // stream via `collectSessionEvents`. Everything downstream — the gathering/action
6
+ // classifier and the ratio math — lives here once and is shared, so a tool reclassified
7
+ // for one provider is reclassified for all, and a new provider only implements the
8
+ // normalization.
9
+ //
10
+ // The classifier is model-free and runs in the runner, so it costs zero agent tokens and
11
+ // the agent can't game it.
12
+
13
+ // A normalized, ordered session event. Order is significant: `leadingGather` counts the
14
+ // run of gathering tools before the first action.
15
+ export type SessionEvent =
16
+ // A tool invocation. Gathering-vs-action is decided here by `isGatheringTool(name)`.
17
+ | { type: "tool"; name: string }
18
+ // A failed tool result (paired outcome proxy — failures/workarounds the agent hit).
19
+ | { type: "tool_error" }
20
+ // A real user prompt (paired outcome proxy — more back-and-forth ~ clarification/correction).
21
+ | { type: "user_prompt" }
22
+ // A substantive assistant turn (one that produced text or a tool call).
23
+ | { type: "turn" };
24
+
25
+ // Tools that acquire context without changing anything. Anything not matched here is
26
+ // treated as an action (mutation, execution, or a delegation/direction decision) — Bash
27
+ // counts as an action because it executes (a conservative, documented choice for v0;
28
+ // `cat`/`ls` via Bash are misclassified, refine later if the data warrants it).
29
+ const GATHERING_TOOLS = new Set([
30
+ "Read", "Grep", "Glob", "LS", "NotebookRead", "WebFetch", "WebSearch",
31
+ ]);
32
+ const GATHERING_NAME = /(?:^|[._-])(read|get|list|search|grep|glob|find|fetch|query|browse|view|show|cat|status|inspect|lookup|symbols|snippet)/i;
33
+
34
+ export function isGatheringTool(name: string): boolean {
35
+ if (GATHERING_TOOLS.has(name)) return true;
36
+ // MCP / custom tools: classify by name shape (e.g. mcp__callmux__searxng_web_search).
37
+ return GATHERING_NAME.test(name);
38
+ }
39
+
40
+ interface ContextRatioMetric {
41
+ /** Session-wide gathering fraction: gatheringCalls / totalToolCalls. The headline metric. */
42
+ ratio: number;
43
+ gatheringCalls: number;
44
+ actionCalls: number;
45
+ totalToolCalls: number;
46
+ /** Consecutive gathering calls before the first action — the "read N files before moving" signal. */
47
+ leadingGather: number;
48
+ /** Substantive assistant turns (turns that produced text or a tool call). */
49
+ turns: number;
50
+ }
51
+
52
+ interface SessionOutcomeProxy {
53
+ /** Real user prompts in the session — more back-and-forth ~ more clarification/correction. */
54
+ userPrompts: number;
55
+ /** tool_result blocks flagged is_error — failures/workarounds the agent hit. */
56
+ toolErrors: number;
57
+ }
58
+
59
+ export interface SessionAnalysis {
60
+ metric: ContextRatioMetric;
61
+ outcome: SessionOutcomeProxy;
62
+ }
63
+
64
+ /**
65
+ * Reduce a normalized event stream to the context-gathering ratio plus paired outcome
66
+ * proxies. Returns null when there's nothing substantive to measure (no tool calls) —
67
+ * trivial segments have nothing to learn from and shouldn't pollute the baselines.
68
+ *
69
+ * Per-segment by construction: callers pass only the events since the last capture
70
+ * boundary (compact/clear/restart/shutdown), so each result describes one work chunk.
71
+ */
72
+ export function computeContextRatio(events: SessionEvent[]): SessionAnalysis | null {
73
+ let gatheringCalls = 0;
74
+ let actionCalls = 0;
75
+ let leadingGather = 0;
76
+ let sawAction = false;
77
+ let userPrompts = 0;
78
+ let toolErrors = 0;
79
+ let turns = 0;
80
+
81
+ for (const event of events) {
82
+ switch (event.type) {
83
+ case "user_prompt":
84
+ userPrompts++;
85
+ break;
86
+ case "tool_error":
87
+ toolErrors++;
88
+ break;
89
+ case "turn":
90
+ turns++;
91
+ break;
92
+ case "tool":
93
+ if (isGatheringTool(event.name)) {
94
+ gatheringCalls++;
95
+ if (!sawAction) leadingGather++;
96
+ } else {
97
+ actionCalls++;
98
+ sawAction = true;
99
+ }
100
+ break;
101
+ }
102
+ }
103
+
104
+ const totalToolCalls = gatheringCalls + actionCalls;
105
+ if (totalToolCalls === 0) return null;
106
+
107
+ return {
108
+ metric: {
109
+ ratio: gatheringCalls / totalToolCalls,
110
+ gatheringCalls,
111
+ actionCalls,
112
+ totalToolCalls,
113
+ leadingGather,
114
+ turns,
115
+ },
116
+ outcome: { userPrompts, toolErrors },
117
+ };
118
+ }
@@ -0,0 +1,271 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { accessSync, appendFileSync, constants, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
6
+
7
+ const SCRATCH_DIR_NAME = ".agent-relay";
8
+ // The local-ignore entry. Leading + trailing slash scopes it to the dir at the
9
+ // base, matching git's gitignore semantics.
10
+ const EXCLUDE_ENTRY = "/.agent-relay/";
11
+ const LAUNCHER_DIR = join(tmpdir(), `agent-relay-launchers-${typeof process.getuid === "function" ? process.getuid() : "user"}`);
12
+ const DEFAULT_LAUNCHER_SWEEP_MIN_AGE_MS = 24 * 60 * 60 * 1000;
13
+
14
+ export interface SessionScratchLayout {
15
+ baseDir: string; // dir that contains .agent-relay (cwd, or fallback base dir)
16
+ rootDir: string; // <base>/.agent-relay
17
+ sessionsDir: string; // <base>/.agent-relay/sessions
18
+ sessionDir: string; // <base>/.agent-relay/sessions/<id>
19
+ tmpDir: string; // <base>/.agent-relay/sessions/<id>/tmp
20
+ replyFile: string; // <tmp>/reply.md
21
+ }
22
+
23
+ interface SessionScratchTarget {
24
+ agentId: string;
25
+ cwd: string;
26
+ // Orchestrator base dir, used only when cwd is not writable. NEVER home — a
27
+ // home fallback would place scratch above the orchestrator base dir and break
28
+ // cwd containment.
29
+ fallbackBaseDir?: string;
30
+ }
31
+
32
+ interface SessionLauncherTarget {
33
+ launcherScript?: string;
34
+ launcherDir?: string;
35
+ tmuxSession?: string;
36
+ }
37
+
38
+ function isWritableDir(dir: string): boolean {
39
+ try {
40
+ if (!statSync(dir).isDirectory()) return false;
41
+ accessSync(dir, constants.W_OK);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ // Prefer cwd; fall back to the orchestrator base dir only when cwd is not a
49
+ // writable dir. Returns cwd as a last resort so callers can still attempt mkdir
50
+ // (and surface the real error) rather than silently picking an unrelated path.
51
+ export function resolveScratchBase(cwd: string, fallbackBaseDir?: string): string {
52
+ if (isWritableDir(cwd)) return cwd;
53
+ if (fallbackBaseDir && isWritableDir(fallbackBaseDir)) return fallbackBaseDir;
54
+ return cwd;
55
+ }
56
+
57
+ function sessionScratchLayout(baseDir: string, agentId: string): SessionScratchLayout {
58
+ const rootDir = join(baseDir, SCRATCH_DIR_NAME);
59
+ const sessionsDir = join(rootDir, "sessions");
60
+ const sessionDir = join(sessionsDir, agentId);
61
+ const tmpDir = join(sessionDir, "tmp");
62
+ return { baseDir, rootDir, sessionsDir, sessionDir, tmpDir, replyFile: join(tmpDir, "reply.md") };
63
+ }
64
+
65
+ // Resolve git's exclude file for baseDir, correctly handling plain repos,
66
+ // worktrees, and submodules (git rev-parse returns the shared commondir's
67
+ // info/exclude). Returns null when baseDir is not inside a git work tree.
68
+ function gitExcludePath(baseDir: string): string | null {
69
+ try {
70
+ const out = execFileSync("git", ["-C", baseDir, "rev-parse", "--git-path", "info/exclude"], {
71
+ encoding: "utf8",
72
+ stdio: ["ignore", "pipe", "ignore"],
73
+ }).trim();
74
+ if (!out) return null;
75
+ return isAbsolute(out) ? out : resolve(baseDir, out);
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ // Idempotently append EXCLUDE_ENTRY to an ignore file (grep-before-write).
82
+ function appendIgnoreEntry(file: string): void {
83
+ let existing = "";
84
+ try {
85
+ existing = readFileSync(file, "utf8");
86
+ } catch {}
87
+ const wanted = EXCLUDE_ENTRY.trim();
88
+ if (existing.split("\n").some((line) => line.trim() === wanted)) return;
89
+ mkdirSync(dirname(file), { recursive: true });
90
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
91
+ appendFileSync(file, `${prefix}${EXCLUDE_ENTRY}\n`);
92
+ }
93
+
94
+ // Ensure `.agent-relay/` is locally ignored. Prefers .git/info/exclude (git's
95
+ // local, uncommitted ignore list) so no tracked file is ever modified; falls
96
+ // back to .gitignore only when baseDir is not a git repo. Returns which file was
97
+ // used (for logging/tests).
98
+ export function ensureScratchIgnored(baseDir: string): "exclude" | "gitignore" {
99
+ const excludePath = gitExcludePath(baseDir);
100
+ if (excludePath) {
101
+ appendIgnoreEntry(excludePath);
102
+ return "exclude";
103
+ }
104
+ appendIgnoreEntry(join(baseDir, ".gitignore"));
105
+ return "gitignore";
106
+ }
107
+
108
+ // SessionStart: create the session tmp dir and ensure local ignore. Dir
109
+ // creation is the load-bearing part; ignore setup is best-effort.
110
+ export function ensureSessionScratch(target: SessionScratchTarget): SessionScratchLayout {
111
+ const base = resolveScratchBase(target.cwd, target.fallbackBaseDir);
112
+ const layout = sessionScratchLayout(base, target.agentId);
113
+ mkdirSync(layout.tmpDir, { recursive: true });
114
+ try {
115
+ ensureScratchIgnored(base);
116
+ } catch {
117
+ // best-effort: a missing ignore entry never pollutes git because the dir is
118
+ // already created and the entry is the only thing at risk.
119
+ }
120
+ return layout;
121
+ }
122
+
123
+ function resolveLauncherDir(launcherDir?: string): string {
124
+ return launcherDir ?? LAUNCHER_DIR;
125
+ }
126
+
127
+ export function launcherScriptPathForSession(sessionName: string, launcherDir?: string): string {
128
+ const sanitized = sanitizeFsName(sessionName, { replacement: "-", collapse: false });
129
+ return join(resolveLauncherDir(launcherDir), `${sanitized}.sh`);
130
+ }
131
+
132
+ function dedupeBases(bases: Array<string | undefined>): string[] {
133
+ const seen = new Set<string>();
134
+ const out: string[] = [];
135
+ for (const b of bases) {
136
+ if (!b || seen.has(b)) continue;
137
+ seen.add(b);
138
+ out.push(b);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ function dedupePaths(paths: Array<string | undefined>): string[] {
144
+ const seen = new Set<string>();
145
+ const out: string[] = [];
146
+ for (const path of paths) {
147
+ if (!path || seen.has(path)) continue;
148
+ seen.add(path);
149
+ out.push(path);
150
+ }
151
+ return out;
152
+ }
153
+
154
+ // SessionEnd: remove this session's dir from every candidate base.
155
+ export function reapSessionScratch(target: SessionScratchTarget): void {
156
+ for (const base of dedupeBases([target.cwd, target.fallbackBaseDir])) {
157
+ const { sessionDir } = sessionScratchLayout(base, target.agentId);
158
+ try {
159
+ rmSync(sessionDir, { recursive: true, force: true });
160
+ } catch {}
161
+ }
162
+ }
163
+
164
+ // Claude headless sessions externalize their provider launch command into a
165
+ // per-session script under /tmp. Remove it on runner shutdown alongside the
166
+ // scratch dir reap to avoid leaking one file per spawn.
167
+ export function reapSessionLauncher(target: SessionLauncherTarget): string[] {
168
+ const removed: string[] = [];
169
+ for (const path of dedupePaths([
170
+ target.launcherScript,
171
+ target.tmuxSession ? launcherScriptPathForSession(target.tmuxSession, target.launcherDir) : undefined,
172
+ ])) {
173
+ try {
174
+ const exists = statSync(path).isFile();
175
+ rmSync(path, { force: true });
176
+ if (exists) removed.push(path);
177
+ } catch {}
178
+ }
179
+ return removed;
180
+ }
181
+
182
+ interface SweepOptions {
183
+ cwd: string;
184
+ fallbackBaseDir?: string;
185
+ // Agent ids to keep (currently-known agents + self). Any session dir whose id
186
+ // is not in this set AND older than minAgeMs is removed.
187
+ keepAgentIds: Set<string>;
188
+ now: number;
189
+ minAgeMs?: number;
190
+ }
191
+
192
+ interface LauncherSweepOptions {
193
+ // Agent ids to keep (currently-known agents + self). Launcher scripts embed
194
+ // AGENT_RELAY_ID, so a stale sweep can safely remove only scripts whose agent
195
+ // is no longer live.
196
+ keepAgentIds: Set<string>;
197
+ launcherDir?: string;
198
+ now: number;
199
+ minAgeMs?: number;
200
+ }
201
+
202
+ function maybeShellUnquote(value: string): string {
203
+ const trimmed = value.trim();
204
+ if (!trimmed.startsWith("'") || !trimmed.endsWith("'")) return trimmed;
205
+ return trimmed.slice(1, -1).replace(/'\\''/g, "'");
206
+ }
207
+
208
+ function launcherAgentId(scriptPath: string): string | null {
209
+ try {
210
+ const match = readFileSync(scriptPath, "utf8").match(/^export AGENT_RELAY_ID=(.+)$/m);
211
+ if (!match) return null;
212
+ const agentId = maybeShellUnquote(match[1] ?? "");
213
+ return agentId || null;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ // Periodic sweep of leftover session dirs from agents no longer known to the
220
+ // relay. The minAgeMs grace avoids racing a peer that just created its dir but
221
+ // has not yet appeared in the agent list.
222
+ export function sweepStaleSessions(opts: SweepOptions): string[] {
223
+ const minAge = opts.minAgeMs ?? 60 * 60 * 1000;
224
+ const removed: string[] = [];
225
+ for (const base of dedupeBases([opts.cwd, opts.fallbackBaseDir])) {
226
+ const sessionsDir = join(base, SCRATCH_DIR_NAME, "sessions");
227
+ let ids: string[];
228
+ try {
229
+ ids = readdirSync(sessionsDir);
230
+ } catch {
231
+ continue;
232
+ }
233
+ for (const id of ids) {
234
+ if (opts.keepAgentIds.has(id)) continue;
235
+ const dir = join(sessionsDir, id);
236
+ try {
237
+ if (opts.now - statSync(dir).mtimeMs < minAge) continue;
238
+ rmSync(dir, { recursive: true, force: true });
239
+ removed.push(dir);
240
+ } catch {}
241
+ }
242
+ }
243
+ return removed;
244
+ }
245
+
246
+ // Periodic sweep of leaked per-spawn launcher scripts. Old scripts whose agent
247
+ // id is no longer live are safe to prune; old unparsable files are treated as
248
+ // stale too so the next run clears historical buildup without manual cleanup.
249
+ export function sweepStaleLaunchers(opts: LauncherSweepOptions): string[] {
250
+ const minAge = opts.minAgeMs ?? DEFAULT_LAUNCHER_SWEEP_MIN_AGE_MS;
251
+ const removed: string[] = [];
252
+ const launcherDir = resolveLauncherDir(opts.launcherDir);
253
+ let entries: string[];
254
+ try {
255
+ entries = readdirSync(launcherDir);
256
+ } catch {
257
+ return removed;
258
+ }
259
+ for (const entry of entries) {
260
+ const scriptPath = join(launcherDir, entry);
261
+ try {
262
+ if (!statSync(scriptPath).isFile()) continue;
263
+ if (opts.now - statSync(scriptPath).mtimeMs < minAge) continue;
264
+ const agentId = launcherAgentId(scriptPath);
265
+ if (agentId && opts.keepAgentIds.has(agentId)) continue;
266
+ rmSync(scriptPath, { force: true });
267
+ removed.push(scriptPath);
268
+ } catch {}
269
+ }
270
+ return removed;
271
+ }
@@ -0,0 +1,29 @@
1
+ import type { RelayHttpClient } from "agent-relay-sdk";
2
+ import { RUNNER_PROMPT_TEMPLATE_DEFAULTS } from "agent-relay-sdk/prompt-template-defaults";
3
+
4
+ const RUNNER_DEFAULTS = new Map<string, string>(RUNNER_PROMPT_TEMPLATE_DEFAULTS.map((template) => [template.slug, template.defaultTemplate]));
5
+ const templateCache = new Map<string, string>();
6
+
7
+ function renderValue(value: string | undefined): string {
8
+ return value ?? "";
9
+ }
10
+
11
+ function applyVariables(template: string, vars: Record<string, string>): string {
12
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => renderValue(vars[key]));
13
+ }
14
+
15
+ export async function initializeRunnerTemplates(http: RelayHttpClient): Promise<void> {
16
+ try {
17
+ const templates = await http.listPromptTemplates();
18
+ templateCache.clear();
19
+ for (const template of templates) templateCache.set(template.slug, template.template);
20
+ } catch {
21
+ templateCache.clear();
22
+ }
23
+ }
24
+
25
+ export function renderRunnerTemplate(slug: string, vars: Record<string, string> = {}): string {
26
+ const template = templateCache.get(slug) ?? RUNNER_DEFAULTS.get(slug);
27
+ if (template === undefined) throw new Error(`unknown runner prompt template slug: ${slug}`);
28
+ return applyVariables(template, vars);
29
+ }
@@ -0,0 +1,43 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { CONTRACT_VERSIONS } from "agent-relay-sdk";
5
+ import { getManifest } from "agent-relay-providers";
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { name?: string; version?: string };
9
+
10
+ export const PACKAGE_NAME = pkg.name || "agent-relay-runner";
11
+ export const VERSION = pkg.version || "0.0.0";
12
+ // Protocol versions are owned by the SDK (CONTRACT_VERSIONS) — derive, never redeclare.
13
+ export const RUNNER_PROTOCOL_VERSION = CONTRACT_VERSIONS.runnerProtocol;
14
+ export const PROVIDER_PLUGIN_PROTOCOL_VERSION = CONTRACT_VERSIONS.providerPluginProtocol;
15
+
16
+ export const CONTRACTS = {
17
+ runnerProtocol: RUNNER_PROTOCOL_VERSION,
18
+ providerPluginProtocol: PROVIDER_PLUGIN_PROTOCOL_VERSION,
19
+ } as const;
20
+
21
+ export const RUNTIME_CAPABILITIES = {
22
+ relayCommandBus: true,
23
+ managedRegistration: true,
24
+ lifecycleCommands: true,
25
+ claimRenewal: true,
26
+ deliveryRetry: true,
27
+ } as const;
28
+
29
+ export function runtimeMetadata(provider?: string) {
30
+ const manifest = provider ? getManifest(provider) : undefined;
31
+ return {
32
+ package: {
33
+ name: PACKAGE_NAME,
34
+ version: VERSION,
35
+ },
36
+ contracts: CONTRACTS,
37
+ capabilities: {
38
+ ...RUNTIME_CAPABILITIES,
39
+ ...(manifest?.provisioning?.bundledPlugin ? { bundledPlugin: true } : {}),
40
+ ...(manifest?.provisioning?.bundledSkills ? { bundledSkills: true } : {}),
41
+ },
42
+ };
43
+ }
@@ -11,10 +11,9 @@
11
11
  // package manager (from `packageManager` field or lockfile). A repo whose
12
12
  // `scripts.test` is `jest` gets `<pm> run test`, never `bun test`. When the repo
13
13
  // declares a full-gate script (`ci:land` preferred, else `ci`), that IS its gate
14
- // (the set the release enforces): the steward runs it wholesale so land gates ==
15
- // release gates (#1044), instead of a file-relevance subset that would let
16
- // drift/lint/doc breakage land. `ci:land` exists for the worktree-safe subset — see
17
- // the note at the derivation for why land gates can't run every release check.
14
+ // (the set the repo wants enforced before land/release): the steward runs it
15
+ // wholesale so land gates == release gates (#1044/#1089), instead of a
16
+ // file-relevance subset that would let drift/lint/doc breakage land.
18
17
  // 3. Default — the legacy bun heuristic, only when there's nothing to derive from
19
18
  // (no repo path, or no readable package.json).
20
19
  import { existsSync, readFileSync } from "node:fs";
@@ -109,14 +108,10 @@ export function resolveStewardChecks(files: string[], repoRoot?: string): Stewar
109
108
  // drift/lint/doc breakage that only a narrower gate misses and that would otherwise
110
109
  // surface at release time (failing `*:check`/lint gates).
111
110
  //
112
- // Prefer a `ci:land` script when the repo declares one: land gates run inside a git
113
- // WORKTREE, and some release checks are not worktree-safe. agent-relay's own
114
- // `build:claude-monitor:check` bundles provider modules whose resolution depends on
115
- // checkout topology a worktree's symlinked `node_modules` double-inlines them, so
116
- // that check false-fails in every worktree (its topology-determinism root fix is
117
- // tracked in #1045). `ci:land` is the worktree-safe subset of `ci` (ci == the two
118
- // bundle steps + ci:land), so the steward and the release share ONE source of truth
119
- // for the land-relevant gates without the steward hitting a structural false failure.
111
+ // Prefer an explicit `ci:land` script when the repo declares one: it is the repo's
112
+ // named pre-land full gate, so the steward should respect that contract over a more
113
+ // generic `ci`. Repos that want exact parity can make `ci` delegate to `ci:land`;
114
+ // repos that need a different land-safe full gate still have a first-class name for it.
120
115
  const fullGate = pkg.scripts["ci:land"] ? "ci:land" : pkg.scripts.ci ? "ci" : undefined;
121
116
  if (fullGate && files.length) {
122
117
  return [{ command: runScript(fullGate), reason: `repo declares a full "${fullGate}" gate (land==release parity)` }];