@uchibeke/gallivant 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +26 -0
- package/LICENSE +204 -0
- package/README.md +239 -2
- package/SECURITY.md +19 -0
- package/bin/gallivant.mjs +261 -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 +72 -0
- package/docs/agent-setup.md +228 -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 +649 -0
- package/docs/config.md +437 -0
- package/docs/execution.md +796 -0
- package/docs/guardrails.md +311 -0
- package/docs/install.md +398 -0
- package/docs/launch.md +45 -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 +149 -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 +466 -0
- package/lib/commands/init.mjs +245 -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 +249 -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 +102 -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 +707 -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 +140 -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 +1654 -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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// `gallivant init`: set up the repository you are standing in, then hand over to `doctor`.
|
|
2
|
+
//
|
|
3
|
+
// Explicit setup only. It never signs in for you, never reads a password or token, never touches global harness
|
|
4
|
+
// settings, credentials or repository permissions, and never rewrites a key the operator wrote. Repeating it is safe:
|
|
5
|
+
// an existing config keeps every value as written and gains at most one `repos` entry, the one for this checkout.
|
|
6
|
+
//
|
|
7
|
+
// Every prerequisite is checked before anything is written, so a signed-out `gh`, a signed-out harness or a checkout
|
|
8
|
+
// that is not a GitHub repository stops the command with the exact action and leaves no partial state behind.
|
|
9
|
+
import { mkdirSync, openSync, closeSync, writeFileSync, lstatSync, realpathSync } from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { gallivantHome, expand, templatePath, readJson, writeJsonAtomic, contract, VERSION, PACKAGE_NAME } from "../fs.mjs";
|
|
13
|
+
import { discoverHarnesses, validate, merge, configPath, repoEntry } from "../config.mjs";
|
|
14
|
+
import { makeGh, repoFromRemote } from "../gh.mjs";
|
|
15
|
+
import { silentLog } from "../log.mjs";
|
|
16
|
+
import { checkHarness } from "../probe.mjs";
|
|
17
|
+
import { resolveCredential, describe as describeCredential } from "../credential.mjs";
|
|
18
|
+
import { doctor as runDoctor, repositoryWriteScopeCheck } from "./doctor.mjs";
|
|
19
|
+
import { acquireCliMutation } from "../daemon.mjs";
|
|
20
|
+
|
|
21
|
+
export const INIT_HARNESSES = ["codex", "claude"];
|
|
22
|
+
|
|
23
|
+
/** Every message that stops setup ends with this, so nobody reads a stopped run as a half-finished one. */
|
|
24
|
+
const NOTHING_WRITTEN = "Setup did not complete and nothing was written.";
|
|
25
|
+
|
|
26
|
+
const firstLine = e => String(e?.message || e).split("\n")[0];
|
|
27
|
+
const isSymlink = p => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A remote URL with its userinfo removed. `https://x-access-token:SECRET@github.com/...` is a remote git and
|
|
31
|
+
* `repoFromRemote` both accept, and every message here that names the origin would otherwise print the secret into
|
|
32
|
+
* a terminal or a CI log. Only scheme URLs carry userinfo; `git@github.com:owner/name` names a login, not a secret.
|
|
33
|
+
*/
|
|
34
|
+
export const safeOrigin = url => String(url || "").replace(/^([a-z][a-z0-9+.-]*:\/\/)[^@/]*@/i, "$1");
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The repository a checkout belongs to, read from its `origin` remote. Local only: no network, no `gh`. Throws a
|
|
38
|
+
* message naming the unmet prerequisite, so a directory that is not a GitHub checkout is refused before anything else
|
|
39
|
+
* runs. `owner/name` is lowercased as `repoFromRemote` does, which is also how `rememberRepo` and `doctor` spell it.
|
|
40
|
+
*/
|
|
41
|
+
export function targetRepository(dir = process.cwd(), exec = execFileSync) {
|
|
42
|
+
const git = args => {
|
|
43
|
+
try { return exec("git", args, { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }).trim(); }
|
|
44
|
+
catch (e) {
|
|
45
|
+
if (e?.code === "ENOENT") throw new Error(`git is not on PATH. Install Git, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const checkout = git(["rev-parse", "--show-toplevel"]);
|
|
50
|
+
if (!checkout) throw new Error(`${dir} is not inside a Git checkout. Run gallivant init from the repository you want to set up (cd into it first). ${NOTHING_WRITTEN}`);
|
|
51
|
+
const origin = git(["remote", "get-url", "origin"]);
|
|
52
|
+
if (!origin) throw new Error(`${checkout} has no origin remote. Create the repository on GitHub and add it (git remote add origin https://github.com/<owner>/<name>.git), then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
53
|
+
const repo = repoFromRemote(origin);
|
|
54
|
+
if (!repo) throw new Error(`origin remote ${safeOrigin(origin)} is not a github.com repository URL. Gallivant opens pull requests on github.com; point origin at the GitHub repository, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
55
|
+
// The redacted form is the only one returned: nothing downstream needs the credential, and nothing may print it.
|
|
56
|
+
return { checkout, origin: safeOrigin(origin), repo };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Infer a harness only when there is one local choice; otherwise setup requires an explicit selection. */
|
|
60
|
+
const onlyLocalHarness = raw => {
|
|
61
|
+
const names = Object.keys(raw?.harness || {}).filter(n => INIT_HARNESSES.includes(n));
|
|
62
|
+
return names.length === 1 ? names[0] : null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** `checkHarness` reads expanded paths, as `load` would hand them over; a raw config may still spell them with `~`. */
|
|
66
|
+
const expandedFor = (config, name) => {
|
|
67
|
+
const h = config.harness[name];
|
|
68
|
+
return { ...config, harness: { ...config.harness, [name]: { ...h, bin: expand(h.bin), ...(h.settings ? { settings: expand(h.settings) } : {}) } } };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Options:
|
|
73
|
+
* home the Gallivant home; `config.json` lives here
|
|
74
|
+
* harness `codex` or `claude`; null keeps an existing config's harnesses and defaults a new one to codex
|
|
75
|
+
* worktrees where task checkouts go; refused when an existing config already chose a different directory
|
|
76
|
+
* dir the target checkout; the repository it belongs to is what gets recorded
|
|
77
|
+
* The rest are injected for tests: harness discovery, the operator's own `gh` (used only to name the login), the
|
|
78
|
+
* credential resolver and the `gh` built from what it resolves, repository resolution, the readiness check and `doctor`.
|
|
79
|
+
*/
|
|
80
|
+
export async function init(options = {}) {
|
|
81
|
+
for (let attempt = 0; ; attempt++) {
|
|
82
|
+
try { return await initAttempt(options); }
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code !== "INIT_CONFIG_CHANGED" || attempt >= 2) throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function initAttempt({ home = gallivantHome(), harness = null, worktrees = null, dir = process.cwd(),
|
|
90
|
+
discover = discoverHarnesses, gh = makeGh({ log: silentLog }), repository = targetRepository, harnessCheck = checkHarness, doctor = runDoctor,
|
|
91
|
+
resolve = resolveCredential, ghFor = credential => makeGh({ credential: async () => credential, log: silentLog }) } = {}) {
|
|
92
|
+
if (harness != null && !INIT_HARNESSES.includes(harness)) throw new Error("init supports --harness codex or claude; configure other adapters explicitly after checking docs/install.md");
|
|
93
|
+
home = expand(home);
|
|
94
|
+
const file = configPath(home);
|
|
95
|
+
// `lstat` rather than `exists`: a dangling symlink does not exist and is still a symlink.
|
|
96
|
+
for (let d = home; ; d = dirname(d)) {
|
|
97
|
+
if (isSymlink(d)) throw new Error(`Setup directory must not be a symlink: ${d}. ${NOTHING_WRITTEN}`);
|
|
98
|
+
if (dirname(d) === d) break;
|
|
99
|
+
}
|
|
100
|
+
const missing = Symbol("missing config");
|
|
101
|
+
const raw = readJson(file, missing);
|
|
102
|
+
const existing = raw !== missing;
|
|
103
|
+
if (existing && (raw === null || typeof raw !== "object" || Array.isArray(raw))) throw new Error(`${file} is not a JSON object. Fix or move it, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
104
|
+
|
|
105
|
+
// 1. The repository, before any network call: a directory that is not a GitHub checkout has nothing to set up.
|
|
106
|
+
const target = repository(dir);
|
|
107
|
+
|
|
108
|
+
// 2. GitHub sign-in, by asking `gh` who it is. The fix is `gh`'s own login flow; nothing here reads a credential.
|
|
109
|
+
let login = null, ghError = null;
|
|
110
|
+
try { login = await gh.user(); } catch (e) { ghError = firstLine(e); }
|
|
111
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(login || "")) throw new Error(`GitHub CLI is not signed in${ghError ? ` (${ghError})` : ""}.\nRun: gh auth login\nThen rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
112
|
+
|
|
113
|
+
// 3. The config this run would leave behind, built but not yet written.
|
|
114
|
+
const selected = harness ?? (existing ? onlyLocalHarness(raw) : "codex");
|
|
115
|
+
const candidate = existing ? structuredClone(raw) : {
|
|
116
|
+
trusted_authors: [login], worktrees: expand(worktrees || join(dirname(home), ".gallivant-worktrees")),
|
|
117
|
+
harness: {}, notify: null, github_token_file: null, repos: [],
|
|
118
|
+
};
|
|
119
|
+
if (existing && worktrees && expand(worktrees) !== expand(raw.worktrees)) throw new Error(`config already sets worktrees to ${raw.worktrees}; edit ${contract(file)} to move it rather than passing --worktrees. ${NOTHING_WRITTEN}`);
|
|
120
|
+
if (selected && !candidate.harness?.[selected]) {
|
|
121
|
+
const found = discover(home)[selected];
|
|
122
|
+
if (!found) throw new Error(`${selected} is not on PATH. Install it and sign in, then retry gallivant init --harness ${selected}. ${NOTHING_WRITTEN}`);
|
|
123
|
+
candidate.harness = { ...(candidate.harness || {}), [selected]: found };
|
|
124
|
+
}
|
|
125
|
+
const merged = merge(candidate, home);
|
|
126
|
+
const problems = validate(merged, home);
|
|
127
|
+
if (problems.length) throw new Error(`${existing ? `Config problems in ${contract(file)}: ` : ""}${problems.join("; ")}. ${NOTHING_WRITTEN}`);
|
|
128
|
+
if (!selected) throw new Error(`Choose a harness: rerun gallivant init --harness codex or gallivant init --harness claude. ${NOTHING_WRITTEN}`);
|
|
129
|
+
|
|
130
|
+
// 4. The selected harness has to be installed at a supported version and signed in. The adapter owns the fix line
|
|
131
|
+
// (`codex login`, `claude auth login`); init only repeats it. Version and sign-in say nothing about model access.
|
|
132
|
+
if (selected) {
|
|
133
|
+
const ready = await harnessCheck(selected, expandedFor(merged, selected));
|
|
134
|
+
if (!ready.ok) throw new Error(`${selected} is not ready (${ready.detail}).\nRun: ${ready.fix}\nThen rerun gallivant init --harness ${selected}. ${NOTHING_WRITTEN}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 5. The repository has to be one a RUN can push to, asked of GitHub with the credential this config resolves
|
|
138
|
+
// (`github_token_file` first, then the environment, then `gh`), exactly as `start` and `doctor` resolve it. The
|
|
139
|
+
// operator's ambient login is the wrong identity to ask: an explicit PAT that cannot reach the repository is the
|
|
140
|
+
// failure this check exists to catch, and an ambient login that can would hide it.
|
|
141
|
+
const runConfig = { ...merged, github_token_file: merged.github_token_file ? expand(merged.github_token_file) : null };
|
|
142
|
+
let credential;
|
|
143
|
+
try { credential = await resolve(runConfig); } catch (e) { throw new Error(`${firstLine(e)}\n${NOTHING_WRITTEN}`); }
|
|
144
|
+
if (!credential?.token) throw new Error(`${credential?.why || "No GitHub credential could be resolved for a run."}\n${NOTHING_WRITTEN}`);
|
|
145
|
+
const identity = describeCredential(credential);
|
|
146
|
+
const runGh = ghFor(credential);
|
|
147
|
+
const perm = await runGh.repoPermissions(target.repo).catch(e => ({ error: firstLine(e) }));
|
|
148
|
+
if (!perm || perm.error) throw new Error(`GitHub cannot resolve ${target.repo} with the run credential (${identity})${perm?.error ? `: ${perm.error}` : ""}. Check that origin (${safeOrigin(target.origin)}) names a repository that credential can see, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
149
|
+
if (perm.archived) throw new Error(`${target.repo} is archived, so nothing can be pushed to it. Unarchive it or run gallivant init in another repository. ${NOTHING_WRITTEN}`);
|
|
150
|
+
if (!perm.push) throw new Error(`the run credential (${identity}) has no push permission on ${target.repo}. ${runConfig.github_token_file ? `Give the token in ${contract(runConfig.github_token_file)} Contents: write on ${target.repo}` : "Ask for write access, or fork it and point origin at the fork"}, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
151
|
+
// Repository role alone does not grant a classic token write scope, even when that account can push.
|
|
152
|
+
const scope = await repositoryWriteScopeCheck(runGh, credential);
|
|
153
|
+
if (!scope.ok) throw new Error(`${scope.detail}.\nFix: ${scope.fix}\n${NOTHING_WRITTEN}`);
|
|
154
|
+
|
|
155
|
+
// 6. Write. The repository is deduplicated by lowercased name whichever form each entry takes, so a second run,
|
|
156
|
+
// or a run from a clone whose remote spells the owner differently, never appends a duplicate. The string form is
|
|
157
|
+
// what a first use writes; a base branch is chosen with `start --base` or by hand, not here.
|
|
158
|
+
const repos = Array.isArray(candidate.repos) ? candidate.repos : [];
|
|
159
|
+
const known = repos.some(r => repoEntry(r)?.repo === target.repo);
|
|
160
|
+
if (!known) candidate.repos = [...repos, target.repo];
|
|
161
|
+
const changed = !existing || JSON.stringify(candidate) !== JSON.stringify(raw);
|
|
162
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
163
|
+
const owner = acquireCliMutation(home);
|
|
164
|
+
try {
|
|
165
|
+
// Probes await external tools. Another CLI writer may have changed the config meanwhile. Compare under the
|
|
166
|
+
// shared mutation lock, then retry all probes against the new settings rather than overwriting or trusting them.
|
|
167
|
+
const current = readJson(file, missing);
|
|
168
|
+
if (JSON.stringify(current) !== JSON.stringify(raw)) throw Object.assign(new Error(`Config changed during setup; rerun gallivant init. ${NOTHING_WRITTEN}`), { code: "INIT_CONFIG_CHANGED" });
|
|
169
|
+
mkdirSync(expand(merged.worktrees), { recursive: true, mode: 0o700 });
|
|
170
|
+
if (!existing) {
|
|
171
|
+
// wx also refuses a dangling symlink.
|
|
172
|
+
const fd = openSync(file, "wx", 0o600);
|
|
173
|
+
try { writeFileSync(fd, JSON.stringify(candidate, null, 2) + "\n"); } finally { closeSync(fd); }
|
|
174
|
+
} else if (changed) {
|
|
175
|
+
writeJsonAtomic(file, candidate, 0o600);
|
|
176
|
+
}
|
|
177
|
+
} finally { owner.release(); }
|
|
178
|
+
|
|
179
|
+
// 7. The same report `doctor` prints, so the operator reads one checklist rather than two.
|
|
180
|
+
const results = await doctor({ home });
|
|
181
|
+
return {
|
|
182
|
+
file, created: !existing, changed, login,
|
|
183
|
+
repo: target.repo, checkout: target.checkout, recorded: !known,
|
|
184
|
+
harness: selected, harnesses: Object.keys(merged.harness),
|
|
185
|
+
worktrees: expand(merged.worktrees),
|
|
186
|
+
preserved: existing ? Object.keys(raw).filter(k => k !== "repos") : [],
|
|
187
|
+
config: merged, doctor: results,
|
|
188
|
+
templates: { passport: templatePath("passport.gallivant.json"), sandbox: templatePath("claude-settings.json") },
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether this process was started by `npx` / `npm exec` rather than from an installed `gallivant` command. npm
|
|
194
|
+
* marks the child's environment; the `_npx` cache directory in the script path is the fallback for a shell that
|
|
195
|
+
* stripped the environment.
|
|
196
|
+
*/
|
|
197
|
+
export const viaNpx = (env = process.env, script = process.argv[1] || "") =>
|
|
198
|
+
env.npm_command === "exec" || env.npm_lifecycle_event === "npx" || /[\\/]_npx[\\/]/.test(script);
|
|
199
|
+
|
|
200
|
+
/** The command that runs this package again, spelled for how it was started this time. */
|
|
201
|
+
export const commandPrefix = npx => (npx ? `npx --yes ${PACKAGE_NAME}` : "gallivant");
|
|
202
|
+
|
|
203
|
+
export const FIRST_TASK = "Document how to run the existing tests in CONTRIBUTING.md. Use the commands already defined in the repository. Change documentation only.";
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* What `gallivant init` prints: what it did, doctor's report, and what to run next. `report` is `renderDoctor`'s
|
|
207
|
+
* result. The `npx` paragraph says what `npx` did and did not do, because a one-command setup that silently looked
|
|
208
|
+
* like a global installation would send the operator looking for a `gallivant` command that is not there.
|
|
209
|
+
*/
|
|
210
|
+
export function renderInit(r, report, { npx = viaNpx(), script = process.argv[1] } = {}) {
|
|
211
|
+
const cmd = commandPrefix(npx);
|
|
212
|
+
const failed = r.doctor.filter(x => !x.ok).length;
|
|
213
|
+
const lines = [
|
|
214
|
+
`Package: ${PACKAGE_NAME} ${VERSION}${npx ? " (run by npx)" : ""}`,
|
|
215
|
+
`Running: ${script ? safeRealpath(script) : "(unknown)"}`,
|
|
216
|
+
`Config: ${contract(r.file)} (${r.created ? "created, mode 600" : r.changed ? `kept; ${r.preserved.length} existing key(s) preserved` : "unchanged"})`,
|
|
217
|
+
`Harness: ${r.harness ? `${r.harness} (${r.config.harness[r.harness]?.bin || "configured"})` : r.harnesses.join(", ") || "none"}`,
|
|
218
|
+
`Repository: ${r.repo} (${r.recorded ? "recorded in repos" : "already in repos; not duplicated"})`,
|
|
219
|
+
`Worktrees: ${r.worktrees}`,
|
|
220
|
+
"",
|
|
221
|
+
"Doctor:",
|
|
222
|
+
report.text,
|
|
223
|
+
"",
|
|
224
|
+
"Next:",
|
|
225
|
+
];
|
|
226
|
+
if (failed) lines.push(` Fix the ${failed} failing check(s) above, then run: ${cmd} doctor`);
|
|
227
|
+
else lines.push(` All checks passed. Confirm the reviewer setting in ${contract(r.file)} (default: the Codex GitHub review integration), then start a small task.`);
|
|
228
|
+
lines.push(
|
|
229
|
+
` First task (uses your ${r.harness} account for inference; --run authorizes implementation once the brief is ready):`,
|
|
230
|
+
` ${cmd} task ${JSON.stringify(FIRST_TASK)} --harness ${r.harness} --verify 'git diff --check' --run`,
|
|
231
|
+
` ${cmd} start`,
|
|
232
|
+
" Setup did not create a GitHub ruleset, install harness hooks, start a task or merge anything; doctor names each remaining step.",
|
|
233
|
+
` Guide: https://github.com/uchibeke/gallivant/blob/main/docs/install.md`,
|
|
234
|
+
);
|
|
235
|
+
if (npx) {
|
|
236
|
+
lines.push("",
|
|
237
|
+
`npx ran ${PACKAGE_NAME} on demand from npm's cache. Nothing was installed globally and no gallivant command was added to your PATH.`,
|
|
238
|
+
` Run it again any time: ${cmd} doctor`,
|
|
239
|
+
` Optional persistent install: npm install -g ${PACKAGE_NAME} # then use plain: gallivant doctor`);
|
|
240
|
+
}
|
|
241
|
+
lines.push("", "Packaged templates:", ` ${r.templates.passport}`, ` ${r.templates.sandbox}`);
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const safeRealpath = p => { try { return realpathSync(p); } catch { return p; } };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { createLearningService } from "../learning.mjs";
|
|
3
|
+
import { gallivantHome } from "../fs.mjs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { candidateId, digestField, learningError, canonical } from "../learning_data.mjs";
|
|
6
|
+
|
|
7
|
+
const allowed = {
|
|
8
|
+
collect: ["repo", "since", "until", "dry-run", "json"], doctor: ["repo", "json"], setup: ["key-path", "ssh-keygen", "json"],
|
|
9
|
+
configure: ["repo", "mode", "json"], list: ["repo", "status", "json"], show: ["repo", "revision", "json"],
|
|
10
|
+
revise: ["repo", "from", "json"], approve: ["repo", "revision", "reason", "json"], reject: ["repo", "revision", "reason", "json"],
|
|
11
|
+
retire: ["repo", "revision", "reason", "json"], explain: ["repo", "run", "json"], report: ["repo", "json"],
|
|
12
|
+
recover: ["repo", "json"], prune: ["repo", "json"], extract: ["repo", "learning-budget", "json"],
|
|
13
|
+
};
|
|
14
|
+
export async function operatorConfirmation(preview) {
|
|
15
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw learningError("TERMINAL", "review requires an interactive operator terminal");
|
|
16
|
+
process.stdout.write(preview + "\n"); // Complete frozen JSON. No pager, links, editor, ANSI or truncation.
|
|
17
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
18
|
+
try { return (await rl.question("Review every displayed field. Type APPROVE to request fresh passphrase signing: ")).trim() === "APPROVE"; }
|
|
19
|
+
finally { rl.close(); }
|
|
20
|
+
}
|
|
21
|
+
export async function learnCommand(ctx, { args = [], values = {}, confirm = operatorConfirmation } = {}) {
|
|
22
|
+
const operation = args[0] || "collect", id = args[1];
|
|
23
|
+
if (!allowed[operation] || args.length > (["show", "revise", "approve", "reject", "retire"].includes(operation) ? 2 : 1)) throw learningError("INPUT", "unknown learning command or extra arguments");
|
|
24
|
+
for (const key of Object.keys(values)) if (!allowed[operation].includes(key)) throw learningError("INPUT", `--${key} is not allowed for learn ${operation}`);
|
|
25
|
+
let service = ctx.learning || ctx.learningFor?.(operation === "collect" ? await ctx.gh.user() : undefined, { signerExecutable: values["ssh-keygen"] });
|
|
26
|
+
if (!service) service = createLearningService({ root: join(ctx.home || gallivantHome(), "learning"), signerExecutable: values["ssh-keygen"] });
|
|
27
|
+
if (operation === "setup") return service.setup({ keyPath: values["key-path"], confirm });
|
|
28
|
+
if (operation === "doctor" && !values.repo) return service.doctor();
|
|
29
|
+
if (!values.repo) throw learningError("INPUT", "--repo owner/repository is required");
|
|
30
|
+
// Local inspection and emergency disable must work during credential/repository outages.
|
|
31
|
+
const disabling = operation === "configure" && values.mode === "off";
|
|
32
|
+
const repo = disabling || ["list", "show", "report", "explain", "recover", "prune", "extract"].includes(operation) ? service.identityFor(values.repo) : await service.resolveRepo(values.repo);
|
|
33
|
+
if (!repo) throw learningError("REPOSITORY", "repository has no learning state; configure collect first");
|
|
34
|
+
if (operation === "configure" && !disabling) {
|
|
35
|
+
const remembered = service.identityFor(values.repo);
|
|
36
|
+
if (remembered && canonical(remembered) !== canonical(repo)) throw learningError("REPOSITORY", "repository identity changed; remembered identity retained for local disable");
|
|
37
|
+
}
|
|
38
|
+
if (["show", "revise", "approve", "reject", "retire"].includes(operation)) candidateId(id);
|
|
39
|
+
switch (operation) {
|
|
40
|
+
case "doctor": return service.doctor(repo);
|
|
41
|
+
case "configure": { const result = await service.configure(repo, values.mode); if (!disabling) service.rememberRepo(values.repo, repo); return result; }
|
|
42
|
+
case "collect": return service.collect(repo, values.repo, { since: values.since, until: values.until, dryRun: values["dry-run"] || false });
|
|
43
|
+
case "list": return service.list(repo, values.status);
|
|
44
|
+
case "show": return service.show(repo, id, values.revision);
|
|
45
|
+
case "revise": if (!values.from) throw learningError("INPUT", "revise requires --from reviewed-rule.json"); return service.reviseFromFile(repo, id, values.from);
|
|
46
|
+
case "reject": return service.reject(repo, id, values.revision, values.reason);
|
|
47
|
+
case "approve": case "retire": return service.approve(repo, [{ id, digest: digestField(values.revision) }], { action: operation === "retire" ? "retire" : "approve", reason: values.reason || null, confirm });
|
|
48
|
+
case "recover": return service.recover(repo);
|
|
49
|
+
case "prune": return service.prune(repo);
|
|
50
|
+
case "extract": return service.extract(repo, { allowanceUsd: Number(values["learning-budget"] || 0) });
|
|
51
|
+
case "explain": if (!values.run) throw learningError("INPUT", "explain requires --run RUN_ID"); return service.explain(repo, values.run);
|
|
52
|
+
case "report": return service.report(repo);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// `gallivant new <name>`: write a starting PRD into the repository you are standing in.
|
|
2
|
+
//
|
|
3
|
+
// The command exists because the documented alternative did not work from anywhere. Every quickstart said
|
|
4
|
+
// `cp templates/prd.md path/to/repo/docs/prds/your-prd.md`, which resolves only inside a Gallivant clone, so an
|
|
5
|
+
// operator who installed the tool and then walked into their own repository had nothing to copy. The template ships
|
|
6
|
+
// inside the package, and only the package knows where that is, so finding it is the tool's job rather than the
|
|
7
|
+
// reader's.
|
|
8
|
+
//
|
|
9
|
+
// It writes a file and nothing else: no registry entry, no git operation, no network. `ready` is the next step, and
|
|
10
|
+
// it will refuse this file until the placeholders are replaced, which is the point of starting from a template that
|
|
11
|
+
// fails the contract loudly rather than one that passes it vacuously.
|
|
12
|
+
import { join, resolve, relative, basename, dirname } from "node:path";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { readText, writeText, contract, templatePath } from "../fs.mjs";
|
|
15
|
+
import { toPrdId } from "../prd.mjs";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where a new PRD goes: `docs/prds/<id>.md` under the repository root, wherever the operator is standing inside it.
|
|
19
|
+
* `git rev-parse --show-toplevel` is asked rather than the cwd assumed, so running this from a subdirectory writes
|
|
20
|
+
* to the same place as running it from the root.
|
|
21
|
+
*/
|
|
22
|
+
export async function newPrd(ctx, { name, harness = null, dir = null }) {
|
|
23
|
+
const { git, config, log } = ctx;
|
|
24
|
+
const id = toPrdId(name);
|
|
25
|
+
if (!id) throw new Error(`"${name}" has no letters or digits in it; a PRD id is lowercase letters, digits and dashes`);
|
|
26
|
+
|
|
27
|
+
const root = dir ? resolve(dir) : await git.toplevel(process.cwd()).catch(() => {
|
|
28
|
+
throw new Error(`${process.cwd()} is not inside a git repository. Run this in the repo the work belongs to, or pass --dir <repo>`);
|
|
29
|
+
});
|
|
30
|
+
const abs = join(root, "docs", "prds", `${id}.md`);
|
|
31
|
+
if (existsSync(abs)) throw new Error(`${contract(abs)} already exists; pick another name or edit that file`);
|
|
32
|
+
|
|
33
|
+
const template = readText(templatePath("prd.md"));
|
|
34
|
+
if (template === null) throw new Error(`the packaged PRD template is missing from ${contract(templatePath("prd.md"))}`);
|
|
35
|
+
|
|
36
|
+
// The harness the operator will actually run. A template naming one they have not configured would fail `start`
|
|
37
|
+
// after they had written the whole brief, so the default is whatever their config offers.
|
|
38
|
+
const configured = Object.keys(config?.harness || {});
|
|
39
|
+
const chosen = harness || configured[0] || "claude";
|
|
40
|
+
if (harness && configured.length && !configured.includes(harness)) throw new Error(`harness "${harness}" is not configured; you have ${configured.join(", ")}`);
|
|
41
|
+
const text = template.replace(/^harness:.*$/m, `harness: ${chosen}`);
|
|
42
|
+
|
|
43
|
+
// 0o644/0o755: a PRD is a file in the operator's repository that they will edit and commit, not daemon state.
|
|
44
|
+
writeText(abs, text, 0o644, 0o755);
|
|
45
|
+
log?.info(`${id}: wrote ${contract(abs)}`);
|
|
46
|
+
return { id, path: abs, rel: relative(process.cwd(), abs) || basename(abs), root, harness: chosen, dir: dirname(abs) };
|
|
47
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// `gallivant ready <prd.md> [--check]`: the only way a PRD becomes `ready`. Runs every rule in the contract,
|
|
2
|
+
// prints the checklist, asks APort to record the decision, and flips the status only on an allow. `--check`
|
|
3
|
+
// prints without writing. The decision id is written into the PRD's frontmatter as `aport_decision`.
|
|
4
|
+
//
|
|
5
|
+
// It does not commit: the daemon reads its own snapshot of the PRD taken by `start`, so the file's git state does
|
|
6
|
+
// not affect the loop, and committing on the operator's behalf from an unknown cwd was a guess.
|
|
7
|
+
//
|
|
8
|
+
// The verdict comes from `check(request)`, the same gate `start` runs, over the same Request shape. Where a verdict
|
|
9
|
+
// is recorded is the source's business: for a file that is its own frontmatter, which is why this command reads and
|
|
10
|
+
// writes one and takes no git or GitHub dependency.
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { writeFileSync } from "node:fs";
|
|
13
|
+
import { check, renderCheck, withStatus, withFrontmatterKey, rulesFor, kindOf } from "../prd.mjs";
|
|
14
|
+
import { request as prdRequest } from "../sources/prd.mjs";
|
|
15
|
+
import { decidePrdReady, PRD_LIMITS } from "../deliverable.mjs";
|
|
16
|
+
import { readText } from "../fs.mjs";
|
|
17
|
+
|
|
18
|
+
export async function ready({ path, checkOnly = false, config = null, apiKey = null, fetch = globalThis.fetch, log = null, onProgress = null }) {
|
|
19
|
+
const abs = resolve(path);
|
|
20
|
+
const text = readText(abs);
|
|
21
|
+
if (text === null) throw new Error(`cannot read ${abs}`);
|
|
22
|
+
const prd = prdRequest(text, abs);
|
|
23
|
+
const verdict = check(prd);
|
|
24
|
+
let report = renderCheck(prd, verdict);
|
|
25
|
+
if (!verdict.ok || checkOnly) return { ok: verdict.ok, report, changed: false, decisionId: null };
|
|
26
|
+
|
|
27
|
+
// The contract passed locally. With a config whose gate is on, APort records the decision and its deny wins.
|
|
28
|
+
// Without a config, or with the gate off, the decision is local only.
|
|
29
|
+
const d = config?.deliverable ?? null;
|
|
30
|
+
const limits = { ...PRD_LIMITS, ...(d ? { evaluator: d.evaluator, api_url: d.api_url } : { evaluator: "local" }) };
|
|
31
|
+
if (limits.evaluator === "aport") onProgress?.("Recording readiness with APort...");
|
|
32
|
+
// Only the rules this source was actually held to become criteria, so a criterion never names a rule not checked.
|
|
33
|
+
const decision = await decidePrdReady({ prd, verdict, rules: rulesFor(kindOf(prd)), limits, apiKey, fetch, log });
|
|
34
|
+
if (!decision.allow) {
|
|
35
|
+
report += `\n\nAPort (${decision.source}) denied readiness:\n${decision.denials.map(x => ` - ${x.code}: ${x.message}`).join("\n")}`;
|
|
36
|
+
return { ok: false, report, changed: false, decisionId: null };
|
|
37
|
+
}
|
|
38
|
+
report += `\nAPort decision (${decision.source}): allow${decision.decisionId ? ` ${decision.decisionId}` : ""}`;
|
|
39
|
+
if (prd.status === "ready") return { ok: true, report: report + "\n(already ready)", changed: false, decisionId: decision.decisionId };
|
|
40
|
+
|
|
41
|
+
let next = withStatus(text, "ready");
|
|
42
|
+
if (decision.decisionId) next = withFrontmatterKey(next, "aport_decision", decision.decisionId);
|
|
43
|
+
writeFileSync(abs, next);
|
|
44
|
+
return { ok: true, report: report + "\nstatus set to ready. Commit the PRD when you commit the rest of your work.", changed: true, decisionId: decision.decisionId };
|
|
45
|
+
}
|