bareloop 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/validate.js CHANGED
@@ -177,6 +177,69 @@ function unboundedQuantLen(src, i) {
177
177
  return len;
178
178
  }
179
179
 
180
+ /** How many characters of `(`-prologue sit at `src[i]` before the group's BODY:
181
+ * 1 for a plain or capturing group, 3 for `(?:` `(?=` `(?!`, 4 for `(?<=` `(?<!`,
182
+ * and past the `>` of a named group `(?<name>`. The body is read back when the
183
+ * group closes, and a prologue counted as body text would make `(?:a|aa)` split
184
+ * as `?:a` / `aa` — no prefix relation, and the hazard read as safe.
185
+ * @param {string} src @param {number} i index of the `(` */
186
+ function groupPrologueLen(src, i) {
187
+ if (src[i + 1] !== '?') return 1;
188
+ const c = src[i + 2];
189
+ if (c === ':' || c === '=' || c === '!') return 3;
190
+ if (c === '<') {
191
+ if (src[i + 3] === '=' || src[i + 3] === '!') return 4; // lookbehind
192
+ const close = src.indexOf('>', i + 3); // named group
193
+ return close === -1 ? 3 : close - i + 1;
194
+ }
195
+ return 1; // modifier groups etc — read the lot as body (fail-safe)
196
+ }
197
+
198
+ /**
199
+ * Do a group body's TOP-LEVEL alternation branches overlap — is one a prefix of
200
+ * another, equality included? That is the practical detectable core of the
201
+ * overlapping-alternation blowup (`(a|aa)`, `(a|a)`, `(\d|\d\d)`, `(a|a?)`):
202
+ * two branches that can consume the same text give the engine two ways to reach
203
+ * the same position, and an unbounded repeat multiplies them.
204
+ *
205
+ * The comparison is over the branches' SOURCE TEXT, which is what makes it
206
+ * conservative rather than exact — `(ab|abc)+` carries the prefix relation and
207
+ * did not blow up on the body measured. Exactness needs the real regex engine
208
+ * F49 declined to take as a dependency, and guessing it wrong would ADMIT an
209
+ * exponential pattern, so the over-rejection stands as a named limit.
210
+ *
211
+ * Splitting respects escapes, character classes and nested groups: a `|` inside
212
+ * `[a|b]` or inside `(x|y)` is not this group's alternation, and treating it as
213
+ * one would invent branches nobody wrote.
214
+ * @param {string} body the text between a group's prologue and its `)`
215
+ * @returns {boolean}
216
+ */
217
+ function altBranchesOverlap(body) {
218
+ /** @type {string[]} */
219
+ const branches = [];
220
+ let start = 0;
221
+ let depth = 0;
222
+ for (let i = 0; i < body.length; i++) {
223
+ const c = body[i];
224
+ if (c === '\\') { i++; continue; }
225
+ if (c === '[') { i++; if (body[i] === '^') i++; while (i < body.length && body[i] !== ']') { if (body[i] === '\\') i++; i++; } continue; }
226
+ if (c === '(') { depth++; continue; }
227
+ if (c === ')') { depth--; continue; }
228
+ if (c === '|' && depth === 0) { branches.push(body.slice(start, i)); start = i + 1; }
229
+ }
230
+ if (branches.length === 0) return false; // no top-level alternation at all
231
+ branches.push(body.slice(start));
232
+ for (let i = 0; i < branches.length; i++) {
233
+ for (let j = 0; j < branches.length; j++) {
234
+ // `startsWith` covers EQUALITY, which is the 2ⁿ case: `(a|a)+$` took 52.3s
235
+ // on a 27-character body. Skipping i===j is what keeps that from being
236
+ // every pattern's own reflexive match.
237
+ if (i !== j && branches[j].startsWith(branches[i])) return true;
238
+ }
239
+ }
240
+ return false;
241
+ }
242
+
180
243
  /**
181
244
  * F49 — a heuristic reject for the catastrophic-backtracking footgun: an
182
245
  * unbounded quantifier applied to a group whose body itself repeats unboundedly
@@ -201,16 +264,40 @@ function unboundedQuantLen(src, i) {
201
264
  * So the reject targets the dominant exponential class and fails it as a
202
265
  * mechanical gap at the validation gate, before any tokens burn. A full ReDoS
203
266
  * analyzer needs a real regex engine (an external native dep we do not take for
204
- * a LOW issue); exotic overlapping-alternation blowup is out of scope by
205
- * decision (PARKED to the close-authoring rung — PRD v1.55).
267
+ * a LOW issue).
268
+ *
269
+ * SECOND CLASS, added 2026-08-12 — OVERLAPPING ALTERNATION under an unbounded
270
+ * repeat (`(a|aa)+`, `(a|a)+`, `(\d|\d\d)*`). F49 recorded this as a genuine
271
+ * false NEGATIVE of the shape approach and PARKED the widening, because changing
272
+ * the detector changes which SIGNED specs are admissible. It is closed here on
273
+ * measurement, one fresh process per point (a hot RegExp tiers up out of the V8
274
+ * interpreter, and a single-process sweep read a LONGER body as faster —
275
+ * backwards for an exponential, and the harness rather than the regex).
276
+ * `(a|aa)+$` against a run of `a` with one non-matching tail character:
277
+ *
278
+ * 27ch 218ms · 31ch 1,879ms · 35ch 12,082ms · 39ch 52,563ms · 41ch 189,420ms
279
+ *
280
+ * ~6.8× per 4 characters — the Fibonacci decomposition count, φ⁴. `(a|a)+$` (the
281
+ * 2ⁿ equal-branch case) took 52,256ms on 27ch, and `((a|aa))+$` — the redundant
282
+ * wrapper the `((a+))+` entry above exists for — 15,426ms on 35ch, which is why
283
+ * the overlap propagates up through a wrapper exactly as the inner repeat does.
284
+ * Input-bounding is theater here for F49's own reason: tens of characters suffice.
285
+ *
286
+ * The detected core is the PREFIX RELATION between top-level branches (one branch
287
+ * a prefix of another, equality included — `(a|a)+` is the 2ⁿ case). Whether a
288
+ * given prefix overlap can actually be DRIVEN exponentially depends on what
289
+ * follows the group, and deciding that needs the real engine this file declined
290
+ * to take as a dependency — so the reject is conservative by construction, which
291
+ * is the fail-safe direction.
292
+ *
293
+ * MONOTONIC, and that is the rule this change lives under: it only ever ADDS
294
+ * rejections. Nothing above is narrowed, and no previously-rejected shape became
295
+ * admissible (asserted over the whole corpus in tests/plan.test.js). F49's
296
+ * "never sharpen the detector" bars chasing false POSITIVES; closing a false
297
+ * NEGATIVE by adding rejections is the opposite, allowed direction — distinct
298
+ * rules, not in tension (the `((a+))+` widening set the precedent).
206
299
  *
207
300
  * SCOPE IS ASYMMETRIC, both directions named on purpose:
208
- * - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
209
- * detected — there is no inner QUANTIFIER to find, so the shape scan cannot
210
- * see it; the blowup comes from overlapping alternation BRANCHES under a
211
- * repeat. A genuine limit of the approach, not a bug in it. Widening the
212
- * detector changes which SIGNED specs are admissible, which is
213
- * arbiter-adjacent — parked, not chased (PRD v1.55).
214
301
  * - false POSITIVE: a group whose repetitions are disambiguated by a literal
215
302
  * anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
216
303
  * though a real engine runs it linearly — the scan sees the nested-quantifier
@@ -221,13 +308,28 @@ function unboundedQuantLen(src, i) {
221
308
  * and guessing it wrong would ADMIT an exponential pattern — so the shape
222
309
  * reject stands, and the over-rejection is a named, accepted limitation.
223
310
  *
311
+ * The alternation class has its OWN instance of the same limit, measured and
312
+ * locked in `REDOS_OVERREJECTED_ALT`: `(ab|abc)+$`, `(x|xy|xyz)+$` and
313
+ * `(x(a|aa))+$` carry the prefix relation and did NOT blow up on the bodies
314
+ * measured (0.3–0.4ms at 41–91 characters). "Did not blow up on the body
315
+ * measured" is the honest claim — no adversarial search was run for a worse
316
+ * input — and rejecting them is again the direction that never admits an
317
+ * exponential pattern.
318
+ *
224
319
  * The input is guaranteed to compile as a RegExp (checked first by the caller),
225
320
  * so this scan assumes valid, balanced JS regex syntax.
226
321
  * @param {string} src a compiled regex source string
227
- * @returns {boolean} true iff a nested unbounded quantifier is present
322
+ * @returns {boolean} true iff a nested unbounded quantifier, or an unboundedly
323
+ * repeated group with overlapping alternation branches, is present
228
324
  */
