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/gh.js ADDED
@@ -0,0 +1,188 @@
1
+ import { execFile } from "node:child_process";
2
+ /**
3
+ * The person's own GitHub access, used through the tool they already signed in
4
+ * to. Balladeer holds no GitHub token and this command never sends one anywhere.
5
+ *
6
+ * Every call goes through `execFile` with an argument array, never a shell, so
7
+ * nothing here can be turned into a command by a repository name. Standard error
8
+ * is captured rather than inherited, because a `gh` failure has to become one
9
+ * plain sentence with a next action rather than provider noise in the middle of
10
+ * a step.
11
+ */
12
+ const TIMEOUT_MS = 20_000;
13
+ export function runCommand(file, args, options = {}) {
14
+ return new Promise((done) => {
15
+ execFile(file, [...args], {
16
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
17
+ ...(options.env === undefined ? {} : { env: { ...options.env } }),
18
+ timeout: options.timeoutMs ?? TIMEOUT_MS,
19
+ maxBuffer: 4 * 1024 * 1024,
20
+ windowsHide: true,
21
+ }, (error, stdout, stderr) => {
22
+ const code = error === null ? 0 : typeof error.code === "number" ? error.code : 1;
23
+ done({
24
+ ok: error === null,
25
+ code,
26
+ stdout: String(stdout),
27
+ // Bounded: a refusal is a sentence, never a page of output.
28
+ stderr: String(stderr).trim().slice(0, 500),
29
+ });
30
+ });
31
+ });
32
+ }
33
+ function readFacts(raw, authenticated) {
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(raw);
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ const id = typeof parsed.id === "number" ? String(parsed.id) : undefined;
42
+ const ownerId = typeof parsed.owner?.id === "number" ? String(parsed.owner.id) : undefined;
43
+ const branch = typeof parsed.default_branch === "string" ? parsed.default_branch : undefined;
44
+ if (!id || !ownerId || !branch)
45
+ return undefined;
46
+ // GitHub returns `permissions` only for an authenticated request, so its
47
+ // absence is exactly the case where nothing was proved about the caller.
48
+ const canWrite = parsed.permissions?.admin === true || parsed.permissions?.push === true;
49
+ return {
50
+ githubRepositoryId: id,
51
+ githubOwnerId: ownerId,
52
+ defaultBranch: branch,
53
+ canWrite,
54
+ authenticated,
55
+ };
56
+ }
57
+ export async function ghAvailable() {
58
+ return (await runCommand("gh", ["--version"])).ok;
59
+ }
60
+ export async function ghSignedIn() {
61
+ return (await runCommand("gh", ["auth", "status"])).ok;
62
+ }
63
+ /**
64
+ * Reads a repository's immutable ids, its default branch, and what the person
65
+ * may do with it.
66
+ *
67
+ * The unauthenticated read is deliberately NOT a fallback for enrolling. It
68
+ * proves nothing about who is asking, so a workspace could otherwise end up
69
+ * holding an enrollment, a CI identity, and eventually a protection claim about
70
+ * a repository nobody in it controls. It exists only so the command can render
71
+ * the do-it-by-hand block with real values.
72
+ */
73
+ export async function readRepositoryFacts(repository) {
74
+ if (await ghAvailable()) {
75
+ if (!(await ghSignedIn())) {
76
+ return {
77
+ kind: "unavailable",
78
+ reason: "gh is installed but not signed in",
79
+ next: "Run `gh auth login`, then run this command again.",
80
+ };
81
+ }
82
+ const answer = await runCommand("gh", ["api", `repos/${repository}`]);
83
+ if (answer.ok) {
84
+ const facts = readFacts(answer.stdout, true);
85
+ if (facts)
86
+ return { kind: "found", facts };
87
+ return {
88
+ kind: "unavailable",
89
+ reason: "GitHub's answer did not carry the repository's ids",
90
+ next: "Check that the name is right and that GitHub is reachable, then run this command again.",
91
+ };
92
+ }
93
+ return {
94
+ kind: "unavailable",
95
+ reason: answer.stderr || `gh could not read repos/${repository}`,
96
+ next: `Check that ${repository} exists and that your GitHub account can see it, then run this command again.`,
97
+ };
98
+ }
99
+ return {
100
+ kind: "unavailable",
101
+ reason: "the GitHub CLI is not installed",
102
+ next: "Install the GitHub CLI from https://cli.github.com, sign in with `gh auth login`, then run this command again.",
103
+ };
104
+ }
105
+ /**
106
+ * The public read is a display convenience with no next action of its own: the
107
+ * caller is already printing the by-hand block, and this only fills its values.
108
+ */
109
+ const PUBLIC_NEXT = "Add the repository by hand instead.";
110
+ /** The public read, for rendering the fallback block only. */
111
+ export async function readPublicRepositoryFacts(repository) {
112
+ let response;
113
+ try {
114
+ response = await fetch(`https://api.github.com/repos/${repository}`, {
115
+ headers: { accept: "application/vnd.github+json" },
116
+ redirect: "manual",
117
+ signal: AbortSignal.timeout(TIMEOUT_MS),
118
+ });
119
+ }
120
+ catch {
121
+ return { kind: "unavailable", reason: "GitHub could not be reached", next: PUBLIC_NEXT };
122
+ }
123
+ if (!response.ok) {
124
+ return {
125
+ kind: "unavailable",
126
+ reason: `GitHub answered ${response.status}`,
127
+ next: PUBLIC_NEXT,
128
+ };
129
+ }
130
+ const facts = readFacts(await response.text(), false);
131
+ return facts === undefined
132
+ ? {
133
+ kind: "unavailable",
134
+ reason: "GitHub's answer did not carry the repository's ids",
135
+ next: PUBLIC_NEXT,
136
+ }
137
+ : { kind: "found", facts };
138
+ }
139
+ const WRITE_PERMISSIONS = new Set(["ADMIN", "MAINTAIN", "WRITE"]);
140
+ function readRepositoryList(raw) {
141
+ let parsed;
142
+ try {
143
+ parsed = JSON.parse(raw);
144
+ }
145
+ catch {
146
+ return [];
147
+ }
148
+ if (!Array.isArray(parsed))
149
+ return [];
150
+ const rows = [];
151
+ for (const entry of parsed) {
152
+ const name = typeof entry?.nameWithOwner === "string" ? entry.nameWithOwner : undefined;
153
+ if (name === undefined || entry.isArchived === true)
154
+ continue;
155
+ const permission = typeof entry.viewerPermission === "string" ? entry.viewerPermission.toUpperCase() : "";
156
+ rows.push({ repository: name, canWrite: WRITE_PERMISSIONS.has(permission) });
157
+ }
158
+ return rows;
159
+ }
160
+ /**
161
+ * The repositories one GitHub account or organization holds, newest first.
162
+ *
163
+ * `owner` empty means the signed-in account's own. A refusal is an empty list
164
+ * rather than an error: this is a convenience for offering a choice, and a
165
+ * person who cannot list an organization can still name a repository by hand.
166
+ */
167
+ export async function listVisibleRepositories(owner, limit = 100) {
168
+ const answer = await runCommand("gh", [
169
+ "repo",
170
+ "list",
171
+ ...(owner === undefined ? [] : [owner]),
172
+ "--limit",
173
+ String(limit),
174
+ "--no-archived",
175
+ "--json",
176
+ "nameWithOwner,isArchived,viewerPermission",
177
+ ]);
178
+ return answer.ok ? readRepositoryList(answer.stdout) : [];
179
+ }
180
+ /** The signed-in GitHub account's login, or nothing. */
181
+ export async function ghLogin() {
182
+ const answer = await runCommand("gh", ["api", "user", "--jq", ".login"]);
183
+ const login = answer.stdout.trim();
184
+ return answer.ok && /^[A-Za-z0-9-]{1,39}$/.test(login) ? login : undefined;
185
+ }
186
+ export async function setRepositoryVariable(repository, name, value) {
187
+ return runCommand("gh", ["variable", "set", name, "--repo", repository, "--body", value]);
188
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ export declare const CI_BRANCH = "balladeer/connect-ci";
2
+ export declare function repositoryRoot(cwd: string): Promise<string | undefined>;
3
+ /**
4
+ * A repository in the middle of a merge or a rebase is not a repository to open
5
+ * a pull request from. The person is mid-operation, and anything this command
6
+ * does to it will land in the middle of that work.
7
+ */
8
+ export declare function midOperation(root: string): Promise<boolean>;
9
+ export type BranchResult = Readonly<{
10
+ kind: "opened";
11
+ pullRequestUrl: string | undefined;
12
+ }>
13
+ /**
14
+ * The branch was already open with a pull request on it, and now carries the
15
+ * new bytes. This is what a release move looks like where the first pull
16
+ * request has not merged yet: one pull request that moves, rather than a
17
+ * second one competing to change the same file.
18
+ */
19
+ | Readonly<{
20
+ kind: "updated";
21
+ pullRequestUrl: string | undefined;
22
+ }>
23
+ /** The branch already carries these exact bytes, so there was nothing to commit. */
24
+ | Readonly<{
25
+ kind: "unchanged";
26
+ }> | Readonly<{
27
+ kind: "refused";
28
+ reason: string;
29
+ }>;
30
+ /** The one-line subject of the commit and the pull request this branch carries. */
31
+ export declare const CI_COMMIT_SUBJECT = "Connect Balladeer CI";
32
+ /**
33
+ * The workflow as the default branch currently has it, fetched fresh.
34
+ *
35
+ * This is what tells a re-run that the pull request was merged. GitHub deletes
36
+ * the head branch on merge in most repositories, so looking for that branch
37
+ * answers "no" for a repository where the work is finished, and the command
38
+ * would go on to cut the branch again, write identical bytes, and be told by git
39
+ * that there is nothing to commit. A person would then read an instruction to
40
+ * commit a file that is already on their default branch.
41
+ */
42
+ export declare function workflowOnDefaultBranch(root: string, defaultBranch: string, workflowPath: string): Promise<string | undefined>;
43
+ /**
44
+ * Commits ONE file on a branch cut from the freshly fetched default branch, in a
45
+ * throwaway worktree.
46
+ *
47
+ * Three properties, each the answer to a way this could have gone wrong:
48
+ *
49
+ * - The branch is cut from `origin/<default branch>` as just fetched, never from
50
+ * whatever HEAD happens to be. A developer sitting on a feature branch would
51
+ * otherwise open a pull request carrying every local commit they had not
52
+ * pushed, including anything they had not meant to. Where the branch is
53
+ * already open on the remote it is BUILT ON instead, so a second commit lands
54
+ * on the pull request the reviewer is already looking at. Re-cutting it from
55
+ * the default branch would rewrite a pushed branch, which the remote refuses,
56
+ * and forcing past that refusal would discard whatever the reviewer had asked
57
+ * for on it.
58
+ * - It is a separate worktree, so the developer's own checkout is never switched,
59
+ * never stashed, and never left on a branch they did not ask for. Their
60
+ * uncommitted work stays exactly where it was.
61
+ * - The commit names its path explicitly. The `.mcp.json` and instructions
62
+ * changes from the previous step stay in the developer's working tree for them
63
+ * to commit deliberately: a pull request titled "Connect Balladeer CI" must
64
+ * contain the CI change and nothing else, because its reviewer is approving
65
+ * what they can see.
66
+ */
67
+ export declare function commitWorkflowOnBranch(input: {
68
+ root: string;
69
+ defaultBranch: string;
70
+ workflowPath: string;
71
+ workflowYaml: string;
72
+ repository: string;
73
+ bodyPath: string;
74
+ /** What the commit and any new pull request are called. */
75
+ subject?: string;
76
+ }): Promise<BranchResult>;
package/dist/git.js ADDED
@@ -0,0 +1,203 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { runCommand } from "./gh.js";
6
+ export const CI_BRANCH = "balladeer/connect-ci";
7
+ export async function repositoryRoot(cwd) {
8
+ const answer = await runCommand("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
9
+ return answer.ok ? answer.stdout.trim() : undefined;
10
+ }
11
+ async function gitDirectory(root) {
12
+ const answer = await runCommand("git", ["-C", root, "rev-parse", "--absolute-git-dir"]);
13
+ return answer.ok ? answer.stdout.trim() : undefined;
14
+ }
15
+ /**
16
+ * A repository in the middle of a merge or a rebase is not a repository to open
17
+ * a pull request from. The person is mid-operation, and anything this command
18
+ * does to it will land in the middle of that work.
19
+ */
20
+ export async function midOperation(root) {
21
+ const directory = await gitDirectory(root);
22
+ if (directory === undefined)
23
+ return false;
24
+ return ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD"].some((marker) => existsSync(join(directory, marker)));
25
+ }
26
+ /** The one-line subject of the commit and the pull request this branch carries. */
27
+ export const CI_COMMIT_SUBJECT = "Connect Balladeer CI";
28
+ /**
29
+ * The workflow as the default branch currently has it, fetched fresh.
30
+ *
31
+ * This is what tells a re-run that the pull request was merged. GitHub deletes
32
+ * the head branch on merge in most repositories, so looking for that branch
33
+ * answers "no" for a repository where the work is finished, and the command
34
+ * would go on to cut the branch again, write identical bytes, and be told by git
35
+ * that there is nothing to commit. A person would then read an instruction to
36
+ * commit a file that is already on their default branch.
37
+ */
38
+ export async function workflowOnDefaultBranch(root, defaultBranch, workflowPath) {
39
+ const fetched = await runCommand("git", ["-C", root, "fetch", "origin", defaultBranch]);
40
+ if (!fetched.ok)
41
+ return undefined;
42
+ const shown = await runCommand("git", ["-C", root, "show", `FETCH_HEAD:${workflowPath}`]);
43
+ return shown.ok ? shown.stdout : undefined;
44
+ }
45
+ /**
46
+ * Commits ONE file on a branch cut from the freshly fetched default branch, in a
47
+ * throwaway worktree.
48
+ *
49
+ * Three properties, each the answer to a way this could have gone wrong:
50
+ *
51
+ * - The branch is cut from `origin/<default branch>` as just fetched, never from
52
+ * whatever HEAD happens to be. A developer sitting on a feature branch would
53
+ * otherwise open a pull request carrying every local commit they had not
54
+ * pushed, including anything they had not meant to. Where the branch is
55
+ * already open on the remote it is BUILT ON instead, so a second commit lands
56
+ * on the pull request the reviewer is already looking at. Re-cutting it from
57
+ * the default branch would rewrite a pushed branch, which the remote refuses,
58
+ * and forcing past that refusal would discard whatever the reviewer had asked
59
+ * for on it.
60
+ * - It is a separate worktree, so the developer's own checkout is never switched,
61
+ * never stashed, and never left on a branch they did not ask for. Their
62
+ * uncommitted work stays exactly where it was.
63
+ * - The commit names its path explicitly. The `.mcp.json` and instructions
64
+ * changes from the previous step stay in the developer's working tree for them
65
+ * to commit deliberately: a pull request titled "Connect Balladeer CI" must
66
+ * contain the CI change and nothing else, because its reviewer is approving
67
+ * what they can see.
68
+ */
69
+ export async function commitWorkflowOnBranch(input) {
70
+ const subject = input.subject ?? CI_COMMIT_SUBJECT;
71
+ if (await midOperation(input.root)) {
72
+ return {
73
+ kind: "refused",
74
+ reason: "this repository is in the middle of a merge or rebase",
75
+ };
76
+ }
77
+ const fetched = await runCommand("git", [
78
+ "-C",
79
+ input.root,
80
+ "fetch",
81
+ "origin",
82
+ input.defaultBranch,
83
+ ]);
84
+ if (!fetched.ok) {
85
+ return {
86
+ kind: "refused",
87
+ reason: fetched.stderr || `git could not fetch ${input.defaultBranch}`,
88
+ };
89
+ }
90
+ const defaultHead = await runCommand("git", ["-C", input.root, "rev-parse", "FETCH_HEAD"]);
91
+ if (!defaultHead.ok) {
92
+ return {
93
+ kind: "refused",
94
+ reason: defaultHead.stderr || `git could not read ${input.defaultBranch}`,
95
+ };
96
+ }
97
+ // Resolved to an explicit commit, because the second fetch below moves
98
+ // FETCH_HEAD and the branch has to be cut from one of the two deliberately.
99
+ let base = defaultHead.stdout.trim();
100
+ const existingBranch = await runCommand("git", ["-C", input.root, "fetch", "origin", CI_BRANCH]);
101
+ if (existingBranch.ok) {
102
+ const branchHead = await runCommand("git", ["-C", input.root, "rev-parse", "FETCH_HEAD"]);
103
+ if (branchHead.ok)
104
+ base = branchHead.stdout.trim();
105
+ }
106
+ const worktree = join(mkdtempSync(join(tmpdir(), "balladeer-ci-")), "tree");
107
+ const added = await runCommand("git", [
108
+ "-C",
109
+ input.root,
110
+ "worktree",
111
+ "add",
112
+ "-B",
113
+ CI_BRANCH,
114
+ worktree,
115
+ base,
116
+ ]);
117
+ if (!added.ok) {
118
+ return { kind: "refused", reason: added.stderr || "git could not create a working branch" };
119
+ }
120
+ try {
121
+ const file = join(worktree, input.workflowPath);
122
+ mkdirSync(dirname(file), { recursive: true });
123
+ writeFileSync(file, input.workflowYaml, { encoding: "utf8", mode: 0o644 });
124
+ const staged = await runCommand("git", ["-C", worktree, "add", "--", input.workflowPath]);
125
+ if (!staged.ok)
126
+ return { kind: "refused", reason: staged.stderr || "git could not stage the workflow" };
127
+ // Nothing staged means the branch already carries these exact bytes. That is
128
+ // a finished state, not a failure, and committing is what git would refuse.
129
+ const pending = await runCommand("git", [
130
+ "-C",
131
+ worktree,
132
+ "diff",
133
+ "--cached",
134
+ "--quiet",
135
+ "--",
136
+ input.workflowPath,
137
+ ]);
138
+ if (pending.ok)
139
+ return { kind: "unchanged" };
140
+ const committed = await runCommand("git", [
141
+ "-C",
142
+ worktree,
143
+ "-c",
144
+ "commit.gpgsign=false",
145
+ "commit",
146
+ "-m",
147
+ subject,
148
+ "--",
149
+ input.workflowPath,
150
+ ]);
151
+ if (!committed.ok) {
152
+ return { kind: "refused", reason: committed.stderr || "git could not commit the workflow" };
153
+ }
154
+ const pushed = await runCommand("git", ["-C", worktree, "push", "-u", "origin", CI_BRANCH]);
155
+ if (!pushed.ok) {
156
+ return { kind: "refused", reason: pushed.stderr || `git could not push ${CI_BRANCH}` };
157
+ }
158
+ // Only where the branch was already on the remote is there a pull request to
159
+ // find. Asking on a branch this run just created would be one GitHub call
160
+ // whose answer is always "none".
161
+ if (existingBranch.ok) {
162
+ const open = await runCommand("gh", [
163
+ "pr",
164
+ "list",
165
+ "--repo",
166
+ input.repository,
167
+ "--head",
168
+ CI_BRANCH,
169
+ "--state",
170
+ "open",
171
+ "--json",
172
+ "url",
173
+ "--jq",
174
+ ".[0].url",
175
+ ]);
176
+ const url = /(https:\/\/[^\s]*\/pull\/\d+)/.exec(open.ok ? open.stdout : "");
177
+ if (url)
178
+ return { kind: "updated", pullRequestUrl: url[1] };
179
+ }
180
+ const opened = await runCommand("gh", [
181
+ "pr",
182
+ "create",
183
+ "--repo",
184
+ input.repository,
185
+ "--base",
186
+ input.defaultBranch,
187
+ "--head",
188
+ CI_BRANCH,
189
+ "--title",
190
+ subject,
191
+ "--body-file",
192
+ input.bodyPath,
193
+ ]);
194
+ if (!opened.ok) {
195
+ return { kind: "refused", reason: opened.stderr || "gh could not open the pull request" };
196
+ }
197
+ const match = /(https:\/\/[^\s]*\/pull\/\d+)/.exec(opened.stdout);
198
+ return { kind: "opened", pullRequestUrl: match?.[1] };
199
+ }
200
+ finally {
201
+ await runCommand("git", ["-C", input.root, "worktree", "remove", "--force", worktree]);
202
+ }
203
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Which of a promise's scope markers no longer name anything in this checkout.
3
+ *
4
+ * A rename is the ordinary way a promise stops being retrievable. Somebody
5
+ * moves `src/import/preview.ts` to `src/ingest/preview.ts`, the code keeps
6
+ * working, the check keeps passing, and the promise about it quietly stops
7
+ * overlapping any path an agent will ever name. Nothing on the server can see
8
+ * that: Balladeer holds no token for the repository and never reads a file, so
9
+ * the only place the fact exists is the working tree in front of the runner.
10
+ *
11
+ * So it is observed here, locally, and reported as attention rather than
12
+ * repaired. A marker is a piece of approved meaning; moving one is a semantic
13
+ * revision a named person makes, and a command that rewrote markers because a
14
+ * file moved would be editing what somebody agreed to on the strength of a
15
+ * `git mv`.
16
+ *
17
+ * What leaves this machine is nothing. The observation is printed where the
18
+ * person ran the command.
19
+ */
20
+ /** How many missing markers one run reports, and how many it will even look at. */
21
+ export declare const MARKER_OBSERVATION_LIMITS: {
22
+ /** Markers checked on disk in one run. Beyond this the run says it stopped counting. */
23
+ readonly maxChecked: 500;
24
+ /** Promises named in the printed row. The rest are counted, not listed. */
25
+ readonly maxNamed: 5;
26
+ };
27
+ /**
28
+ * A marker is a path when it names one.
29
+ *
30
+ * The grammar admits both `src/import/preview.ts` and `supplier-import-preview`.
31
+ * Only the first can be missing from a checkout; the second is a topic, and
32
+ * reporting it as a missing file would tell a person to go looking for
33
+ * something that was never a file.
34
+ */
35
+ export declare function isPathShapedMarker(marker: string): boolean;
36
+ export type MarkerObservation = Readonly<{
37
+ promiseId: string;
38
+ title: string;
39
+ /** The markers this promise names that are not in this checkout. */
40
+ missing: readonly string[];
41
+ }>;
42
+ export type MarkerObservationResult = Readonly<{
43
+ /** Promises with at least one missing path marker, in the order they were given. */
44
+ observations: readonly MarkerObservation[];
45
+ /** How many path-shaped markers were actually looked at. */
46
+ checked: number;
47
+ /** True when the run stopped at its ceiling and did not look at every marker. */
48
+ stoppedEarly: boolean;
49
+ }>;
50
+ export type MarkerSubject = Readonly<{
51
+ promiseId: string;
52
+ title: string;
53
+ surfaces?: readonly string[];
54
+ labels?: readonly string[];
55
+ }>;
56
+ /**
57
+ * Look for each promise's path markers in this working tree.
58
+ *
59
+ * Bounded twice over: it stops after `maxChecked` markers whatever remains, and
60
+ * it says that it stopped. A repository with a thousand promises must not turn
61
+ * `status` into a filesystem walk, and a run that silently examined the first
62
+ * few hundred would report "nothing missing" about a catalog it never read.
63
+ */
64
+ export declare function observeMissingMarkers(repositoryRoot: string, subjects: readonly MarkerSubject[], limits?: Readonly<{
65
+ maxChecked: number;
66
+ }>, exists?: (path: string) => boolean): MarkerObservationResult;
67
+ /**
68
+ * The attention row a person reads, or nothing at all when every marker is
69
+ * still there.
70
+ *
71
+ * It names what to do and who does it: a marker is approved meaning, so the
72
+ * remedy is a revision somebody agrees to, never an edit this command makes.
73
+ */
74
+ export declare function staleMarkerRow(result: MarkerObservationResult, limits?: Readonly<{
75
+ maxNamed: number;
76
+ }>): string | undefined;
@@ -0,0 +1,125 @@
1
+ import { existsSync } from "node:fs";
2
+ import { isAbsolute, join, normalize, sep } from "node:path";
3
+ /**
4
+ * Which of a promise's scope markers no longer name anything in this checkout.
5
+ *
6
+ * A rename is the ordinary way a promise stops being retrievable. Somebody
7
+ * moves `src/import/preview.ts` to `src/ingest/preview.ts`, the code keeps
8
+ * working, the check keeps passing, and the promise about it quietly stops
9
+ * overlapping any path an agent will ever name. Nothing on the server can see
10
+ * that: Balladeer holds no token for the repository and never reads a file, so
11
+ * the only place the fact exists is the working tree in front of the runner.
12
+ *
13
+ * So it is observed here, locally, and reported as attention rather than
14
+ * repaired. A marker is a piece of approved meaning; moving one is a semantic
15
+ * revision a named person makes, and a command that rewrote markers because a
16
+ * file moved would be editing what somebody agreed to on the strength of a
17
+ * `git mv`.
18
+ *
19
+ * What leaves this machine is nothing. The observation is printed where the
20
+ * person ran the command.
21
+ */
22
+ /** How many missing markers one run reports, and how many it will even look at. */
23
+ export const MARKER_OBSERVATION_LIMITS = {
24
+ /** Markers checked on disk in one run. Beyond this the run says it stopped counting. */
25
+ maxChecked: 500,
26
+ /** Promises named in the printed row. The rest are counted, not listed. */
27
+ maxNamed: 5,
28
+ };
29
+ /**
30
+ * A marker is a path when it names one.
31
+ *
32
+ * The grammar admits both `src/import/preview.ts` and `supplier-import-preview`.
33
+ * Only the first can be missing from a checkout; the second is a topic, and
34
+ * reporting it as a missing file would tell a person to go looking for
35
+ * something that was never a file.
36
+ */
37
+ export function isPathShapedMarker(marker) {
38
+ const trimmed = marker.trim();
39
+ if (trimmed === "")
40
+ return false;
41
+ if (trimmed.includes("/"))
42
+ return true;
43
+ return /^[^/]+\.[A-Za-z0-9]{1,8}$/.test(trimmed);
44
+ }
45
+ /**
46
+ * A marker refused rather than resolved.
47
+ *
48
+ * A marker that escapes the repository root, or that arrives absolute, is not a
49
+ * marker this checkout can answer for. It is skipped rather than reported
50
+ * missing: telling a person a path is gone because the command would not look
51
+ * outside their repository is a false alarm about their catalog.
52
+ */
53
+ function resolveInside(root, marker) {
54
+ const cleaned = marker.trim().replaceAll("\\", "/");
55
+ if (cleaned === "" || isAbsolute(cleaned))
56
+ return undefined;
57
+ const full = normalize(join(root, cleaned));
58
+ const bounded = root.endsWith(sep) ? root : `${root}${sep}`;
59
+ return full === root || full.startsWith(bounded) ? full : undefined;
60
+ }
61
+ /**
62
+ * Look for each promise's path markers in this working tree.
63
+ *
64
+ * Bounded twice over: it stops after `maxChecked` markers whatever remains, and
65
+ * it says that it stopped. A repository with a thousand promises must not turn
66
+ * `status` into a filesystem walk, and a run that silently examined the first
67
+ * few hundred would report "nothing missing" about a catalog it never read.
68
+ */
69
+ export function observeMissingMarkers(repositoryRoot, subjects, limits = MARKER_OBSERVATION_LIMITS, exists = existsSync) {
70
+ const root = normalize(repositoryRoot);
71
+ const observations = [];
72
+ let checked = 0;
73
+ let stoppedEarly = false;
74
+ for (const subject of subjects) {
75
+ const missing = [];
76
+ for (const marker of [...(subject.surfaces ?? []), ...(subject.labels ?? [])]) {
77
+ if (!isPathShapedMarker(marker))
78
+ continue;
79
+ if (checked >= limits.maxChecked) {
80
+ stoppedEarly = true;
81
+ break;
82
+ }
83
+ checked += 1;
84
+ const resolved = resolveInside(root, marker);
85
+ if (resolved === undefined)
86
+ continue;
87
+ if (!exists(resolved))
88
+ missing.push(marker.trim());
89
+ }
90
+ if (missing.length > 0) {
91
+ observations.push({ promiseId: subject.promiseId, title: subject.title, missing });
92
+ }
93
+ if (stoppedEarly)
94
+ break;
95
+ }
96
+ return { observations, checked, stoppedEarly };
97
+ }
98
+ /**
99
+ * The attention row a person reads, or nothing at all when every marker is
100
+ * still there.
101
+ *
102
+ * It names what to do and who does it: a marker is approved meaning, so the
103
+ * remedy is a revision somebody agrees to, never an edit this command makes.
104
+ */
105
+ export function staleMarkerRow(result, limits = MARKER_OBSERVATION_LIMITS) {
106
+ const { observations } = result;
107
+ if (observations.length === 0)
108
+ return undefined;
109
+ const named = observations.slice(0, Math.max(0, limits.maxNamed));
110
+ const lines = named.map((observation) => ` ${observation.promiseId}: ${observation.missing.join(", ")} (${observation.title})`);
111
+ const rest = observations.length - named.length;
112
+ const head = observations.length === 1
113
+ ? " Attention: 1 promise names a path that is not in this checkout, so it will not be found by the paths a change touches."
114
+ : ` Attention: ${observations.length} promises name a path that is not in this checkout, so they will not be found by the paths a change touches.`;
115
+ const tail = [
116
+ ...(rest > 0 ? [` and ${rest} more.`] : []),
117
+ ...(result.stoppedEarly
118
+ ? [
119
+ ` This run looked at ${result.checked} markers and stopped there, so there may be more.`,
120
+ ]
121
+ : []),
122
+ " A marker is part of what somebody agreed to. Propose a revision and let its owner decide; nothing here changed it.",
123
+ ];
124
+ return [head, ...lines, ...tail].join("\n");
125
+ }