balladeer 0.0.4 → 1.0.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.
Files changed (60) hide show
  1. package/LICENSE +200 -5
  2. package/README.md +154 -68
  3. package/dist/agent.d.ts +126 -0
  4. package/dist/agent.js +209 -0
  5. package/dist/cli.d.ts +34 -0
  6. package/dist/cli.js +392 -0
  7. package/dist/client.d.ts +44 -0
  8. package/dist/client.js +114 -0
  9. package/dist/commands/affected.d.ts +22 -0
  10. package/dist/commands/affected.js +122 -0
  11. package/dist/commands/check-seals.d.ts +37 -0
  12. package/dist/commands/check-seals.js +289 -0
  13. package/dist/commands/discover.d.ts +68 -0
  14. package/dist/commands/discover.js +395 -0
  15. package/dist/commands/explain.d.ts +35 -0
  16. package/dist/commands/explain.js +90 -0
  17. package/dist/commands/invite.d.ts +24 -0
  18. package/dist/commands/invite.js +197 -0
  19. package/dist/commands/mcp.d.ts +65 -0
  20. package/dist/commands/mcp.js +202 -0
  21. package/dist/commands/propose.d.ts +59 -0
  22. package/dist/commands/propose.js +262 -0
  23. package/dist/commands/repositories.d.ts +18 -0
  24. package/dist/commands/repositories.js +185 -0
  25. package/dist/commands/setup.d.ts +75 -0
  26. package/dist/commands/setup.js +1471 -0
  27. package/dist/commands/status.d.ts +35 -0
  28. package/dist/commands/status.js +482 -0
  29. package/dist/commands/touch-map.d.ts +42 -0
  30. package/dist/commands/touch-map.js +251 -0
  31. package/dist/commands/whoami.d.ts +8 -0
  32. package/dist/commands/whoami.js +79 -0
  33. package/dist/conventions.d.ts +69 -0
  34. package/dist/conventions.js +175 -0
  35. package/dist/copy.d.ts +148 -0
  36. package/dist/copy.js +459 -0
  37. package/dist/currency.d.ts +31 -0
  38. package/dist/currency.js +72 -0
  39. package/dist/gh.d.ts +80 -0
  40. package/dist/gh.js +188 -0
  41. package/dist/git.d.ts +76 -0
  42. package/dist/git.js +203 -0
  43. package/dist/markers.d.ts +76 -0
  44. package/dist/markers.js +125 -0
  45. package/dist/mcp-config.d.ts +99 -0
  46. package/dist/mcp-config.js +230 -0
  47. package/dist/release.d.ts +55 -0
  48. package/dist/release.js +67 -0
  49. package/dist/repository.d.ts +8 -0
  50. package/dist/repository.js +32 -0
  51. package/dist/seals.d.ts +48 -0
  52. package/dist/seals.js +112 -0
  53. package/dist/store.d.ts +98 -0
  54. package/dist/store.js +225 -0
  55. package/dist/touch-map.d.ts +241 -0
  56. package/dist/touch-map.js +487 -0
  57. package/dist/wire.d.ts +588 -0
  58. package/dist/wire.js +20 -0
  59. package/package.json +19 -10
  60. package/bin/balladeer.js +0 -136
