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
@@ -0,0 +1,251 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { runCommand } from "../gh.js";
5
+ import { repositoryRoot } from "../git.js";
6
+ import { CONTINUITY_DIRECTORY, TOUCH_MAP_FILE, buildTouchMap, executedPaths, mappablePath, measurableWithNode, readCoverageDirectory, readLocalPackages, repositoryPath, serializeTouchMap, touchMapBody, touchMapEnvironment, verifierCwd, verifierDigest, } from "../touch-map.js";
7
+ import {} from "../wire.js";
8
+ /** A verifier gets this long before the measurement gives up on it. */
9
+ const DEFAULT_TIMEOUT_MS = 120_000;
10
+ /** However long a package asks for, no verifier holds this command longer. */
11
+ const MAXIMUM_TIMEOUT_MS = 300_000;
12
+ /** How many promises a plain-text summary names before it says how many more. */
13
+ const NAMED_LIMIT = 20;
14
+ /**
15
+ * Where the promises are, which is not always the git root.
16
+ *
17
+ * A repository keeps its promises at its top level, so the git root is the
18
+ * answer almost every time. It is not the answer when somebody is standing in a
19
+ * sub-tree that carries its own `.continuity/` directory, which is how this
20
+ * repository's own dogfood packages are laid out, and running there and mapping
21
+ * the outer tree instead would answer a question nobody asked.
22
+ */
23
+ export async function promiseRoot(cwd) {
24
+ const chosen = existsSync(join(cwd, CONTINUITY_DIRECTORY)) ? cwd : await repositoryRoot(cwd);
25
+ if (chosen === undefined)
26
+ return undefined;
27
+ // Resolved through its symlinks, because V8 reports the real path of every
28
+ // script it recorded. A checkout reached through a link would otherwise
29
+ // measure correctly and attribute nothing, which reads as a repository where
30
+ // no promise touches anything.
31
+ try {
32
+ return realpathSync(chosen);
33
+ }
34
+ catch {
35
+ return chosen;
36
+ }
37
+ }
38
+ /**
39
+ * Measure one promise: run its verifier against this working tree, under
40
+ * coverage, and keep the list of repository files it executed.
41
+ *
42
+ * It runs the verifier's own declared entry point rather than the pinned
43
+ * runner. The runner hands its child a closed environment on purpose, and
44
+ * `NODE_V8_COVERAGE` is not in it, so a measurement taken through the runner
45
+ * would come back empty every time. The invocation is the package's own, so
46
+ * what runs here is what CI runs; only the environment differs, by the one
47
+ * variable that turns the recorder on.
48
+ */
49
+ async function measure(root, pkg, environment) {
50
+ if (pkg.target.executable === "" || pkg.target.args.length === 0) {
51
+ return {
52
+ promiseId: pkg.promiseId,
53
+ reason: "no_verifier_target",
54
+ detail: `${pkg.promiseId} declares no runnable verifier target in ${pkg.packageFile}, so nothing could be measured.`,
55
+ };
56
+ }
57
+ if (!measurableWithNode(pkg.target)) {
58
+ return {
59
+ promiseId: pkg.promiseId,
60
+ reason: "unsupported_executable",
61
+ detail: `${pkg.promiseId} runs ${pkg.target.executable}, and this release measures Node verifiers ` +
62
+ "only. Other languages come later; until then its path markers answer for it.",
63
+ };
64
+ }
65
+ const cwd = verifierCwd(root, pkg.target);
66
+ if (cwd === undefined) {
67
+ return {
68
+ promiseId: pkg.promiseId,
69
+ reason: "cwd_outside_repository",
70
+ detail: `${pkg.promiseId} runs its verifier outside this repository, so it was not measured.`,
71
+ };
72
+ }
73
+ const scratch = mkdtempSync(join(tmpdir(), "balladeer-touch-map-"));
74
+ try {
75
+ const coverage = join(scratch, "coverage");
76
+ mkdirSync(coverage, { recursive: true });
77
+ const budget = Math.min(pkg.target.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAXIMUM_TIMEOUT_MS);
78
+ const started = Date.now();
79
+ const answer = await runCommand(process.execPath, [...pkg.target.args], {
80
+ cwd,
81
+ timeoutMs: budget,
82
+ // Cast, not a widening: this is a closed allow-list by construction, and
83
+ // the ambient environment type insists on names a verifier must not get.
84
+ env: touchMapEnvironment(environment, coverage, join(scratch, "result.json")),
85
+ });
86
+ // A verifier that outlived the budget was stopped by this command, and
87
+ // saying "could not be run (exit 1)" would send somebody hunting for a crash
88
+ // that never happened. The elapsed time is what tells the two apart, because
89
+ // a killed process reports an ordinary non-zero exit.
90
+ const outOfTime = !answer.ok && Date.now() - started >= budget;
91
+ const executed = new Set();
92
+ for (const document of readCoverageDirectory(coverage))
93
+ for (const path of executedPaths(root, document))
94
+ executed.add(path);
95
+ // Whether the verifier ran is answered by whether V8 recorded the verifier
96
+ // itself, not by its exit code. A verifier that reports a refutation exits
97
+ // non-zero and has still told us exactly which files it read; one that died
98
+ // on a missing import exits non-zero and has told us nothing, and the two
99
+ // must not be reported as the same thing.
100
+ if (!ranAtAll(root, cwd, pkg, executed)) {
101
+ return {
102
+ promiseId: pkg.promiseId,
103
+ reason: outOfTime ? "timed_out" : answer.ok ? "no_coverage" : "did_not_run",
104
+ detail: outOfTime
105
+ ? `${pkg.promiseId} was still running after ${Math.round(budget / 1000)} seconds and was stopped, so it is not in the map.`
106
+ : answer.ok
107
+ ? `${pkg.promiseId} ran and left no coverage of its own verifier behind, so nothing could be attributed to it.`
108
+ : `${pkg.promiseId} could not be run here (exit ${answer.code ?? "none"}), so it is not in the map.`,
109
+ };
110
+ }
111
+ return {
112
+ promiseId: pkg.promiseId,
113
+ verifierDigest: verifierDigest(root, pkg),
114
+ // A promise whose verifier reads only its own sealed tree maps no files,
115
+ // and that is a true answer rather than a failure: nothing else in the
116
+ // repository is what it checks.
117
+ paths: [...executed].filter(mappablePath).sort(),
118
+ };
119
+ }
120
+ finally {
121
+ rmSync(scratch, { recursive: true, force: true });
122
+ }
123
+ }
124
+ function isCoverage(value) {
125
+ return "paths" in value;
126
+ }
127
+ /**
128
+ * Whether the verifier itself is among the scripts V8 recorded.
129
+ *
130
+ * The package names the program to run, so the honest test of "did it run" is
131
+ * whether that program appears in the coverage. Where the invocation's first
132
+ * argument is not a file in this repository, which no package the scaffold
133
+ * writes produces, any recorded repository file stands in for it rather than
134
+ * refusing to answer.
135
+ */
136
+ function ranAtAll(root, cwd, pkg, executed) {
137
+ const entry = pkg.target.args[0];
138
+ const path = entry === undefined ? undefined : repositoryPath(root, resolve(cwd, entry));
139
+ if (path === undefined)
140
+ return executed.size > 0;
141
+ return executed.has(path);
142
+ }
143
+ /**
144
+ * The command that turns "which files does this verifier read" from a guess
145
+ * into a measurement.
146
+ *
147
+ * It runs every sealed promise in this checkout under V8's coverage recorder
148
+ * and writes what each one executed to `.continuity/touch-map.json`. The file
149
+ * stays on the customer's disk. Nothing here posts it, and the map is a list of
150
+ * a customer's own file names, which is exactly the thing the boundary copy
151
+ * promises Balladeer never receives.
152
+ */
153
+ export async function runTouchMap(options) {
154
+ const emit = (step) => {
155
+ if (options.json)
156
+ options.write(`${JSON.stringify(step)}\n`);
157
+ };
158
+ const say = (text) => {
159
+ if (!options.json)
160
+ options.write(`${text}\n`);
161
+ };
162
+ const root = await promiseRoot(options.cwd);
163
+ if (root === undefined) {
164
+ const message = "This is not a git repository and there are no promise packages here, so there is nothing to map. Run it from inside your checkout.";
165
+ if (options.json)
166
+ emit({ step: "error", reason: "not_a_repository", message, changed: false, exitCode: 4 });
167
+ else
168
+ options.write(`${message}\n`);
169
+ return 4;
170
+ }
171
+ const packages = readLocalPackages(root);
172
+ if (packages.length === 0) {
173
+ const message = "This checkout holds no sealed promise packages, so there is nothing to map yet. A promise gets one when somebody seals its verifier.";
174
+ emit({ step: "touch_map", promises: 0, files: 0, unmapped: 0, bytes: 0, changed: false });
175
+ say(message);
176
+ return 0;
177
+ }
178
+ const covered = [];
179
+ const unmapped = [];
180
+ for (const pkg of packages) {
181
+ const answer = await measure(root, pkg, options.environment);
182
+ if (isCoverage(answer))
183
+ covered.push(answer);
184
+ else
185
+ unmapped.push(answer);
186
+ }
187
+ const now = options.now?.() ?? new Date();
188
+ const map = buildTouchMap({ generatedAt: now.toISOString(), covered, unmapped });
189
+ const written = writeTouchMap(root, map);
190
+ emit({
191
+ step: "touch_map",
192
+ promises: map.promises.length,
193
+ files: map.files.length,
194
+ unmapped: map.unmapped.length,
195
+ bytes: written.bytes,
196
+ changed: written.changed,
197
+ ...(map.truncated ? { truncated: true } : {}),
198
+ });
199
+ say(`Mapped ${map.promises.length} ${map.promises.length === 1 ? "promise" : "promises"} across ` +
200
+ `${map.files.length} ${map.files.length === 1 ? "file" : "files"}, in ${TOUCH_MAP_FILE} ` +
201
+ `(${written.bytes} bytes).`);
202
+ say("This file stays here. Balladeer is never sent it.");
203
+ if (map.truncated) {
204
+ say("It hit this release's size cap, so it is a partial map. Treat a file it does not name as unanswered rather than as untouched.");
205
+ }
206
+ if (map.unmapped.length > 0) {
207
+ say("");
208
+ say(map.unmapped.length === 1
209
+ ? "One promise could not be measured, so nothing is mapped to it:"
210
+ : `${map.unmapped.length} promises could not be measured, so nothing is mapped to them:`);
211
+ for (const entry of map.unmapped.slice(0, NAMED_LIMIT))
212
+ say(` ${entry.detail}`);
213
+ if (map.unmapped.length > NAMED_LIMIT)
214
+ say(` and ${map.unmapped.length - NAMED_LIMIT} more, not listed here.`);
215
+ }
216
+ say("");
217
+ say("Ask it which promises a change touches with: balladeer affected <paths...>");
218
+ return 0;
219
+ }
220
+ /**
221
+ * Write the map, and leave the file alone when nothing about it changed.
222
+ *
223
+ * The timestamp is the only field that moves on every run, so comparing the
224
+ * document without it is what makes a second run a no-op. A command that
225
+ * rewrote a byte of a committed file every time it was asked a question would
226
+ * put a diff in front of somebody who changed nothing.
227
+ */
228
+ export function writeTouchMap(root, map) {
229
+ const path = join(root, TOUCH_MAP_FILE);
230
+ const serialized = serializeTouchMap(map);
231
+ let existing;
232
+ try {
233
+ existing = readFileSync(path, "utf8");
234
+ }
235
+ catch {
236
+ existing = undefined;
237
+ }
238
+ if (existing !== undefined) {
239
+ try {
240
+ const before = JSON.parse(existing);
241
+ if (touchMapBody(before) === touchMapBody(map))
242
+ return { changed: false, bytes: Buffer.byteLength(existing, "utf8") };
243
+ }
244
+ catch {
245
+ // An unreadable map is replaced rather than kept.
246
+ }
247
+ }
248
+ mkdirSync(dirname(path), { recursive: true });
249
+ writeFileSync(path, serialized, "utf8");
250
+ return { changed: true, bytes: Buffer.byteLength(serialized, "utf8") };
251
+ }
@@ -0,0 +1,8 @@
1
+ export type WhoamiOptions = Readonly<{
2
+ controlPlane: string;
3
+ json: boolean;
4
+ environment: NodeJS.ProcessEnv;
5
+ write: (text: string) => void;
6
+ }>;
7
+ /** Reads the session's standing from the server, never from the stored copy. */
8
+ export declare function runWhoami(options: WhoamiOptions): Promise<number>;
@@ -0,0 +1,79 @@
1
+ import { ClientTooOldError, RefusalError, TransportError, request } from "../client.js";
2
+ import { StoreError, dropSession, findSession, readCredentials, writeCredentials, } from "../store.js";
3
+ import { commandLine } from "../release.js";
4
+ import {} from "../wire.js";
5
+ function emit(options, step) {
6
+ if (options.json)
7
+ options.write(`${JSON.stringify(step)}\n`);
8
+ }
9
+ /**
10
+ * The same sentence a person reads, in the shape `--json` promised. Without it,
11
+ * the machine-readable mode answers a failure with prose on the stream it said
12
+ * would carry objects only.
13
+ */
14
+ function fail(options, reason, message, exitCode, changed = false) {
15
+ if (options.json)
16
+ emit(options, { step: "error", reason, message, changed, exitCode });
17
+ else
18
+ options.write(`${message}\n`);
19
+ return exitCode;
20
+ }
21
+ function noSession(options, changed = false) {
22
+ return fail(options, "no_stored_session", `No Balladeer session is stored for ${options.controlPlane}. Run: ${commandLine(null, "setup")}`, 4, changed);
23
+ }
24
+ /** Reads the session's standing from the server, never from the stored copy. */
25
+ export async function runWhoami(options) {
26
+ let credentials;
27
+ try {
28
+ credentials = readCredentials(options.environment);
29
+ }
30
+ catch (error) {
31
+ return fail(options, error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
32
+ }
33
+ const stored = findSession(credentials, options.controlPlane);
34
+ if (!stored)
35
+ return noSession(options);
36
+ try {
37
+ const answer = await request(options.controlPlane, {
38
+ method: "GET",
39
+ path: "/api/pair/session",
40
+ bearer: stored.token,
41
+ });
42
+ const session = answer.session;
43
+ if (options.json) {
44
+ emit(options, { step: "whoami", session });
45
+ }
46
+ else {
47
+ options.write([
48
+ `Signed in as ${session.membershipDisplayName}, workspace "${session.workspaceName}" (${session.workspaceSlug}).`,
49
+ `Role: ${session.role}.`,
50
+ `This session may: ${session.scopeMeanings.join(", ")}.`,
51
+ `It expires at ${session.expiresAt}.`,
52
+ "",
53
+ ].join("\n"));
54
+ }
55
+ return 0;
56
+ }
57
+ catch (error) {
58
+ if (error instanceof ClientTooOldError) {
59
+ return fail(options, "client_too_old", error.message, 3);
60
+ }
61
+ if (error instanceof TransportError) {
62
+ return fail(options, "control_plane_unreachable", `Balladeer could not be reached at ${options.controlPlane}: ${error.message}. Nothing was changed.`, 5);
63
+ }
64
+ if (error instanceof RefusalError) {
65
+ // Expired, revoked, or the membership behind it is gone. The stored copy
66
+ // is now a lie, so it is deleted rather than left to mislead the next run.
67
+ let changed = false;
68
+ try {
69
+ writeCredentials(dropSession(credentials, options.controlPlane), options.environment);
70
+ changed = true;
71
+ }
72
+ catch {
73
+ // Reporting the truth matters more than tidying the file.
74
+ }
75
+ return noSession(options, changed);
76
+ }
77
+ return fail(options, "session_unreadable", "Balladeer could not read this session. Nothing was changed.", 5);
78
+ }
79
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The fence, which never changes again.
3
+ *
4
+ * The first version of this file spelled the marker
5
+ * `<!-- balladeer:conventions:start v1 -->`, so the version travelled in the one
6
+ * string the lookup matches on. Bumping the block therefore bumped the marker,
7
+ * the lookup for the old marker missed, and the next run appended a second block
8
+ * below the stale one instead of replacing it: the customer's committed file
9
+ * grew a copy per release, each of them contradicting the last.
10
+ *
11
+ * The version now lives in a managed-by line inside the fence, where a refresh
12
+ * can read it and the lookup never depends on it. The marker a version 1 install
13
+ * wrote is still recognised, so a repository onboarded before this change is
14
+ * migrated in place rather than given a second block of its own.
15
+ */
16
+ export declare const CONVENTIONS_START = "<!-- balladeer:conventions:start -->";
17
+ export declare const CONVENTIONS_END = "<!-- balladeer:conventions:end -->";
18
+ /**
19
+ * The version of the text between the markers. It is written into the block and
20
+ * read back out of it, and nothing about finding the block depends on it.
21
+ */
22
+ export declare const CONVENTIONS_VERSION = 10;
23
+ export declare function managedByLine(version: number): string;
24
+ /**
25
+ * Which version of the block a file already carries, or nothing when it carries
26
+ * no Balladeer block at all.
27
+ *
28
+ * A version 1 install has no managed-by line, so its version is read from the
29
+ * marker it was written with. A block with neither is one somebody hand-copied,
30
+ * and treating it as version 0 refreshes it rather than leaving it to rot.
31
+ */
32
+ export declare function installedConventionsVersion(contents: string): number | undefined;
33
+ export type ConventionsResult = Readonly<{
34
+ kind: "written";
35
+ file: string;
36
+ changed: boolean;
37
+ version: number;
38
+ /** What the file carried before this run, when it carried a block. */
39
+ previousVersion?: number;
40
+ }> | Readonly<{
41
+ kind: "refused";
42
+ file: string;
43
+ reason: string;
44
+ }>;
45
+ /**
46
+ * Writes the marker-fenced conventions block into the repository's agent
47
+ * instructions file, so a future session reads the team's promises before
48
+ * planning without being told to.
49
+ *
50
+ * The markers are the whole discipline. Everything outside them is somebody
51
+ * else's file and is copied through byte for byte; everything between them is
52
+ * replaced. Running the command twice therefore leaves the file identical, which
53
+ * is what makes it safe to run on every setup.
54
+ *
55
+ * A file whose markers are damaged is refused rather than appended to. One start
56
+ * with no end, an end with no start, a pair in the wrong order, or two of either
57
+ * are all states this command cannot repair without guessing where somebody's
58
+ * own writing begins, and guessing wrong means either deleting their text or
59
+ * leaving a second block behind. The person is told which file and what is wrong
60
+ * with it.
61
+ *
62
+ * Project-scoped and committed by the developer, never a file in their home
63
+ * directory: the promises belong to this repository, and a teammate who clones
64
+ * it should get the same instructions.
65
+ */
66
+ export declare function writeConventions(repositoryRoot: string, options?: Readonly<{
67
+ version?: number;
68
+ body?: string;
69
+ }>): ConventionsResult;
@@ -0,0 +1,175 @@
1
+ import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { dirname, join } from "node:path";
4
+ import { CONVENTIONS_BLOCK } from "./copy.js";
5
+ /**
6
+ * The fence, which never changes again.
7
+ *
8
+ * The first version of this file spelled the marker
9
+ * `<!-- balladeer:conventions:start v1 -->`, so the version travelled in the one
10
+ * string the lookup matches on. Bumping the block therefore bumped the marker,
11
+ * the lookup for the old marker missed, and the next run appended a second block
12
+ * below the stale one instead of replacing it: the customer's committed file
13
+ * grew a copy per release, each of them contradicting the last.
14
+ *
15
+ * The version now lives in a managed-by line inside the fence, where a refresh
16
+ * can read it and the lookup never depends on it. The marker a version 1 install
17
+ * wrote is still recognised, so a repository onboarded before this change is
18
+ * migrated in place rather than given a second block of its own.
19
+ */
20
+ export const CONVENTIONS_START = "<!-- balladeer:conventions:start -->";
21
+ export const CONVENTIONS_END = "<!-- balladeer:conventions:end -->";
22
+ /**
23
+ * The version of the text between the markers. It is written into the block and
24
+ * read back out of it, and nothing about finding the block depends on it.
25
+ */
26
+ export const CONVENTIONS_VERSION = 10;
27
+ /** Both spellings: the stable marker, and the versioned one version 1 wrote. */
28
+ const START_MARKER = /<!-- balladeer:conventions:start(?: v(\d{1,4}))? -->/g;
29
+ const END_MARKER = /<!-- balladeer:conventions:end -->/g;
30
+ const MANAGED_BY = /Managed by Balladeer \(conventions v(\d{1,4})\)/;
31
+ const CANDIDATE_FILES = ["CLAUDE.md", "AGENTS.md"];
32
+ export function managedByLine(version) {
33
+ return (`Managed by Balladeer (conventions v${version}). Everything between the markers is replaced ` +
34
+ `whenever setup runs in this repository, so put your own notes outside them.`);
35
+ }
36
+ function fenced(version, body) {
37
+ return `${CONVENTIONS_START}\n${managedByLine(version)}\n\n${body.trim()}\n${CONVENTIONS_END}\n`;
38
+ }
39
+ /**
40
+ * Which version of the block a file already carries, or nothing when it carries
41
+ * no Balladeer block at all.
42
+ *
43
+ * A version 1 install has no managed-by line, so its version is read from the
44
+ * marker it was written with. A block with neither is one somebody hand-copied,
45
+ * and treating it as version 0 refreshes it rather than leaving it to rot.
46
+ */
47
+ export function installedConventionsVersion(contents) {
48
+ const start = [...contents.matchAll(START_MARKER)][0];
49
+ if (start === undefined)
50
+ return undefined;
51
+ const managed = MANAGED_BY.exec(contents.slice(start.index));
52
+ if (managed?.[1] !== undefined)
53
+ return Number(managed[1]);
54
+ return start[1] === undefined ? 0 : Number(start[1]);
55
+ }
56
+ function writeAtomically(path, contents) {
57
+ const temporary = join(dirname(path), `.conventions.${randomBytes(8).toString("hex")}.tmp`);
58
+ let descriptor;
59
+ try {
60
+ descriptor = openSync(temporary, "wx", 0o644);
61
+ writeSync(descriptor, contents);
62
+ fsyncSync(descriptor);
63
+ closeSync(descriptor);
64
+ descriptor = undefined;
65
+ renameSync(temporary, path);
66
+ }
67
+ catch (error) {
68
+ if (descriptor !== undefined) {
69
+ try {
70
+ closeSync(descriptor);
71
+ }
72
+ catch {
73
+ // The unlink below is the cleanup that matters.
74
+ }
75
+ }
76
+ try {
77
+ unlinkSync(temporary);
78
+ }
79
+ catch {
80
+ // The temporary file may never have been created.
81
+ }
82
+ throw error;
83
+ }
84
+ }
85
+ /**
86
+ * Writes the marker-fenced conventions block into the repository's agent
87
+ * instructions file, so a future session reads the team's promises before
88
+ * planning without being told to.
89
+ *
90
+ * The markers are the whole discipline. Everything outside them is somebody
91
+ * else's file and is copied through byte for byte; everything between them is
92
+ * replaced. Running the command twice therefore leaves the file identical, which
93
+ * is what makes it safe to run on every setup.
94
+ *
95
+ * A file whose markers are damaged is refused rather than appended to. One start
96
+ * with no end, an end with no start, a pair in the wrong order, or two of either
97
+ * are all states this command cannot repair without guessing where somebody's
98
+ * own writing begins, and guessing wrong means either deleting their text or
99
+ * leaving a second block behind. The person is told which file and what is wrong
100
+ * with it.
101
+ *
102
+ * Project-scoped and committed by the developer, never a file in their home
103
+ * directory: the promises belong to this repository, and a teammate who clones
104
+ * it should get the same instructions.
105
+ */
106
+ export function writeConventions(repositoryRoot, options = {}) {
107
+ const version = options.version ?? CONVENTIONS_VERSION;
108
+ const existingName = CANDIDATE_FILES.find((name) => existsSync(join(repositoryRoot, name)));
109
+ const name = existingName ?? "CLAUDE.md";
110
+ const path = join(repositoryRoot, name);
111
+ const block = fenced(version, options.body ?? CONVENTIONS_BLOCK);
112
+ let existing = "";
113
+ try {
114
+ existing = readFileSync(path, "utf8");
115
+ }
116
+ catch {
117
+ writeAtomically(path, block);
118
+ return { kind: "written", file: name, changed: true, version };
119
+ }
120
+ const starts = [...existing.matchAll(START_MARKER)];
121
+ const ends = [...existing.matchAll(END_MARKER)];
122
+ const damaged = markerDamage(starts.length, ends.length, starts[0]?.index, ends[0]?.index);
123
+ if (damaged !== undefined) {
124
+ return {
125
+ kind: "refused",
126
+ file: name,
127
+ reason: `${name} ${damaged}, so I could not tell where Balladeer's block ends and yours begins; I did not change it.`,
128
+ };
129
+ }
130
+ const start = starts[0];
131
+ const end = ends[0];
132
+ let next;
133
+ let previousVersion;
134
+ if (start !== undefined && end !== undefined) {
135
+ previousVersion = installedConventionsVersion(existing);
136
+ const after = end.index + CONVENTIONS_END.length;
137
+ next = `${existing.slice(0, start.index)}${block}${existing.slice(existing[after] === "\n" ? after + 1 : after)}`;
138
+ }
139
+ else {
140
+ const separator = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
141
+ next = `${existing}${separator}${block}`;
142
+ }
143
+ if (next === existing) {
144
+ return {
145
+ kind: "written",
146
+ file: name,
147
+ changed: false,
148
+ version,
149
+ ...(previousVersion === undefined ? {} : { previousVersion }),
150
+ };
151
+ }
152
+ writeAtomically(path, next);
153
+ return {
154
+ kind: "written",
155
+ file: name,
156
+ changed: true,
157
+ version,
158
+ ...(previousVersion === undefined ? {} : { previousVersion }),
159
+ };
160
+ }
161
+ /** What is wrong with the markers, said the way a person would fix it. */
162
+ function markerDamage(startCount, endCount, startAt, endAt) {
163
+ if (startCount > 1 || endCount > 1)
164
+ return "carries more than one Balladeer conventions block";
165
+ if (startCount === 1 && endCount === 0) {
166
+ return "has a Balladeer conventions start marker with no end marker";
167
+ }
168
+ if (startCount === 0 && endCount === 1) {
169
+ return "has a Balladeer conventions end marker with no start marker";
170
+ }
171
+ if (startAt !== undefined && endAt !== undefined && endAt < startAt) {
172
+ return "has its Balladeer conventions markers in the wrong order";
173
+ }
174
+ return undefined;
175
+ }