@uchibeke/gallivant 0.0.0-stage → 0.2.0
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/CONTRIBUTING.md +26 -0
- package/LICENSE +204 -0
- package/README.md +201 -2
- package/SECURITY.md +19 -0
- package/bin/gallivant.mjs +257 -0
- package/bin/package-plugin.mjs +16 -0
- package/bin/release-live.mjs +29 -0
- package/bin/release-package.mjs +23 -0
- package/bin/session-runtime.mjs +13 -0
- package/bin/task-delivery-state.mjs +12 -0
- package/docs/README.md +70 -0
- package/docs/agent-setup.md +214 -0
- package/docs/app.md +111 -0
- package/docs/architecture.md +265 -0
- package/docs/chains.md +102 -0
- package/docs/codex-cloud.md +284 -0
- package/docs/commands.md +620 -0
- package/docs/config.md +430 -0
- package/docs/execution.md +796 -0
- package/docs/guardrails.md +311 -0
- package/docs/install.md +338 -0
- package/docs/launch.md +42 -0
- package/docs/learning.md +369 -0
- package/docs/network-recovery.md +101 -0
- package/docs/openai-agents.md +457 -0
- package/docs/prd.md +246 -0
- package/docs/preparation.md +110 -0
- package/docs/release-qualification.md +196 -0
- package/docs/releasing.md +140 -0
- package/docs/runtime-environment.md +113 -0
- package/docs/task-delivery.md +476 -0
- package/docs/tasks.md +739 -0
- package/lib/aport.mjs +95 -0
- package/lib/attribution.mjs +95 -0
- package/lib/block.mjs +89 -0
- package/lib/capabilities.mjs +3 -0
- package/lib/chains.mjs +222 -0
- package/lib/codex_cloud.mjs +160 -0
- package/lib/codex_cloud_config.mjs +20 -0
- package/lib/codex_cloud_input.mjs +49 -0
- package/lib/codex_cloud_result.mjs +74 -0
- package/lib/commands/doctor.mjs +457 -0
- package/lib/commands/init.mjs +34 -0
- package/lib/commands/learn.mjs +54 -0
- package/lib/commands/new.mjs +47 -0
- package/lib/commands/ready.mjs +45 -0
- package/lib/commands/register.mjs +195 -0
- package/lib/commands/resume.mjs +224 -0
- package/lib/commands/serve.mjs +349 -0
- package/lib/commands/start.mjs +247 -0
- package/lib/commands/start_services.mjs +22 -0
- package/lib/commands/status.mjs +150 -0
- package/lib/commands/task.mjs +73 -0
- package/lib/commands/up.mjs +228 -0
- package/lib/commands/why.mjs +143 -0
- package/lib/config.mjs +461 -0
- package/lib/context.mjs +100 -0
- package/lib/control.mjs +202 -0
- package/lib/control_contract.mjs +16 -0
- package/lib/coordinator.mjs +19 -0
- package/lib/credential.mjs +161 -0
- package/lib/daemon.mjs +156 -0
- package/lib/deliverable.mjs +357 -0
- package/lib/events.mjs +261 -0
- package/lib/executable.mjs +31 -0
- package/lib/execution.mjs +631 -0
- package/lib/execution_contract.mjs +15 -0
- package/lib/execution_input.mjs +4 -0
- package/lib/execution_io.mjs +94 -0
- package/lib/execution_journal.mjs +69 -0
- package/lib/execution_preparation.mjs +29 -0
- package/lib/execution_result.mjs +266 -0
- package/lib/fs.mjs +133 -0
- package/lib/gh.mjs +289 -0
- package/lib/git.mjs +434 -0
- package/lib/harness.mjs +521 -0
- package/lib/harness_log.mjs +432 -0
- package/lib/hash.mjs +16 -0
- package/lib/identity.mjs +98 -0
- package/lib/learning.mjs +654 -0
- package/lib/learning_contract.mjs +2 -0
- package/lib/learning_data.mjs +135 -0
- package/lib/learning_input.mjs +111 -0
- package/lib/learning_qualification.mjs +67 -0
- package/lib/learning_signer.mjs +151 -0
- package/lib/learning_store.mjs +88 -0
- package/lib/log.mjs +53 -0
- package/lib/network_diagnostics.mjs +64 -0
- package/lib/network_recovery.mjs +42 -0
- package/lib/notify.mjs +58 -0
- package/lib/openai_agents.mjs +373 -0
- package/lib/openai_agents_config.mjs +14 -0
- package/lib/openai_agents_http.mjs +87 -0
- package/lib/openai_agents_input.mjs +15 -0
- package/lib/openai_agents_sources.mjs +112 -0
- package/lib/owner_delivery.mjs +443 -0
- package/lib/plugin_package.mjs +340 -0
- package/lib/prd.mjs +635 -0
- package/lib/preparation_agents.mjs +33 -0
- package/lib/preparation_codex.mjs +212 -0
- package/lib/probe.mjs +126 -0
- package/lib/prompt.mjs +238 -0
- package/lib/provider_health.mjs +55 -0
- package/lib/public-format.mjs +31 -0
- package/lib/public_output.mjs +49 -0
- package/lib/release_package.mjs +154 -0
- package/lib/repository_files.mjs +86 -0
- package/lib/run.mjs +385 -0
- package/lib/run_log.mjs +94 -0
- package/lib/runtime-environment.mjs +87 -0
- package/lib/runtime_dependencies.mjs +15 -0
- package/lib/runtime_node.mjs +107 -0
- package/lib/runtime_state.mjs +174 -0
- package/lib/sources/index.mjs +27 -0
- package/lib/sources/prd.mjs +50 -0
- package/lib/start_contract.mjs +37 -0
- package/lib/state.mjs +246 -0
- package/lib/task_allowance.mjs +31 -0
- package/lib/task_completion.mjs +61 -0
- package/lib/task_contract.mjs +450 -0
- package/lib/task_delivery.mjs +219 -0
- package/lib/task_delivery_cancel.mjs +34 -0
- package/lib/task_delivery_contract.mjs +61 -0
- package/lib/task_delivery_mcp.mjs +40 -0
- package/lib/task_delivery_store.mjs +43 -0
- package/lib/task_delivery_view.mjs +88 -0
- package/lib/task_environment.mjs +281 -0
- package/lib/task_prepare.mjs +224 -0
- package/lib/task_store.mjs +38 -0
- package/lib/tasks.mjs +513 -0
- package/lib/tick.mjs +1635 -0
- package/lib/time.mjs +36 -0
- package/lib/tracker/assets.mjs +43 -0
- package/lib/tracker/pages.mjs +320 -0
- package/lib/tracker/runs.mjs +102 -0
- package/lib/tracker/stream.mjs +74 -0
- package/lib/tracker_process.mjs +107 -0
- package/lib/verification_sandbox.mjs +181 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +289 -0
- package/node_modules/smol-toml/dist/index.cjs +770 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/npm-shrinkwrap.json +1823 -0
- package/package.json +105 -4
- package/templates/claude-settings.json +18 -0
- package/templates/passport.gallivant.json +83 -0
- package/templates/plugin/bundled-runtime.json +26 -0
- package/templates/plugin/schemas/mcp.schema.json +120 -0
- package/templates/plugin/schemas/plugin.schema.json +65 -0
- package/templates/plugin/task-delivery.SKILL.md +60 -0
- package/templates/prd.md +32 -0
- package/templates/tracker/tracker.css +301 -0
- package/templates/tracker/tracker.js +721 -0
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// Config: one JSON file the operator writes by hand, validated here so every other module can trust its shape.
|
|
2
|
+
//
|
|
3
|
+
// Defaults hold nothing machine-specific. Anything that identifies the operator (trusted logins, token file,
|
|
4
|
+
// binaries) has to be written explicitly, which is what `doctor` walks you through.
|
|
5
|
+
import { join, relative, isAbsolute } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { realpathSync } from "node:fs";
|
|
8
|
+
import { expand, readJson, writeJsonAtomic, gallivantHome, isDir } from "./fs.mjs";
|
|
9
|
+
import { validateAgentsConfig } from "./openai_agents_config.mjs";
|
|
10
|
+
import { validateCloudConfig } from "./codex_cloud_config.mjs";
|
|
11
|
+
import { adapters, executionRegistry } from "./harness.mjs";
|
|
12
|
+
import { findExecutable } from "./executable.mjs";
|
|
13
|
+
|
|
14
|
+
// Recognition is independent of new-dispatch availability: a registered provider
|
|
15
|
+
// must retain configuration and recovery identities while its readiness is refused.
|
|
16
|
+
export const HARNESSES = Object.keys(executionRegistry);
|
|
17
|
+
|
|
18
|
+
/** The harness a brief with no `harness:` key is read as, when there is no config to narrow it. */
|
|
19
|
+
export const DEFAULT_HARNESS = HARNESSES[0];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The harness to use when nothing named one. An install with exactly one configured harness means it, so a
|
|
23
|
+
* goose-only machine is not handed `claude` and then told `harness "claude" is not configured`.
|
|
24
|
+
*/
|
|
25
|
+
export function defaultHarness(config) {
|
|
26
|
+
const names = Object.keys(config?.harness || {});
|
|
27
|
+
return names.length === 1 ? names[0] : DEFAULT_HARNESS;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Prefer inherited PATH, then the operator's login shell for version managers.
|
|
31
|
+
// Memoised per process; restart the daemon after changing runtime installations.
|
|
32
|
+
const found = new Map();
|
|
33
|
+
export const whence = name => {
|
|
34
|
+
if (!found.has(name)) {
|
|
35
|
+
found.set(name, findExecutable(name));
|
|
36
|
+
}
|
|
37
|
+
return found.get(name);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The harnesses installed on this machine, in the shape `config.harness` takes. Used when the operator names none,
|
|
42
|
+
* so a fresh install works with whichever of the two they happen to have. Naming one explicitly in the config
|
|
43
|
+
* turns discovery off for that install, which is what an operator wants when two versions are on the PATH or when
|
|
44
|
+
* a harness is deliberately not in play.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* A path with its symlinks resolved, or the path itself when it cannot be resolved (it does not exist yet, or a
|
|
48
|
+
* component is not readable). Used by every refusal in `validateContextPaths`: a lexical comparison against a
|
|
49
|
+
* symlink is not a refusal at all, and the directories on that list are the ones that bound and audit the run.
|
|
50
|
+
*/
|
|
51
|
+
const realOf = p => { try { return realpathSync(p); } catch { return p; } };
|
|
52
|
+
|
|
53
|
+
export function discoverHarnesses(home = gallivantHome()) {
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const name of HARNESSES) {
|
|
56
|
+
if (executionRegistry[name].transport !== "cli") continue;
|
|
57
|
+
const bin = whence(name);
|
|
58
|
+
if (!bin) continue;
|
|
59
|
+
// Only Claude takes a settings file; the other four are configured by flags and env in the adapter.
|
|
60
|
+
out[name] = name === "claude" ? { bin, settings: join(home, "claude", "settings.json") } : { bin };
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Limits for the completion gate, in the shape of APort's deliverable.task.complete passport limits. */
|
|
66
|
+
export const DEFAULT_DELIVERABLE = Object.freeze({
|
|
67
|
+
evaluator: "aport", // "aport": confirm with APort's hosted evaluator; "local": the built-in rules only
|
|
68
|
+
api_url: "https://api.aport.io",
|
|
69
|
+
on_unreachable: "wait", // "wait": no decision until APort answers (the daemon retries next tick); "local": the built-in verdict stands
|
|
70
|
+
require_summary: true,
|
|
71
|
+
min_summary_words: 20,
|
|
72
|
+
require_tests_passing: true,
|
|
73
|
+
scan_output: true,
|
|
74
|
+
blocked_patterns: ["TODO", "FIXME", "XXX", "console.log(", "debugger;", "lorem ipsum"],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const DEFAULTS = Object.freeze({
|
|
78
|
+
deliverable: DEFAULT_DELIVERABLE,
|
|
79
|
+
poll_seconds: 120,
|
|
80
|
+
max_iterations: 30,
|
|
81
|
+
checks_grace_minutes: 5, // a head younger than this with no checks yet is not "green", it is "not reported yet"
|
|
82
|
+
// What this number is FOR, because the value only makes sense once that is settled. The timeout catches a session that
|
|
83
|
+
// is genuinely wedged: spinning on a command that never returns, waiting on a prompt that will never come, looping.
|
|
84
|
+
// Nothing else. What bounds SPEND is `max_iterations` and `max_budget_usd`, which are the right tools for it and
|
|
85
|
+
// already do the job; a clock is a bad spend limit because it cannot tell an expensive round from a stuck one.
|
|
86
|
+
//
|
|
87
|
+
// The measured facts, which are the whole argument. One review round that had not yet reached its `git commit` cost
|
|
88
|
+
// 31.2 minutes and was killed at the then-limit of 30: it had 846 tests green and a tsc count matching its 611
|
|
89
|
+
// baseline, and the kill destroyed the round and recorded it as `reason: timeout`, as though the session had failed.
|
|
90
|
+
// The healthy run before it took 7.3 hours and exited 0. So any default under roughly 8 hours can cut a run like the
|
|
91
|
+
// second one, and 30, 45 and 180 are all the same error at different scales: a limit set near the cost of real work.
|
|
92
|
+
//
|
|
93
|
+
// Hence "longer than any legitimate run" rather than "about what work costs". The tradeoff is real and is accepted
|
|
94
|
+
// deliberately: a wedged session now burns up to 12 hours before the daemon stops it. That is a spend question and
|
|
95
|
+
// `max_budget_usd` is its control. Do not add a second clock to compensate.
|
|
96
|
+
//
|
|
97
|
+
// A repository with a different shape sets its own on its `repos` entry; this is only the fallback. The validation
|
|
98
|
+
// floor stays at 1, so an operator who deliberately wants a short timeout can have one.
|
|
99
|
+
run_timeout_minutes: 720,
|
|
100
|
+
max_budget_usd: 5,
|
|
101
|
+
rereview_minutes: 6,
|
|
102
|
+
stall_hours: 6,
|
|
103
|
+
trusted_authors: [],
|
|
104
|
+
reviewer: {
|
|
105
|
+
login: "chatgpt-codex-connector[bot]",
|
|
106
|
+
rereview_comment: "@codex review",
|
|
107
|
+
blocking_pattern: "\\bP[01]\\b",
|
|
108
|
+
// How the reviewer says which commit it looked at, as a regex whose first capture group is the sha. It is read
|
|
109
|
+
// from a review's body and from a plain comment's, and it is what lets a verdict posted as a comment clear a head.
|
|
110
|
+
//
|
|
111
|
+
// The default is Codex's wording because Codex is the reviewer this ships configured for, exactly as
|
|
112
|
+
// `rereview_comment` defaults to its phrase. A reviewer that names commits differently sets this and needs no code
|
|
113
|
+
// change; one that never names a commit leaves it and simply cannot clear a head from a comment, which is the safe
|
|
114
|
+
// answer rather than a broken one.
|
|
115
|
+
reviewed_commit_pattern: "Reviewed commit:\\*{0,2}\\s*`([0-9a-f]{7,40})`",
|
|
116
|
+
},
|
|
117
|
+
notify: null,
|
|
118
|
+
worktrees: null,
|
|
119
|
+
github_token_file: null,
|
|
120
|
+
harness: {},
|
|
121
|
+
context_paths: [],
|
|
122
|
+
repos: [],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const configPath = (home = gallivantHome()) => join(home, "config.json");
|
|
126
|
+
|
|
127
|
+
/** True when `child` is inside `parent` (or equal), by path segments, not by string prefix. */
|
|
128
|
+
export const isInside = (child, parent) => {
|
|
129
|
+
const rel = relative(parent, child);
|
|
130
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Directories a run may READ beyond its own checkout, validated. `label` names where the list came from, so a bad
|
|
135
|
+
* entry in a PRD's frontmatter does not read as a config problem.
|
|
136
|
+
*
|
|
137
|
+
* The refusals are the point. A run that can read the Gallivant home or `~/.claude` can read the settings file that
|
|
138
|
+
* bounds it and the APort passport that audits it; a run that can read `$HOME` can read every credential on the
|
|
139
|
+
* machine. Neither is a theoretical loss: whatever the agent reads can leave in a commit, a PR body or a log.
|
|
140
|
+
* A parent of any of those directories is refused for the same reason a child would be pointless to refuse.
|
|
141
|
+
*/
|
|
142
|
+
export function validateContextPaths(list, home = gallivantHome(), label = "context_paths") {
|
|
143
|
+
const problems = [];
|
|
144
|
+
if (list === undefined || list === null) return problems; // absent means none, as every optional key does
|
|
145
|
+
if (!Array.isArray(list)) return [`${label} must be an array of directory paths`];
|
|
146
|
+
const forbidden = [
|
|
147
|
+
[home, "the Gallivant home"],
|
|
148
|
+
[expand("~/.claude"), "~/.claude"],
|
|
149
|
+
[expand("~/.codex"), "~/.codex"],
|
|
150
|
+
[expand("~/.config/opencode"), "~/.config/opencode"],
|
|
151
|
+
[expand("~/.aport"), "~/.aport"],
|
|
152
|
+
];
|
|
153
|
+
for (const raw of list) {
|
|
154
|
+
if (typeof raw !== "string" || !raw.trim()) { problems.push(`${label} entries must be non-empty strings`); continue; }
|
|
155
|
+
// `expand` resolves a relative path against the process cwd, which for a daemon is whatever directory it was
|
|
156
|
+
// launched from. So the absolute check runs on what the operator WROTE: only `/` and `~` are accepted, and
|
|
157
|
+
// "./docs" is refused by name rather than quietly meaning something different on the next boot.
|
|
158
|
+
if (!isAbsolute(raw) && !raw.startsWith("~")) { problems.push(`${label}: "${raw}" is not an absolute path`); continue; }
|
|
159
|
+
// Every comparison below is against the resolved path, not the written one. `expand` handles `~` and makes the
|
|
160
|
+
// path absolute; it does not follow symlinks, so a link to `$HOME` or to `~/.claude` passed all of these checks
|
|
161
|
+
// and handed the run exactly what they exist to refuse. The written form is kept for the message, because that is
|
|
162
|
+
// what the operator has to go and change.
|
|
163
|
+
const p = realOf(expand(raw));
|
|
164
|
+
if (p === realOf(homedir())) { problems.push(`${label}: "${raw}" is your home directory; name the one directory the run needs, not everything you own`); continue; }
|
|
165
|
+
const hit = forbidden.find(([dir]) => isInside(realOf(dir), p));
|
|
166
|
+
if (hit) { problems.push(`${label}: "${raw}" contains ${hit[1]}, which holds the settings and passport that bound the run`); continue; }
|
|
167
|
+
if (!isDir(p)) { problems.push(`${label}: "${raw}" does not exist (looked for ${p})`); continue; }
|
|
168
|
+
}
|
|
169
|
+
return problems;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The one place the two lists are merged: the operator's config list plus whatever this brief's own frontmatter
|
|
174
|
+
* adds. `tick` and `start` both call this rather than merging for themselves.
|
|
175
|
+
*
|
|
176
|
+
* The PRD's `context_paths:` key is read from the daemon's snapshot, taken when the run started, never from the
|
|
177
|
+
* agent-writable checkout. A session cannot widen its own reads by editing the PRD it was given.
|
|
178
|
+
*/
|
|
179
|
+
export function resolveContextPaths(config, prd = null, home = config?.home) {
|
|
180
|
+
const own = Array.isArray(config?.context_paths) ? config.context_paths : [];
|
|
181
|
+
// The config list was validated by `load`. A brief's own list was validated by `start`, but an entry recovered
|
|
182
|
+
// from GitHub by `up` never passed through `start`, and a directory that existed then may be gone now. So each
|
|
183
|
+
// frontmatter entry is checked again here and a bad one is dropped rather than handed to a harness: a stale path
|
|
184
|
+
// must not silently become a grant, and it must not fail a fix round either.
|
|
185
|
+
const extra = parseContextPathsMeta(prd?.meta?.context_paths)
|
|
186
|
+
.filter(p => validateContextPaths([p], home ?? gallivantHome()).length === 0);
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
return [...own, ...extra].map(p => expand(p)).filter(p => !seen.has(p) && seen.add(p));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A frontmatter `context_paths:` value as a list. Comma-separated or a `[a, b]` literal; anything else is empty. */
|
|
192
|
+
export function parseContextPathsMeta(value) {
|
|
193
|
+
if (Array.isArray(value)) return value.map(String);
|
|
194
|
+
if (typeof value !== "string") return [];
|
|
195
|
+
return value.replace(/^\[|\]$/g, "").split(",").map(s => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The supporting files a brief carries into the checkout, from its `sources` frontmatter.
|
|
200
|
+
*
|
|
201
|
+
* Shares `parseContextPathsMeta`'s reader because both read a list out of frontmatter and both must tolerate the two
|
|
202
|
+
* shapes YAML-ish frontmatter arrives in: a real list, and a bracketed string. What differs is what is legal in the
|
|
203
|
+
* list, and every refusal below is a path shape that would turn one named file into something else:
|
|
204
|
+
*
|
|
205
|
+
* - An absolute path names a file outside the operator's tree, which is the one place a brief may not reach: the
|
|
206
|
+
* whole point is that these travel WITH the brief.
|
|
207
|
+
* - A `..` segment escapes the checkout by another spelling, so it is refused on the same grounds.
|
|
208
|
+
* - A glob or a directory would make the brief's list a pattern rather than a manifest. A glob is how a brief
|
|
209
|
+
* accidentally carries a `.env`; an operator naming files one at a time cannot do that by accident.
|
|
210
|
+
*
|
|
211
|
+
* Refusals are returned rather than thrown, with the offending value, because the caller is a contract rule that
|
|
212
|
+
* reports every problem at once instead of stopping at the first.
|
|
213
|
+
*/
|
|
214
|
+
export function parseSourcesMeta(value) {
|
|
215
|
+
const raw = parseContextPathsMeta(value);
|
|
216
|
+
const paths = [];
|
|
217
|
+
const problems = [];
|
|
218
|
+
for (const entry of raw) {
|
|
219
|
+
const path = entry.trim();
|
|
220
|
+
if (!path) continue;
|
|
221
|
+
if (path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path)) { problems.push(`"${path}" is absolute; name files relative to the repository root`); continue; }
|
|
222
|
+
if (path.split(/[\\/]/).includes("..")) { problems.push(`"${path}" leaves the repository; name files inside it`); continue; }
|
|
223
|
+
if (/[\\\x00-\x1f\x7f]/.test(path) || path.split("/").some(p => p.toLowerCase() === ".git")) { problems.push(`"${path}" is not a safe repository file path`); continue; }
|
|
224
|
+
if (/[*?[\]]/.test(path)) { problems.push(`"${path}" is a glob; name each file on its own line`); continue; }
|
|
225
|
+
if (path.endsWith("/")) { problems.push(`"${path}" is a directory; name each file, or use context_paths for a whole tree`); continue; }
|
|
226
|
+
if (paths.includes(path)) continue;
|
|
227
|
+
paths.push(path);
|
|
228
|
+
}
|
|
229
|
+
return { paths, problems };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The base branch for one repository, and where it came from. One definition, read in one place, so the branch the
|
|
234
|
+
* clone is cut from, the branch the prompt tells the session to target, and the branch a merge brings in can never
|
|
235
|
+
* be three different answers.
|
|
236
|
+
*
|
|
237
|
+
* Precedence, and the reason for each rung:
|
|
238
|
+
* 1. `asked`, an explicit `--base` on the command. The operator is looking at the output and has typed the answer.
|
|
239
|
+
* 2. the base configured for this repository in `repos`. Typed once, then it is the repository's answer forever.
|
|
240
|
+
* 3. `defaultBranch`, whatever `gh repo view` reports. Today's behaviour, and what an operator who configures
|
|
241
|
+
* nothing keeps.
|
|
242
|
+
*
|
|
243
|
+
* `source` rides along because `doctor`, `status` and `why` all print the base and an operator reading "staging"
|
|
244
|
+
* needs to know whether that came from their config or from GitHub. A derived base would be the case where that
|
|
245
|
+
* matters most; nothing here derives one, and that is deliberate (see docs/config.md).
|
|
246
|
+
*/
|
|
247
|
+
export function resolveBase({ config, repo, defaultBranch = null, asked = null }) {
|
|
248
|
+
const configured = configuredBase(config, repo);
|
|
249
|
+
if (asked) return { base: asked, source: "flag" };
|
|
250
|
+
if (configured) return { base: configured, source: "config" };
|
|
251
|
+
return { base: defaultBranch, source: "default" };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* How long a run in one repository may take, and where the number came from.
|
|
256
|
+
*
|
|
257
|
+
* Same shape as `resolveBase` and for the same reason: "how long does verification take here" is a property of the
|
|
258
|
+
* codebase, not of the operator. An operator with a small repository and a large one has no single right global number,
|
|
259
|
+
* and the one they pick is wrong for one of the two. So the repository answers it if it has been told, and the global
|
|
260
|
+
* `run_timeout_minutes` is the fallback, which is what an operator who configures nothing keeps.
|
|
261
|
+
*
|
|
262
|
+
* `source` rides along because a timeout kill has to name the limit it exceeded and where that limit was set: an
|
|
263
|
+
* operator reading "exceeded its 30 minute timeout" needs to know whether to raise the global or the repository's own.
|
|
264
|
+
*/
|
|
265
|
+
export function resolveTimeout({ config, repo }) {
|
|
266
|
+
const configured = configuredTimeout(config, repo);
|
|
267
|
+
if (configured != null) return { minutes: configured, source: "repo" };
|
|
268
|
+
return { minutes: config?.run_timeout_minutes ?? DEFAULTS.run_timeout_minutes, source: "config" };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Problems with the `repos` list. Both forms are legal and mixing them in one list is legal, because an operator who
|
|
273
|
+
* sets a base on one repository should not have to rewrite the rest.
|
|
274
|
+
*
|
|
275
|
+
* A misspelled key is the failure worth naming. `{ "repo": "acme/widget", "branch": "staging" }` is a plausible thing
|
|
276
|
+
* to write and would silently target the default branch forever, so an object with no `base` and an unknown key is
|
|
277
|
+
* refused here rather than quietly read as the string form.
|
|
278
|
+
*/
|
|
279
|
+
export function validateRepos(list) {
|
|
280
|
+
if (list === undefined || list === null) return [];
|
|
281
|
+
if (!Array.isArray(list)) return ['repos must be an array of "owner/name" strings or { repo, base, run_timeout_minutes } objects'];
|
|
282
|
+
const problems = [];
|
|
283
|
+
const seen = new Set();
|
|
284
|
+
for (const raw of list) {
|
|
285
|
+
if (typeof raw === "string") {
|
|
286
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(raw)) { problems.push(`repos: "${raw}" is not owner/name`); continue; }
|
|
287
|
+
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
288
|
+
if (typeof raw.repo !== "string" || !/^[\w.-]+\/[\w.-]+$/.test(raw.repo)) { problems.push(`repos: ${JSON.stringify(raw)} needs a "repo" of the form owner/name`); continue; }
|
|
289
|
+
if (raw.base !== undefined && raw.base !== null && (typeof raw.base !== "string" || !raw.base.trim())) { problems.push(`repos: ${raw.repo} base must be a branch name`); continue; }
|
|
290
|
+
// Refused rather than ignored, for the reason the unknown-key check above exists: a timeout typed as "45" or as 0
|
|
291
|
+
// would read as "not configured" and the repository would keep the global forever, which is the silent failure.
|
|
292
|
+
if (raw.run_timeout_minutes !== undefined && raw.run_timeout_minutes !== null && (typeof raw.run_timeout_minutes !== "number" || raw.run_timeout_minutes < 1)) { problems.push(`repos: ${raw.repo} run_timeout_minutes must be a number >= 1`); continue; }
|
|
293
|
+
const extra = Object.keys(raw).filter(k => k !== "repo" && k !== "base" && k !== "run_timeout_minutes");
|
|
294
|
+
if (extra.length) { problems.push(`repos: ${raw.repo} has unknown key${extra.length === 1 ? "" : "s"} ${extra.join(", ")} (only repo, base and run_timeout_minutes are read)`); continue; }
|
|
295
|
+
} else { problems.push(`repos: ${JSON.stringify(raw)} is neither "owner/name" nor a { repo } object`); continue; }
|
|
296
|
+
const key = repoEntry(raw).repo;
|
|
297
|
+
if (seen.has(key)) problems.push(`repos: ${key} is listed twice`);
|
|
298
|
+
seen.add(key);
|
|
299
|
+
}
|
|
300
|
+
return problems;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Raw file contents merged with defaults, the one way both `load` and `doctor` build the object they validate. */
|
|
304
|
+
export function merge(raw, home = gallivantHome()) {
|
|
305
|
+
// No harness named: use whatever is installed. An empty object still means "none found", which validate refuses
|
|
306
|
+
// with the install instruction rather than a path the operator has to write by hand.
|
|
307
|
+
const harness = raw.harness && Object.keys(raw.harness).length ? raw.harness : discoverHarnesses(home);
|
|
308
|
+
return {
|
|
309
|
+
...DEFAULTS, ...raw, harness,
|
|
310
|
+
deliverable: raw.deliverable === undefined ? DEFAULTS.deliverable : raw.deliverable === null ? null : { ...DEFAULTS.deliverable, ...raw.deliverable },
|
|
311
|
+
// `reviewer` is merged key by key, like `deliverable` above it and for the reason that block already demonstrates:
|
|
312
|
+
// a spread of `raw` replaces the whole object, so every key the operator's file does not name disappears rather
|
|
313
|
+
// than falling back to its default. That was latent until a key was ADDED: configs written before
|
|
314
|
+
// `reviewed_commit_pattern` existed had no value for it, the merge left it undefined, and `validate` refused the
|
|
315
|
+
// file. Every command then died at config load, which an operator saw as `gallivant resume` printing nothing at all.
|
|
316
|
+
//
|
|
317
|
+
// `null` is preserved as null, because that is the documented way to say there is no reviewer, and a deep merge
|
|
318
|
+
// that turned it into the defaults would silently give a repository a reviewer it had switched off.
|
|
319
|
+
reviewer: raw.reviewer === undefined ? DEFAULTS.reviewer : raw.reviewer === null ? null : { ...DEFAULTS.reviewer, ...raw.reviewer },
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Problems as human sentences; empty means valid. Used by `load` (throws) and by `doctor` (prints). */
|
|
324
|
+
export function validate(cfg, home = gallivantHome()) {
|
|
325
|
+
const problems = [];
|
|
326
|
+
const num = (k, min) => { if (typeof cfg[k] !== "number" || cfg[k] < min) problems.push(`${k} must be a number >= ${min}`); };
|
|
327
|
+
num("poll_seconds", 30); num("max_iterations", 1); num("run_timeout_minutes", 1); num("max_budget_usd", 0);
|
|
328
|
+
num("rereview_minutes", 1); num("stall_hours", 1); num("checks_grace_minutes", 0);
|
|
329
|
+
if (cfg.execution !== undefined) {
|
|
330
|
+
const ranges = { deadlineMs: [10, 120_000], progressMs: [100, 300_000], maxBackoffMs: [100, 3_600_000], concurrency: [1, 8], eventBytes: [1024, 65_536], events: [1, 100] };
|
|
331
|
+
for (const [key, value] of Object.entries(cfg.execution || {})) {
|
|
332
|
+
const range = ranges[key];
|
|
333
|
+
if (!range || !Number.isSafeInteger(value) || value < range[0] || value > range[1]) problems.push(`execution.${key} must be an integer in ${range ? range.join("..") : "the documented execution limits"}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!Array.isArray(cfg.trusted_authors) || cfg.trusted_authors.length === 0) problems.push("trusted_authors must list at least one GitHub login");
|
|
337
|
+
if (cfg.reviewer !== null) {
|
|
338
|
+
for (const k of ["login", "rereview_comment", "blocking_pattern"]) if (typeof cfg.reviewer?.[k] !== "string") problems.push(`reviewer.${k} must be a string (or set reviewer to null)`);
|
|
339
|
+
// `reviewed_commit_pattern` is optional, and that is deliberate rather than an omission. It was added after configs
|
|
340
|
+
// existed, so requiring it made every config written before it invalid: `loadConfig` threw on the key, and every
|
|
341
|
+
// command died at config load. What an operator saw was `gallivant resume` producing no output at all. A key added
|
|
342
|
+
// later must be absent-tolerant, and absent means "no pattern", which the reader already handles by declining to
|
|
343
|
+
// clear a head from a comment.
|
|
344
|
+
if (cfg.reviewer?.reviewed_commit_pattern !== undefined && typeof cfg.reviewer.reviewed_commit_pattern !== "string") problems.push("reviewer.reviewed_commit_pattern must be a string when set");
|
|
345
|
+
for (const k of ["blocking_pattern", "reviewed_commit_pattern"]) {
|
|
346
|
+
if (typeof cfg.reviewer?.[k] === "string") { try { new RegExp(cfg.reviewer[k]); } catch { problems.push(`reviewer.${k} is not a valid regex`); } }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (cfg.notify !== null && typeof cfg.notify?.ntfy !== "string") problems.push("notify.ntfy must be a URL (or set notify to null)");
|
|
350
|
+
if (cfg.deliverable !== null && (typeof cfg.deliverable !== "object" || Array.isArray(cfg.deliverable))) problems.push("deliverable must be an object of limits (or null to disable the completion gate)");
|
|
351
|
+
else if (cfg.deliverable && !["aport", "local"].includes(cfg.deliverable.evaluator)) problems.push('deliverable.evaluator must be "aport" or "local"');
|
|
352
|
+
else if (cfg.deliverable && !["wait", "local"].includes(cfg.deliverable.on_unreachable)) problems.push('deliverable.on_unreachable must be "wait" or "local"');
|
|
353
|
+
if (!cfg.worktrees) problems.push("worktrees must be a directory path outside the Gallivant home");
|
|
354
|
+
else if (isInside(expand(cfg.worktrees), home)) problems.push("worktrees must not be under the Gallivant home directory");
|
|
355
|
+
problems.push(...validateContextPaths(cfg.context_paths, home));
|
|
356
|
+
problems.push(...validateRepos(cfg.repos));
|
|
357
|
+
const names = Object.keys(cfg.harness || {});
|
|
358
|
+
if (names.length === 0) problems.push(`no harness configured and none found on PATH. Install one of them, or set harness.<name>.bin explicitly. Looked for: ${HARNESSES.join(", ")}`);
|
|
359
|
+
for (const n of names) {
|
|
360
|
+
if (!HARNESSES.includes(n)) problems.push(`harness.${n} is not a known harness (${HARNESSES.join(", ")})`);
|
|
361
|
+
else if (executionRegistry[n].transport === "remote") { try { (n === "codex-cloud" ? validateCloudConfig : validateAgentsConfig)(cfg.harness[n]); } catch (e) { problems.push(e.message); } }
|
|
362
|
+
else if (typeof cfg.harness[n]?.bin !== "string") problems.push(`harness.${n}.bin must be an absolute path`);
|
|
363
|
+
if (n === "claude" && typeof cfg.harness[n]?.settings !== "string") problems.push("harness.claude.settings must point at the Gallivant Claude settings file");
|
|
364
|
+
}
|
|
365
|
+
return problems;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export const hasConfig = (home = gallivantHome()) => readJson(configPath(home)) !== null;
|
|
369
|
+
|
|
370
|
+
/** Merge defaults, expand paths, validate. Throws a single error listing every problem. */
|
|
371
|
+
export function load(home = gallivantHome()) {
|
|
372
|
+
const raw = readJson(configPath(home));
|
|
373
|
+
if (!raw) throw new Error(`No config at ${configPath(home)}. Run \`gallivant doctor\` for the file to write.`);
|
|
374
|
+
const cfg = merge(raw, home);
|
|
375
|
+
const problems = validate(cfg, home);
|
|
376
|
+
if (problems.length) throw new Error(`Config problems in ${configPath(home)}:\n - ${problems.join("\n - ")}`);
|
|
377
|
+
return {
|
|
378
|
+
...cfg,
|
|
379
|
+
home,
|
|
380
|
+
worktrees: expand(cfg.worktrees),
|
|
381
|
+
github_token_file: cfg.github_token_file ? expand(cfg.github_token_file) : null,
|
|
382
|
+
context_paths: (cfg.context_paths || []).map(p => expand(p)),
|
|
383
|
+
harness: Object.fromEntries(Object.entries(cfg.harness).map(([n, h]) => [n, executionRegistry[n]?.transport === "remote" ? { ...h, ...(h.api_key_file ? { api_key_file: expand(h.api_key_file) } : {}) } : { ...h, bin: expand(h.bin), settings: h.settings ? expand(h.settings) : undefined }])),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* A `repos` entry as `{ repo, base }`, whichever of the two forms the operator wrote.
|
|
389
|
+
*
|
|
390
|
+
* `repos` was a flat array of `owner/name` strings, appended by `rememberRepo` and read by `reconcile` and `doctor`
|
|
391
|
+
* to know which repositories to recover from and check. It now also accepts `{ repo, base }`, because the base branch
|
|
392
|
+
* a pull request should target is a property of the repository and there was nowhere to put one. The string form
|
|
393
|
+
* keeps meaning exactly what it meant: a repository whose base is its own default branch.
|
|
394
|
+
*
|
|
395
|
+
* `base` is null for the string form and for an object that names none, and null is what every reader treats as
|
|
396
|
+
* "ask GitHub for the default branch", which is today's behaviour and the behaviour an operator who configures
|
|
397
|
+
* nothing keeps.
|
|
398
|
+
*/
|
|
399
|
+
export function repoEntry(raw) {
|
|
400
|
+
if (typeof raw === "string") return { repo: raw.toLowerCase(), base: null, run_timeout_minutes: null };
|
|
401
|
+
if (raw && typeof raw === "object" && typeof raw.repo === "string") {
|
|
402
|
+
const base = typeof raw.base === "string" && raw.base.trim() ? raw.base.trim() : null;
|
|
403
|
+
const t = typeof raw.run_timeout_minutes === "number" && raw.run_timeout_minutes >= 1 ? raw.run_timeout_minutes : null;
|
|
404
|
+
return { repo: raw.repo.toLowerCase(), base, run_timeout_minutes: t };
|
|
405
|
+
}
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Every `repos` entry as `{ repo, base }`, bad ones dropped. `validate` is what refuses them; this is for readers. */
|
|
410
|
+
export const repoEntries = cfg => (Array.isArray(cfg?.repos) ? cfg.repos : []).map(repoEntry).filter(Boolean);
|
|
411
|
+
|
|
412
|
+
/** Just the `owner/name` strings, for the three readers that only ever wanted the name. */
|
|
413
|
+
export const repoNames = cfg => repoEntries(cfg).map(r => r.repo);
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The base branch configured for one repository, or null when none is.
|
|
417
|
+
*
|
|
418
|
+
* Null rather than a guess on purpose. The caller that wants a branch name has the repository's default branch to
|
|
419
|
+
* fall back on, and it knows where that came from; a function here that returned "main" would be inventing one.
|
|
420
|
+
*/
|
|
421
|
+
export const configuredBase = (cfg, nameWithOwner) =>
|
|
422
|
+
repoEntries(cfg).find(r => r.repo === String(nameWithOwner || "").toLowerCase())?.base ?? null;
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The run timeout configured for one repository, in minutes, or null when none is. Null rather than the global, for the
|
|
426
|
+
* same reason `configuredBase` returns null: the caller knows what its own fallback is and where that came from, and a
|
|
427
|
+
* function here that returned 45 would be hiding which of the two answered.
|
|
428
|
+
*/
|
|
429
|
+
export const configuredTimeout = (cfg, nameWithOwner) =>
|
|
430
|
+
repoEntries(cfg).find(r => r.repo === String(nameWithOwner || "").toLowerCase())?.run_timeout_minutes ?? null;
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Record a repo for recovery, and the base branch it targets when one was chosen. Rewrites the raw file, keeping the
|
|
434
|
+
* operator's other values as written.
|
|
435
|
+
*
|
|
436
|
+
* Two properties this has to keep. The string form is what a first use writes when nothing named a base, because an
|
|
437
|
+
* operator who has never configured one must not find their config rewritten into a shape they did not choose. And
|
|
438
|
+
* the dedupe stays a lowercased comparison of the repository name, whichever form each existing entry is in, so a
|
|
439
|
+
* second start never appends a duplicate.
|
|
440
|
+
*
|
|
441
|
+
* Returns true when the file changed, which is what `start` reads to decide whether the labels are worth creating.
|
|
442
|
+
*/
|
|
443
|
+
export function rememberRepo(nameWithOwner, home = gallivantHome(), base = null) {
|
|
444
|
+
const raw = readJson(configPath(home)) || {};
|
|
445
|
+
const repos = Array.isArray(raw.repos) ? raw.repos : [];
|
|
446
|
+
const key = nameWithOwner.toLowerCase();
|
|
447
|
+
const at = repos.findIndex(r => repoEntry(r)?.repo === key);
|
|
448
|
+
if (at === -1) {
|
|
449
|
+
// A base only becomes an object entry. Without one this appends the bare string it always did.
|
|
450
|
+
const next = base ? { repo: key, base } : key;
|
|
451
|
+
writeJsonAtomic(configPath(home), { ...raw, repos: [...repos, next] }, 0o600);
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
// Already known. The only thing left to write is a base that differs from the one recorded, which is how
|
|
455
|
+
// `start --base` retargets a repository that was first used without one.
|
|
456
|
+
if (!base || repoEntry(repos[at]).base === base) return false;
|
|
457
|
+
const updated = [...repos];
|
|
458
|
+
updated[at] = { ...(typeof repos[at] === "object" && repos[at] ? repos[at] : {}), repo: key, base };
|
|
459
|
+
writeJsonAtomic(configPath(home), { ...raw, repos: updated }, 0o600);
|
|
460
|
+
return true;
|
|
461
|
+
}
|
package/lib/context.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { remoteInput } from "./execution_input.mjs";
|
|
2
|
+
import { makeCloudTransport } from "./codex_cloud.mjs";
|
|
3
|
+
import { contentHash } from "./openai_agents_sources.mjs";
|
|
4
|
+
// Builds the real dependencies once for the CLI. Tests build their own with fakes; nothing else imports this.
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { load as loadConfig } from "./config.mjs";
|
|
7
|
+
import { makeState, idSlug } from "./state.mjs";
|
|
8
|
+
import { makeGh } from "./gh.mjs";
|
|
9
|
+
import { makeGit } from "./git.mjs";
|
|
10
|
+
import { resolveCredential } from "./credential.mjs";
|
|
11
|
+
import { resolveIdentity } from "./identity.mjs";
|
|
12
|
+
import { makeNotifier } from "./notify.mjs";
|
|
13
|
+
import { makeLog } from "./log.mjs";
|
|
14
|
+
import { makeRunner } from "./run.mjs";
|
|
15
|
+
import { ensureDir, gallivantHome, readText, writeText } from "./fs.mjs";
|
|
16
|
+
import { aportDirFor, guardrailMode } from "./aport.mjs";
|
|
17
|
+
import { makeExecution, assertExecutionDependency } from "./execution.mjs";
|
|
18
|
+
import { makeAgentsTransport } from "./openai_agents.mjs";
|
|
19
|
+
import { capabilitiesFor } from "./harness.mjs";
|
|
20
|
+
import { createLearningService } from "./learning.mjs";
|
|
21
|
+
import { makeTrust } from "./events.mjs";
|
|
22
|
+
import { revokeLearningAttempts } from "./learning_input.mjs";
|
|
23
|
+
import { silentLog } from "./log.mjs";
|
|
24
|
+
const { createTaskService } = await import("./tasks.mjs").catch(error => {
|
|
25
|
+
if (error.code === "ERR_MODULE_NOT_FOUND") assertExecutionDependency(null);
|
|
26
|
+
throw error;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** Where the daemon keeps its own copy of each PRD, taken at start, so a session cannot rewrite its brief. */
|
|
30
|
+
export const prdSnapshotPath = (home, id) => join(home, "prds", `${idSlug(id)}.md`);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Where a carried file's snapshot lives: one directory per entry, beside the brief's own snapshot and named the same
|
|
34
|
+
* way. The relative path is kept inside that directory, so `docs/a.md` and `a.md` are two files rather than one and a
|
|
35
|
+
* carried file lands back at the path the brief named.
|
|
36
|
+
*/
|
|
37
|
+
export const sourceSnapshotPath = (home, id, rel) => join(home, "prds", `${idSlug(id)}.sources`, rel);
|
|
38
|
+
|
|
39
|
+
export function makeContext({ home = gallivantHome(), needConfig = true, readOnly = false } = {}) {
|
|
40
|
+
assertExecutionDependency(createTaskService);
|
|
41
|
+
if (!readOnly) ensureDir(home, 0o700);
|
|
42
|
+
const log = readOnly ? silentLog : makeLog(home);
|
|
43
|
+
if (!needConfig) return { home, log };
|
|
44
|
+
const config = loadConfig(home);
|
|
45
|
+
// One credential resolver for the whole process, shared by `gh` and `git` so both act as the same identity.
|
|
46
|
+
//
|
|
47
|
+
// Per call, not per boot. It costs one `gh auth token` subprocess (only when neither the config file nor the
|
|
48
|
+
// daemon's own environment answers, which are both free), and it is the only way an operator who re-authenticates
|
|
49
|
+
// under a long-lived daemon gets the new token without restarting it. A value cached at boot would expire under
|
|
50
|
+
// exactly the daemon this product is built around.
|
|
51
|
+
const credential = (opts = {}) => resolveCredential(config, opts);
|
|
52
|
+
// The git identity a run commits as, resolved the same way and for the same reason: per call, so an operator who
|
|
53
|
+
// changes their name or email under a long-lived daemon is committed as who they are now.
|
|
54
|
+
const identity = () => resolveIdentity();
|
|
55
|
+
const gh = makeGh({ credential, log });
|
|
56
|
+
const state = makeState(home);
|
|
57
|
+
const context = {
|
|
58
|
+
home, log, config, gh, credential, identity,
|
|
59
|
+
state, capabilitiesFor,
|
|
60
|
+
executionInputFor: (id, entry, input, contextPaths) => remoteInput(context, id, entry, input, contextPaths),
|
|
61
|
+
// `identity` as well as `credential`, because `git.mergeBase` commits: the merge that brings a base into a branch
|
|
62
|
+
// is the daemon's own commit, and with no identity pairs it is either attributed to a machine-derived address or
|
|
63
|
+
// refused outright, depending on whether the host's git can guess one. See makeGit for the two shapes.
|
|
64
|
+
git: makeGit({ config, credential, identity }),
|
|
65
|
+
notify: makeNotifier({ config, log }),
|
|
66
|
+
runnerFor: id => makeRunner(join(home, "runs", idSlug(id))),
|
|
67
|
+
executionFor: (id, provider) => {
|
|
68
|
+
const root = join(home, "runs", idSlug(id)), selected = config.harness[provider];
|
|
69
|
+
const account = selected?.account || "operator";
|
|
70
|
+
return makeExecution({ root, taskId: id, provider, account,
|
|
71
|
+
...(provider === "openai-agents" ? { transport: makeAgentsTransport({ root, taskId: id, account, config: selected }), capabilities: { ...capabilitiesFor(provider), models: [selected?.model] } } : {}),
|
|
72
|
+
...(provider === "codex-cloud" ? { transport: makeCloudTransport({ root, taskId: id, account, config: selected, verifyRemote: async input => {
|
|
73
|
+
if (await gh.refSha(input.repo, input.sources.inputBranch) !== input.inputHead || await gh.refSha(input.repo, input.branch) !== input.inputHead) throw new Error("Cloud remote input revision moved; reconcile before dispatch");
|
|
74
|
+
for (const [path, hash] of Object.entries(input.sources.hashes)) {
|
|
75
|
+
if (contentHash(await gh.fileAtStrict(input.repo, path, input.inputHead)) !== hash) throw new Error("Cloud remote snapshot hash differs");
|
|
76
|
+
}
|
|
77
|
+
} }), capabilities: { ...capabilitiesFor(provider), environments: [selected?.environment] } } : {}),
|
|
78
|
+
legacySessionId: state.get(id)?.harness === provider ? state.get(id).session : null,
|
|
79
|
+
runner: capabilitiesFor(provider).transport === "remote" ? null : makeRunner(root), limits: config.execution });
|
|
80
|
+
},
|
|
81
|
+
// The APort API key the Claude install recorded, if any, so hosted gate calls count against the same account.
|
|
82
|
+
aportApiKey: guardrailMode(aportDirFor(config.harness?.claude?.settings)).apiKey,
|
|
83
|
+
readPrd: id => readText(prdSnapshotPath(home, id)),
|
|
84
|
+
writePrd: (id, text) => writeText(prdSnapshotPath(home, id), text),
|
|
85
|
+
// The carried files, read and written one at a time so the caller decides what a missing one means. They are
|
|
86
|
+
// daemon state, not files in a source tree, so they take writeText's default 0o600 like the brief's snapshot does.
|
|
87
|
+
readSource: (id, rel) => readText(sourceSnapshotPath(home, id, rel)),
|
|
88
|
+
writeSource: (id, rel, text) => writeText(sourceSnapshotPath(home, id, rel), text),
|
|
89
|
+
};
|
|
90
|
+
const learningServices = new Map();
|
|
91
|
+
context.learningFor = (ownLogin = "learning-coordinator", { signerExecutable } = {}) => {
|
|
92
|
+
const cacheKey = JSON.stringify([ownLogin, signerExecutable ?? null]);
|
|
93
|
+
if (!learningServices.has(cacheKey)) learningServices.set(cacheKey, createLearningService({
|
|
94
|
+
root: join(home, "learning"), signerExecutable, gh, trust: makeTrust(config, ownLogin),
|
|
95
|
+
onRevocation: intent => revokeLearningAttempts(context, intent),
|
|
96
|
+
}));
|
|
97
|
+
return learningServices.get(cacheKey);
|
|
98
|
+
};
|
|
99
|
+
return context;
|
|
100
|
+
}
|