balladeer 1.0.14 → 1.0.16

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 (77) hide show
  1. package/README.md +2 -0
  2. package/dist/behavior-map-schema.d.ts +107 -0
  3. package/dist/behavior-map-schema.js +232 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +77 -2
  6. package/dist/commands/guidance.d.ts +1 -1
  7. package/dist/commands/guidance.js +6 -0
  8. package/dist/commands/judge.d.ts +271 -0
  9. package/dist/commands/judge.js +1170 -0
  10. package/dist/commands/map.d.ts +120 -0
  11. package/dist/commands/map.js +601 -0
  12. package/dist/commands/mcp.d.ts +6 -0
  13. package/dist/commands/mcp.js +53 -0
  14. package/dist/commands/offers.d.ts +265 -0
  15. package/dist/commands/offers.js +801 -0
  16. package/dist/commands/risk.d.ts +29 -0
  17. package/dist/commands/risk.js +133 -0
  18. package/dist/copy.d.ts +24 -1
  19. package/dist/copy.js +91 -0
  20. package/dist/guidance-hook.mjs +230 -74
  21. package/dist/guidance.d.ts +2 -0
  22. package/dist/guidance.js +7 -1
  23. package/dist/headless-agent.d.ts +166 -0
  24. package/dist/headless-agent.js +416 -0
  25. package/dist/hook-trust.d.ts +31 -0
  26. package/dist/hook-trust.js +63 -0
  27. package/dist/judge-brief.d.ts +36 -0
  28. package/dist/judge-brief.js +89 -0
  29. package/dist/judge-hook.d.ts +84 -0
  30. package/dist/judge-hook.js +420 -0
  31. package/dist/map-brief.d.ts +16 -0
  32. package/dist/map-brief.js +41 -0
  33. package/dist/offer-brief.d.ts +95 -0
  34. package/dist/offer-brief.js +217 -0
  35. package/dist/owned-process.d.ts +65 -0
  36. package/dist/owned-process.js +146 -0
  37. package/dist/promise-meaning.d.ts +126 -0
  38. package/dist/promise-meaning.js +292 -0
  39. package/dist/risk/contract.d.ts +145 -0
  40. package/dist/risk/contract.js +74 -0
  41. package/dist/risk/describe.d.ts +7 -0
  42. package/dist/risk/describe.js +45 -0
  43. package/dist/risk/diff.d.ts +26 -0
  44. package/dist/risk/diff.js +174 -0
  45. package/dist/risk/extract.d.ts +43 -0
  46. package/dist/risk/extract.js +336 -0
  47. package/dist/risk/git.d.ts +30 -0
  48. package/dist/risk/git.js +118 -0
  49. package/dist/risk/import-graph.d.ts +41 -0
  50. package/dist/risk/import-graph.js +487 -0
  51. package/dist/risk/index.d.ts +20 -0
  52. package/dist/risk/index.js +20 -0
  53. package/dist/risk/paths.d.ts +16 -0
  54. package/dist/risk/paths.js +73 -0
  55. package/dist/risk/pipeline.d.ts +47 -0
  56. package/dist/risk/pipeline.js +121 -0
  57. package/dist/risk/priors.d.ts +18 -0
  58. package/dist/risk/priors.js +86 -0
  59. package/dist/risk/resources.d.ts +56 -0
  60. package/dist/risk/resources.js +374 -0
  61. package/dist/risk/score.d.ts +85 -0
  62. package/dist/risk/score.js +543 -0
  63. package/dist/risk/symbols.d.ts +35 -0
  64. package/dist/risk/symbols.js +348 -0
  65. package/dist/risk/text.d.ts +43 -0
  66. package/dist/risk/text.js +277 -0
  67. package/dist/risk/validate.d.ts +19 -0
  68. package/dist/risk/validate.js +154 -0
  69. package/dist/scratch-worktree.d.ts +51 -0
  70. package/dist/scratch-worktree.js +153 -0
  71. package/dist/self-update.d.ts +29 -2
  72. package/dist/self-update.js +96 -11
  73. package/dist/user-scope.d.ts +2 -0
  74. package/dist/user-scope.js +26 -2
  75. package/dist/wire.d.ts +55 -2
  76. package/dist/wire.js +5 -1
  77. package/package.json +1 -1
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The fixed instructions a map builder works from, `map-brief/v1`.
3
+ *
4
+ * The map says where one promise's behavior lives, so a later change can be
5
+ * matched against it cheaply and a judge can start in the right place. It is
6
+ * built once per promise, from the code, by the person's own agent, in a
7
+ * disposable read-only checkout, and it never leaves the machine. The runner
8
+ * fills in the fields it owns (the promise id, the digest, when and on what
9
+ * commit it was mapped, and the meaning excerpt) and checks the rest against
10
+ * the contract before anything is written.
11
+ */
12
+ export const MAP_BRIEF_REVISION = "map-brief/v1";
13
+ export const MAP_BRIEF = `You are mapping where one promise's behavior lives in this repository. A named person agreed the promise's meaning; it is below. Your working directory is a disposable copy of the repository at its current commit. Read only: anything you write here is thrown away.
14
+
15
+ Find, by reading and searching the code:
16
+
17
+ 1. entryPoints: where the behavior starts, such as a route handler, a command, a UI action, a scheduled job or an event handler. kind is one of route, command, ui, job, event, function. note is one short phrase or "".
18
+ 2. symbols: the specific functions, classes, methods, constants, types or modules on the path from those entry points to the outcome. role is core for the ones that decide the outcome and supporting for the ones it passes through. kind is one of function, class, method, const, type, module. Name each symbol exactly as it is spelled in its file, because a later check searches the file for that name. Prefer specific symbols over whole modules.
19
+ 3. resources: what the behavior depends on outside the code: tables, columns, routes, feature flags, environment keys, queues, events, outbound calls, files, configuration. kind is one of table, column, route, flag, env, queue, event, outbound, file, config; access is one of read, write, both, call.
20
+ 4. linkedTests: search the test suite for tests that exercise this behavior. For each, give the test file, the test's own name as written in the file, and which passing cases it covers (passing-1, passing-2, in the order the meaning lists them). Only tests that exist; an empty list is a fine answer.
21
+ 5. sinks: for each failing case (failing-1, failing-2, in order), the effect that must not happen, the resources where it would happen, and the symbols where it would happen, written as path#symbol.
22
+ 6. confidence: a number from 0 to 1 for how sure you are that this map points at the code that decides the behavior.
23
+ 7. notes: anything a later reader needs, in at most three sentences, or "".
24
+ 8. meaning: copy this excerpt of the promise verbatim: title, observableOutcome, nonGoals, and failingExamples as {"id": "failing-1", "text": "..."}.
25
+
26
+ Paths are relative to the repository root, with forward slashes. Every path must exist. Keep the map small: at most 20 entry points, 60 symbols, 40 resources, 20 linked tests.
27
+
28
+ Do not push, publish or change anything outside this copy. Do not read or print credentials.
29
+
30
+ Your final message must be exactly one JSON object and nothing after it:
31
+
32
+ {"entryPoints": [{"file": "", "symbol": "", "kind": "function", "note": ""}], "symbols": [{"file": "", "name": "", "kind": "function", "role": "core"}], "resources": [{"kind": "table", "name": "", "access": "read"}], "linkedTests": [{"file": "", "name": "", "cases": ["passing-1"]}], "sinks": [{"caseId": "failing-1", "effect": "", "resources": [], "symbols": ["path#symbol"]}], "confidence": 0.5, "notes": "", "meaning": {"title": "", "observableOutcome": "", "failingExamples": [{"id": "failing-1", "text": ""}], "nonGoals": []}}`;
33
+ /** The brief plus the promise, as the prompt the agent receives. */
34
+ export function mapPrompt(promise) {
35
+ return `${MAP_BRIEF}\n\nThe promise, as Balladeer returns it:\n${JSON.stringify({
36
+ promiseId: promise.promiseId,
37
+ title: promise.title,
38
+ semanticDigest: promise.semanticDigest,
39
+ meaning: promise.meaning,
40
+ }, null, 2)}\n`;
41
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The fixed instructions for reading one change and naming the rules it
3
+ * establishes that no promise records yet, `offer-brief/v1`.
4
+ *
5
+ * Robert, 29 September 2026: an offer should be made where the work is, at
6
+ * the moment it is pushed, and should not outlive that session. So this runs
7
+ * from the same hook that judges a push, on the person's own agent, and what
8
+ * it finds is handed to the session that is pushing: up to three rules, each
9
+ * with the evidence in the change that shows it. Nothing is stored and
10
+ * nothing is recorded; recording is the person's yes, given in that session.
11
+ *
12
+ * The text is a constant so a result can name the brief it was given. The
13
+ * change itself is in the prompt, bounded, so the reader never depends on a
14
+ * shell to see it; the read-only git commands stay allowed for what was cut.
15
+ */
16
+ export declare const OFFER_BRIEF_REVISION = "offer-brief/v1";
17
+ /** The most rules handed to the pushing session. */
18
+ export declare const MAX_OFFERS = 3;
19
+ /** The most rules one reading may name: three offered, the rest kept in `held`. */
20
+ export declare const MAX_RULES_READ = 6;
21
+ /** The exact question the pushing session asks, once per offer. */
22
+ export declare const OFFER_QUESTION = "Do you want this recorded as a promise for your review?";
23
+ /** How much of the diff the prompt carries, in characters. */
24
+ export declare const DIFF_CHARACTERS = 60000;
25
+ /** How much of any one file's diff the prompt carries, so one generated file cannot fill it. */
26
+ export declare const DIFF_FILE_CHARACTERS = 20000;
27
+ export declare const OFFER_BRIEF = "You are reading one change to this repository for the rules about the software's behavior that it establishes and that no promise records yet. A promise is a durable commitment about observable behavior that a person would care about losing, stated so that anyone can tell whether it is kept or broken. What you find is offered to the person who made the change, who decides whether to keep it. You record nothing.\n\nYour working directory is the repository. Read only: do not edit, commit, push or run anything that changes state. The change and what its author said about it are below. You may also read files and run git diff, git log and git show. The working tree may not be at the head commit, so read a file as the change left it with git show <head>:<path>.\n\nWhat is worth offering:\n\n1. A rule is a guarantee, an invariant or a boundary this change makes true about how the software behaves for somebody: what always happens, what never happens, what is refused. It needs an intention somebody stated (a commit message, or a test's name or assertion) and code in this change that makes it so. Commit messages and test names say what someone intended; the code says what is true.\n2. Not worth offering: how something is implemented, a refactor, a rename or a move, styling or wording, a one-off fix with no rule behind it, anything speculative or that the change does not yet do, and a sentence that narrates the change (\"this change adds a retry\") rather than stating what the software must do (\"a retried payment is charged once\").\n3. None is a good answer, and often the right one. A refactor, a rename, a dependency bump, a formatting change or a documentation change establishes no rule: answer with an empty list. Never invent a rule to have something to say.\n4. Leave out anything an existing promise listed below already states, and name that promise under \"covered\". Covered means the same beneficiary, trigger and outcome, not shared words or nearby files.\n\nMost valuable first, in this order, so that the same change always leads with the same rule:\n- a rule that a test added or changed in this change exercises and a commit message states;\n- then a rule that a test added or changed in this change exercises;\n- then a rule a commit message states that no test in the change exercises;\n- then a rule only the code shows.\nWithin each, the rule whose breaking would cost the person who depends on it most comes first. List at most 6.\n\nHow to write each one:\n\n5. rule: one plain sentence under 200 characters, written for the person who depends on the behavior, saying what the software must do. No file names, function names, identifiers or the author's name.\n6. beneficiary: who relies on it. trigger: when it applies. passingExample: one concrete situation where it is kept. failingExample: what must not happen, said that way (\"a second charge must not appear\").\n7. evidence: the files in this change that show it, each with what it shows. A rule with no file in this change behind it is an opinion; leave it out.\n8. statedBy: \"commit\" when a commit message states the rule, else \"test\" when a test added or changed in this change exercises it, else \"code\". confidence: \"high\" or \"medium\"; leave out anything you are less sure of.\n9. Name no person, email or account. Put no source code, secret or credential in your answer.\n\nYour final message must be exactly one JSON object and nothing after it:\n\n{\"offers\": [{\"rule\": \"<one sentence>\", \"beneficiary\": \"<who relies on it>\", \"trigger\": \"<when it applies>\", \"passingExample\": \"<what keeping it looks like>\", \"failingExample\": \"<what must not happen>\", \"evidence\": [{\"file\": \"<path in this change>\", \"shows\": \"<what in the change shows it>\"}], \"statedBy\": \"commit\" | \"test\" | \"code\", \"confidence\": \"high\" | \"medium\"}], \"covered\": [{\"promiseId\": \"<id>\", \"rule\": \"<the rule it already states>\"}], \"notes\": \"<at most two sentences>\"}";
28
+ /** What the reader is told about one promise already recorded. */
29
+ export type ExistingPromise = Readonly<{
30
+ promiseId: string;
31
+ title: string;
32
+ outcome?: string;
33
+ beneficiary?: string;
34
+ trigger?: string;
35
+ /** The labels of its failing cases, which say what it forbids in a few words each. */
36
+ failingExamples?: readonly string[];
37
+ }>;
38
+ /** The diff as the prompt carries it, and what was left out of it. */
39
+ export type BoundedDiff = Readonly<{
40
+ text: string;
41
+ totalCharacters: number;
42
+ shownCharacters: number;
43
+ /** The raw diff itself was cut by the reading process, so the total is a floor. */
44
+ totalIsFloor: boolean;
45
+ /** Files whose part was cut short. */
46
+ shortened: readonly string[];
47
+ /** Files left out entirely. */
48
+ omitted: readonly string[];
49
+ /** The line saying how much was cut, when anything was. */
50
+ cutLine?: string;
51
+ }>;
52
+ /**
53
+ * Where a file's part of the diff goes: code and tests first, then prose and
54
+ * configuration, then data, fixtures, evidence and lockfiles. A rule is shown
55
+ * by code and tests, and a change that also commits a large fixture must not
56
+ * push its own code out of the prompt.
57
+ */
58
+ export declare function diffTier(path: string): number;
59
+ /**
60
+ * A whole number with its thousands grouped by commas, the way the lines print
61
+ * character counts. Written out rather than asked of the locale machinery,
62
+ * which this command keeps for instants alone (`local-time.ts`).
63
+ */
64
+ export declare function grouped(value: number): string;
65
+ /**
66
+ * Hold a diff to what the prompt can carry.
67
+ *
68
+ * The raw diff is split per file, ordered by `diffTier` (git's own order
69
+ * within a tier), each file held to `DIFF_FILE_CHARACTERS`, and the whole to
70
+ * `DIFF_CHARACTERS`. When anything is cut, one line says how much, names the
71
+ * files cut short or left out, and says how to read them.
72
+ */
73
+ export declare function boundDiff(raw: string, input: Readonly<{
74
+ base: string;
75
+ head: string;
76
+ totalIsFloor?: boolean;
77
+ limit?: number;
78
+ fileLimit?: number;
79
+ }>): BoundedDiff;
80
+ export type OfferBriefInput = Readonly<{
81
+ base: string;
82
+ head: string;
83
+ changedFiles: readonly string[];
84
+ diff: BoundedDiff;
85
+ /** The commit messages of the change, oldest first, trailers removed. */
86
+ commitMessages: string;
87
+ existing: readonly ExistingPromise[];
88
+ /** False when the promises already recorded could not be read, so nothing can be called covered. */
89
+ existingKnown: boolean;
90
+ minutes?: number;
91
+ }>;
92
+ /** One promise already recorded, as one line of the prompt. */
93
+ export declare function existingLine(promise: ExistingPromise): string;
94
+ /** The whole prompt: the brief, then the change and what is already recorded. */
95
+ export declare function offerPrompt(input: OfferBriefInput): string;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The fixed instructions for reading one change and naming the rules it
3
+ * establishes that no promise records yet, `offer-brief/v1`.
4
+ *
5
+ * Robert, 29 September 2026: an offer should be made where the work is, at
6
+ * the moment it is pushed, and should not outlive that session. So this runs
7
+ * from the same hook that judges a push, on the person's own agent, and what
8
+ * it finds is handed to the session that is pushing: up to three rules, each
9
+ * with the evidence in the change that shows it. Nothing is stored and
10
+ * nothing is recorded; recording is the person's yes, given in that session.
11
+ *
12
+ * The text is a constant so a result can name the brief it was given. The
13
+ * change itself is in the prompt, bounded, so the reader never depends on a
14
+ * shell to see it; the read-only git commands stay allowed for what was cut.
15
+ */
16
+ export const OFFER_BRIEF_REVISION = "offer-brief/v1";
17
+ /** The most rules handed to the pushing session. */
18
+ export const MAX_OFFERS = 3;
19
+ /** The most rules one reading may name: three offered, the rest kept in `held`. */
20
+ export const MAX_RULES_READ = 6;
21
+ /** The exact question the pushing session asks, once per offer. */
22
+ export const OFFER_QUESTION = "Do you want this recorded as a promise for your review?";
23
+ /** How much of the diff the prompt carries, in characters. */
24
+ export const DIFF_CHARACTERS = 60_000;
25
+ /** How much of any one file's diff the prompt carries, so one generated file cannot fill it. */
26
+ export const DIFF_FILE_CHARACTERS = 20_000;
27
+ const MESSAGE_CHARACTERS = 8_000;
28
+ const CHANGED_FILES_SHOWN = 300;
29
+ const EXISTING_LINE_CHARACTERS = 600;
30
+ export const OFFER_BRIEF = `You are reading one change to this repository for the rules about the software's behavior that it establishes and that no promise records yet. A promise is a durable commitment about observable behavior that a person would care about losing, stated so that anyone can tell whether it is kept or broken. What you find is offered to the person who made the change, who decides whether to keep it. You record nothing.
31
+
32
+ Your working directory is the repository. Read only: do not edit, commit, push or run anything that changes state. The change and what its author said about it are below. You may also read files and run git diff, git log and git show. The working tree may not be at the head commit, so read a file as the change left it with git show <head>:<path>.
33
+
34
+ What is worth offering:
35
+
36
+ 1. A rule is a guarantee, an invariant or a boundary this change makes true about how the software behaves for somebody: what always happens, what never happens, what is refused. It needs an intention somebody stated (a commit message, or a test's name or assertion) and code in this change that makes it so. Commit messages and test names say what someone intended; the code says what is true.
37
+ 2. Not worth offering: how something is implemented, a refactor, a rename or a move, styling or wording, a one-off fix with no rule behind it, anything speculative or that the change does not yet do, and a sentence that narrates the change ("this change adds a retry") rather than stating what the software must do ("a retried payment is charged once").
38
+ 3. None is a good answer, and often the right one. A refactor, a rename, a dependency bump, a formatting change or a documentation change establishes no rule: answer with an empty list. Never invent a rule to have something to say.
39
+ 4. Leave out anything an existing promise listed below already states, and name that promise under "covered". Covered means the same beneficiary, trigger and outcome, not shared words or nearby files.
40
+
41
+ Most valuable first, in this order, so that the same change always leads with the same rule:
42
+ - a rule that a test added or changed in this change exercises and a commit message states;
43
+ - then a rule that a test added or changed in this change exercises;
44
+ - then a rule a commit message states that no test in the change exercises;
45
+ - then a rule only the code shows.
46
+ Within each, the rule whose breaking would cost the person who depends on it most comes first. List at most ${MAX_RULES_READ}.
47
+
48
+ How to write each one:
49
+
50
+ 5. rule: one plain sentence under 200 characters, written for the person who depends on the behavior, saying what the software must do. No file names, function names, identifiers or the author's name.
51
+ 6. beneficiary: who relies on it. trigger: when it applies. passingExample: one concrete situation where it is kept. failingExample: what must not happen, said that way ("a second charge must not appear").
52
+ 7. evidence: the files in this change that show it, each with what it shows. A rule with no file in this change behind it is an opinion; leave it out.
53
+ 8. statedBy: "commit" when a commit message states the rule, else "test" when a test added or changed in this change exercises it, else "code". confidence: "high" or "medium"; leave out anything you are less sure of.
54
+ 9. Name no person, email or account. Put no source code, secret or credential in your answer.
55
+
56
+ Your final message must be exactly one JSON object and nothing after it:
57
+
58
+ {"offers": [{"rule": "<one sentence>", "beneficiary": "<who relies on it>", "trigger": "<when it applies>", "passingExample": "<what keeping it looks like>", "failingExample": "<what must not happen>", "evidence": [{"file": "<path in this change>", "shows": "<what in the change shows it>"}], "statedBy": "commit" | "test" | "code", "confidence": "high" | "medium"}], "covered": [{"promiseId": "<id>", "rule": "<the rule it already states>"}], "notes": "<at most two sentences>"}`;
59
+ const LOCKFILES = /(^|\/)(pnpm-lock\.yaml|package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|bun\.lockb?|Cargo\.lock|poetry\.lock|Pipfile\.lock|uv\.lock|go\.sum|Gemfile\.lock|composer\.lock|mix\.lock|[^/]+\.lock)$/;
60
+ const DATA_DIRECTORIES = /(^|\/)(evidence|fixtures?|__snapshots__|snapshots|vendor|dist|build|generated|node_modules)\//;
61
+ const DATA_FILES = /\.(jsonl|csv|tsv|snap|map|min\.js|min\.css|svg|png|jpe?g|gif|ico|pdf|lock)$/;
62
+ const DOCS_AND_CONFIG = /\.(md|mdx|markdown|txt|rst|adoc|ya?ml|toml|json|ini|cfg|conf|env)$/;
63
+ /**
64
+ * Where a file's part of the diff goes: code and tests first, then prose and
65
+ * configuration, then data, fixtures, evidence and lockfiles. A rule is shown
66
+ * by code and tests, and a change that also commits a large fixture must not
67
+ * push its own code out of the prompt.
68
+ */
69
+ export function diffTier(path) {
70
+ if (LOCKFILES.test(path) || DATA_DIRECTORIES.test(path) || DATA_FILES.test(path))
71
+ return 2;
72
+ if (DOCS_AND_CONFIG.test(path) || /(^|\/)docs?\//.test(path) || /(^|\/)\.[^/]+$/.test(path))
73
+ return 1;
74
+ return 0;
75
+ }
76
+ function sectionPath(header) {
77
+ const quoted = /^diff --git "?a\/.*?"? "?b\/(.*?)"?$/.exec(header);
78
+ return quoted?.[1] ?? header.replace(/^diff --git /, "");
79
+ }
80
+ function names(paths, limit = 40) {
81
+ const shown = paths.slice(0, limit).join(", ");
82
+ return paths.length > limit ? `${shown} and ${paths.length - limit} more` : shown;
83
+ }
84
+ /**
85
+ * A whole number with its thousands grouped by commas, the way the lines print
86
+ * character counts. Written out rather than asked of the locale machinery,
87
+ * which this command keeps for instants alone (`local-time.ts`).
88
+ */
89
+ export function grouped(value) {
90
+ return String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
91
+ }
92
+ const count = grouped;
93
+ /**
94
+ * Hold a diff to what the prompt can carry.
95
+ *
96
+ * The raw diff is split per file, ordered by `diffTier` (git's own order
97
+ * within a tier), each file held to `DIFF_FILE_CHARACTERS`, and the whole to
98
+ * `DIFF_CHARACTERS`. When anything is cut, one line says how much, names the
99
+ * files cut short or left out, and says how to read them.
100
+ */
101
+ export function boundDiff(raw, input) {
102
+ const limit = input.limit ?? DIFF_CHARACTERS;
103
+ const fileLimit = input.fileLimit ?? DIFF_FILE_CHARACTERS;
104
+ const sections = [];
105
+ const starts = [];
106
+ const pattern = /^diff --git .*$/gm;
107
+ for (let match = pattern.exec(raw); match !== null; match = pattern.exec(raw))
108
+ starts.push(match.index);
109
+ if (starts.length === 0 && raw.trim() !== "")
110
+ sections.push({ path: "", text: raw, tier: 0, order: 0 });
111
+ starts.forEach((start, index) => {
112
+ const text = raw.slice(start, starts[index + 1] ?? raw.length);
113
+ const path = sectionPath(text.slice(0, text.indexOf("\n") < 0 ? text.length : text.indexOf("\n")));
114
+ sections.push({ path, text, tier: diffTier(path), order: index });
115
+ });
116
+ sections.sort((a, b) => a.tier - b.tier || a.order - b.order);
117
+ const parts = [];
118
+ const shortened = [];
119
+ const omitted = [];
120
+ let used = 0;
121
+ const marker = (path, rest) => `[this file's diff continues for ${count(rest)} more characters; read it with git diff ${input.base} ${input.head} -- ${path}]\n`;
122
+ for (const section of sections) {
123
+ // The share this file may take, marker included, so no bound is exceeded.
124
+ const share = Math.min(fileLimit, limit - used);
125
+ if (section.text.length <= share) {
126
+ parts.push(section.text);
127
+ used += section.text.length;
128
+ continue;
129
+ }
130
+ const keep = share - marker(section.path, section.text.length).length - 1;
131
+ if (keep < 800) {
132
+ omitted.push(section.path);
133
+ continue;
134
+ }
135
+ const kept = section.text.slice(0, keep);
136
+ const text = `${kept.endsWith("\n") ? kept : `${kept}\n`}${marker(section.path, section.text.length - keep)}`;
137
+ shortened.push(section.path);
138
+ parts.push(text);
139
+ used += text.length;
140
+ }
141
+ const text = parts.join("");
142
+ const total = raw.length;
143
+ const cutAnything = shortened.length > 0 || omitted.length > 0 || input.totalIsFloor === true;
144
+ const cutLine = cutAnything
145
+ ? [
146
+ `The diff above is cut: it shows ${count(text.length)} of ${input.totalIsFloor === true ? "more than " : ""}${count(total)} characters.`,
147
+ shortened.length > 0 ? ` Cut short: ${names(shortened)}.` : "",
148
+ omitted.length > 0 ? ` Not shown: ${names(omitted)}.` : "",
149
+ ` Read any file's whole change with git diff ${input.base} ${input.head} -- <path>.`,
150
+ ].join("")
151
+ : undefined;
152
+ return {
153
+ text,
154
+ totalCharacters: total,
155
+ shownCharacters: text.length,
156
+ totalIsFloor: input.totalIsFloor === true,
157
+ shortened,
158
+ omitted,
159
+ ...(cutLine === undefined ? {} : { cutLine }),
160
+ };
161
+ }
162
+ function bounded(text, limit) {
163
+ const flat = text.replace(/\s+/g, " ").trim();
164
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 3).trimEnd()}...`;
165
+ }
166
+ /** One promise already recorded, as one line of the prompt. */
167
+ export function existingLine(promise) {
168
+ const parts = [`${promise.promiseId}: ${promise.title}.`];
169
+ if (promise.outcome)
170
+ parts.push(`Outcome: ${promise.outcome}`);
171
+ if (promise.beneficiary)
172
+ parts.push(`For: ${promise.beneficiary}.`);
173
+ if (promise.trigger)
174
+ parts.push(`When: ${promise.trigger}.`);
175
+ if (promise.failingExamples && promise.failingExamples.length > 0)
176
+ parts.push(`Must not: ${promise.failingExamples.join("; ")}.`);
177
+ return `- ${bounded(parts.join(" "), EXISTING_LINE_CHARACTERS)}`;
178
+ }
179
+ /** The whole prompt: the brief, then the change and what is already recorded. */
180
+ export function offerPrompt(input) {
181
+ const sections = [OFFER_BRIEF, ""];
182
+ sections.push(`Base commit (without the change): ${input.base}`);
183
+ sections.push(`Head commit (with the change): ${input.head}`);
184
+ if (input.minutes !== undefined)
185
+ sections.push(`You have about ${input.minutes} minute${input.minutes === 1 ? "" : "s"}. If time is short, answer with what you are sure of.`);
186
+ sections.push("");
187
+ if (!input.existingKnown)
188
+ sections.push('The existing promises could not be read, so leave "covered" empty and say so in notes.');
189
+ else if (input.existing.length === 0)
190
+ sections.push("No promise is recorded for this repository yet.");
191
+ else {
192
+ sections.push(`Existing promises (${input.existing.length}). Leave out what these already state:`);
193
+ for (const promise of input.existing)
194
+ sections.push(existingLine(promise));
195
+ }
196
+ sections.push("");
197
+ const messages = input.commitMessages.trim();
198
+ sections.push("What its author said, commit by commit, oldest first:");
199
+ sections.push(messages === ""
200
+ ? "(no commit message)"
201
+ : messages.length <= MESSAGE_CHARACTERS
202
+ ? messages
203
+ : `${messages.slice(0, MESSAGE_CHARACTERS)}\n(the rest of the commit messages was cut; read them with git log ${input.base}..${input.head})`);
204
+ sections.push("");
205
+ const shown = input.changedFiles.slice(0, CHANGED_FILES_SHOWN);
206
+ sections.push(`Files this change touches (${input.changedFiles.length}):`);
207
+ for (const file of shown)
208
+ sections.push(`- ${file}`);
209
+ if (input.changedFiles.length > shown.length)
210
+ sections.push(`- and ${input.changedFiles.length - shown.length} more`);
211
+ sections.push("");
212
+ sections.push(`The change, as git diff --no-color ${input.base} ${input.head} prints it, code and tests first:`);
213
+ sections.push(input.diff.text === "" ? "(the diff is empty)" : input.diff.text.trimEnd());
214
+ if (input.diff.cutLine !== undefined)
215
+ sections.push("", input.diff.cutLine);
216
+ return `${sections.join("\n")}\n`;
217
+ }
@@ -0,0 +1,65 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ /**
3
+ * One child process this command owns outright: its own process group, a hard
4
+ * time limit, a ceiling on how much output is kept, and a kill that reaches
5
+ * every descendant rather than only the process it started.
6
+ *
7
+ * Both the headless agent and the commands a judge hands back run through
8
+ * here. A coding agent starts shells, test runners and servers of its own, and
9
+ * a limit that stopped only the parent would leave those running after the
10
+ * push that started them had moved on. The same lesson is recorded in
11
+ * `scripts/client-compatibility/run.mjs`, which this follows.
12
+ */
13
+ /** The part of a spawned child this module reads. A test hands in its own. */
14
+ export type OwnedChild = Readonly<{
15
+ pid?: number | undefined;
16
+ stdin: Writable | null;
17
+ stdout: Readable | null;
18
+ stderr: Readable | null;
19
+ on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): void;
20
+ on(event: "error", listener: (error: Error) => void): void;
21
+ kill(signal?: NodeJS.Signals): boolean;
22
+ }>;
23
+ export type SpawnLike = (command: string, args: readonly string[], options: Readonly<{
24
+ cwd: string;
25
+ env: NodeJS.ProcessEnv;
26
+ detached: boolean;
27
+ stdio: readonly ["pipe", "pipe", "pipe"];
28
+ }>) => OwnedChild;
29
+ /** Signals a whole process group. A test replaces it to see what would be killed. */
30
+ export type KillGroup = (pid: number, signal: NodeJS.Signals) => void;
31
+ export declare const defaultSpawn: SpawnLike;
32
+ export declare const defaultKillGroup: KillGroup;
33
+ export type OwnedRun = Readonly<{
34
+ exitCode: number | null;
35
+ signal: NodeJS.Signals | null;
36
+ stdout: string;
37
+ stderr: string;
38
+ durationMs: number;
39
+ /** Stopped because the time limit ran out. */
40
+ timedOut: boolean;
41
+ /** Stopped because the caller's signal fired, which is a budget, not a failure. */
42
+ aborted: boolean;
43
+ /** Stopped because it printed more than the ceiling. */
44
+ bounded: boolean;
45
+ /** Present when the program could not be started at all, such as a missing binary. */
46
+ spawnError?: string;
47
+ }>;
48
+ export type OwnedRunOptions = Readonly<{
49
+ command: string;
50
+ args: readonly string[];
51
+ cwd: string;
52
+ env: NodeJS.ProcessEnv;
53
+ /** Written to the child's standard input, which is then closed. */
54
+ input?: string;
55
+ timeoutMs: number;
56
+ maxOutputBytes?: number;
57
+ signal?: AbortSignal;
58
+ spawn?: SpawnLike;
59
+ killGroup?: KillGroup;
60
+ /** How long a stopped child gets between SIGTERM and SIGKILL. */
61
+ graceMs?: number;
62
+ }>;
63
+ export declare function runOwnedProcess(options: OwnedRunOptions): Promise<OwnedRun>;
64
+ /** The last part of some output, for a message a person reads. */
65
+ export declare function tail(text: string, limit?: number): string;
@@ -0,0 +1,146 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ export const defaultSpawn = (command, args, options) => nodeSpawn(command, [...args], {
3
+ cwd: options.cwd,
4
+ env: options.env,
5
+ detached: options.detached,
6
+ stdio: [...options.stdio],
7
+ windowsHide: true,
8
+ });
9
+ export const defaultKillGroup = (pid, signal) => {
10
+ process.kill(-pid, signal);
11
+ };
12
+ const DEFAULT_MAX_OUTPUT = 8 * 1024 * 1024;
13
+ export function runOwnedProcess(options) {
14
+ const spawn = options.spawn ?? defaultSpawn;
15
+ const killGroup = options.killGroup ?? defaultKillGroup;
16
+ const maxOutput = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT;
17
+ const graceMs = options.graceMs ?? 2000;
18
+ const started = Date.now();
19
+ return new Promise((resolve) => {
20
+ const stdout = [];
21
+ const stderr = [];
22
+ let bytes = 0;
23
+ let timedOut = false;
24
+ let aborted = false;
25
+ let bounded = false;
26
+ let settled = false;
27
+ const timers = {};
28
+ let child;
29
+ const finish = (exitCode, signal, spawnError) => {
30
+ if (settled)
31
+ return;
32
+ settled = true;
33
+ if (timers.run)
34
+ clearTimeout(timers.run);
35
+ if (timers.kill)
36
+ clearTimeout(timers.kill);
37
+ options.signal?.removeEventListener("abort", onAbort);
38
+ // The parent can close before descendants it started. They belong to the
39
+ // group this command created, so they go with it.
40
+ if (child?.pid !== undefined) {
41
+ try {
42
+ killGroup(child.pid, "SIGKILL");
43
+ }
44
+ catch {
45
+ /* The group is already gone. */
46
+ }
47
+ }
48
+ resolve({
49
+ exitCode,
50
+ signal,
51
+ stdout: Buffer.concat(stdout).toString("utf8"),
52
+ stderr: Buffer.concat(stderr).toString("utf8"),
53
+ durationMs: Date.now() - started,
54
+ timedOut,
55
+ aborted,
56
+ bounded,
57
+ ...(spawnError === undefined ? {} : { spawnError }),
58
+ });
59
+ };
60
+ const stop = () => {
61
+ if (settled || child === undefined)
62
+ return;
63
+ const pid = child.pid;
64
+ try {
65
+ if (pid !== undefined)
66
+ killGroup(pid, "SIGTERM");
67
+ else
68
+ child.kill("SIGTERM");
69
+ }
70
+ catch {
71
+ try {
72
+ child.kill("SIGTERM");
73
+ }
74
+ catch {
75
+ /* Already stopped. */
76
+ }
77
+ }
78
+ if (timers.kill === undefined) {
79
+ timers.kill = setTimeout(() => {
80
+ try {
81
+ if (pid !== undefined)
82
+ killGroup(pid, "SIGKILL");
83
+ else
84
+ child.kill("SIGKILL");
85
+ }
86
+ catch {
87
+ /* Already stopped. */
88
+ }
89
+ }, graceMs);
90
+ }
91
+ };
92
+ function onAbort() {
93
+ aborted = true;
94
+ stop();
95
+ }
96
+ try {
97
+ child = spawn(options.command, options.args, {
98
+ cwd: options.cwd,
99
+ env: options.env,
100
+ detached: true,
101
+ stdio: ["pipe", "pipe", "pipe"],
102
+ });
103
+ }
104
+ catch (error) {
105
+ finish(null, null, error instanceof Error ? error.message : String(error));
106
+ return;
107
+ }
108
+ const keep = (into) => (chunk) => {
109
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
110
+ bytes += buffer.length;
111
+ if (bytes > maxOutput) {
112
+ if (!bounded) {
113
+ bounded = true;
114
+ stop();
115
+ }
116
+ return;
117
+ }
118
+ into.push(buffer);
119
+ };
120
+ child.stdout?.on("data", keep(stdout));
121
+ child.stderr?.on("data", keep(stderr));
122
+ child.on("error", (error) => {
123
+ finish(null, null, error.message);
124
+ });
125
+ child.on("close", (code, signal) => finish(code, signal));
126
+ if (options.signal?.aborted)
127
+ onAbort();
128
+ else
129
+ options.signal?.addEventListener("abort", onAbort, { once: true });
130
+ timers.run = setTimeout(() => {
131
+ timedOut = true;
132
+ stop();
133
+ }, options.timeoutMs);
134
+ if (child.stdin) {
135
+ child.stdin.on("error", () => {
136
+ /* A child that exits before reading its input is reported by its exit. */
137
+ });
138
+ child.stdin.end(options.input ?? "");
139
+ }
140
+ });
141
+ }
142
+ /** The last part of some output, for a message a person reads. */
143
+ export function tail(text, limit = 2000) {
144
+ const trimmed = text.trim();
145
+ return trimmed.length <= limit ? trimmed : trimmed.slice(trimmed.length - limit);
146
+ }