package/dist/client.js ADDED
@@ -0,0 +1,114 @@
1
+ import { noteServerVersion } from "./currency.js";
2
+ import { commandLine } from "./release.js";
3
+ import { CLIENT_HEADER, CLIENT_HEADER_VALUE } from "./wire.js";
4
+ export class TransportError extends Error {
5
+ controlPlane;
6
+ constructor(controlPlane, reason) {
7
+ super(reason);
8
+ this.name = "TransportError";
9
+ this.controlPlane = controlPlane;
10
+ }
11
+ }
12
+ export class ClientTooOldError extends Error {
13
+ update;
14
+ controlPlane;
15
+ constructor(controlPlane, update) {
16
+ super(`This copy of balladeer is too old for ${controlPlane}. Run: ${update}`);
17
+ this.name = "ClientTooOldError";
18
+ this.controlPlane = controlPlane;
19
+ this.update = update;
20
+ }
21
+ }
22
+ export class RefusalError extends Error {
23
+ code;
24
+ status;
25
+ constructor(status, refusal) {
26
+ super(refusal.message ?? refusal.error);
27
+ this.name = "RefusalError";
28
+ this.code = refusal.error;
29
+ this.status = status;
30
+ }
31
+ }
32
+ /**
33
+ * A link this command prints, always built from the address it paired with.
34
+ *
35
+ * Behind a platform proxy a server can resolve a link against the machine it is
36
+ * running on rather than against the address customers use, and the first
37
+ * production `propose` printed exactly that: `https://localhost:8080/candidates/...`
38
+ * as the place to go and agree. A command that prints such a link is worse than
39
+ * one that prints none, because the person tries it.
40
+ *
41
+ * So no link a server sends is ever printed. The proposal path answers with an
42
+ * identifier and this builds the address, which is why there is no parameter
43
+ * here for a server's own link to arrive through.
44
+ */
45
+ export function reviewLink(controlPlane, path) {
46
+ return new URL(path, ensureTrailing(controlPlane)).toString();
47
+ }
48
+ function ensureTrailing(controlPlane) {
49
+ return controlPlane.endsWith("/") ? controlPlane : `${controlPlane}/`;
50
+ }
51
+ /** Bounded, so a server's body can never become pages of terminal output. */
52
+ function boundedReason(error) {
53
+ if (!(error instanceof Error))
54
+ return "unknown transport failure";
55
+ return error.message.trim().slice(0, 200) || error.name;
56
+ }
57
+ /**
58
+ * The one place this command talks to a network.
59
+ *
60
+ * `redirect: "manual"` so no redirect can carry a bearer to a host the person
61
+ * never paired with, and the 426 handshake is decoded here so every caller gets
62
+ * the same actionable refusal rather than a status code.
63
+ */
64
+ export async function request(controlPlane, options) {
65
+ const url = new URL(options.path, `${controlPlane}/`);
66
+ const headers = {
67
+ accept: "application/json",
68
+ [CLIENT_HEADER]: CLIENT_HEADER_VALUE,
69
+ };
70
+ if (options.body !== undefined)
71
+ headers["content-type"] = "application/json";
72
+ if (options.bearer)
73
+ headers.authorization = `Bearer ${options.bearer}`;
74
+ let response;
75
+ try {
76
+ response = await fetch(url, {
77
+ method: options.method,
78
+ headers,
79
+ redirect: "manual",
80
+ signal: AbortSignal.timeout(options.timeoutMs ?? 20_000),
81
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
82
+ });
83
+ }
84
+ catch (error) {
85
+ throw new TransportError(controlPlane, boundedReason(error));
86
+ }
87
+ // Read before anything branches on the status, so a refusal teaches this copy
88
+ // that it is old just as a success does. A client whose call failed is the
89
+ // client most likely to be the one that needs updating.
90
+ noteServerVersion(response.headers);
91
+ if (response.status >= 300 && response.status < 400) {
92
+ throw new TransportError(controlPlane, `unexpected redirect (${response.status})`);
93
+ }
94
+ let payload;
95
+ try {
96
+ payload = await response.json();
97
+ }
98
+ catch {
99
+ payload = undefined;
100
+ }
101
+ if (response.status === 426) {
102
+ const refusal = (payload ?? {});
103
+ // The server's own remedy first, because it is the one that knows whether
104
+ // the package is published. A dist-tag would be the wrong fallback: it
105
+ // resolves to whatever the registry holds under that name today, which is
106
+ // not this program.
107
+ throw new ClientTooOldError(controlPlane, refusal.update ?? commandLine(null, "setup"));
108
+ }
109
+ if (!response.ok) {
110
+ const refusal = (payload ?? { error: `http_${response.status}` });
111
+ throw new RefusalError(response.status, refusal);
112
+ }
113
+ return payload;
114
+ }
@@ -0,0 +1,22 @@
1
+ export type AffectedOptions = Readonly<{
2
+ cwd: string;
3
+ json: boolean;
4
+ paths: readonly string[];
5
+ write: (text: string) => void;
6
+ }>;
7
+ /** The path as the map spells it: repository-relative, forward slashes. */
8
+ export declare function normalizePath(root: string, cwd: string, given: string): string;
9
+ /**
10
+ * Which promises a change touches, answered from the map and from nothing else.
11
+ *
12
+ * The point of answering here rather than asking Balladeer is that the question
13
+ * contains the answer's evidence: the file names of the change in front of the
14
+ * person. Sending them anywhere to find out which promises they touch would
15
+ * hand a vendor the shape of a repository it has promised never to see. So this
16
+ * reads one local file, and it says so.
17
+ *
18
+ * A stale row is still printed. The map's answer for a promise whose verifier
19
+ * has since changed is the last true thing anybody measured, and hiding it
20
+ * would leave a coder believing their change touches nothing.
21
+ */
22
+ export declare function runAffected(options: AffectedOptions): Promise<number>;
@@ -0,0 +1,122 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { TOUCH_MAP_FILE, affectedPaths, readLocalPackages, readTouchMap, verifierDigest, } from "../touch-map.js";
3
+ import {} from "../wire.js";
4
+ import { promiseRoot } from "./touch-map.js";
5
+ /** How many promises one path lists before the report says how many more. */
6
+ const NAMED_LIMIT = 20;
7
+ /** The path as the map spells it: repository-relative, forward slashes. */
8
+ export function normalizePath(root, cwd, given) {
9
+ const absolute = isAbsolute(given) ? given : resolve(cwd, given);
10
+ const inside = relative(root, absolute);
11
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside))
12
+ return given.replace(/\\/g, "/").replace(/^\.\//, "");
13
+ return inside.split(sep).join("/");
14
+ }
15
+ /**
16
+ * Which promises a change touches, answered from the map and from nothing else.
17
+ *
18
+ * The point of answering here rather than asking Balladeer is that the question
19
+ * contains the answer's evidence: the file names of the change in front of the
20
+ * person. Sending them anywhere to find out which promises they touch would
21
+ * hand a vendor the shape of a repository it has promised never to see. So this
22
+ * reads one local file, and it says so.
23
+ *
24
+ * A stale row is still printed. The map's answer for a promise whose verifier
25
+ * has since changed is the last true thing anybody measured, and hiding it
26
+ * would leave a coder believing their change touches nothing.
27
+ */
28
+ export async function runAffected(options) {
29
+ const emit = (step) => {
30
+ if (options.json)
31
+ options.write(`${JSON.stringify(step)}\n`);
32
+ };
33
+ const say = (text) => {
34
+ if (!options.json)
35
+ options.write(`${text}\n`);
36
+ };
37
+ if (options.paths.length === 0) {
38
+ const message = "Name at least one path: balladeer affected src/orders/total.ts";
39
+ if (options.json)
40
+ emit({ step: "error", reason: "usage", message, changed: false, exitCode: 4 });
41
+ else
42
+ options.write(`${message}\n`);
43
+ return 4;
44
+ }
45
+ const root = await promiseRoot(options.cwd);
46
+ if (root === undefined) {
47
+ const message = "This is not a git repository and there are no promise packages here, so there is no map to read. Run it from inside your checkout.";
48
+ if (options.json)
49
+ emit({ step: "error", reason: "not_a_repository", message, changed: false, exitCode: 4 });
50
+ else
51
+ options.write(`${message}\n`);
52
+ return 4;
53
+ }
54
+ const reading = readTouchMap(root);
55
+ if (reading.kind !== "map") {
56
+ const message = reading.kind === "absent"
57
+ ? `There is no touch map in this checkout yet. Build one with: balladeer touch-map`
58
+ : `${TOUCH_MAP_FILE} could not be read as a touch map. Build it again with: balladeer touch-map`;
59
+ emit({ step: "affected", mapped: false, paths: [], changed: false });
60
+ say(message);
61
+ // Zero, because having no map is not a fault in the change somebody is
62
+ // making. A command that failed here would break every hook it was put in.
63
+ return 0;
64
+ }
65
+ const packages = readLocalPackages(root);
66
+ const details = new Map(packages.map((pkg) => [pkg.promiseId, pkg]));
67
+ const current = new Map(packages.map((pkg) => [pkg.promiseId, verifierDigest(root, pkg)]));
68
+ const paths = options.paths.map((given) => normalizePath(root, options.cwd, given));
69
+ const answers = affectedPaths(reading.map, paths, current, details);
70
+ const touched = answers.filter((answer) => answer.promises.length > 0);
71
+ const stale = answers.reduce((count, answer) => count + answer.promises.filter((promise) => promise.stale).length, 0);
72
+ emit({
73
+ step: "affected",
74
+ mapped: true,
75
+ generatedAt: reading.map.generatedAt,
76
+ ...(reading.map.truncated ? { truncated: true } : {}),
77
+ paths: answers.map((answer) => ({
78
+ path: answer.path,
79
+ promises: answer.promises.slice(0, NAMED_LIMIT).map((promise) => ({
80
+ promiseId: promise.promiseId,
81
+ stale: promise.stale,
82
+ ...(promise.title === undefined ? {} : { title: promise.title }),
83
+ ...(promise.claim === undefined ? {} : { claim: promise.claim }),
84
+ })),
85
+ })),
86
+ changed: false,
87
+ });
88
+ if (touched.length === 0) {
89
+ say(`No promise in this map ran any of those files. The map was built ${reading.map.generatedAt}; a promise sealed since then is not in it.`);
90
+ return 0;
91
+ }
92
+ for (const answer of answers) {
93
+ if (answer.promises.length === 0)
94
+ continue;
95
+ say(answer.path);
96
+ for (const promise of answer.promises.slice(0, NAMED_LIMIT)) {
97
+ say(` ${promise.promiseId}${promise.title ? `: ${promise.title}` : ""}`);
98
+ if (promise.claim !== undefined)
99
+ say(` ${promise.claim}`);
100
+ if (promise.stale)
101
+ say(" Stale: this promise's verifier has changed since the map was built.");
102
+ }
103
+ if (answer.promises.length > NAMED_LIMIT)
104
+ say(` and ${answer.promises.length - NAMED_LIMIT} more, not listed here.`);
105
+ }
106
+ // Named and absent is an answer, and a silent omission is not. A person who
107
+ // listed four files and read two of them back cannot tell whether the other
108
+ // two are untouched or were dropped.
109
+ const untouched = answers.filter((answer) => answer.promises.length === 0);
110
+ if (untouched.length > 0) {
111
+ say("");
112
+ say(`No promise in this map ran: ${untouched.map((answer) => answer.path).join(", ")}`);
113
+ }
114
+ say("");
115
+ if (stale > 0) {
116
+ say(stale === 1
117
+ ? "One answer above is stale. Run balladeer touch-map to measure it again."
118
+ : `${stale} of those answers are stale. Run balladeer touch-map to measure them again.`);
119
+ }
120
+ say("This came from your own checkout. Balladeer was not asked, and was told nothing.");
121
+ return 0;
122
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Where the pinned runner sits once setup's instructions have been followed.
3
+ *
4
+ * Setup tells an agent to check the enrolled attestor release out beside the
5
+ * repository, at exactly the commit CI pins, and to run its prebuilt entry
6
+ * point. This command runs that same binary rather than deciding for itself
7
+ * whether a seal holds: the runner's answer is the one CI will give, and a
8
+ * second opinion computed here would eventually disagree with it.
9
+ */
10
+ export declare const PINNED_RUNNER = "../balladeer-attestor/release/continuity-runner/cli.js";
11
+ export type CheckSealsOptions = Readonly<{
12
+ cwd: string;
13
+ json: boolean;
14
+ /** Write the optional pre-push hook instead of checking anything. */
15
+ installHook: boolean;
16
+ /** Where the pinned runner is, when it is not in the usual place. */
17
+ runner?: string;
18
+ environment: NodeJS.ProcessEnv;
19
+ write: (text: string) => void;
20
+ }>;
21
+ /**
22
+ * The one command a coder runs before they push, and the one they never notice
23
+ * when nothing is wrong.
24
+ *
25
+ * A seal is broken by an ordinary edit, and the person who breaks one almost
26
+ * never meant to. Finding out in CI costs the promise's owner an afternoon of
27
+ * qualifying it again; finding out here costs a `git checkout` of one file. So
28
+ * this says nothing at all when the change is ordinary, and when it is not it
29
+ * names the promise, the person who agreed it, and the page they agreed it on,
30
+ * because those are what the coder needs to decide whether to back the edit out
31
+ * or to finish the repair.
32
+ *
33
+ * It never decides for itself whether a seal holds. It works out which promises
34
+ * this push touches and asks the pinned runner about each one, so its verdict
35
+ * and the protected check's verdict come from the same program.
36
+ */
37
+ export declare function runCheckSeals(options: CheckSealsOptions): Promise<number>;
@@ -0,0 +1,289 @@
1
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { runCommand } from "../gh.js";
4
+ import { repositoryRoot } from "../git.js";
5
+ import { promiseForPath, sealedPromises } from "../seals.js";
6
+ import {} from "../wire.js";
7
+ /**
8
+ * Where the pinned runner sits once setup's instructions have been followed.
9
+ *
10
+ * Setup tells an agent to check the enrolled attestor release out beside the
11
+ * repository, at exactly the commit CI pins, and to run its prebuilt entry
12
+ * point. This command runs that same binary rather than deciding for itself
13
+ * whether a seal holds: the runner's answer is the one CI will give, and a
14
+ * second opinion computed here would eventually disagree with it.
15
+ */
16
+ export const PINNED_RUNNER = "../balladeer-attestor/release/continuity-runner/cli.js";
17
+ /** The exit code `run-one-target` uses for a broken seal, and only for that. */
18
+ const SEAL_BROKEN_EXIT = 4;
19
+ /** A verifier gets this long before the check gives up on it. */
20
+ const RUNNER_TIMEOUT_MS = 120_000;
21
+ /** How many broken seals are named before the report says how many more. */
22
+ const NAMED_LIMIT = 20;
23
+ /** How many of one promise's changed files are listed under it. */
24
+ const CHANGED_SHOWN = 10;
25
+ /**
26
+ * Every path this push would carry that the person has not pushed yet.
27
+ *
28
+ * Two questions, because a coder is in one of two states when this runs. Before
29
+ * the commit, the change is staged; after it, the change is committed and the
30
+ * remote has never seen it. Both are asked, and the union is what a push would
31
+ * take. Unstaged edits are deliberately not included: a file being edited right
32
+ * now is not a change anybody is pushing, and a check that went red on every
33
+ * keystroke would be turned off within the hour.
34
+ */
35
+ async function pendingPaths(root) {
36
+ const paths = new Set();
37
+ const staged = await runCommand("git", ["-C", root, "diff", "--cached", "--name-only"]);
38
+ if (staged.ok)
39
+ for (const line of staged.stdout.split("\n"))
40
+ if (line.trim())
41
+ paths.add(line);
42
+ // `@{upstream}` names the branch this one pushes to. A branch that has never
43
+ // been pushed has none, and then every commit not on any remote is pending,
44
+ // which is what the second form answers.
45
+ const upstream = await runCommand("git", [
46
+ "-C",
47
+ root,
48
+ "rev-parse",
49
+ "--abbrev-ref",
50
+ "--symbolic-full-name",
51
+ "@{upstream}",
52
+ ]);
53
+ if (upstream.ok) {
54
+ const committed = await runCommand("git", [
55
+ "-C",
56
+ root,
57
+ "diff",
58
+ "--name-only",
59
+ `${upstream.stdout.trim()}...HEAD`,
60
+ ]);
61
+ if (committed.ok)
62
+ for (const line of committed.stdout.split("\n"))
63
+ if (line.trim())
64
+ paths.add(line);
65
+ return [...paths].sort();
66
+ }
67
+ // No upstream: take every commit that sits on no remote branch at all.
68
+ const unpushed = await runCommand("git", [
69
+ "-C",
70
+ root,
71
+ "log",
72
+ "--format=%H",
73
+ "--branches",
74
+ "--not",
75
+ "--remotes",
76
+ ]);
77
+ const commits = unpushed.ok
78
+ ? unpushed.stdout.split("\n").filter((line) => line.trim() !== "")
79
+ : [];
80
+ const oldest = commits[commits.length - 1];
81
+ if (oldest !== undefined) {
82
+ const changed = await runCommand("git", [
83
+ "-C",
84
+ root,
85
+ "diff",
86
+ "--name-only",
87
+ `${oldest}^`,
88
+ "HEAD",
89
+ ]);
90
+ // A repository whose first commit is unpushed has no `^` to diff against,
91
+ // so everything it holds is what it would push.
92
+ const all = changed.ok
93
+ ? changed
94
+ : await runCommand("git", ["-C", root, "ls-tree", "-r", "--name-only", "HEAD"]);
95
+ if (all.ok)
96
+ for (const line of all.stdout.split("\n"))
97
+ if (line.trim())
98
+ paths.add(line);
99
+ }
100
+ return [...paths].sort();
101
+ }
102
+ /**
103
+ * The one command a coder runs before they push, and the one they never notice
104
+ * when nothing is wrong.
105
+ *
106
+ * A seal is broken by an ordinary edit, and the person who breaks one almost
107
+ * never meant to. Finding out in CI costs the promise's owner an afternoon of
108
+ * qualifying it again; finding out here costs a `git checkout` of one file. So
109
+ * this says nothing at all when the change is ordinary, and when it is not it
110
+ * names the promise, the person who agreed it, and the page they agreed it on,
111
+ * because those are what the coder needs to decide whether to back the edit out
112
+ * or to finish the repair.
113
+ *
114
+ * It never decides for itself whether a seal holds. It works out which promises
115
+ * this push touches and asks the pinned runner about each one, so its verdict
116
+ * and the protected check's verdict come from the same program.
117
+ */
118
+ export async function runCheckSeals(options) {
119
+ const emit = (step) => {
120
+ if (options.json)
121
+ options.write(`${JSON.stringify(step)}\n`);
122
+ };
123
+ const say = (text) => {
124
+ if (!options.json)
125
+ options.write(`${text}\n`);
126
+ };
127
+ const root = await repositoryRoot(options.cwd);
128
+ if (root === undefined) {
129
+ const message = "This is not a git repository, so there is no change to check. Run it from inside your checkout.";
130
+ if (options.json)
131
+ emit({ step: "error", reason: "not_a_repository", message, changed: false, exitCode: 4 });
132
+ else
133
+ options.write(`${message}\n`);
134
+ return 4;
135
+ }
136
+ if (options.installHook)
137
+ return installPrePushHook(root, options, emit, say);
138
+ const sealed = new Map(sealedPromises(root).map((promise) => [promise.promiseId, promise]));
139
+ if (sealed.size === 0)
140
+ return 0;
141
+ const paths = await pendingPaths(root);
142
+ const touched = new Map();
143
+ for (const path of paths) {
144
+ const promiseId = promiseForPath(path);
145
+ if (promiseId === undefined || !sealed.has(promiseId))
146
+ continue;
147
+ const changed = touched.get(promiseId) ?? [];
148
+ changed.push(path);
149
+ touched.set(promiseId, changed);
150
+ }
151
+ if (touched.size === 0)
152
+ return 0;
153
+ const runner = options.runner ?? PINNED_RUNNER;
154
+ // Resolved against the repository root, so the documented relative default
155
+ // and an absolute path a person passed both land where they mean.
156
+ const runnerPath = resolve(root, runner);
157
+ if (!existsSync(runnerPath)) {
158
+ // Loud, and still zero. Whether a seal holds is the pinned runner's answer
159
+ // and nobody else's, so with no runner here this has no verdict to give; it
160
+ // says which promises went unchecked rather than implying they are fine.
161
+ const names = [...touched.keys()].sort().join(", ");
162
+ const message = `This change touches sealed files for ${names}, and the pinned Balladeer runner is not at ` +
163
+ `${runner}, so nothing was checked. Check the enrolled attestor release out beside this ` +
164
+ `repository, or pass --runner with the path to its cli.js.`;
165
+ emit({ step: "error", reason: "runner_unavailable", message, changed: false, exitCode: 0 });
166
+ say(message);
167
+ return 0;
168
+ }
169
+ const head = await runCommand("git", ["-C", root, "rev-parse", "HEAD"]);
170
+ const sourceSha = head.ok ? head.stdout.trim() : "";
171
+ const broken = [];
172
+ for (const promiseId of [...touched.keys()].sort()) {
173
+ const promise = sealed.get(promiseId);
174
+ const answer = await runCommand(process.execPath, [runnerPath, "run-one-target", promise.packageFile], {
175
+ cwd: root,
176
+ timeoutMs: RUNNER_TIMEOUT_MS,
177
+ // The runner refuses to produce a result without an exact commit to
178
+ // attribute it to. This is a local question about the tree in front of
179
+ // it, so the commit it is standing on is the honest answer.
180
+ env: { ...options.environment, BALLADEER_SOURCE_SHA: sourceSha },
181
+ });
182
+ // Only a broken seal. A refuted behavior, a verifier that could not run, and
183
+ // a runner that fell over are all somebody's problem, and none of them is
184
+ // this command's: a check that blocked a push for any of them would be
185
+ // turned off, and then it would catch nothing at all.
186
+ if (answer.code !== SEAL_BROKEN_EXIT)
187
+ continue;
188
+ broken.push({ promise, changedPaths: (touched.get(promiseId) ?? []).slice(0, CHANGED_SHOWN) });
189
+ }
190
+ if (broken.length === 0)
191
+ return 0;
192
+ emit({
193
+ step: "seals",
194
+ broken: broken.length,
195
+ promises: broken.slice(0, NAMED_LIMIT).map((entry) => ({
196
+ promiseId: entry.promise.promiseId,
197
+ sealedPath: entry.promise.sealedPath,
198
+ ...(entry.promise.title === undefined ? {} : { title: entry.promise.title }),
199
+ ...(entry.promise.owner === undefined ? {} : { owner: entry.promise.owner }),
200
+ ...(entry.promise.promiseUrl === undefined ? {} : { promiseUrl: entry.promise.promiseUrl }),
201
+ changedPaths: [...entry.changedPaths],
202
+ resealWith: resealCommand(promiseFile(entry.promise), entry.promise, runner),
203
+ })),
204
+ changed: false,
205
+ });
206
+ say(broken.length === 1
207
+ ? "Pushing this would break one promise's seal."
208
+ : `Pushing this would break the seals of ${broken.length} promises.`);
209
+ for (const entry of broken.slice(0, NAMED_LIMIT)) {
210
+ say("");
211
+ say(` ${entry.promise.promiseId}${entry.promise.title ? `: ${entry.promise.title}` : ""}`);
212
+ say(` Sealed files: ${entry.promise.sealedPath}`);
213
+ if (entry.promise.owner !== undefined)
214
+ say(` Agreed by: ${entry.promise.owner}`);
215
+ if (entry.promise.promiseUrl !== undefined)
216
+ say(` Read it: ${entry.promise.promiseUrl}`);
217
+ for (const path of entry.changedPaths)
218
+ say(` Changed here: ${path}`);
219
+ }
220
+ if (broken.length > NAMED_LIMIT) {
221
+ say("");
222
+ say(` and ${broken.length - NAMED_LIMIT} more, not listed here.`);
223
+ }
224
+ say("");
225
+ say("If you did not mean to touch these files, put them back and push again. If you were repairing");
226
+ say("a verifier, the repair is not finished until the promise is sealed again with the pinned runner:");
227
+ say("");
228
+ for (const entry of broken.slice(0, NAMED_LIMIT)) {
229
+ say(` ${resealCommand(promiseFile(entry.promise), entry.promise, runner)}`);
230
+ }
231
+ say("");
232
+ say("Then a named person qualifies the promise again before it protects anything.");
233
+ return 1;
234
+ }
235
+ /** The draft the reseal reads, which the scaffold wrote beside the package. */
236
+ function promiseFile(promise) {
237
+ return `.continuity/drafts/${promise.promiseId}.json`;
238
+ }
239
+ /**
240
+ * The exact line that reseals one promise.
241
+ *
242
+ * The seal command refuses to overwrite an existing package on purpose, so the
243
+ * old one is removed in the same line rather than left for the coder to find
244
+ * out about from a refusal.
245
+ */
246
+ function resealCommand(draftFile, promise, runner) {
247
+ return `rm ${promise.packageFile} && node ${runner} seal ${draftFile} ${promise.packageFile}`;
248
+ }
249
+ /**
250
+ * The optional hook, written only when somebody asks for it by name.
251
+ *
252
+ * Setup offers this and never installs it. A tool that writes into somebody's
253
+ * `.git/hooks` without being asked has taken over a file the person may already
254
+ * be using, and the first they would know of it is a push that stopped for a
255
+ * reason they cannot find.
256
+ */
257
+ function installPrePushHook(root, options, emit, say) {
258
+ const hookPath = join(root, ".git", "hooks", "pre-push");
259
+ if (existsSync(hookPath)) {
260
+ const message = `${hookPath} already exists, so nothing was written. Add this line to it yourself if you ` +
261
+ "want the check: balladeer check-seals";
262
+ emit({ step: "error", reason: "hook_exists", message, changed: false, exitCode: 4 });
263
+ say(message);
264
+ return 4;
265
+ }
266
+ const script = [
267
+ "#!/bin/sh",
268
+ "# Installed by: balladeer check-seals --install-hook",
269
+ "# It stops a push that would break a promise's seal, and says nothing otherwise.",
270
+ "# Delete this file to remove it.",
271
+ "balladeer check-seals || exit 1",
272
+ "",
273
+ ].join("\n");
274
+ try {
275
+ mkdirSync(dirname(hookPath), { recursive: true });
276
+ writeFileSync(hookPath, script, { encoding: "utf8", flag: "wx" });
277
+ chmodSync(hookPath, 0o755);
278
+ }
279
+ catch (error) {
280
+ const message = `That hook could not be written: ${error instanceof Error ? error.message : String(error)}`;
281
+ emit({ step: "error", reason: "hook_unwritable", message, changed: false, exitCode: 4 });
282
+ say(message);
283
+ return 4;
284
+ }
285
+ emit({ step: "hook", path: ".git/hooks/pre-push", changed: true });
286
+ say("Written: .git/hooks/pre-push. Every push from this checkout now stops if it would break a promise's seal.");
287
+ say("Delete that file to remove it. It is yours, and Balladeer never rewrites it.");
288
+ return 0;
289
+ }
@@ -0,0 +1,68 @@
1
+ import { type TeachBackRequest } from "./propose.js";
2
+ export type DiscoverOptions = Readonly<{
3
+ controlPlane: string;
4
+ json: boolean;
5
+ /** The catalog file: one object with a `promises` array. */
6
+ file?: string;
7
+ /** The repository whose connection to use, named as `owner/name`. */
8
+ repo?: string;
9
+ /** The repository whose connection to use, named by id, as `mcp` takes it. */
10
+ repository?: string;
11
+ /** The membership every promise is proposed as owned by, when it is not the paired one. */
12
+ owner?: string;
13
+ environment: NodeJS.ProcessEnv;
14
+ cwd: string;
15
+ write: (text: string) => void;
16
+ }>;
17
+ /**
18
+ * Ten promises at 64 KiB each, which is the per-promise cap `propose` already
19
+ * enforces. The whole file is measured before it is parsed, so a file nobody
20
+ * meant to hand this command is refused on its size rather than after a parser
21
+ * has walked it.
22
+ */
23
+ export declare const MAX_PROMISES = 10;
24
+ export type CatalogRead = Readonly<{
25
+ ok: true;
26
+ value: readonly TeachBackRequest[];
27
+ }> | Readonly<{
28
+ ok: false;
29
+ reason: string;
30
+ }>;
31
+ /**
32
+ * The shape check that happens on the developer's machine, for a whole catalog.
33
+ *
34
+ * It is `readTeachBackFile` run over every entry rather than a second, looser
35
+ * gate written beside it. That matters more here than it does for one proposal:
36
+ * this command exists because an agent has just walked a repository's tests,
37
+ * pull requests, documents and reverts, and everything it read while doing that
38
+ * is exactly what Balladeer promises never to receive. One entry that carries a
39
+ * path, a transcript, a diff, or a stray key it picked up on the way is refused
40
+ * here, before anything leaves the machine.
41
+ *
42
+ * So it returns the exact objects to send rather than a verdict on the file, and
43
+ * one bad entry refuses the whole catalog. Sending the good half of a file
44
+ * somebody has to fix anyway would leave a person a review page they cannot
45
+ * finish and a second run that duplicates what already landed.
46
+ */
47
+ export declare function readCatalogFile(raw: string): CatalogRead;
48
+ /**
49
+ * The one link that opens every promise this run filed.
50
+ *
51
+ * A person who has just been handed eight proposals should be handed one page,
52
+ * not eight. The ids travel in the query string because the batch is exactly
53
+ * these promises and nothing else: a link to the whole inbox would also open
54
+ * whatever was already waiting there, and a filter by repository would open a
55
+ * different set tomorrow.
56
+ */
57
+ export declare function batchReviewLink(controlPlane: string, candidateIds: readonly string[]): string;
58
+ /**
59
+ * Files a whole discovered catalog over this repository's own agent connection,
60
+ * every promise owned by the person who ran setup, and hands back one link.
61
+ *
62
+ * The owner is named explicitly on every proposal rather than left to a server
63
+ * default. Who owns a promise decides who may agree to it, and a catalog of ten
64
+ * promises waiting on nobody is a catalog nobody can finish; naming the person
65
+ * who ran setup is the one honest answer this command can give without asking
66
+ * anyone anything.
67
+ */
68
+ export declare function runDiscover(options: DiscoverOptions): Promise<number>;