229
325
  export function hasNestedQuantifier(src) {
230
- /** @type {{ quant: boolean }[]} */
326
+ /** `quant`: the body repeats unboundedly. `alt`: the body (or a group beneath
327
+ * it) carries overlapping alternation branches. `at`: where the body starts,
328
+ * so the branches can be read back when the group closes. The two hazard flags
329
+ * are tracked separately and propagate separately — merging them would let a
330
+ * plain nested repeat be reported as an alternation overlap and vice versa,
331
+ * and the two are different rewrites for the author. */
332
+ /** @type {{ quant: boolean, alt: boolean, at: number }[]} */
231
333
  const stack = [];
232
334
  for (let i = 0; i < src.length; i++) {
233
335
  const c = src[i];
@@ -254,11 +356,19 @@ export function hasNestedQuantifier(src) {
254
356
  while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
255
357
  continue;
256
358
  }
257
- if (c === '(') { stack.push({ quant: false }); continue; }
359
+ if (c === '(') { stack.push({ quant: false, alt: false, at: i + groupPrologueLen(src, i) }); continue; }
258
360
  if (c === ')') {
259
361
  const g = stack.pop();
260
362
  const qlen = unboundedQuantLen(src, i + 1);
261
363
  if (g && g.quant && qlen) return true; // group repeats unboundedly AND its body did too
364
+ // …and the SECOND class: the body's own top-level branches overlap, so the
365
+ // engine has more than one way to consume the same text and an unbounded
366
+ // repeat multiplies those ways (MEASURED: `(a|aa)+$` 12.1s on a 35-char
367
+ // body). `g.alt` carries an overlap found in a group BENEATH this one, for
368
+ // the redundant-wrapper reason one comment down.
369
+ const overlap = !!g && (g.alt || altBranchesOverlap(src.slice(g.at, i)));
370
+ if (overlap && qlen) return true;
371
+ if (overlap && stack.length) stack[stack.length - 1].alt = true;
262
372
  // The group is an unbounded-repeated atom of its parent when it is directly
263
373
  // re-quantified (qlen) OR its own body already repeats unboundedly (g.quant):
264
374
  // a redundant wrapper — ((a+))+ , (?:(a+))* , (((\d*)))+ — is the SAME
@@ -276,3 +386,50 @@ export function hasNestedQuantifier(src) {
276
386
  }
277
387
  return false;
278
388
  }
389
+
390
+ /**
391
+ * The catastrophic-backtracking reject, applied to a WHOLE signed document by
392
+ * tree walk — `sweepSecretLiterals`'s twin, and for its reason: some signed
393
+ * documents carry their regexes at a depth no per-field check enumerates.
394
+ *
395
+ * WHY IT EXISTS (PRD v1.60 §1, an OPEN GAP now closed). v1.55 took F49's reject
396
+ * to the operator's own regex fields, and the admissibility sweep that backed it
397
+ * walked `jobs/*.json`. That is not the population of signed specs: a REGISTRY
398
+ * BRIDGE is signed the same way, ships its plans VERBATIM (reuse stores the
399
+ * whole plan, never a template), and lives outside `jobs/`, so a pathological
400
+ * pattern arriving through a bridge was never swept. Nothing had decided
401
+ * bridge-borne patterns were safe — the sweep was written against the directory
402
+ * that held every spec at the time and was never re-aimed.
403
+ *
404
+ * WHAT is rejected is UNCHANGED: `hasNestedQuantifier`, one inventory, shared
405
+ * with job.js and plan.js. Only WHERE it looks is wider. Widening the DETECTOR
406
+ * is arbiter-adjacent and stays parked (F49); widening the POPULATION is this.
407
+ *
408
+ * KEY-SCOPED, deliberately. `pattern` and `gapKeep` are the exactly-three regex
409
+ * fields F49 names (`artifact-written.pattern`, `judged.pattern`, `gapKeep`);
410
+ * every other string in a signed document is prose, a path or a command, and
411
+ * running a regex-shape scan over prose would red goals for containing `(a+)+`
412
+ * in a sentence. A field carrying a regex under a NEW key is not swept until
413
+ * this list names it — stated so the limit is read rather than rediscovered.
414
+ *
415
+ * COMPILE-FIRST, like both per-field callers: the scan's contract is that its
416
+ * input is valid regex syntax, and a string that does not compile is the OTHER
417
+ * validator's red. Two reds on one string bury the real one.
418
+ * @param {any} root
419
+ * @param {(code: string, path: string, detail?: string) => void} red
420
+ */
421
+ export function sweepNestedQuantifiers(root, red) {
422
+ (/** @type {(node: any, at: string, key: string) => void} */
423
+ function sweep(node, at, key) {
424
+ if (typeof node === 'string') {
425
+ if (key !== 'pattern' && key !== 'gapKeep') return;
426
+ try { new RegExp(node, 'm'); } catch { return; }
427
+ if (hasNestedQuantifier(node)) {
428
+ red('invalid-value', at, `catastrophic-backtracking risk: a nested unbounded quantifier (${node}) can hang the untimed evaluator on the MAIN event loop, where the in-process stall fuse cannot fire (F49). Rewrite the pattern — drop the outer repeat, or match once.`);
429
+ }
430
+ return;
431
+ }
432
+ if (Array.isArray(node)) node.forEach((v, i) => sweep(v, `${at}.${i}`, key));
433
+ else if (isObj(node)) for (const [k, v] of Object.entries(node)) sweep(v, at ? `${at}.${k}` : k, k);
434
+ })(root, '', '');
435
+ }
@@ -0,0 +1,261 @@
1
+ // THE WORK BRANCH — runner mechanics, arbiter side. PRD Addendum v1.57 §3,
2
+ // hamr's ruling verbatim:
3
+ //
4
+ // "The agent creates a NEW BRANCH before it touches any code — a HARD RULE, no
5
+ // exceptions. Not a default, not a preference: no job edits the branch it was
6
+ // handed, and none edits `main`. Blast radius is bounded by where the work
7
+ // lands, not only by what the fence allows. Named with a meaningful slug —
8
+ // tool and problem, human-readable, of the form `bareloop-typecheck-fix`. A
9
+ // human scanning `git branch` must be able to tell what the run was for
10
+ // without opening the spec, which a run-id slug does not give them."
11
+ //
12
+ // WHAT IT IS AND IS NOT, stated here rather than left to be inferred (v1.57's own
13
+ // known-limitations discipline). bareloop never commits: the run leaves its work
14
+ // in the tree. So the guarantee this module actually delivers is precise —
15
+ //
16
+ // * the run's tree is standing on a branch NOBODY handed it, from the first
17
+ // moment a write-capable worker can exist to the end of the run, and
18
+ // * the handed ref (`main`, or whatever was checked out) is never moved,
19
+ // never written through, and never the thing a later commit attaches to.
20
+ //
21
+ // It is NOT containment: v1.41's local-trust model stands untouched, and a close
22
+ // or worker child can still reach anything the operator's OS user can reach. The
23
+ // branch bounds where the work LANDS. That is the whole claim.
24
+ //
25
+ // THE NAME IS OURS, NEVER THE MODEL'S. A branch name is arbiter bookkeeping: it
26
+ // says which history a run's blast radius belongs to. A model-authored one would
27
+ // be the agent authoring the arbiter's own books, so the name is DERIVED,
28
+ // mechanically and deterministically, from the SIGNED spec's own `job` field
29
+ // (see `workBranchName`).
30
+ //
31
+ // WHY THIS FILE OWNS ITS OWN git SEAM. `src/kinds.js` has one too, and its
32
+ // docstring says what it is: "git, read-only, in a repository" — the CLOSE's
33
+ // measuring instrument, whose faults are close faults. This one MUTATES the
34
+ // repository (it creates and checks out a ref) and its faults are runner
35
+ // instrument stops before a single token is bought. Two verbs, two failure
36
+ // vocabularies, two owners; the read-only one is not exported and could not be
37
+ // shared without widening it into a write seam.
38
+
39
+ import { execFile } from 'node:child_process';
40
+ import { promisify } from 'node:util';
41
+
42
+ const exec = promisify(execFile);
43
+
44
+ /** Every work branch carries it. It is what makes `main`, `master` and every
45
+ * branch a human could plausibly have handed the run INEXPRESSIBLE as a work
46
+ * branch name — a structural guarantee rather than a denylist somebody has to
47
+ * keep current. */
48
+ export const WORK_BRANCH_PREFIX = 'bareloop-';
49
+
50
+ /** The full legal shape of a work branch. Deliberately narrower than git's own
51
+ * `check-ref-format`: the kebab alphabet the job validator already holds every
52
+ * spec slug to (`src/job.js` SLUG_RE), plus the prefix. Everything git forbids —
53
+ * `..`, `@{`, a trailing `.lock`, a leading `-`, a `/` component, control
54
+ * characters — is unreachable inside it, so ref-safety is a property of the
55
+ * alphabet and never a sanitiser somebody has to remember to call. */
56
+ export const WORK_BRANCH_RE = /^bareloop-[a-z0-9][a-z0-9-]*$/;
57
+
58
+ /** The slug's ceiling, before any collision suffix. A ref component has a
59
+ * filesystem-length limit and a human scanning `git branch` has a much smaller
60
+ * one; 60 characters is longer than every job slug in `jobs/` and short enough
61
+ * to read in a column. */
62
+ export const MAX_SLUG_LEN = 60;
63
+
64
+ /** How far the collision walk goes before it STOPS and says so. A run that
65
+ * cannot find a free name in this many attempts is looking at a patient nobody
66
+ * has swept in a very long time, and silently walking to `-500` would be the
67
+ * runner papering over an operator problem. */
68
+ export const MAX_BRANCH_COLLISIONS = 50;
69
+
70
+ /** git, in a repository, WRITE-CLASS. A non-zero exit is a value, never a throw:
71
+ * every caller here decides for itself whether git refusing is a stop.
72
+ * @param {string} cwd @param {string[]} args
73
+ * @returns {Promise<{ok: boolean, out: string, err: string}>}
74
+ */
75
+ async function git(cwd, args) {
76
+ try {
77
+ const { stdout, stderr } = await exec('git', args, {
78
+ cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 1024 * 1024,
79
+ });
80
+ return { ok: true, out: String(stdout ?? ''), err: String(stderr ?? '') };
81
+ } catch (e) {
82
+ const err = /** @type {any} */ (e);
83
+ // A spawn failure (no git on PATH, cwd does not exist) has no streams at all;
84
+ // its `message` is the only thing there is to report, and reporting nothing
85
+ // would turn "git is not installed" into a mysterious silent refusal.
86
+ const detail = String(err?.stderr ?? '').trim() || String(err?.message ?? err).trim();
87
+ return { ok: false, out: String(err?.stdout ?? ''), err: detail };
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The spec's own words, reduced to the kebab alphabet. Lossy on purpose: the
93
+ * output alphabet IS the ref-safety guarantee, so anything outside it collapses
94
+ * to a separator rather than being escaped into something clever.
95
+ * @param {unknown} s
96
+ * @returns {string} `''` when nothing usable survives
97
+ */
98
+ function slugify(s) {
99
+ return String(s ?? '')
100
+ .toLowerCase()
101
+ .replace(/[^a-z0-9]+/g, '-')
102
+ .replace(/^-+/, '')
103
+ .slice(0, MAX_SLUG_LEN)
104
+ .replace(/-+$/, '');
105
+ }
106
+
107
+ /**
108
+ * The branch name a job's run works on, DERIVED from the signed spec.
109
+ *
110
+ * **The source is `spec.job`, and the choice is load-bearing.** It is the one
111
+ * human-authored, REQUIRED, kebab-case name the spec carries; it is inside the
112
+ * spec hash, so the branch name cannot drift from what the operator signed; and
113
+ * it is stable across a resume and across a cold re-run of the same spec, which
114
+ * is precisely why the collision walk below has to exist. In practice it already
115
+ * reads as tool-and-problem (`bareagent-u-types`, `litectx-u-types`), which is
116
+ * the shape hamr's ruling asks for.
117
+ *
118
+ * **The goal prose was REJECTED as a source**, and not for tidiness: a goal is
119
+ * long, is edited freely between runs without changing what the job IS, and
120
+ * opens on boilerplate ("An existing pytest test suite for the SOAR
121
+ * orchestrator…"). Deriving from it would make the branch name unstable under
122
+ * edits that change nothing, and unreadable when it was stable. A run id was
123
+ * rejected by the ruling itself.
124
+ *
125
+ * Deterministic, pure, and total: any input yields a name matching
126
+ * `WORK_BRANCH_RE`, so a caller never has to check.
127
+ * @param {any} spec a job-v1 spec (validated or raw)
128
+ * @returns {string}
129
+ */
130
+ export function workBranchName(spec) {
131
+ const slug = slugify(spec?.job);
132
+ // `job` is REQUIRED and kebab-validated by `validateJob`, which runs before the
133
+ // runner ever reaches here — so the fallback is unreachable through `runJob`.
134
+ // It exists because this function is exported and total: a name is the wrong
135
+ // thing to throw about, and `bareloop-job` is honest ("a bareloop job") rather
136
+ // than invented.
137
+ return `${WORK_BRANCH_PREFIX}${slug || 'job'}`;
138
+ }
139
+
140
+ /** does a local branch of this name exist? @param {string} cwd @param {string} name */
141
+ async function branchExists(cwd, name) {
142
+ const r = await git(cwd, ['show-ref', '--verify', '--quiet', `refs/heads/${name}`]);
143
+ return r.ok;
144
+ }
145
+
146
+ /**
147
+ * Put the run on its OWN branch, before it can touch any code.
148
+ *
149
+ * The three shapes, and each one's honest answer:
150
+ *
151
+ * - **Cold.** `name` is free → create it at HEAD and switch. The handed branch
152
+ * keeps its ref and its history; a new branch at HEAD moves no commit, so this
153
+ * costs the patient nothing and changes nothing it can measure (the declared
154
+ * close's seed is HEAD either way).
155
+ * - **Cold, and the name is taken.** Suffix `-2`, `-3`… until one is free.
156
+ * Deterministic given the repository, and it NEVER reuses another run's
157
+ * branch — a second run of the same spec is a second blast radius.
158
+ * - **Resume.** `resume` names the branch the killed leg recorded on its own
159
+ * spine. It is checked out, never re-created and never suffixed: the work the
160
+ * operator already paid for is sitting on it, and minting `-2` beside it would
161
+ * strand exactly the progress a resume exists to keep. A `resume` branch that
162
+ * is GONE is a STOP, never a silent fresh start — the run would be grading a
163
+ * tree whose history it does not own.
164
+ *
165
+ * **Every failure is a stop with a name.** Not a git repository, no git on PATH,
166
+ * a checkout git refuses, a crowded namespace, a resume branch a human deleted:
167
+ * each comes back as `stop` and `branch: null`. There is no fallback to "just
168
+ * work on whatever was handed to us" anywhere in this function, because that
169
+ * fallback IS the thing the hard rule forbids.
170
+ *
171
+ * A DETACHED head is not a failure. Creating a branch from it is exactly the
172
+ * bounding the rule asks for (and it attaches a head that was floating), so it
173
+ * succeeds with `from: null` — there was no branch to be handed, and the record
174
+ * says so rather than inventing one.
175
+ *
176
+ * @param {string} workdir the patient (the fence's root)
177
+ * @param {object} o
178
+ * @param {string} o.name the derived name (`workBranchName`); refused unless it
179
+ * matches `WORK_BRANCH_RE`, which is what makes `main` unreachable even from a
180
+ * hand-passed argument
181
+ * @param {string|null} [o.resume] RESUME — the branch this run already owns
182
+ * @returns {Promise<{stop: string, branch: null}
183
+ * | {stop: null, branch: string, created: boolean, resumed: boolean, from: string|null, base: string|null,
184
+ * repo: string, collided: number}>}
185
+ */
186
+ export async function prepareWorkBranch(workdir, { name, resume = null }) {
187
+ const wanted = String(name ?? '');
188
+ if (!WORK_BRANCH_RE.test(wanted)) {
189
+ return { stop: `the work branch name ${JSON.stringify(wanted)} is not a bareloop work branch (${WORK_BRANCH_RE.source}) — a run never works on a branch it was handed`, branch: null };
190
+ }
191
+
192
+ // Is there a repository at all? Asked FIRST and by its own question, so "this
193
+ // patient is not a git checkout" reads as itself instead of arriving later
194
+ // disguised as a checkout failure.
195
+ //
196
+ // `--show-toplevel` rather than `--git-dir`, and the answer is REPORTED: git
197
+ // resolves upwards, so a workdir that is a SUBDIRECTORY of a repository gets a
198
+ // branch in that enclosing repository — a blast radius wider than the operator
199
+ // handed over. That is the pre-existing semantics of every other git read in
200
+ // the flow (`seedAtHead`, `changedSet`), so it is not changed here; what it must
201
+ // not be is invisible, and the record names the repository that got the branch.
202
+ const inside = await git(workdir, ['rev-parse', '--show-toplevel']);
203
+ if (!inside.ok) {
204
+ return { stop: `the work branch cannot be created in ${workdir}: ${inside.err || 'not a git repository'}`, branch: null };
205
+ }
206
+ const repo = inside.out.trim().split('\n')[0].trim() || workdir;
207
+
208
+ // WHERE the run was handed. A symbolic HEAD names the handed branch; a detached
209
+ // one names nothing, and `null` is the honest reading of that (never the string
210
+ // "HEAD", which a reader cannot tell from a branch called HEAD).
211
+ const sym = await git(workdir, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
212
+ const from = sym.ok ? sym.out.trim() || null : null;
213
+ const rev = await git(workdir, ['rev-parse', 'HEAD']);
214
+ const base = rev.ok ? (rev.out.trim().split('\n')[0].trim() || null) : null;
215
+
216
+ if (resume !== null && resume !== undefined) {
217
+ const own = String(resume);
218
+ if (!WORK_BRANCH_RE.test(own)) {
219
+ return { stop: `the recorded work branch ${JSON.stringify(own)} is not a bareloop work branch (${WORK_BRANCH_RE.source}) — this resume would continue on a branch the run does not own`, branch: null };
220
+ }
221
+ if (!await branchExists(workdir, own)) {
222
+ return { stop: `the work branch ${JSON.stringify(own)} this run was killed on no longer exists in ${workdir} — the resume stops rather than starting a new branch beside work it cannot see`, branch: null };
223
+ }
224
+ // Already standing on it (the ordinary case — a kill leaves the tree where it
225
+ // was) is a NO-OP, deliberately: `git checkout` onto the current branch is
226
+ // harmless today, and not running it at all is what keeps a dirty in-flight
227
+ // tree out of git's hands entirely.
228
+ if (from !== own) {
229
+ const co = await git(workdir, ['checkout', own]);
230
+ if (!co.ok) return { stop: `could not return to the work branch ${JSON.stringify(own)} in ${workdir}: ${co.err}`, branch: null };
231
+ }
232
+ return { stop: null, branch: own, created: false, resumed: true, from, base, repo, collided: 0 };
233
+ }
234
+
235
+ // The collision walk. `-2` is the first suffix rather than `-1`, so the branch a
236
+ // human reads without a number is the first run and the numbers count runs the
237
+ // way a person does.
238
+ //
239
+ // CHECK-THEN-ACT, named rather than fixed: another process can create the
240
+ // candidate between the `branchExists` read and the `checkout -b` below. It is
241
+ // documented instead of closed because git already fails CLOSED on it — `git
242
+ // checkout -b` REFUSES an existing branch, so the race ends in the named stop
243
+ // right beneath this loop, never in two runs sharing one blast radius. Closing
244
+ // it properly means atomic ref creation, which is a wider change than the
245
+ // failure it would prevent.
246
+ let collided = 0;
247
+ let candidate = wanted;
248
+ while (await branchExists(workdir, candidate)) {
249
+ collided += 1;
250
+ if (collided >= MAX_BRANCH_COLLISIONS) {
251
+ return { stop: `every work-branch name from ${JSON.stringify(wanted)} through ${JSON.stringify(`${wanted}-${MAX_BRANCH_COLLISIONS}`)} is already taken in ${workdir} — sweep the patient's old work branches rather than letting the runner walk further`, branch: null };
252
+ }
253
+ candidate = `${wanted}-${collided + 1}`;
254
+ }
255
+
256
+ const co = await git(workdir, ['checkout', '-b', candidate]);
257
+ if (!co.ok) {
258
+ return { stop: `could not create the work branch ${JSON.stringify(candidate)} in ${workdir}: ${co.err}`, branch: null };
259
+ }
260
+ return { stop: null, branch: candidate, created: true, resumed: false, from, base, repo, collided };
261
+ }