@uchibeke/gallivant 0.2.0 → 0.3.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/README.md +120 -60
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +41 -14
- package/bin/promote-bitbucket.mjs +40 -0
- package/bin/task-pr.mjs +9 -0
- package/bin/task-review.mjs +15 -0
- package/docs/README.md +6 -2
- package/docs/agent-setup.md +55 -41
- package/docs/architecture.md +11 -3
- package/docs/bitbucket-promotion.md +69 -0
- package/docs/bitbucket-review.md +330 -0
- package/docs/chains.md +30 -1
- package/docs/codex-cloud.md +7 -0
- package/docs/codex-review.md +50 -0
- package/docs/commands.md +103 -14
- package/docs/config.md +45 -13
- package/docs/execution.md +1 -1
- package/docs/first-party-review.md +266 -0
- package/docs/git-hosts.md +294 -0
- package/docs/guardrails.md +56 -0
- package/docs/install.md +137 -67
- package/docs/launch.md +7 -4
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +3 -2
- package/docs/prd.md +23 -2
- package/docs/preparation.md +2 -2
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +46 -22
- package/docs/task-delivery.md +5 -5
- package/docs/task-profiles.md +537 -0
- package/docs/tasks.md +59 -197
- package/lib/attribution.mjs +7 -3
- package/lib/block.mjs +10 -1
- package/lib/chains.mjs +101 -30
- package/lib/codex_cloud.mjs +2 -1
- package/lib/codex_cloud_input.mjs +15 -6
- package/lib/codex_cloud_result.mjs +1 -1
- package/lib/commands/doctor.mjs +22 -453
- package/lib/commands/init.mjs +281 -28
- package/lib/commands/register.mjs +81 -13
- package/lib/commands/resume.mjs +5 -1
- package/lib/commands/review.mjs +66 -0
- package/lib/commands/serve.mjs +8 -0
- package/lib/commands/start.mjs +30 -11
- package/lib/commands/status.mjs +18 -2
- package/lib/commands/task.mjs +9 -1
- package/lib/commands/up.mjs +19 -11
- package/lib/commands/why.mjs +7 -3
- package/lib/config.mjs +42 -9
- package/lib/context.mjs +28 -21
- package/lib/control.mjs +21 -6
- package/lib/credential.mjs +20 -14
- package/lib/deliverable.mjs +2 -0
- package/lib/events.mjs +96 -53
- package/lib/execution.mjs +132 -11
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- package/lib/fs.mjs +10 -3
- package/lib/gh.mjs +144 -56
- package/lib/git.mjs +133 -32
- package/lib/git_diff.mjs +121 -0
- package/lib/harness.mjs +14 -10
- package/lib/host_contract.mjs +71 -0
- package/lib/host_definitions.mjs +22 -0
- package/lib/hosts/bitbucket.mjs +553 -0
- package/lib/hosts/bitbucket_contract.mjs +57 -0
- package/lib/hosts/bitbucket_credential.mjs +62 -0
- package/lib/hosts/bitbucket_review.mjs +44 -0
- package/lib/hosts/bitbucket_review_producer.mjs +50 -0
- package/lib/hosts/github.mjs +57 -0
- package/lib/hosts/github_contract.mjs +27 -0
- package/lib/hosts/github_doctor.mjs +482 -0
- package/lib/hosts/github_review_summary.mjs +38 -0
- package/lib/hosts/transport_doctor.mjs +35 -0
- package/lib/hosts.mjs +130 -0
- package/lib/learning_qualification.mjs +1 -1
- package/lib/notify.mjs +1 -1
- package/lib/openai_agents_input.mjs +7 -2
- package/lib/openai_agents_sources.mjs +16 -8
- package/lib/plugin_package.mjs +13 -1
- package/lib/pr_operation.mjs +41 -0
- package/lib/prd.mjs +18 -3
- package/lib/preparation_codex.mjs +6 -1
- package/lib/probe.mjs +6 -4
- package/lib/process.mjs +24 -0
- package/lib/promotion.mjs +132 -0
- package/lib/promotion_git.mjs +63 -0
- package/lib/prompt.mjs +16 -13
- package/lib/provider_health.mjs +14 -3
- package/lib/repository.mjs +68 -0
- package/lib/repository_preflight.mjs +11 -0
- package/lib/review_evaluation.mjs +25 -0
- package/lib/review_evidence.mjs +500 -0
- package/lib/review_history.mjs +73 -0
- package/lib/review_image.mjs +145 -0
- package/lib/review_link.mjs +12 -0
- package/lib/review_policy.mjs +132 -0
- package/lib/review_policy_contract.mjs +18 -0
- package/lib/review_state.mjs +13 -0
- package/lib/review_visual.mjs +94 -0
- package/lib/runtime-environment.mjs +6 -0
- package/lib/runtime_node.mjs +3 -2
- package/lib/runtime_state.mjs +39 -5
- package/lib/sources/prd.mjs +6 -3
- package/lib/state.mjs +137 -24
- package/lib/task_completion.mjs +7 -3
- package/lib/task_contract.mjs +201 -27
- package/lib/task_delivery.mjs +9 -2
- package/lib/task_delivery_contract.mjs +13 -9
- package/lib/task_delivery_view.mjs +18 -2
- package/lib/task_environment.mjs +13 -1
- package/lib/task_limits.mjs +13 -0
- package/lib/task_model_config.mjs +99 -0
- package/lib/task_model_route.mjs +243 -0
- package/lib/task_prepare.mjs +21 -8
- package/lib/task_profile.mjs +1875 -0
- package/lib/task_repository_context.mjs +65 -0
- package/lib/tasks.mjs +252 -51
- package/lib/tick.mjs +373 -98
- package/lib/time.mjs +1 -0
- package/lib/tracker/pages.mjs +19 -5
- package/lib/tracker/review.mjs +21 -0
- package/lib/tracker_process.mjs +8 -1
- package/lib/verification_sandbox.mjs +2 -1
- package/lib/work_artifacts.mjs +79 -0
- package/npm-shrinkwrap.json +12 -3
- package/package.json +9 -3
- package/templates/plugin/task-delivery.SKILL.md +17 -10
- package/templates/plugin/task-intake-contract.json +18 -0
- package/templates/plugin/task-profile-requests.mjs +1448 -0
- package/templates/tracker/tracker.css +3 -0
- package/templates/tracker/tracker.js +38 -0
package/lib/commands/start.mjs
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { publicationPolicyOf } from "../work_artifacts.mjs";
|
|
2
|
+
import { repositoryReviewConfig } from "../review_policy.mjs";
|
|
3
|
+
import { repositoryPreflight } from "../repository_preflight.mjs";
|
|
4
|
+
import { hosts } from "../hosts.mjs";
|
|
5
|
+
import { assertRepositoryAccess } from "../host_contract.mjs";
|
|
6
|
+
import { hostApi } from "../hosts.mjs";
|
|
1
7
|
// `gallivant start <prd path>`: one brief becomes one running session in its own checkout.
|
|
2
8
|
//
|
|
3
9
|
// The brief arrives as a Request (lib/sources/), which already names the target repository. `start` no longer works
|
|
@@ -10,7 +16,7 @@ import { remoteInput } from "../execution_input.mjs";
|
|
|
10
16
|
import { learningInput, learningDispatch, learningDelivered, learningDeliveryPatch } from "../learning_input.mjs";
|
|
11
17
|
import { capabilitiesFor, adapterFor, buildRun } from "../harness.mjs";
|
|
12
18
|
import { newEntry, entryId, STATES } from "../state.mjs";
|
|
13
|
-
import { branchFor,
|
|
19
|
+
import { branchFor, runSessionEnv, worktreePath } from "../git.mjs";
|
|
14
20
|
import { resolveCredentialFor, describe as describeCredential } from "../credential.mjs";
|
|
15
21
|
import { requireIdentity, describeIdentity } from "../identity.mjs";
|
|
16
22
|
import { rememberRepo, resolveContextPaths, resolveBase, configuredBase, repoNames } from "../config.mjs";
|
|
@@ -21,6 +27,7 @@ import { requireHarness } from "../probe.mjs";
|
|
|
21
27
|
import { criteriaFromPrd, receiptInstructions } from "../deliverable.mjs";
|
|
22
28
|
import { executionRunner, EXECUTION_VERSION } from "../execution.mjs";
|
|
23
29
|
import { taskExecutionConfig } from "../task_allowance.mjs";
|
|
30
|
+
import { selectReviewPolicy } from "../review_policy.mjs";
|
|
24
31
|
|
|
25
32
|
export async function start(ctx, options) {
|
|
26
33
|
ctx = { ...ctx, config: taskExecutionConfig(ctx.config, options.task) };
|
|
@@ -37,10 +44,11 @@ export async function start(ctx, options) {
|
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
async function startRequest(ctx, { path, base: askedBase = null, chain = null, request: prepared = null, task = null }, progress) {
|
|
40
|
-
const { config, state,
|
|
47
|
+
const { config, state, git, log, runnerFor, writePrd, writeSource, readSource } = ctx;
|
|
48
|
+
const host = hostApi(ctx);
|
|
41
49
|
// Intake supplies an immutable, repository-resolved draft snapshot. All gates,
|
|
42
50
|
// source capture and dispatch ownership below remain shared with file starts.
|
|
43
|
-
const request = prepared ?? await requestFor(path, { git,
|
|
51
|
+
const request = prepared ?? await requestFor(path, { git, host });
|
|
44
52
|
// The gate. Every rule that applies to this source, every time, no override: a brief that fails cannot start.
|
|
45
53
|
// A `context_paths:` key in the frontmatter adds to the config list for this brief alone. It is held to exactly
|
|
46
54
|
// the same rules, so a PRD cannot hand a session the home directory or the Gallivant home that the config file is
|
|
@@ -57,8 +65,14 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
57
65
|
// and there was no second half to join it to. It is the operator's path, never the session's, so nothing a session
|
|
58
66
|
// does can move it, and the daemon reads it for exactly one thing: the divergence check.
|
|
59
67
|
const { repo: nameWithOwner, defaultBranch, relPath: prdRel, checkout } = request;
|
|
68
|
+
const selectedHost = (ctx.hosts ?? hosts).select(nameWithOwner);
|
|
69
|
+
repositoryReviewConfig(config, nameWithOwner, selectedHost.adapter);
|
|
70
|
+
assertRepositoryAccess(capabilitiesFor(request.harness), selectedHost.repository);
|
|
60
71
|
const id = entryId(nameWithOwner, request.id);
|
|
61
72
|
if (state.get(id)) throw new Error(`${id} already exists; \`gallivant why ${id}\` explains it, \`gallivant remove ${id}\` forgets it`);
|
|
73
|
+
// Pin policy before cloning or launching the author. Config edits only apply
|
|
74
|
+
// to future tasks; the explicit review policy command owns migration.
|
|
75
|
+
const reviewPolicy = ctx.reviewService ? await ctx.reviewService.preflight(nameWithOwner) : selectReviewPolicy(config, nameWithOwner);
|
|
62
76
|
|
|
63
77
|
// The base branch this pull request will target, resolved once here and then carried everywhere it is needed: the
|
|
64
78
|
// branch the clone is cut from, the instruction the session is given, and the branch the daemon merges in before
|
|
@@ -69,17 +83,18 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
69
83
|
// Intake pins the resolved base, including its original provenance. Passing
|
|
70
84
|
// that frozen value must not turn a default into a persisted operator override.
|
|
71
85
|
const baseSource = task?.baseSource || selection.source;
|
|
72
|
-
if (!base) throw new Error(`no base branch for ${nameWithOwner}:
|
|
86
|
+
if (!base) throw new Error(`no base branch for ${nameWithOwner}: the host reported no default branch and none is configured. Set one with \`gallivant start ${path} --base <branch>\`.`);
|
|
73
87
|
// A base that is not on the remote must fail HERE. Left to `gh pr create --base`, it fails after the session has
|
|
74
88
|
// implemented the entire brief, run the tests and spent the budget, with the work stranded on a branch nobody asked
|
|
75
89
|
// for. One API call before anything is cloned is the whole cost of not doing that.
|
|
76
90
|
//
|
|
77
91
|
// `branchExists` returns false for HTTP404; transport/CLI failures throw a
|
|
78
92
|
// bounded credential-redacted diagnostic before any clone or provider dispatch.
|
|
79
|
-
if (!await
|
|
93
|
+
if (!await host.branchExists(nameWithOwner, base)) {
|
|
80
94
|
const how = baseSource === "flag" ? "--base names it" : baseSource === "config" ? `repos in config.json names it for ${nameWithOwner}` : `it is ${nameWithOwner}'s default branch`;
|
|
81
|
-
throw new Error(`base branch "${base}" is not on ${nameWithOwner} (${how}), or
|
|
95
|
+
throw new Error(`base branch "${base}" is not on ${nameWithOwner} (${how}), or the host could not be asked. Nothing was cloned and nothing was started. Check the branch name and host access.`);
|
|
82
96
|
}
|
|
97
|
+
if (selectedHost.adapter.features?.transportSetup) await repositoryPreflight(config, nameWithOwner, { registry: ctx.hosts ?? hosts, git, base });
|
|
83
98
|
if (baseSource !== "default") log.info(`${id}: base branch ${base} (from ${baseSource === "flag" ? "--base" : "config"}), not ${nameWithOwner}'s default ${defaultBranch ?? "branch"}`);
|
|
84
99
|
|
|
85
100
|
// Nothing is recorded or cloned for a harness too old to honour its adapter, or one that is signed out.
|
|
@@ -94,9 +109,9 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
94
109
|
// There is no checkout yet, so the host reads as github.com, which is what `gh repo clone owner/name` resolves.
|
|
95
110
|
// An enterprise install is the case this cannot see from here, and `noCredentialMessage` says so by name: the
|
|
96
111
|
// answer there is `github_token_file`, rather than sending a github.com token to a host it cannot authenticate.
|
|
97
|
-
const credential = await resolveCredentialFor(ctx);
|
|
112
|
+
const credential = await resolveCredentialFor(ctx, { repo: nameWithOwner });
|
|
98
113
|
if (!credential.token) throw new Error(credential.why);
|
|
99
|
-
log.info(`${id}:
|
|
114
|
+
log.info(`${id}: repository credential from ${describeCredential(credential)}`);
|
|
100
115
|
// And an identity, refused here for the same reason and in the same place. A run commits as the operator, so with no
|
|
101
116
|
// identity on the machine every commit the session makes would fail; refusing before the clone means no money spent.
|
|
102
117
|
const identity = await requireIdentity(ctx);
|
|
@@ -117,7 +132,7 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
117
132
|
// The list the brief declared, which the contract rule has already proved readable, so the prompt can name the files
|
|
118
133
|
// before they are snapshotted below and there is no second source of truth about what a brief carries.
|
|
119
134
|
const declared = sourcePathsOf(request);
|
|
120
|
-
const prompt = startPrompt({ prd: request, repo: nameWithOwner, branch, base, verify: verifyCommands(request), receipt, carried: declared });
|
|
135
|
+
const prompt = startPrompt({ host: selectedHost.adapter.prompts, prd: request, repo: nameWithOwner, branch, base, verify: verifyCommands(request), receipt, carried: declared });
|
|
121
136
|
// Read-only extras for this session: the config list plus this brief's own frontmatter, merged in one place.
|
|
122
137
|
const contextPaths = resolveContextPaths(config, request);
|
|
123
138
|
if (remote && contextPaths.length) throw Object.assign(new Error("hosted backend cannot enforce private read-only context_paths"), { code: "EXECUTION_POLICY" });
|
|
@@ -168,6 +183,7 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
168
183
|
if (remote) {
|
|
169
184
|
state.validateStart(id, claim.token, chain);
|
|
170
185
|
const pending = newEntry({ prd: prdRel, harness: request.harness, session: null, repo: nameWithOwner, branch, worktree, state: STATES.needsHuman, nowIso: nowIso(), checkout, prdFingerprint: prdFingerprint(request), prdDecision: prdDecision(request), base, sources: carried });
|
|
186
|
+
pending.reviewPolicy = reviewPolicy;
|
|
171
187
|
// The clone is owned, but snapshot delivery and read-only API preparation
|
|
172
188
|
// can still fail. Consume the claim into an actionable entry before them;
|
|
173
189
|
// preserve the checkout rather than silently deleting unfinished files.
|
|
@@ -184,6 +200,8 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
184
200
|
// operator edits the file in the second between the two writes. Same 0o644/0o755 as the brief, because these are
|
|
185
201
|
// files in a source tree and must not be clamped to daemon-state modes.
|
|
186
202
|
for (const rel of carried) writeRepositoryText(worktree, rel, readSource(id, rel));
|
|
203
|
+
const hook = git.installHooks?.(worktree, publicationPolicyOf(request, { sources: carried, base }));
|
|
204
|
+
if (hook && !hook.installed) log.warn(`${id}: publication hook could not be installed (${hook.why}); coordinator publication checks remain mandatory`);
|
|
187
205
|
if (carried.length) log.info(`${id}: carried ${carried.length} file${carried.length === 1 ? "" : "s"} from ${request.checkout} into the checkout`);
|
|
188
206
|
if (!remote) state.validateStart(id, claim.token, chain);
|
|
189
207
|
const execution = executionRunner(ctx, id, request.harness);
|
|
@@ -209,7 +227,7 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
209
227
|
} else {
|
|
210
228
|
// Even a throwing local spawn can have created a child.
|
|
211
229
|
progress.spawnAttempted = true;
|
|
212
|
-
job = await learningDispatch(ctx, id, managed, () => execution.start("start", spec.cmd, spec.args, { cwd: worktree, runtimeSettings: spec.runtimeSettings, env:
|
|
230
|
+
job = await learningDispatch(ctx, id, managed, () => execution.start("start", spec.cmd, spec.args, { cwd: worktree, runtimeSettings: spec.runtimeSettings, env: runSessionEnv(spec.env, config, credential, identity, worktree), execution: dispatchInput }));
|
|
213
231
|
}
|
|
214
232
|
|
|
215
233
|
// `checkout` and the two prd fields are what the divergence check compares against: where the operator's copy lives,
|
|
@@ -220,6 +238,7 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
220
238
|
prd: prdRel, harness: request.harness, session, repo: nameWithOwner, branch, worktree, state: STATES.running, nowIso: nowIso(),
|
|
221
239
|
checkout, prdFingerprint: prdFingerprint(request), prdDecision: prdDecision(request), base, sources: carried,
|
|
222
240
|
});
|
|
241
|
+
entry.reviewPolicy = reviewPolicy;
|
|
223
242
|
const startedEntry = { ...entry, ...(remote ? { initial_dispatch: null } : {}), ...(job.execution ? { execution: job.execution } : {}), ...(task ? { task: { ...task, run: job.id } } : {}), public_since: publicSince, ...(chain ? { chain: { name: chain.name, index: chain.index, dispatch: chain.dispatch } } : {}), last: { at: nowIso(), run: job.id, exit: null, event: "start" } };
|
|
224
243
|
if (remoteRegistered) state.update(id, current => ({ ...startedEntry, ...learningDeliveryPatch(current, managed, job) }));
|
|
225
244
|
else state.putStarted(id, { ...startedEntry, ...learningDeliveryPatch(null, managed, job) }, claim.token);
|
|
@@ -237,7 +256,7 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
237
256
|
const persistBase = baseSource === "flag" && !chain ? base : null;
|
|
238
257
|
rememberRepo(nameWithOwner, config.home, persistBase);
|
|
239
258
|
if (persistBase) log.info(`${nameWithOwner}: base branch ${base} recorded in ${config.home}/config.json; later starts in this repository need no --base`);
|
|
240
|
-
if (isNewRepo) await
|
|
259
|
+
if (isNewRepo) await host.ensureLabels(nameWithOwner).catch(e => log.warn(`${nameWithOwner}: labels not created: ${e.message}`));
|
|
241
260
|
log.info(`${id}: started ${request.harness} session in ${worktree}`);
|
|
242
261
|
return { id, worktree, branch, base, baseSource, session, run: job.id };
|
|
243
262
|
} catch (error) {
|
package/lib/commands/status.mjs
CHANGED
|
@@ -10,6 +10,19 @@ import { chainRows } from "../chains.mjs";
|
|
|
10
10
|
import { executionRunner } from "../execution.mjs";
|
|
11
11
|
import { networkRecoveryDetail } from "../network_recovery.mjs";
|
|
12
12
|
import { probeStatus } from "../probe.mjs";
|
|
13
|
+
import { reviewDisclosure, reviewProgressText } from "../review_policy.mjs";
|
|
14
|
+
import { repoNames } from "../config.mjs";
|
|
15
|
+
import { hostGuidance } from "../host_definitions.mjs";
|
|
16
|
+
|
|
17
|
+
// Registry writes include routine polling. Show an actual run/review start or
|
|
18
|
+
// recorded event instead of making an idle row look newly active on each tick.
|
|
19
|
+
const activityAt = (entry, run, inFlight) => {
|
|
20
|
+
const reviewStart = entry.firstPartyReview?.startedAt;
|
|
21
|
+
const times = [reviewStart, inFlight ? run?.startedAt : null, entry.activity_at, entry.last?.at, entry.created_at]
|
|
22
|
+
.map(value => value == null ? NaN : new Date(value).getTime())
|
|
23
|
+
.filter(Number.isFinite);
|
|
24
|
+
return times.length ? new Date(Math.max(...times)).toISOString() : null;
|
|
25
|
+
};
|
|
13
26
|
|
|
14
27
|
/**
|
|
15
28
|
* The divergence for one entry, never fatal. `status` is what an operator runs when something already looks wrong, so a
|
|
@@ -44,8 +57,9 @@ export function model(ctx) {
|
|
|
44
57
|
else if (e.state === STATES.needsHuman) { section = "needs_you"; label = "needs-human"; detail = e.reason; }
|
|
45
58
|
// `gate_skipped` says the completion gate never ran, which is a different thing from passing it, and a person
|
|
46
59
|
// deciding whether to merge on this line needs to be told which of the two it was.
|
|
47
|
-
else if (e.state === STATES.ready) { section = "needs_you"; label = "ready"; detail = e.gate_skipped ? `
|
|
60
|
+
else if (e.state === STATES.ready) { section = "needs_you"; label = "ready"; detail = `${e.review_readiness?.note || reviewDisclosure(e)}; ${e.gate_skipped ? `RECEIPT NOT CHECKED (${e.gate_skipped})` : "receipt accepted"}`; }
|
|
48
61
|
else if (e.state === STATES.done) { section = "done"; label = "done"; detail = ""; }
|
|
62
|
+
else if (e.firstPartyReview?.status === "running") { section = "running"; label = "reviewing"; detail = reviewProgressText(e); }
|
|
49
63
|
// `retry` names the one GitHub write the tick is declining a step for, and it is the truest thing this row can say
|
|
50
64
|
// while it is set. Without it, a start run whose identity block will not write read as "ended, settling on the next
|
|
51
65
|
// tick" on every poll: the process HAD ended, and the tick was not settling it, it was retrying one write and
|
|
@@ -86,7 +100,7 @@ export function model(ctx) {
|
|
|
86
100
|
base: e.base ?? null,
|
|
87
101
|
// When the daemon last merged the base into this branch, so an operator can see the history was changed and when.
|
|
88
102
|
base_merge: e.base_merge ?? null,
|
|
89
|
-
retry: e.retry ?? null, network_retry: e.network_retry ?? null, pr: e.pr, url: e.url || null, harness: e.harness, since: e
|
|
103
|
+
retry: e.retry ?? null, network_retry: e.network_retry ?? null, pr: e.pr, url: e.url || null, harness: e.harness, since: activityAt(e, run, inFlight),
|
|
90
104
|
// `handle` rides along so a consumer can print the command that resolves it without rebuilding the handle from the
|
|
91
105
|
// other fields: the tracker does exactly that, and `handleFor` is not its to know.
|
|
92
106
|
divergence: divReason ? { verdict: divergence.verdict, reason: divReason, why: divergence.why, diff: divergence.diff, path: divergence.path ?? null, handle } : null,
|
|
@@ -95,6 +109,7 @@ export function model(ctx) {
|
|
|
95
109
|
};
|
|
96
110
|
});
|
|
97
111
|
return {
|
|
112
|
+
repositoryGuidance: [...new Set([...repoNames(config), ...state.entries().map(([, entry]) => entry.repo)])].map(repo => ({ repo, detail: hostGuidance(repo) })).filter(r => r.detail),
|
|
98
113
|
daemon: daemon ? { alive: daemon.alive, unverified: Boolean(daemon.unverified), pid: daemon.pid, startedAt: daemon.startedAt, lastTick: daemon.lastTick, nextTick: daemon.nextTick, probes: daemon.probes || {}, stale: Boolean(daemon.stale) } : null,
|
|
99
114
|
latestError: log.latest(),
|
|
100
115
|
chains: chainRows(ctx),
|
|
@@ -120,6 +135,7 @@ export function render(m, { all = false, clock = Date } = {}) {
|
|
|
120
135
|
if (m.daemon.stale) lines.push(" the daemon is running code older than what is installed; its runs use the old behaviour. Restart it: gallivant down && gallivant up");
|
|
121
136
|
}
|
|
122
137
|
if (m.latestError) lines.push(`latest error: ${m.latestError}`);
|
|
138
|
+
for (const guidance of m.repositoryGuidance ?? []) lines.push(`${guidance.repo}: ${guidance.detail}`);
|
|
123
139
|
const section = (title, rows) => {
|
|
124
140
|
lines.push("", title);
|
|
125
141
|
if (!rows.length) { lines.push(" (none)"); return; }
|
package/lib/commands/task.mjs
CHANGED
|
@@ -31,6 +31,7 @@ export async function taskCommand(ctx, { args, values, service = null, stdin = r
|
|
|
31
31
|
const shared = { taskId: id, expectedRevision, noDaemon: values["no-daemon"] };
|
|
32
32
|
if (values.context !== undefined && !["prepare", "answer"].includes(operation)) throw taskError("TASK_INPUT", "--context is supported by task prepare and task answer");
|
|
33
33
|
if (values["completion-target"] !== undefined && operation !== "prepare") throw taskError("TASK_INPUT", "--completion-target is selected only at task creation");
|
|
34
|
+
for (const flag of ["delivery", "route"]) if (values[flag] !== undefined && operation !== "prepare") throw taskError("TASK_INPUT", `--${flag} is selected only at task creation`);
|
|
34
35
|
if (operation === "list") return (service || createTaskService(ctx)).list();
|
|
35
36
|
if (operation === "inspect") return (service || createTaskService(ctx)).inspect(shared);
|
|
36
37
|
const owner = acquireCliMutation(ctx.home);
|
|
@@ -53,7 +54,10 @@ export async function taskCommand(ctx, { args, values, service = null, stdin = r
|
|
|
53
54
|
const textArgs = first === "prepare" ? args.slice(1) : args;
|
|
54
55
|
if (values.stdin && textArgs.length) throw taskError("TASK_INPUT", "use text or --stdin, not both");
|
|
55
56
|
const text = textArgs.length ? textArgs.join(" ") : await stdin();
|
|
56
|
-
|
|
57
|
+
// --delivery selects the typed profile action; --completion-target keeps its
|
|
58
|
+
// meaning as the profile's completion condition. Both are owner choices.
|
|
59
|
+
const delivery = values.delivery === undefined ? undefined : { action: values.delivery, ...(values["completion-target"] === undefined ? {} : { completion: values["completion-target"] }) };
|
|
60
|
+
return await service.prepare({ text, dir: values.dir, repo: values.repo, base: values.base, harness: values.harness, context: values.context || [], requestId: values["request-id"], run: values.run, noDaemon: values["no-daemon"], verifyCommands: values.verify || [], ...(values["completion-target"] === undefined ? {} : { completion: { target: values["completion-target"] } }), ...(delivery ? { delivery } : {}), route: values.route ?? null });
|
|
57
61
|
}); } finally { owner.release(); }
|
|
58
62
|
}
|
|
59
63
|
|
|
@@ -67,6 +71,10 @@ export function renderTask(result) {
|
|
|
67
71
|
result.dispatchRecovery && `Dispatch recovery: inspect the clone at ${result.dispatchRecovery.worktree}; start ownership is retained`,
|
|
68
72
|
result.implementation && `Implementation: ${result.implementation.id} (run ${result.implementation.run})`,
|
|
69
73
|
result.completion && `Completion target: ${result.completion.target} (${result.completion.status})`,
|
|
74
|
+
result.deliveryProfile && (result.deliveryProfile.legacy ? `Delivery profile: historical task (${result.deliveryProfile.label})` : result.deliveryProfile.supported ? `Delivery profile: ${result.deliveryProfile.action} → ${result.deliveryProfile.target.repository}@${result.deliveryProfile.target.base}, completion ${result.deliveryProfile.completion.condition} (v${result.deliveryProfile.version}); no merge or deployment authority` : `Delivery profile: unsupported (${result.deliveryProfile.error?.message})`),
|
|
75
|
+
result.modelRoute && `Planner route: ${result.modelRoute.kind === "model" ? `${result.modelRoute.adapter} ${result.modelRoute.model} via ${result.modelRoute.endpointHost}` : `harness ${result.modelRoute.harness}`}; calls ${result.modelRoute.calls}; cost ${result.cost.status}${Number.isFinite(result.cost.usd) ? ` $${result.cost.usd.toFixed(4)}` : ""}`,
|
|
76
|
+
result.repositoryContext && `Repository context: ${result.repositoryContext.status}${result.repositoryContext.revision ? ` at ${result.repositoryContext.revision.slice(0, 12)} (digest ${result.repositoryContext.digest.slice(0, 12)})` : result.repositoryContext.reason ? `: ${result.repositoryContext.reason}` : ""}`,
|
|
77
|
+
result.review && `Review: revision ${result.review.revision}, digest ${result.review.digest.slice(0, 12)}, ${result.review.requirementMap.length} clauses mapped, ${result.review.questions.length} open question(s)${result.approval ? `, approved at ${result.approval.at}` : ", not yet approved"}`,
|
|
70
78
|
result.daemonError,
|
|
71
79
|
`Next: ${result.nextAction}`,
|
|
72
80
|
].filter(Boolean).join("\n");
|
package/lib/commands/up.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hostApi, hosts } from "../hosts.mjs";
|
|
1
2
|
// `gallivant up`: the daemon. Reconciles with GitHub on boot, then ticks on a setTimeout chain so ticks never
|
|
2
3
|
// overlap. Health is a file (daemon.json) that `status` reads; a second daemon exits when the first is alive.
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -6,7 +7,7 @@ import { openSync, closeSync } from "node:fs";
|
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { makeTick } from "../tick.mjs";
|
|
8
9
|
import { checkGh, checkHarness, probeFix } from "../probe.mjs";
|
|
9
|
-
import { makeProviderHealth } from "../provider_health.mjs";
|
|
10
|
+
import { makeProviderHealth, coordinatorHealthLimits, scheduleProviderHealth } from "../provider_health.mjs";
|
|
10
11
|
import { EXECUTION_LIMITS } from "../execution_io.mjs";
|
|
11
12
|
import { readJson, writeJsonAtomic, VERSION, codeStamp, gallivantHome } from "../fs.mjs";
|
|
12
13
|
import { nowIso, later } from "../time.mjs";
|
|
@@ -17,6 +18,7 @@ import { newEntry, entryId, STATES } from "../state.mjs";
|
|
|
17
18
|
import { receiptHash } from "../deliverable.mjs";
|
|
18
19
|
import { worktreePath, isOurBranch, prdIdFromBranch } from "../git.mjs";
|
|
19
20
|
import { daemonPath, readDaemon, assertDaemonUsable, acquireDaemon, assertUnpinnedHome } from "../daemon.mjs";
|
|
21
|
+
import { repositoryReviewConfig } from "../review_policy.mjs";
|
|
20
22
|
import { repoNames } from "../config.mjs";
|
|
21
23
|
|
|
22
24
|
export { daemonPath, readDaemon } from "../daemon.mjs";
|
|
@@ -54,11 +56,12 @@ export function down(ctx, { home = ctx?.home ?? gallivantHome() } = {}) {
|
|
|
54
56
|
* the session, which the log says. Missing checkouts are recreated lazily by the tick when a run is needed.
|
|
55
57
|
*/
|
|
56
58
|
export async function reconcile(ctx, ownLogin) {
|
|
57
|
-
const { config, state,
|
|
59
|
+
const { config, state, log } = ctx;
|
|
60
|
+
const host = hostApi(ctx);
|
|
58
61
|
const added = [];
|
|
59
62
|
for (const repo of repoNames(config)) {
|
|
60
63
|
let prs;
|
|
61
|
-
try { prs = await
|
|
64
|
+
try { const selected = (ctx.hosts ?? hosts).select(repo); repositoryReviewConfig(config, repo, selected.adapter); prs = await host.prListMine(repo); } catch (e) { log.warn(`reconcile ${repo}: ${e.message}`); continue; }
|
|
62
65
|
for (const pr of prs || []) {
|
|
63
66
|
if (pr.isCrossRepository || !isOurBranch(pr.headRefName)) continue;
|
|
64
67
|
if (state.findByPr(repo, pr.number)) continue;
|
|
@@ -70,15 +73,15 @@ export async function reconcile(ctx, ownLogin) {
|
|
|
70
73
|
if (state.get(id)) { log.warn(`reconcile ${repo}#${pr.number}: id ${id} already in use, ignored`); continue; }
|
|
71
74
|
try { state.assertCanRegister(id); }
|
|
72
75
|
catch (e) { log.warn(`reconcile ${repo}#${pr.number}: ${e.message}, ignored`); continue; }
|
|
73
|
-
const [comments, headDate] = await Promise.all([
|
|
74
|
-
const lastOwn = lastOwnCommentAt(comments, ownLogin);
|
|
76
|
+
const [comments, headDate] = await Promise.all([host.issueComments(repo, pr.number), host.commitDate(repo, pr.headRefOid).catch(() => null)]);
|
|
77
|
+
const lastOwn = lastOwnCommentAt(comments, host.userFor ? await host.userFor(repo) : ownLogin);
|
|
75
78
|
await ensurePrdSnapshot(ctx, { id, repo, path: block.prd, ref: pr.headRefName });
|
|
76
79
|
// `base` is the branch the PR targets, read off the PR rather than guessed from config: a recovered entry has to merge
|
|
77
80
|
// in the branch this pull request will actually be merged into, and only GitHub knows which that is.
|
|
78
81
|
const entry = newEntry({ prd: block.prd, harness: block.harness, session: block.session, repo, branch: pr.headRefName, worktree: worktreePath(config, repo, prdId), pr: pr.number, url: pr.url, state: STATES.prOpen, nowIso: nowIso(), base: typeof pr.baseRefName === "string" && pr.baseRefName ? pr.baseRefName : null });
|
|
79
82
|
try { state.put(id, {
|
|
80
83
|
...entry,
|
|
81
|
-
iteration: countRounds(comments, ownLogin),
|
|
84
|
+
iteration: countRounds(comments, host.userFor ? await host.userFor(repo) : ownLogin),
|
|
82
85
|
// The receipt fingerprint comes from the body `prListMine` already returned, as every other writer of this
|
|
83
86
|
// cursor sets it. Left null, a receipt that was on the PR before the recovery read as a change the next time
|
|
84
87
|
// a run ended, so a run that did nothing counted as a round and posted "Completion receipt updated." to the
|
|
@@ -151,15 +154,18 @@ export async function upInBackground(ctx, { bin = process.execPath, script = fil
|
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
export async function up(ctx, { once = false } = {}) {
|
|
154
|
-
const { home, config,
|
|
157
|
+
const { home, config, notify, log, clock = Date } = ctx;
|
|
158
|
+
const host = hostApi(ctx);
|
|
155
159
|
const existing = readDaemon(home);
|
|
156
160
|
if (existing?.alive && existing.pid !== process.pid) throw new Error(`daemon already running (pid ${existing.pid})`);
|
|
157
161
|
if (existing?.unverified) throw new Error(`daemon pid ${existing.pid} has an unverifiable process identity; verify and stop the older daemon before restarting`);
|
|
158
162
|
const { owner, release } = acquireDaemon(home);
|
|
159
163
|
let ownLogin = null;
|
|
160
164
|
let timer = null;
|
|
165
|
+
let stopProviderHealth = null;
|
|
161
166
|
const cleanup = () => {
|
|
162
167
|
if (timer) clearTimeout(timer);
|
|
168
|
+
stopProviderHealth?.(); stopProviderHealth = null;
|
|
163
169
|
try { writeJsonAtomic(daemonPath(home), { ...readJson(daemonPath(home), {}), ...owner, stoppedAt: nowIso() }); } catch { /* exiting */ }
|
|
164
170
|
try { release(); } catch { /* stale lock can be recovered next boot */ }
|
|
165
171
|
process.off("SIGINT", stop); process.off("SIGTERM", stop); process.off("exit", cleanup);
|
|
@@ -181,11 +187,11 @@ export async function up(ctx, { once = false } = {}) {
|
|
|
181
187
|
catch (e) { log.warn(`cannot write ${daemonPath(home)} (${e.code || e.message}); status will not see this tick`); }
|
|
182
188
|
};
|
|
183
189
|
health({ starting: true });
|
|
184
|
-
ownLogin = await
|
|
185
|
-
const providerHealth = makeProviderHealth({ home, clock, limits: config.execution, probe: (provider, account, signal) => checkHarness(provider, config, { signal }) });
|
|
190
|
+
ownLogin = await host.user();
|
|
191
|
+
const providerHealth = makeProviderHealth({ home, clock, limits: coordinatorHealthLimits(config.execution), probe: (provider, account, signal) => checkHarness(provider, config, { signal }) });
|
|
186
192
|
const githubHealth = makeProviderHealth({ home, file: join(home, "github-health.json"), clock,
|
|
187
193
|
limits: { ...config.execution, progressMs: config.poll_seconds * 1000 },
|
|
188
|
-
probe: (_provider, _account, signal) => checkGh(
|
|
194
|
+
probe: (_provider, _account, signal) => checkGh(host, { signal }) });
|
|
189
195
|
const tickImpl = makeTick({ ...ctx, ownLogin, providerHealth });
|
|
190
196
|
health({ starting: false });
|
|
191
197
|
keepAwake();
|
|
@@ -193,11 +199,13 @@ export async function up(ctx, { once = false } = {}) {
|
|
|
193
199
|
const recovered = await reconcile(ctx, ownLogin);
|
|
194
200
|
if (recovered.length) log.info(`recovered ${recovered.length} PR(s)`);
|
|
195
201
|
const tickMs = Math.min(config.poll_seconds * 1000, config.execution?.progressMs || EXECUTION_LIMITS.progressMs);
|
|
202
|
+
const scopes = Object.keys(config.harness).map(h => [h, config.harness[h]?.account || "operator"]);
|
|
203
|
+
const providerInterval = Math.min(30_000, Math.max(1_000, Math.floor(coordinatorHealthLimits(config.execution).progressMs / 2)));
|
|
204
|
+
stopProviderHealth = scheduleProviderHealth(providerHealth, scopes, { intervalMs: providerInterval, onError: e => log.warn(`provider health refresh failed: ${e.code || "check unavailable"}`) });
|
|
196
205
|
|
|
197
206
|
/** One tick, with every failure caught: a daemon that dies on one bad tick is worse than one that logs it. */
|
|
198
207
|
const loop = async () => {
|
|
199
208
|
try {
|
|
200
|
-
const scopes = Object.keys(config.harness).map(h => [h, config.harness[h]?.account || "operator"]);
|
|
201
209
|
// Provider checks and GitHub/progress work use separate lanes. Failed or
|
|
202
210
|
// due/pending health reads fence only that account/provider, even before
|
|
203
211
|
// refresh gets a worker; the previous healthy cache cannot authorize work.
|
package/lib/commands/why.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// `gallivant why <handle>`: the reason a PR stopped, what was dispatched, what the harness last said, any permission
|
|
2
2
|
// denials, and APort audit lines from the run's window. Reads files; never calls GitHub.
|
|
3
|
+
import { reviewDisclosure } from "../review_policy.mjs";
|
|
3
4
|
import { resolveHandle, shortRepo } from "../state.mjs";
|
|
4
5
|
import { adapterFor } from "../harness.mjs";
|
|
5
6
|
import { prdDivergence, divergenceReason, renderDivergence, prdDecision, sourceDivergence } from "../prd.mjs";
|
|
6
7
|
import { contract } from "../fs.mjs";
|
|
7
8
|
import { aportDirFor, auditEntries } from "../aport.mjs";
|
|
8
9
|
import { READY_LABEL_WRITE, COMPLETION_GATE } from "../tick.mjs";
|
|
9
|
-
import { READY_LABEL } from "../
|
|
10
|
+
import { READY_LABEL } from "../host_contract.mjs";
|
|
10
11
|
import { executionRunner } from "../execution.mjs";
|
|
11
12
|
import { networkRecoveryDetail } from "../network_recovery.mjs";
|
|
12
13
|
import { redact } from "../execution_io.mjs";
|
|
@@ -103,10 +104,13 @@ export function why(ctx, { handle }) {
|
|
|
103
104
|
else if (e.prd_decision && now === e.prd_decision) out.push(` aport_decision is unchanged (${e.prd_decision}), so the edit has not been through \`gallivant ready\`.`);
|
|
104
105
|
}
|
|
105
106
|
if (e.retry) out.push(`waiting on ${e.retry.op === COMPLETION_GATE ? "completion verification" : "GitHub"}: ${e.retry.op} has failed ${e.retry.count} tick${e.retry.count === 1 ? "" : "s"} in a row; the entry stops with "stuck" if it keeps failing`);
|
|
106
|
-
|
|
107
|
+
out.push(` Review: ${reviewDisclosure(e)}`);
|
|
108
|
+
if (e.firstPartyReview?.error) out.push(` Review failure: ${e.firstPartyReview.error}`);
|
|
109
|
+
if (e.firstPartyReview?.unresolvedScope?.length) for (const item of e.firstPartyReview.unresolvedScope) out.push(` Unresolved: ${item}`);
|
|
110
|
+
if (e.retry?.op === COMPLETION_GATE) out.push(` checks settled; ${e.review_readiness?.note || reviewDisclosure(e)}; completion verification is pending: ${e.retry.error ?? "no decision from the gate"}`);
|
|
107
111
|
// The one retry whose op is not a step toward a fix round: everything a person waits for has already happened and
|
|
108
112
|
// only the label that says so will not write, which is worth spelling out where the operator is already reading.
|
|
109
|
-
if (e.retry?.op === READY_LABEL_WRITE) out.push(` this PR is otherwise ready: checks
|
|
113
|
+
if (e.retry?.op === READY_LABEL_WRITE) out.push(` this PR is otherwise ready: checks settled, ${e.review_readiness?.note || reviewDisclosure(e)}, receipt accepted. Only the ${READY_LABEL} label will not write.`);
|
|
110
114
|
// The round comment is the announcement of a round that is already counted, so the operator needs both halves: the
|
|
111
115
|
// work is on the branch and charged, and the PR does not say so. The second half matters because the count on GitHub
|
|
112
116
|
// is what a recovery rebuilds from, so an unposted marker is a round a recovery after this would not see.
|
package/lib/config.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { repositoryIdentity } from "./repository.mjs";
|
|
2
|
+
import { repositoryReviewConfig, reviewPolicyProblems } from "./review_policy.mjs";
|
|
3
|
+
import { HOST_DEFINITIONS } from "./host_definitions.mjs";
|
|
1
4
|
// Config: one JSON file the operator writes by hand, validated here so every other module can trust its shape.
|
|
2
5
|
//
|
|
3
6
|
// Defaults hold nothing machine-specific. Anything that identifies the operator (trusted logins, token file,
|
|
4
7
|
// binaries) has to be written explicitly, which is what `doctor` walks you through.
|
|
8
|
+
import { reviewSettings, validateReviewSettings } from "./review_policy.mjs";
|
|
5
9
|
import { join, relative, isAbsolute } from "node:path";
|
|
6
10
|
import { homedir } from "node:os";
|
|
7
11
|
import { realpathSync } from "node:fs";
|
|
@@ -10,6 +14,7 @@ import { validateAgentsConfig } from "./openai_agents_config.mjs";
|
|
|
10
14
|
import { validateCloudConfig } from "./codex_cloud_config.mjs";
|
|
11
15
|
import { adapters, executionRegistry } from "./harness.mjs";
|
|
12
16
|
import { findExecutable } from "./executable.mjs";
|
|
17
|
+
import { intakeModelProblems } from "./task_model_config.mjs";
|
|
13
18
|
|
|
14
19
|
// Recognition is independent of new-dispatch availability: a registered provider
|
|
15
20
|
// must retain configuration and recovery identities while its readiness is refused.
|
|
@@ -77,7 +82,7 @@ export const DEFAULT_DELIVERABLE = Object.freeze({
|
|
|
77
82
|
export const DEFAULTS = Object.freeze({
|
|
78
83
|
deliverable: DEFAULT_DELIVERABLE,
|
|
79
84
|
poll_seconds: 120,
|
|
80
|
-
max_iterations:
|
|
85
|
+
max_iterations: 60,
|
|
81
86
|
checks_grace_minutes: 5, // a head younger than this with no checks yet is not "green", it is "not reported yet"
|
|
82
87
|
// What this number is FOR, because the value only makes sense once that is settled. The timeout catches a session that
|
|
83
88
|
// is genuinely wedged: spinning on a command that never returns, waiting on a prompt that will never come, looping.
|
|
@@ -97,7 +102,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
97
102
|
// A repository with a different shape sets its own on its `repos` entry; this is only the fallback. The validation
|
|
98
103
|
// floor stays at 1, so an operator who deliberately wants a short timeout can have one.
|
|
99
104
|
run_timeout_minutes: 720,
|
|
100
|
-
max_budget_usd:
|
|
105
|
+
max_budget_usd: 50,
|
|
101
106
|
rereview_minutes: 6,
|
|
102
107
|
stall_hours: 6,
|
|
103
108
|
trusted_authors: [],
|
|
@@ -278,21 +283,23 @@ export function resolveTimeout({ config, repo }) {
|
|
|
278
283
|
*/
|
|
279
284
|
export function validateRepos(list) {
|
|
280
285
|
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'];
|
|
286
|
+
if (!Array.isArray(list)) return ['repos must be an array of "owner/name" strings or { repo, base, run_timeout_minutes, review } objects'];
|
|
282
287
|
const problems = [];
|
|
283
288
|
const seen = new Set();
|
|
284
289
|
for (const raw of list) {
|
|
285
290
|
if (typeof raw === "string") {
|
|
286
|
-
if (
|
|
291
|
+
if (!validRepository(raw)) { problems.push("repos: invalid identity is not owner/name or host/owner/name; URL credentials are not accepted"); continue; }
|
|
287
292
|
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
288
|
-
if (typeof raw.repo !== "string" ||
|
|
293
|
+
if (typeof raw.repo !== "string" || !validRepository(raw.repo)) { problems.push('repos: needs a "repo" of the form owner/name or host/owner/name, without URL credentials'); continue; }
|
|
289
294
|
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
295
|
// Refused rather than ignored, for the reason the unknown-key check above exists: a timeout typed as "45" or as 0
|
|
291
296
|
// would read as "not configured" and the repository would keep the global forever, which is the silent failure.
|
|
292
297
|
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
|
-
|
|
294
|
-
|
|
298
|
+
if (raw.review != null && (typeof raw.review !== "object" || Array.isArray(raw.review))) { problems.push(`repos: ${raw.repo} review must be an object of review settings`); continue; }
|
|
299
|
+
const extra = Object.keys(raw).filter(k => !["repo", "base", "run_timeout_minutes", "review", "reviewer", "trusted_authors", "required_checks"].includes(k));
|
|
300
|
+
if (extra.length) { problems.push(`repos: ${raw.repo} has unknown key${extra.length === 1 ? "" : "s"} ${extra.join(", ")} (use repo, base, run_timeout_minutes and explicit review policy)`); continue; }
|
|
295
301
|
} else { problems.push(`repos: ${JSON.stringify(raw)} is neither "owner/name" nor a { repo } object`); continue; }
|
|
302
|
+
if (typeof raw === "object") problems.push(...reviewPolicyProblems(raw, HOST_DEFINITIONS.find(d => d.host === repositoryIdentity(raw.repo).host)?.reviewPolicy));
|
|
296
303
|
const key = repoEntry(raw).repo;
|
|
297
304
|
if (seen.has(key)) problems.push(`repos: ${key} is listed twice`);
|
|
298
305
|
seen.add(key);
|
|
@@ -323,6 +330,14 @@ export function merge(raw, home = gallivantHome()) {
|
|
|
323
330
|
/** Problems as human sentences; empty means valid. Used by `load` (throws) and by `doctor` (prints). */
|
|
324
331
|
export function validate(cfg, home = gallivantHome()) {
|
|
325
332
|
const problems = [];
|
|
333
|
+
if (cfg.git_hosts != null) {
|
|
334
|
+
if (typeof cfg.git_hosts !== "object" || Array.isArray(cfg.git_hosts)) problems.push("git_hosts must be a host-scoped configuration object");
|
|
335
|
+
else for (const [host, settings] of Object.entries(cfg.git_hosts)) {
|
|
336
|
+
const adapter = HOST_DEFINITIONS.find(d => d.host === host);
|
|
337
|
+
if (!adapter?.validateConfig) problems.push("Unsupported git_hosts configuration; use a registered host's documented credential settings");
|
|
338
|
+
else problems.push(...adapter.validateConfig(settings));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
326
341
|
const num = (k, min) => { if (typeof cfg[k] !== "number" || cfg[k] < min) problems.push(`${k} must be a number >= ${min}`); };
|
|
327
342
|
num("poll_seconds", 30); num("max_iterations", 1); num("run_timeout_minutes", 1); num("max_budget_usd", 0);
|
|
328
343
|
num("rereview_minutes", 1); num("stall_hours", 1); num("checks_grace_minutes", 0);
|
|
@@ -333,7 +348,21 @@ export function validate(cfg, home = gallivantHome()) {
|
|
|
333
348
|
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
349
|
}
|
|
335
350
|
}
|
|
336
|
-
|
|
351
|
+
const legacy = !Array.isArray(cfg.repos) || !cfg.repos.length || cfg.repos.some(r => {
|
|
352
|
+
try { return HOST_DEFINITIONS.find(d => d.host === repositoryIdentity(typeof r === "string" ? r : r.repo).host)?.features?.legacyReviewDefaults; } catch { return true; }
|
|
353
|
+
});
|
|
354
|
+
if (legacy && (!Array.isArray(cfg.trusted_authors) || cfg.trusted_authors.length === 0)) problems.push("trusted_authors must list at least one GitHub login");
|
|
355
|
+
for (const repo of Array.isArray(cfg.repos) ? cfg.repos : []) {
|
|
356
|
+
try { const r = repositoryIdentity(typeof repo === "string" ? repo : repo.repo); if (HOST_DEFINITIONS.find(d => d.host === r.host)?.reviewPolicy) repositoryReviewConfig(cfg, r); }
|
|
357
|
+
catch (error) { problems.push(error.message); }
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
for (const value of [cfg.review, ...(cfg.repos || []).map(r => r?.review)]) {
|
|
361
|
+
if (value !== undefined && (value === null || typeof value !== "object" || Array.isArray(value))) throw new Error("review must be a configuration object");
|
|
362
|
+
}
|
|
363
|
+
validateReviewSettings(reviewSettings(cfg));
|
|
364
|
+
for (const repo of cfg.repos || []) if (repo?.review) validateReviewSettings(reviewSettings(cfg, repo.repo));
|
|
365
|
+
} catch (error) { problems.push(error.message); }
|
|
337
366
|
if (cfg.reviewer !== null) {
|
|
338
367
|
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
368
|
// `reviewed_commit_pattern` is optional, and that is deliberate rather than an omission. It was added after configs
|
|
@@ -350,6 +379,7 @@ export function validate(cfg, home = gallivantHome()) {
|
|
|
350
379
|
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
380
|
else if (cfg.deliverable && !["aport", "local"].includes(cfg.deliverable.evaluator)) problems.push('deliverable.evaluator must be "aport" or "local"');
|
|
352
381
|
else if (cfg.deliverable && !["wait", "local"].includes(cfg.deliverable.on_unreachable)) problems.push('deliverable.on_unreachable must be "wait" or "local"');
|
|
382
|
+
if (cfg.intake_model !== undefined && cfg.intake_model !== null) problems.push(...intakeModelProblems(cfg.intake_model));
|
|
353
383
|
if (!cfg.worktrees) problems.push("worktrees must be a directory path outside the Gallivant home");
|
|
354
384
|
else if (isInside(expand(cfg.worktrees), home)) problems.push("worktrees must not be under the Gallivant home directory");
|
|
355
385
|
problems.push(...validateContextPaths(cfg.context_paths, home));
|
|
@@ -401,7 +431,8 @@ export function repoEntry(raw) {
|
|
|
401
431
|
if (raw && typeof raw === "object" && typeof raw.repo === "string") {
|
|
402
432
|
const base = typeof raw.base === "string" && raw.base.trim() ? raw.base.trim() : null;
|
|
403
433
|
const t = typeof raw.run_timeout_minutes === "number" && raw.run_timeout_minutes >= 1 ? raw.run_timeout_minutes : null;
|
|
404
|
-
|
|
434
|
+
const policy = Object.fromEntries(["review", "reviewer", "trusted_authors", "required_checks"].filter(k => Object.hasOwn(raw, k)).map(k => [k, raw[k]]));
|
|
435
|
+
return { repo: raw.repo.toLowerCase(), base, run_timeout_minutes: t, ...policy };
|
|
405
436
|
}
|
|
406
437
|
return null;
|
|
407
438
|
}
|
|
@@ -459,3 +490,5 @@ export function rememberRepo(nameWithOwner, home = gallivantHome(), base = null)
|
|
|
459
490
|
writeJsonAtomic(configPath(home), { ...raw, repos: updated }, 0o600);
|
|
460
491
|
return true;
|
|
461
492
|
}
|
|
493
|
+
|
|
494
|
+
function validRepository(value) { try { repositoryIdentity(value); return true; } catch { return false; } }
|
package/lib/context.mjs
CHANGED
|
@@ -5,21 +5,22 @@ import { contentHash } from "./openai_agents_sources.mjs";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { load as loadConfig } from "./config.mjs";
|
|
7
7
|
import { makeState, idSlug } from "./state.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import { makeHostRegistry } from "./hosts.mjs";
|
|
9
9
|
import { makeGit } from "./git.mjs";
|
|
10
|
-
import {
|
|
10
|
+
import { repositoryIdentity } from "./repository.mjs";
|
|
11
11
|
import { resolveIdentity } from "./identity.mjs";
|
|
12
12
|
import { makeNotifier } from "./notify.mjs";
|
|
13
13
|
import { makeLog } from "./log.mjs";
|
|
14
14
|
import { makeRunner } from "./run.mjs";
|
|
15
15
|
import { ensureDir, gallivantHome, readText, writeText } from "./fs.mjs";
|
|
16
16
|
import { aportDirFor, guardrailMode } from "./aport.mjs";
|
|
17
|
-
import { makeExecution, assertExecutionDependency } from "./execution.mjs";
|
|
17
|
+
import { makeExecution, executionEpoch, assertExecutionDependency } from "./execution.mjs";
|
|
18
18
|
import { makeAgentsTransport } from "./openai_agents.mjs";
|
|
19
19
|
import { capabilitiesFor } from "./harness.mjs";
|
|
20
20
|
import { createLearningService } from "./learning.mjs";
|
|
21
21
|
import { makeTrust } from "./events.mjs";
|
|
22
22
|
import { revokeLearningAttempts } from "./learning_input.mjs";
|
|
23
|
+
import { createReviewService } from "./first_party_review.mjs";
|
|
23
24
|
import { silentLog } from "./log.mjs";
|
|
24
25
|
const { createTaskService } = await import("./tasks.mjs").catch(error => {
|
|
25
26
|
if (error.code === "ERR_MODULE_NOT_FOUND") assertExecutionDependency(null);
|
|
@@ -42,41 +43,41 @@ export function makeContext({ home = gallivantHome(), needConfig = true, readOnl
|
|
|
42
43
|
const log = readOnly ? silentLog : makeLog(home);
|
|
43
44
|
if (!needConfig) return { home, log };
|
|
44
45
|
const config = loadConfig(home);
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
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);
|
|
46
|
+
// Each adapter resolves its credentials per operation, so reauthentication is
|
|
47
|
+
// picked up without restarting the coordinator or sharing another host's token.
|
|
48
|
+
const hosts = makeHostRegistry({ config, log });
|
|
49
|
+
const credential = (opts = {}) => hosts.credential(repositoryIdentity(opts.path ?? opts.repository ?? opts.repo ?? "owner/name", opts.host));
|
|
52
50
|
// The git identity a run commits as, resolved the same way and for the same reason: per call, so an operator who
|
|
53
51
|
// changes their name or email under a long-lived daemon is committed as who they are now.
|
|
54
52
|
const identity = () => resolveIdentity();
|
|
55
|
-
const
|
|
53
|
+
const host = hosts.api;
|
|
54
|
+
const gh = hosts.select("owner/name").api;
|
|
56
55
|
const state = makeState(home);
|
|
57
56
|
const context = {
|
|
58
|
-
home, log, config, gh, credential, identity,
|
|
57
|
+
home, log, config, host, hosts, gh, credential, identity,
|
|
59
58
|
state, capabilitiesFor,
|
|
60
59
|
executionInputFor: (id, entry, input, contextPaths) => remoteInput(context, id, entry, input, contextPaths),
|
|
61
60
|
// `identity` as well as `credential`, because `git.mergeBase` commits: the merge that brings a base into a branch
|
|
62
61
|
// is the daemon's own commit, and with no identity pairs it is either attributed to a machine-derived address or
|
|
63
62
|
// refused outright, depending on whether the host's git can guess one. See makeGit for the two shapes.
|
|
64
|
-
git: makeGit({ config,
|
|
63
|
+
git: makeGit({ config, identity, hosts }),
|
|
65
64
|
notify: makeNotifier({ config, log }),
|
|
66
65
|
runnerFor: id => makeRunner(join(home, "runs", idSlug(id))),
|
|
67
66
|
executionFor: (id, provider) => {
|
|
68
67
|
const root = join(home, "runs", idSlug(id)), selected = config.harness[provider];
|
|
69
68
|
const account = selected?.account || "operator";
|
|
70
|
-
|
|
69
|
+
const entry = state.get(id), runner = capabilitiesFor(provider).transport === "remote" ? null : makeRunner(root);
|
|
70
|
+
const epoch = executionEpoch({ root, taskId: id, entry, provider, account, runner });
|
|
71
|
+
return makeExecution({ root, taskId: id, provider, account, ledgerEpoch: epoch, ledgerFile: join(root, epoch ? `execution-${epoch}.json` : "execution.json"),
|
|
71
72
|
...(provider === "openai-agents" ? { transport: makeAgentsTransport({ root, taskId: id, account, config: selected }), capabilities: { ...capabilitiesFor(provider), models: [selected?.model] } } : {}),
|
|
72
73
|
...(provider === "codex-cloud" ? { transport: makeCloudTransport({ root, taskId: id, account, config: selected, verifyRemote: async input => {
|
|
73
|
-
if (await
|
|
74
|
+
if (await host.refSha(input.repo, input.sources.inputBranch) !== input.inputHead || await host.refSha(input.repo, input.branch) !== input.inputHead) throw new Error("Cloud remote input revision moved; reconcile before dispatch");
|
|
74
75
|
for (const [path, hash] of Object.entries(input.sources.hashes)) {
|
|
75
|
-
if (contentHash(await
|
|
76
|
+
if (contentHash(await host.fileAtStrict(input.repo, path, input.inputHead)) !== hash) throw new Error("Cloud remote snapshot hash differs");
|
|
76
77
|
}
|
|
77
78
|
} }), capabilities: { ...capabilitiesFor(provider), environments: [selected?.environment] } } : {}),
|
|
78
79
|
legacySessionId: state.get(id)?.harness === provider ? state.get(id).session : null,
|
|
79
|
-
runner
|
|
80
|
+
runner, limits: config.execution });
|
|
80
81
|
},
|
|
81
82
|
// The APort API key the Claude install recorded, if any, so hosted gate calls count against the same account.
|
|
82
83
|
aportApiKey: guardrailMode(aportDirFor(config.harness?.claude?.settings)).apiKey,
|
|
@@ -90,11 +91,17 @@ export function makeContext({ home = gallivantHome(), needConfig = true, readOnl
|
|
|
90
91
|
const learningServices = new Map();
|
|
91
92
|
context.learningFor = (ownLogin = "learning-coordinator", { signerExecutable } = {}) => {
|
|
92
93
|
const cacheKey = JSON.stringify([ownLogin, signerExecutable ?? null]);
|
|
93
|
-
if (!learningServices.has(cacheKey))
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
if (!learningServices.has(cacheKey)) {
|
|
95
|
+
const service = createLearningService({
|
|
96
|
+
root: join(home, "learning"), signerExecutable, gh, trust: makeTrust(config, ownLogin),
|
|
97
|
+
onRevocation: intent => revokeLearningAttempts(context, intent),
|
|
98
|
+
});
|
|
99
|
+
learningServices.set(cacheKey, { ...service,
|
|
100
|
+
identityFor: name => hosts.select(name).adapter.features?.learning ? service.identityFor(name) : null,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
97
103
|
return learningServices.get(cacheKey);
|
|
98
104
|
};
|
|
105
|
+
if (!readOnly) context.reviewService = createReviewService(context);
|
|
99
106
|
return context;
|
|
100
107
|
}
|