@uchibeke/gallivant 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +26 -0
- package/LICENSE +204 -0
- package/README.md +201 -2
- package/SECURITY.md +19 -0
- package/bin/gallivant.mjs +257 -0
- package/bin/package-plugin.mjs +16 -0
- package/bin/release-live.mjs +29 -0
- package/bin/release-package.mjs +23 -0
- package/bin/session-runtime.mjs +13 -0
- package/bin/task-delivery-state.mjs +12 -0
- package/docs/README.md +70 -0
- package/docs/agent-setup.md +214 -0
- package/docs/app.md +111 -0
- package/docs/architecture.md +265 -0
- package/docs/chains.md +102 -0
- package/docs/codex-cloud.md +284 -0
- package/docs/commands.md +620 -0
- package/docs/config.md +430 -0
- package/docs/execution.md +796 -0
- package/docs/guardrails.md +311 -0
- package/docs/install.md +338 -0
- package/docs/launch.md +42 -0
- package/docs/learning.md +369 -0
- package/docs/network-recovery.md +101 -0
- package/docs/openai-agents.md +457 -0
- package/docs/prd.md +246 -0
- package/docs/preparation.md +110 -0
- package/docs/release-qualification.md +196 -0
- package/docs/releasing.md +140 -0
- package/docs/runtime-environment.md +113 -0
- package/docs/task-delivery.md +476 -0
- package/docs/tasks.md +739 -0
- package/lib/aport.mjs +95 -0
- package/lib/attribution.mjs +95 -0
- package/lib/block.mjs +89 -0
- package/lib/capabilities.mjs +3 -0
- package/lib/chains.mjs +222 -0
- package/lib/codex_cloud.mjs +160 -0
- package/lib/codex_cloud_config.mjs +20 -0
- package/lib/codex_cloud_input.mjs +49 -0
- package/lib/codex_cloud_result.mjs +74 -0
- package/lib/commands/doctor.mjs +457 -0
- package/lib/commands/init.mjs +34 -0
- package/lib/commands/learn.mjs +54 -0
- package/lib/commands/new.mjs +47 -0
- package/lib/commands/ready.mjs +45 -0
- package/lib/commands/register.mjs +195 -0
- package/lib/commands/resume.mjs +224 -0
- package/lib/commands/serve.mjs +349 -0
- package/lib/commands/start.mjs +247 -0
- package/lib/commands/start_services.mjs +22 -0
- package/lib/commands/status.mjs +150 -0
- package/lib/commands/task.mjs +73 -0
- package/lib/commands/up.mjs +228 -0
- package/lib/commands/why.mjs +143 -0
- package/lib/config.mjs +461 -0
- package/lib/context.mjs +100 -0
- package/lib/control.mjs +202 -0
- package/lib/control_contract.mjs +16 -0
- package/lib/coordinator.mjs +19 -0
- package/lib/credential.mjs +161 -0
- package/lib/daemon.mjs +156 -0
- package/lib/deliverable.mjs +357 -0
- package/lib/events.mjs +261 -0
- package/lib/executable.mjs +31 -0
- package/lib/execution.mjs +631 -0
- package/lib/execution_contract.mjs +15 -0
- package/lib/execution_input.mjs +4 -0
- package/lib/execution_io.mjs +94 -0
- package/lib/execution_journal.mjs +69 -0
- package/lib/execution_preparation.mjs +29 -0
- package/lib/execution_result.mjs +266 -0
- package/lib/fs.mjs +133 -0
- package/lib/gh.mjs +289 -0
- package/lib/git.mjs +434 -0
- package/lib/harness.mjs +521 -0
- package/lib/harness_log.mjs +432 -0
- package/lib/hash.mjs +16 -0
- package/lib/identity.mjs +98 -0
- package/lib/learning.mjs +654 -0
- package/lib/learning_contract.mjs +2 -0
- package/lib/learning_data.mjs +135 -0
- package/lib/learning_input.mjs +111 -0
- package/lib/learning_qualification.mjs +67 -0
- package/lib/learning_signer.mjs +151 -0
- package/lib/learning_store.mjs +88 -0
- package/lib/log.mjs +53 -0
- package/lib/network_diagnostics.mjs +64 -0
- package/lib/network_recovery.mjs +42 -0
- package/lib/notify.mjs +58 -0
- package/lib/openai_agents.mjs +373 -0
- package/lib/openai_agents_config.mjs +14 -0
- package/lib/openai_agents_http.mjs +87 -0
- package/lib/openai_agents_input.mjs +15 -0
- package/lib/openai_agents_sources.mjs +112 -0
- package/lib/owner_delivery.mjs +443 -0
- package/lib/plugin_package.mjs +340 -0
- package/lib/prd.mjs +635 -0
- package/lib/preparation_agents.mjs +33 -0
- package/lib/preparation_codex.mjs +212 -0
- package/lib/probe.mjs +126 -0
- package/lib/prompt.mjs +238 -0
- package/lib/provider_health.mjs +55 -0
- package/lib/public-format.mjs +31 -0
- package/lib/public_output.mjs +49 -0
- package/lib/release_package.mjs +154 -0
- package/lib/repository_files.mjs +86 -0
- package/lib/run.mjs +385 -0
- package/lib/run_log.mjs +94 -0
- package/lib/runtime-environment.mjs +87 -0
- package/lib/runtime_dependencies.mjs +15 -0
- package/lib/runtime_node.mjs +107 -0
- package/lib/runtime_state.mjs +174 -0
- package/lib/sources/index.mjs +27 -0
- package/lib/sources/prd.mjs +50 -0
- package/lib/start_contract.mjs +37 -0
- package/lib/state.mjs +246 -0
- package/lib/task_allowance.mjs +31 -0
- package/lib/task_completion.mjs +61 -0
- package/lib/task_contract.mjs +450 -0
- package/lib/task_delivery.mjs +219 -0
- package/lib/task_delivery_cancel.mjs +34 -0
- package/lib/task_delivery_contract.mjs +61 -0
- package/lib/task_delivery_mcp.mjs +40 -0
- package/lib/task_delivery_store.mjs +43 -0
- package/lib/task_delivery_view.mjs +88 -0
- package/lib/task_environment.mjs +281 -0
- package/lib/task_prepare.mjs +224 -0
- package/lib/task_store.mjs +38 -0
- package/lib/tasks.mjs +513 -0
- package/lib/tick.mjs +1635 -0
- package/lib/time.mjs +36 -0
- package/lib/tracker/assets.mjs +43 -0
- package/lib/tracker/pages.mjs +320 -0
- package/lib/tracker/runs.mjs +102 -0
- package/lib/tracker/stream.mjs +74 -0
- package/lib/tracker_process.mjs +107 -0
- package/lib/verification_sandbox.mjs +181 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +289 -0
- package/node_modules/smol-toml/dist/index.cjs +770 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/npm-shrinkwrap.json +1823 -0
- package/package.json +105 -4
- package/templates/claude-settings.json +18 -0
- package/templates/passport.gallivant.json +83 -0
- package/templates/plugin/bundled-runtime.json +26 -0
- package/templates/plugin/schemas/mcp.schema.json +120 -0
- package/templates/plugin/schemas/plugin.schema.json +65 -0
- package/templates/plugin/task-delivery.SKILL.md +60 -0
- package/templates/prd.md +32 -0
- package/templates/tracker/tracker.css +301 -0
- package/templates/tracker/tracker.js +721 -0
package/lib/aport.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Reading what APort's installer leaves on disk: the mode file next to a harness settings file, and the audit log.
|
|
2
|
+
// One parser for each, shared by context, doctor and why, so all three agree on quoting and timestamps.
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { readdirSync } from "node:fs";
|
|
5
|
+
import { readText } from "./fs.mjs";
|
|
6
|
+
|
|
7
|
+
/** The APort state directory for a Claude settings file: <dir of settings>/aport. */
|
|
8
|
+
export const aportDirFor = settingsPath => (settingsPath ? join(dirname(settingsPath), "aport") : null);
|
|
9
|
+
|
|
10
|
+
/** `guardrail-mode.env` as a key/value map. Values may be bare, double- or single-quoted. */
|
|
11
|
+
export function readGuardrailEnv(aportDir) {
|
|
12
|
+
const out = {};
|
|
13
|
+
if (!aportDir) return out;
|
|
14
|
+
for (const line of (readText(join(aportDir, "guardrail-mode.env"), "") || "").split("\n")) {
|
|
15
|
+
const m = line.match(/^\s*([A-Z0-9_]+)=(.*)$/);
|
|
16
|
+
if (!m) continue;
|
|
17
|
+
let v = m[2].trim();
|
|
18
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
19
|
+
out[m[1]] = v;
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Summary of the mode file: { mode: "api"|"local"|null, agentId, apiKey, apiUrl, enforcement }. */
|
|
25
|
+
export function guardrailMode(aportDir) {
|
|
26
|
+
const env = readGuardrailEnv(aportDir);
|
|
27
|
+
return {
|
|
28
|
+
mode: env.APORT_GUARDRAIL_MODE?.toLowerCase() || null,
|
|
29
|
+
agentId: env.APORT_AGENT_ID || null,
|
|
30
|
+
apiKey: env.APORT_API_KEY || null,
|
|
31
|
+
apiUrl: env.APORT_API_URL || null,
|
|
32
|
+
// Match hook-runtime.sh; the current name takes precedence over legacy aliases.
|
|
33
|
+
enforcement: env.APORT_ENFORCEMENT_MODE || env.APORT_ENFORCEMENT || env.APORT_GUARDRAIL_ENFORCEMENT || "enforce",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Audit-log lines whose timestamp falls in [fromMs, toMs]. The hook stamps `[2026-09-24 03:48:31]` in UTC without a
|
|
39
|
+
* zone marker; ISO stamps are accepted too. A minute of slack covers clock rounding.
|
|
40
|
+
*/
|
|
41
|
+
export function auditEntries(aportDir, { fromMs, toMs = Date.now(), filter = null } = {}) {
|
|
42
|
+
if (!aportDir) return [];
|
|
43
|
+
let files = [];
|
|
44
|
+
try { files = readdirSync(aportDir).filter(f => /audit/i.test(f)); } catch { return []; }
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const f of files) {
|
|
47
|
+
for (const line of (readText(join(aportDir, f), "") || "").split("\n")) {
|
|
48
|
+
const t = auditStamp(line);
|
|
49
|
+
if (t === null || t < fromMs - 60_000 || t > toMs + 60_000) continue;
|
|
50
|
+
if (filter && !filter.test(line)) continue;
|
|
51
|
+
out.push(line.slice(0, 220));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How the guardrail's own decisions came out, read from `session-decisions.jsonl`.
|
|
59
|
+
*
|
|
60
|
+
* The distinction this exists to draw: `oap.blocked_pattern` is the hook deciding NO, and `oap.evaluation_error` is
|
|
61
|
+
* the hook failing to decide at all and denying because it fails closed. A run cannot tell those apart, and neither
|
|
62
|
+
* could an operator: both arrive at the session as "APort denied this tool call", so a week of rate limits from
|
|
63
|
+
* aport.io reads as a policy that is too tight, and the operator loosens a policy that was never the problem.
|
|
64
|
+
*
|
|
65
|
+
* Measured on one machine's history: of 60 denials, 15 were evaluation errors. Thirteen were HTTP 429 ("Too many
|
|
66
|
+
* failed attempts. Account temporarily blocked") which denied ordinary work for about three minutes straight, and the
|
|
67
|
+
* rest were the hook's own request failing schema validation on fields it builds itself.
|
|
68
|
+
*
|
|
69
|
+
* Counts only, never the message, because a reason can carry a command line and this is printed by `doctor`.
|
|
70
|
+
*/
|
|
71
|
+
export function decisionCounts(aportDir, { file = "session-decisions.jsonl" } = {}) {
|
|
72
|
+
const out = { total: 0, allowed: 0, denied: 0, codes: {}, evaluationErrors: 0 };
|
|
73
|
+
if (!aportDir) return out;
|
|
74
|
+
for (const line of (readText(join(aportDir, file), "") || "").split("\n")) {
|
|
75
|
+
if (!line.trim()) continue;
|
|
76
|
+
let d;
|
|
77
|
+
try { d = JSON.parse(line).decision; } catch { continue; }
|
|
78
|
+
if (!d) continue;
|
|
79
|
+
out.total++;
|
|
80
|
+
if (d.allow) out.allowed++; else out.denied++;
|
|
81
|
+
for (const r of d.reasons || []) {
|
|
82
|
+
if (!r?.code) continue;
|
|
83
|
+
out.codes[r.code] = (out.codes[r.code] || 0) + 1;
|
|
84
|
+
if (r.code === "oap.evaluation_error" && !d.allow) out.evaluationErrors++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const auditStamp = line => {
|
|
91
|
+
const m = line.match(/^\[?(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2}(?:\.\d+)?)Z?\]?/);
|
|
92
|
+
if (!m) return null;
|
|
93
|
+
const t = Date.parse(`${m[1]}T${m[2]}Z`);
|
|
94
|
+
return Number.isFinite(t) ? t : null;
|
|
95
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Harness attribution in a commit message, and the `commit-msg` hook that refuses it.
|
|
2
|
+
//
|
|
3
|
+
// Commits use the operator's configured identity. Their message must also describe the change without runner
|
|
4
|
+
// branding or model attribution. This rule applies to all adapters through one installed Git hook.
|
|
5
|
+
//
|
|
6
|
+
// This is a HOOK and not a line in the prompt, and the choice is the whole design. A prompt is a request a session can
|
|
7
|
+
// forget. A hook fires on whatever process runs
|
|
8
|
+
// `git commit`, so one mechanism covers all five adapters and the sixth nobody has written yet, with no per-harness
|
|
9
|
+
// code and nothing to keep in step. It fails the commit rather than editing the message, because silently rewriting what
|
|
10
|
+
// a session wrote would leave it believing it committed something it did not.
|
|
11
|
+
//
|
|
12
|
+
// `.git/hooks` rather than a directory in the checkout. A relative `core.hooksPath` would also work and would keep
|
|
13
|
+
// `runEnv` free of absolute paths, but it puts a directory INSIDE the working tree, where it shows up as untracked in
|
|
14
|
+
// `git status`, in the `git diff --check` the briefs run, and in the uncommitted-work count a timeout kill now reports.
|
|
15
|
+
// A hook that makes a clean tree look dirty would be traded away by the first session that wanted a clean `git status`.
|
|
16
|
+
// `.git/hooks` is git's own default path, needs no config at all, is already outside the working tree, and dies with
|
|
17
|
+
// the clone. Nothing is added to `runEnv`, which keeps its no-absolute-paths property intact.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The trailers that are refused, matched at the start of a line and case-insensitively.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately narrow. It matches a `Co-Authored-By:` trailer naming a harness or its vendor, and the two forms Claude
|
|
23
|
+
* Code appends to bodies it writes. It does NOT match a bare mention of a harness anywhere in a message, and that is a
|
|
24
|
+
* requirement rather than an oversight: this repository has `lib/harness.mjs`, five adapters and a doctor check, so
|
|
25
|
+
* "fix: claude adapter drops the session id" is a commit that must go through. A matcher that blocked it would be worse
|
|
26
|
+
* than the trailer it was trying to stop, because the way around it is to describe the change dishonestly.
|
|
27
|
+
*/
|
|
28
|
+
const VENDORS = "claude|anthropic|codex|openai|gemini|google|opencode|goose";
|
|
29
|
+
export const ATTRIBUTION_PATTERNS = [
|
|
30
|
+
// Public output uses neutral wording even when the underlying checkout/session keeps a legacy name.
|
|
31
|
+
/^.*gallivant.*$/im,
|
|
32
|
+
// A co-author trailer naming any of them, anywhere on the line: the vendor may be in the name, the email or both.
|
|
33
|
+
new RegExp(String.raw`^\s*co-authored-by:.*(?:${VENDORS}).*$`, "im"),
|
|
34
|
+
// The footer Claude Code adds to messages and PR bodies, in both the forms it writes.
|
|
35
|
+
new RegExp(String.raw`^\s*(?:🤖\s*)?generated with\b.*(?:${VENDORS}).*$`, "im"),
|
|
36
|
+
new RegExp(String.raw`^\s*🤖\s*generated with.*$`, "im"),
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The offending line of a message, or null when there is none. The LINE and not just a boolean, because the whole value
|
|
41
|
+
* of failing early is an error a session can act on: "rejected" with no reason gets the identical message retried.
|
|
42
|
+
*
|
|
43
|
+
* Comment lines are ignored. `git commit` passes the message file with its own `#` commentary still in it, and the
|
|
44
|
+
* template shown on a conflict lists the co-authors of the commits being merged.
|
|
45
|
+
*/
|
|
46
|
+
export function attributionIn(message) {
|
|
47
|
+
const body = String(message ?? "")
|
|
48
|
+
.split("\n")
|
|
49
|
+
.filter(l => !l.trimStart().startsWith("#"))
|
|
50
|
+
.join("\n");
|
|
51
|
+
for (const re of ATTRIBUTION_PATTERNS) {
|
|
52
|
+
const m = body.match(re);
|
|
53
|
+
if (m) return m[0].trim();
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The message the hook prints on a refusal. One place, so the test and the script cannot drift. */
|
|
59
|
+
export const attributionError = line => [
|
|
60
|
+
/gallivant/i.test(line)
|
|
61
|
+
? "This commit message names the task runner, which is not permitted in public output."
|
|
62
|
+
: "This commit message credits an AI harness, which is not permitted on this project.",
|
|
63
|
+
` offending line: ${line}`,
|
|
64
|
+
" Remove that line from the message and commit again. Everything else about the commit is fine:",
|
|
65
|
+
" Preserve accurate technical details and describe the change without runner branding or model attribution.",
|
|
66
|
+
" The commit is authored by the operator, and the harness that wrote it is recorded in the run log, not in the history.",
|
|
67
|
+
].join("\n");
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The `commit-msg` hook, as the text of the file. Self-contained node with no imports, because it runs as its own
|
|
71
|
+
* process inside a sandboxed checkout that cannot read this repository: a `require` of anything here would fail at
|
|
72
|
+
* commit time, on the operator's machine, for a reason nothing would explain. So the matcher is inlined, and
|
|
73
|
+
* `test/attribution.test.mjs` asserts the inlined copy agrees with `attributionIn` on every case rather than trusting
|
|
74
|
+
* that they were kept in step by hand.
|
|
75
|
+
*
|
|
76
|
+
* Exits 1 on a match, which is what makes `git commit` create no commit object at all.
|
|
77
|
+
*/
|
|
78
|
+
export const COMMIT_MSG_HOOK = `#!/usr/bin/env node
|
|
79
|
+
// Written by Gallivant (lib/attribution.mjs). Refuses a commit message that credits an AI harness.
|
|
80
|
+
// Self-contained on purpose: this runs inside a sandboxed checkout that cannot read Gallivant's own source.
|
|
81
|
+
// Dynamic import works whether the checkout's package.json declares module or commonjs.
|
|
82
|
+
import("node:fs").then(({ readFileSync }) => {
|
|
83
|
+
const ATTRIBUTION_PATTERNS = ${JSON.stringify(ATTRIBUTION_PATTERNS.map(re => [re.source, re.flags]))}.map(([source, flags]) => new RegExp(source, flags));
|
|
84
|
+
${attributionIn.toString()}
|
|
85
|
+
const attributionError = ${attributionError.toString()};
|
|
86
|
+
let text = "";
|
|
87
|
+
try { text = readFileSync(process.argv[2], "utf8"); } catch { process.exit(0); }
|
|
88
|
+
const line = attributionIn(text);
|
|
89
|
+
if (line) {
|
|
90
|
+
process.stderr.write(attributionError(line) + "\\n");
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
process.exit(0);
|
|
94
|
+
});
|
|
95
|
+
`;
|
package/lib/block.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Recovery identity in the PR body. The neutral record is versioned canonical JSON, transported as base64url.
|
|
2
|
+
// This is encoding, not secrecy or authorization: all decoded fields retain the legacy strict validation.
|
|
3
|
+
import { HARNESSES } from "./config.mjs";
|
|
4
|
+
import { isSessionFor } from "./harness.mjs";
|
|
5
|
+
import { BLOCK_OPEN, BLOCK_OPENS, BRANCH_PREFIXES } from "./public-format.mjs";
|
|
6
|
+
import { normalizeReceipt } from "./deliverable.mjs";
|
|
7
|
+
|
|
8
|
+
const CLOSE = "-->";
|
|
9
|
+
const FIELDS = ["prd", "harness", "session", "branch"];
|
|
10
|
+
const RULES = {
|
|
11
|
+
prd: v => /^[a-z0-9][a-z0-9_./-]*\.md$/.test(v) && !v.split("/").includes("..") && !v.includes("//"),
|
|
12
|
+
harness: v => HARNESSES.includes(v),
|
|
13
|
+
session: (v, fields) => v === null || isSessionFor(fields.harness, v),
|
|
14
|
+
branch: v => BRANCH_PREFIXES.some(prefix => v.startsWith(prefix) && /^[a-z0-9][a-z0-9-]*$/.test(v.slice(prefix.length))),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Require the opening marker to occupy a complete line. Old round/nudge/reset comments share the old prefix but
|
|
18
|
+
// are not identities, and must never be mistaken for a recovery block or removed when a body is normalized.
|
|
19
|
+
const OPEN_RE = new RegExp("^(?:" + BLOCK_OPENS.join("|") + ")[ \\t]*(?=\\r?$)", "gm");
|
|
20
|
+
function blocks(body) {
|
|
21
|
+
return [...body.matchAll(OPEN_RE)].map(match => ({ start: match.index, open: match[0].trimEnd(), end: body.indexOf(CLOSE, match.index + match[0].length) }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function renderBlock({ prd, harness, session, branch }) {
|
|
25
|
+
const data = Buffer.from(JSON.stringify({ version: 1, prd, harness, session: session ?? null, branch }), "utf8").toString("base64url");
|
|
26
|
+
return `${BLOCK_OPEN}\n${data}\n${CLOSE}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Parsed and validated fields, or null with a reason when anything is off. */
|
|
30
|
+
export function parseBlock(body) {
|
|
31
|
+
const text = String(body || "");
|
|
32
|
+
const found = blocks(text);
|
|
33
|
+
if (!found.length) return { block: null, reason: "no block" };
|
|
34
|
+
if (found.length !== 1) return { block: null, reason: "multiple recovery blocks" };
|
|
35
|
+
const { start, open, end } = found[0];
|
|
36
|
+
if (end < 0) return { block: null, reason: "unterminated block" };
|
|
37
|
+
const payload = text.slice(start + open.length, end).trim();
|
|
38
|
+
let fields;
|
|
39
|
+
if (open === BLOCK_OPEN) {
|
|
40
|
+
if (payload.length > 8192 || !/^[A-Za-z0-9_-]+$/.test(payload)) return { block: null, reason: "invalid recovery record encoding" };
|
|
41
|
+
try {
|
|
42
|
+
const bytes = Buffer.from(payload, "base64url");
|
|
43
|
+
if (bytes.toString("base64url") !== payload) throw new Error("noncanonical encoding");
|
|
44
|
+
const value = JSON.parse(bytes.toString("utf8"));
|
|
45
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.version !== 1
|
|
46
|
+
|| Object.keys(value).some(k => !["version", ...FIELDS].includes(k))) throw new Error("unknown recovery record schema");
|
|
47
|
+
fields = Object.fromEntries(FIELDS.filter(k => Object.hasOwn(value, k)).map(k => [k, value[k]]));
|
|
48
|
+
} catch { return { block: null, reason: "invalid recovery record" }; }
|
|
49
|
+
} else {
|
|
50
|
+
fields = {};
|
|
51
|
+
for (const line of payload.split("\n")) {
|
|
52
|
+
const match = line.trim().match(/^(prd|harness|session|branch):\s*(\S+)$/);
|
|
53
|
+
if (!match) continue;
|
|
54
|
+
if (Object.hasOwn(fields, match[1])) return { block: null, reason: `duplicate ${match[1]}` };
|
|
55
|
+
fields[match[1]] = match[2];
|
|
56
|
+
}
|
|
57
|
+
if (fields.session === "none") fields.session = null;
|
|
58
|
+
}
|
|
59
|
+
for (const key of FIELDS) {
|
|
60
|
+
const value = fields[key];
|
|
61
|
+
if (value === undefined) return { block: null, reason: `missing ${key}` };
|
|
62
|
+
if ((key === "session" && value === null) || (typeof value === "string" && RULES[key](value, fields))) continue;
|
|
63
|
+
return { block: null, reason: `invalid ${key}: ${String(value).slice(0, 40)}` };
|
|
64
|
+
}
|
|
65
|
+
return { block: fields, reason: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Replace one known block (valid or not), or append one. Ambiguous identities are never silently discarded. */
|
|
69
|
+
export function upsertBlock(body, block) {
|
|
70
|
+
const text = String(body || "");
|
|
71
|
+
const found = blocks(text);
|
|
72
|
+
if (found.length > 1) throw new Error("multiple recovery blocks; keep exactly one identity");
|
|
73
|
+
if (found.length) {
|
|
74
|
+
const { start, end } = found[0];
|
|
75
|
+
if (end < 0) throw new Error("unterminated recovery block; repair the body before updating identity");
|
|
76
|
+
return text.slice(0, start) + renderBlock(block) + text.slice(end + CLOSE.length);
|
|
77
|
+
}
|
|
78
|
+
return `${text.replace(/\s+$/, "")}\n\n${renderBlock(block)}\n`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Migrate machine-owned formatting only. Never redact a title, prose, evidence, source path or URL. */
|
|
82
|
+
export function normalizePublicBody(body, { identity = null, taskId = null } = {}) {
|
|
83
|
+
let text = normalizeReceipt(body, taskId);
|
|
84
|
+
const parsed = parseBlock(text);
|
|
85
|
+
if (identity) text = upsertBlock(text, identity);
|
|
86
|
+
else if (parsed.block) text = upsertBlock(text, parsed.block);
|
|
87
|
+
else if (parsed.reason !== "no block") throw new Error(parsed.reason);
|
|
88
|
+
return text;
|
|
89
|
+
}
|
package/lib/chains.mjs
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Linear, operator-approved plans. The normal start/tick machinery still owns every individual run.
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { requestFor } from "./sources/index.mjs";
|
|
4
|
+
import { resolveBase } from "./config.mjs";
|
|
5
|
+
import { entryId } from "./state.mjs";
|
|
6
|
+
import { prdIdFromBranch } from "./git.mjs";
|
|
7
|
+
import { repoFromUrl } from "./gh.mjs";
|
|
8
|
+
import { start } from "./commands/start.mjs";
|
|
9
|
+
import { validateStartRequest, captureStartInput, contentDigest, checkBaseFiles } from "./start_contract.mjs";
|
|
10
|
+
|
|
11
|
+
const MAX_FAILURES = 5;
|
|
12
|
+
const flights = new Map();
|
|
13
|
+
const stamp = () => new Date().toISOString();
|
|
14
|
+
const validName = name => {
|
|
15
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name || "") || name.length > 80) throw new Error("chain name must be 1–80 lowercase letters, digits and single dashes");
|
|
16
|
+
};
|
|
17
|
+
const uncertain = id => `Unresolved start for ${id}. Inspect gallivant why, the runner logs and worktree before recovery; --retry never clears a start claim or launches a second process. Cancel this chain if a new plan is needed.`;
|
|
18
|
+
|
|
19
|
+
async function planStep(ctx, path, askedBase) {
|
|
20
|
+
const request = await requestFor(path, ctx);
|
|
21
|
+
validateStartRequest(request, ctx.config, path);
|
|
22
|
+
const { base } = resolveBase({ config: ctx.config, repo: request.repo, defaultBranch: request.defaultBranch, asked: askedBase });
|
|
23
|
+
if (!base) throw new Error(`no base branch for ${request.repo}`);
|
|
24
|
+
const input = captureStartInput(request, base);
|
|
25
|
+
return { request, input, step: {
|
|
26
|
+
id: entryId(request.repo, request.id), path: request.path, checkout: request.checkout, relPath: request.relPath,
|
|
27
|
+
harness: request.harness, digest: input.digest, baseFiles: {}, dispatch: null, entry: null, merged: null,
|
|
28
|
+
}, repo: request.repo.toLowerCase(), base };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function approveBaseFiles(ctx, plans) {
|
|
32
|
+
const { repo, base } = plans[0];
|
|
33
|
+
if (!await ctx.gh.branchExists(repo, base)) throw new Error(`base ${repo}:${base} is missing or GitHub is unavailable`);
|
|
34
|
+
const sha = await ctx.gh.refSha(repo, base);
|
|
35
|
+
if (!/^[a-f0-9]{40,64}$/.test(sha || "")) throw new Error(`GitHub did not return a base commit for ${repo}:${base}`);
|
|
36
|
+
const files = new Map();
|
|
37
|
+
for (const plan of plans) {
|
|
38
|
+
for (const [path] of plan.input.files) {
|
|
39
|
+
if (!files.has(path)) files.set(path, contentDigest(await ctx.gh.fileAtStrict(repo, path, sha)));
|
|
40
|
+
plan.step.baseFiles[path] = files.get(path);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function queueChain(ctx, { name, paths, base = null }) {
|
|
46
|
+
validName(name);
|
|
47
|
+
if (!Array.isArray(paths) || paths.length < 2) throw new Error("a chain needs at least two ready PRD paths in execution order");
|
|
48
|
+
const plans = [];
|
|
49
|
+
for (const path of paths) plans.push(await planStep(ctx, path, base));
|
|
50
|
+
const first = plans[0];
|
|
51
|
+
if (plans.some(p => p.repo !== first.repo || p.base !== first.base || p.step.checkout !== first.step.checkout)) throw new Error("a chain must use one repository checkout and one base branch");
|
|
52
|
+
if (new Set(plans.map(p => p.step.id)).size !== plans.length) throw new Error("a chain cannot contain duplicate PRDs");
|
|
53
|
+
await approveBaseFiles(ctx, plans);
|
|
54
|
+
return ctx.state.createChain({ name, repo: first.repo, base: first.base, status: "active", index: 0,
|
|
55
|
+
steps: plans.map(p => p.step), reason: null, failures: 0, created_at: stamp(), updated_at: stamp() });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function cancelChain(ctx, name) {
|
|
59
|
+
return ctx.state.updateChain(name, c => {
|
|
60
|
+
if (c.status === "done") throw new Error(`chain ${name} is already done`);
|
|
61
|
+
return { status: "cancelled", reason: "Cancelled by operator; existing runs are preserved", updated_at: stamp() };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function retryChain(ctx, { name, resnapshot = false }) {
|
|
66
|
+
const c = ctx.state.getChain(name);
|
|
67
|
+
if (!c) throw new Error(`no chain ${name}`);
|
|
68
|
+
if (c.status !== "stopped") throw new Error(`chain ${name} is ${c.status}; --retry is for a stopped chain`);
|
|
69
|
+
for (const step of c.steps) {
|
|
70
|
+
if (ctx.state.startClaim(step.id) || (step.dispatch && !ctx.state.get(step.id))) throw new Error(uncertain(step.id));
|
|
71
|
+
}
|
|
72
|
+
const steps = structuredClone(c.steps);
|
|
73
|
+
if (resnapshot) {
|
|
74
|
+
const plans = [];
|
|
75
|
+
for (let i = c.index; i < steps.length; i++) {
|
|
76
|
+
if (steps[i].entry || steps[i].dispatch) continue;
|
|
77
|
+
const p = await planStep(ctx, steps[i].path, c.base);
|
|
78
|
+
if (p.repo !== c.repo || p.step.id !== steps[i].id || p.step.checkout !== steps[i].checkout) throw new Error("resnapshot cannot change a chain's repository or task identity");
|
|
79
|
+
plans.push(p);
|
|
80
|
+
steps[i] = p.step;
|
|
81
|
+
}
|
|
82
|
+
if (plans.length) await approveBaseFiles(ctx, plans);
|
|
83
|
+
}
|
|
84
|
+
return ctx.state.updateChain(name, current => {
|
|
85
|
+
if (JSON.stringify(current) !== JSON.stringify(c)) throw new Error("chain changed while retrying; inspect it again");
|
|
86
|
+
return { steps, status: "active", failures: 0, reason: null, updated_at: stamp() };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Never resurrect cancellation or apply an old tick's result to a different milestone.
|
|
91
|
+
function saveActive(ctx, c, patch) {
|
|
92
|
+
return ctx.state.updateChain(c.name, current => current.status === "active" && current.index === c.index
|
|
93
|
+
? { ...patch, updated_at: stamp() } : {});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function stop(ctx, c, reason) {
|
|
97
|
+
const saved = saveActive(ctx, c, { status: "stopped", reason });
|
|
98
|
+
if (saved.status !== "stopped") return;
|
|
99
|
+
ctx.log.warn(`chain ${c.name}: ${reason}`);
|
|
100
|
+
await ctx.notify?.error?.(`chain ${c.name} stopped`, `${reason}\nInspect: gallivant chain ${c.name}`).catch(() => {});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function matchesEntry(c, step, entry) {
|
|
104
|
+
// Creation writes the current prefix; persisted runs keep their original branch. Decode through the shared
|
|
105
|
+
// current/legacy convention and require the exact approved task slug, without weakening the other identity pins.
|
|
106
|
+
return entry && entry.chain?.name === c.name && entry.chain?.index === c.index && entry.chain?.dispatch === step.dispatch
|
|
107
|
+
&& entry.repo === c.repo && entry.base === c.base && prdIdFromBranch(entry.branch) === step.id.split("/").pop()
|
|
108
|
+
&& entry.checkout === step.checkout && entry.prd === step.relPath
|
|
109
|
+
&& entry.start_token && (!step.entry || step.entry.token === entry.start_token);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function tickChain(ctx, c) {
|
|
113
|
+
let step = c.steps[c.index];
|
|
114
|
+
if (step.dispatch) {
|
|
115
|
+
const entry = ctx.state.get(step.id);
|
|
116
|
+
if (!matchesEntry(c, step, entry)) { await stop(ctx, c, entry ? `${step.id}: registered run identity changed; inspect before continuing` : uncertain(step.id)); return; }
|
|
117
|
+
if (step.entry?.pr && entry.pr !== step.entry.pr) { await stop(ctx, c, `${step.id}: predecessor PR identity changed`); return; }
|
|
118
|
+
// Capture the token immediately, and the PR identity once the normal tick discovers it.
|
|
119
|
+
if (!step.entry || (!step.entry.pr && entry.pr)) {
|
|
120
|
+
const steps = structuredClone(c.steps);
|
|
121
|
+
steps[c.index].entry = { token: entry.start_token, pr: entry.pr || null };
|
|
122
|
+
c = saveActive(ctx, c, { steps }); step = c.steps[c.index];
|
|
123
|
+
if (c.status !== "active") return;
|
|
124
|
+
}
|
|
125
|
+
// The normal run remains the recovery surface. Never have two milestone agents mutating dependent work.
|
|
126
|
+
if (!entry.pr || (entry.last?.run && entry.last.exit == null) || ctx.runnerFor(step.id).current()?.running) return;
|
|
127
|
+
const pr = await ctx.gh.prView(c.repo, entry.pr);
|
|
128
|
+
const identity = repoFromUrl(pr.url);
|
|
129
|
+
if (identity?.repo !== c.repo || identity.number !== entry.pr || pr.number !== entry.pr
|
|
130
|
+
|| pr.headRefName !== entry.branch || pr.isCrossRepository !== false || pr.baseRefName !== c.base) {
|
|
131
|
+
await stop(ctx, c, `${step.id}: PR identity or intended base changed`); return;
|
|
132
|
+
}
|
|
133
|
+
if (pr.state === "CLOSED") { await stop(ctx, c, `${step.id}: PR #${entry.pr} was closed without merging; reopen and register the PR before retrying, or cancel this chain`); return; }
|
|
134
|
+
if (pr.state !== "MERGED") { if (c.failures) saveActive(ctx, c, { failures: 0, reason: null }); return; }
|
|
135
|
+
const sha = pr.mergeCommit?.oid;
|
|
136
|
+
if (!/^[a-f0-9]{40,64}$/.test(sha || "") || !pr.mergedAt || !await ctx.gh.containsCommit(c.repo, c.base, sha)) {
|
|
137
|
+
await stop(ctx, c, `${step.id}: merge commit is missing from the intended base ${c.base}`); return;
|
|
138
|
+
}
|
|
139
|
+
const steps = structuredClone(c.steps);
|
|
140
|
+
steps[c.index].merged = { pr: entry.pr, sha, at: pr.mergedAt };
|
|
141
|
+
const index = c.index + 1;
|
|
142
|
+
saveActive(ctx, c, { steps, index, status: index === steps.length ? "done" : "active", reason: null, failures: 0 });
|
|
143
|
+
return; // At most one transition per chain per tick; another tick starts the successor.
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (ctx.state.get(step.id) || ctx.state.startClaim(step.id)) { await stop(ctx, c, uncertain(step.id)); return; }
|
|
147
|
+
const p = await planStep(ctx, step.path, c.base);
|
|
148
|
+
if (p.input.digest !== step.digest) { await stop(ctx, c, `${step.id}: queued PRD or sources changed; review them, then gallivant chain ${c.name} --retry --resnapshot`); return; }
|
|
149
|
+
const after = c.steps.slice(0, c.index).map(s => s.merged?.sha);
|
|
150
|
+
for (const sha of after) {
|
|
151
|
+
if (!sha || !await ctx.gh.containsCommit(c.repo, c.base, sha)) { await stop(ctx, c, `predecessor merge is no longer in ${c.base}; inspect the base history`); return; }
|
|
152
|
+
}
|
|
153
|
+
// Fail before cloning for a normal source collision; start rechecks against the actual clone to close the race.
|
|
154
|
+
const baseSha = await ctx.gh.refSha(c.repo, c.base);
|
|
155
|
+
await checkBaseFiles(p.input, step.baseFiles, path => ctx.gh.fileAtStrict(c.repo, path, baseSha));
|
|
156
|
+
const dispatch = randomUUID();
|
|
157
|
+
c = ctx.state.updateChain(c.name, current => {
|
|
158
|
+
if (current.status !== "active" || current.index !== c.index || current.steps[current.index].dispatch) return {};
|
|
159
|
+
const steps = structuredClone(current.steps); steps[current.index].dispatch = dispatch;
|
|
160
|
+
return { steps, updated_at: stamp() };
|
|
161
|
+
});
|
|
162
|
+
if (c.status !== "active" || c.steps[c.index].dispatch !== dispatch) return;
|
|
163
|
+
step = c.steps[c.index];
|
|
164
|
+
try {
|
|
165
|
+
await start(ctx, { path: step.path, base: c.base, chain: { name: c.name, index: c.index, dispatch, digest: step.digest, baseFiles: step.baseFiles, after } });
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const entry = ctx.state.get(step.id);
|
|
168
|
+
// A post-registration failure (for example config persistence) still owns a valid run. Reconcile next tick.
|
|
169
|
+
if (matchesEntry(c, step, entry)) { ctx.log.warn(`chain ${c.name}: run registered; ${error.message}`); return; }
|
|
170
|
+
if (!ctx.state.startClaim(step.id)) {
|
|
171
|
+
const steps = structuredClone(c.steps); steps[c.index].dispatch = null;
|
|
172
|
+
c = saveActive(ctx, c, { steps });
|
|
173
|
+
}
|
|
174
|
+
await stop(ctx, c, `${step.id}: ${error.message}${ctx.state.startClaim(step.id) ? `\n${uncertain(step.id)}` : ""}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function tickChains(ctx) {
|
|
179
|
+
// up owns the cross-process daemon lock; this also serializes overlapping callers in tests/embedders.
|
|
180
|
+
const key = ctx.state.file;
|
|
181
|
+
if (flights.has(key)) return flights.get(key);
|
|
182
|
+
const work = (async () => {
|
|
183
|
+
const results = [];
|
|
184
|
+
for (const [, chain] of ctx.state.chains()) {
|
|
185
|
+
if (chain.status !== "active") continue;
|
|
186
|
+
try { await tickChain(ctx, chain); results.push({ chain: chain.name, ok: true }); }
|
|
187
|
+
catch (error) {
|
|
188
|
+
const current = ctx.state.getChain(chain.name);
|
|
189
|
+
const failures = (current.failures || 0) + 1;
|
|
190
|
+
if (error.code === "CHAIN_INPUT_CHANGED") await stop(ctx, current, error.message);
|
|
191
|
+
else if (failures >= MAX_FAILURES) await stop(ctx, current, `${error.message} (${failures} failed checks; retry after fixing)`);
|
|
192
|
+
else saveActive(ctx, current, { failures, reason: `${error.message} (retrying ${failures}/${MAX_FAILURES})` });
|
|
193
|
+
ctx.log.warn(`chain ${chain.name}: ${error.message}`);
|
|
194
|
+
results.push({ chain: chain.name, ok: false, error: error.message });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return results;
|
|
198
|
+
})();
|
|
199
|
+
flights.set(key, work);
|
|
200
|
+
try { return await work; } finally { flights.delete(key); }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function chainRows(ctx) {
|
|
204
|
+
return ctx.state.chains().map(([name, c]) => {
|
|
205
|
+
const step = c.steps[c.index], entry = step ? ctx.state.get(step.id) : null;
|
|
206
|
+
return { name, status: c.status, repo: c.repo, base: c.base, completed: c.index, total: c.steps.length,
|
|
207
|
+
current: step?.id || null, reason: c.reason || (c.status === "done" ? "all milestones merged" : entry ? (entry.reason || (entry.pr ? `waiting for PR #${entry.pr} to merge` : "waiting for the current run")) : "queued"),
|
|
208
|
+
url: entry?.url || null };
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function renderChains(ctx, name = null) {
|
|
213
|
+
const rows = chainRows(ctx).filter(c => !name || c.name === name);
|
|
214
|
+
if (name && !rows.length) throw new Error(`no chain ${name}`);
|
|
215
|
+
const lines = rows.map(c => `${c.name}: ${c.status} — ${c.completed}/${c.total} merged -> ${c.repo}:${c.base}${["done", "cancelled"].includes(c.status) ? "" : ` — ${c.reason}`}`);
|
|
216
|
+
if (name) {
|
|
217
|
+
const c = ctx.state.getChain(name);
|
|
218
|
+
c.steps.forEach((s, i) => lines.push(` ${i + 1}. ${s.id} — ${s.merged ? `merged #${s.merged.pr}` : s.dispatch ? "started" : "queued"}\n ${s.path}`));
|
|
219
|
+
if (c.status === "stopped") lines.push(`Inspect the reason, then: gallivant chain ${name} --retry`);
|
|
220
|
+
}
|
|
221
|
+
return lines.join("\n") || "No chains queued.";
|
|
222
|
+
}
|