bareloop 0.26.0 → 0.27.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/job.js CHANGED
@@ -192,6 +192,42 @@ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
192
192
  * bareloop's OWN menu. Inferring it downstream is how a bareloop refusal gets
193
193
  * filed as an upstream bug (the BA-2 misattribution class). */
194
194
 
195
+ /**
196
+ * The `baseUrl` SHAPE rule, standalone (PRD item 28 ruling (d); PRD item 33
197
+ * close-out, "authoring provider selectable" — a bad `--base-url` used to
198
+ * refuse only at the end of the interview, through `validateJob`, after every
199
+ * question had already been asked). ONE spelling of the rule: `validateJob`
200
+ * below calls this, and so does `scripts/run-interview.mjs`, at flag-parse
201
+ * time, before it asks anything.
202
+ *
203
+ * No network access, so this checks SHAPE only: `https://` for a real
204
+ * endpoint, or `http://` restricted to a loopback host (a local
205
+ * OpenAI-compatible server, the only legitimate reason to skip TLS) — and
206
+ * never a credential embedded IN the URL itself. A URL travels into logs and
207
+ * spine records far more casually than a header ever would, and secrets never
208
+ * enter the tree/spine (hard line #3) — a baseUrl carrying `user:pass@host`
209
+ * is exactly that leak, just spelled differently. The returned message never
210
+ * echoes the value it judged — it is printed as-is by both callers, and a
211
+ * value that failed for carrying credentials must not repeat them back.
212
+ * @param {unknown} value
213
+ * @returns {string|null} a red `detail`, or `null` when the value is admitted
214
+ */
215
+ export function validateBaseUrl(value) {
216
+ if (!isNonEmptyString(value)) {
217
+ return 'non-empty string — an https:// URL (http:// admitted only to 127.0.0.1/localhost/::1)';
218
+ }
219
+ /** @type {URL|null} */
220
+ let u = null;
221
+ try { u = new URL(value); } catch { /* u stays null — caught below */ }
222
+ if (!u) return 'must be a parseable URL';
223
+ if (u.username !== '' || u.password !== '') return 'no embedded credentials (user:pass@host) — secrets never enter a signed spec';
224
+ if (u.protocol === 'https:') return null; // admitted
225
+ if (u.protocol === 'http:' && (u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1')) {
226
+ return null; // admitted — a local OpenAI-compatible endpoint, the one case that legitimately skips TLS
227
+ }
228
+ return 'https:// required (http:// admitted only to 127.0.0.1/localhost/::1)';
229
+ }
230
+
195
231
  /**
196
232
  * Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
197
233
  * JSON text or plain parsed data — the ingest contract; a live object with a
@@ -329,33 +365,13 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
329
365
 
330
366
  // BASE URL — PRD item 28, ruling (d), 2026-09-09: admitted in v1 of
331
367
  // openai-api (an OpenAI-compatible gateway/endpoint), OPTIONAL and part of
332
- // the signed hash like `model` above. This validator has no network
333
- // access, so it checks SHAPE only: `https://` for a real endpoint, or
334
- // `http://` restricted to a loopback host (a local OpenAI-compatible
335
- // server, the only legitimate reason to skip TLS) — and never a
336
- // credential embedded IN the URL itself. A URL travels into logs and
337
- // spine records far more casually than a header ever would, and secrets
338
- // never enter the tree/spine (hard line #3) — a bareUrl carrying
339
- // `user:pass@host` is exactly that leak, just spelled differently.
368
+ // the signed hash like `model` above. `validateBaseUrl` (below) is the ONE
369
+ // spelling of the shape rule — `scripts/run-interview.mjs` calls it too, at
370
+ // flag-parse time, before the interview asks a single question, rather than
371
+ // this file's copy being the only one that ever ran it.
340
372
  if (spec.baseUrl !== undefined) {
341
- if (!isNonEmptyString(spec.baseUrl)) {
342
- red('invalid-value', 'baseUrl', 'non-empty string — an https:// URL (http:// admitted only to 127.0.0.1/localhost/::1)');
343
- } else {
344
- /** @type {URL|null} */
345
- let u = null;
346
- try { u = new URL(spec.baseUrl); } catch { /* u stays null — caught below */ }
347
- if (!u) {
348
- red('invalid-value', 'baseUrl', 'must be a parseable URL');
349
- } else if (u.username !== '' || u.password !== '') {
350
- red('invalid-value', 'baseUrl', 'no embedded credentials (user:pass@host) — secrets never enter a signed spec');
351
- } else if (u.protocol === 'https:') {
352
- // admitted
353
- } else if (u.protocol === 'http:' && (u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1')) {
354
- // admitted — a local OpenAI-compatible endpoint, the one case that legitimately skips TLS
355
- } else {
356
- red('invalid-value', 'baseUrl', 'https:// required (http:// admitted only to 127.0.0.1/localhost/::1)');
357
- }
358
- }
373
+ const err = validateBaseUrl(spec.baseUrl);
374
+ if (err) red('invalid-value', 'baseUrl', err);
359
375
  }
360
376
 
361
377
  // 3. the outer write fence — operator law (interview decision #4), same
package/src/planrun.js CHANGED
@@ -11,7 +11,14 @@
11
11
  // absolute repo root, its target, prior steps' artifacts labeled by id, its
12
12
  // gap, and a cut-off notice. It NEVER sees the budget, the close command, a
13
13
  // check's command, the validator, other steps' grants, or the arbiter's books
14
- // (fs.deny on the gate audit / .smoke / .litectx, unchanged).
14
+ // (fs.deny on the gate audit / .smoke / .litectx, unchanged). `.git` and
15
+ // `node_modules` are a DIFFERENT law (F178/F177): WRITES there are blocked
16
+ // (tools.denyArgPatterns on write/edit, FORBIDDEN_WRITE_SEGMENT_PATTERN,
17
+ // below), but READS are not — fs.deny blocks ALL fs actions including reads
18
+ // (bareguard node_modules/bareguard/src/primitives/fs.js:43,62-66), and
19
+ // hamr's ruling was to block WRITING, never reading; the worker's persona
20
+ // also never names `.git`/`node_modules` as denied books (tests/tools.test.js),
21
+ // which putting either in fs.deny would silently contradict.
15
22
 
16
23
  import { createRequire } from 'node:module';
17
24
  import { readFileSync, readdirSync } from 'node:fs';
@@ -465,7 +472,6 @@ const REPLAN_GAP_KEEP = '\\S';
465
472
  * No gap → the empty string, so the brief renders byte-identically to the pre-F86
466
473
  * one. A labelled empty section would be an invitation to explain an absence the
467
474
  * run never observed (a stall never judged its exits at all).
468
- *
469
475
  * @param {string | null | undefined} gap the step's last exit gap text
470
476
  * @returns {string} the labelled block, or '' when there is nothing to show
471
477
  */
@@ -480,6 +486,48 @@ export function closeGapBlock(gap) {
480
486
  + (scrubbed.length > CHECK_GAP_MAX ? boundGap(scrubbed, REPLAN_GAP_KEEP) : scrubbed);
481
487
  }
482
488
 
489
+ /**
490
+ * The runtime belt for F178 (`.git`) and F177's follow-up (`node_modules`) —
491
+ * hamr's option B ruling (2026-09-14): block WRITING to either, never reading.
492
+ * bareguard's `fs.deny` cannot be used for this: it blocks ALL fs actions,
493
+ * reads included (node_modules/bareguard/src/primitives/fs.js:43 "paths/
494
+ * prefixes denied for all fs actions", checked at :62-66 before the read/write
495
+ * split) — putting `.git` there (the first version of this fix) silently also
496
+ * blocked READING it, which was never ruled, and the worker's persona never
497
+ * names `.git`/`node_modules` as a denied book the way it does the gate audit
498
+ * / `.smoke` / `.litectx` (`tests/tools.test.js`'s persona/fence-drift guard
499
+ * caught exactly this in the full gate). `fs.deny` is exact prefix-containment
500
+ * only anyway — no glob, no path-segment match — confirmed by reading
501
+ * `within()` (node_modules/bareguard/src/primitives/fs.js:26-31): `p === b ||
502
+ * p.startsWith(b + '/')` against each configured entry, nothing else; it
503
+ * could not express `node_modules`-at-any-depth even if reads were not a
504
+ * problem (`node_modules/bareguard/src/glob.js`'s `matchAny`/`globToRegex`
505
+ * exist in the same package but `fs.js` never imports them).
506
+ *
507
+ * `tools.denyArgPatterns` (`node_modules/bareguard/src/primitives/tools.js`,
508
+ * step 3 of `Gate#_stepEval` — the SAME step `fsCheck` runs at, checked right
509
+ * after it in the `??` chain, `node_modules/bareguard/src/gate.js`) is the
510
+ * supported hook used instead: a RegExp tested against
511
+ * `JSON.stringify(action)`, keyed by `action.type` so it is scoped to
512
+ * `write`/`edit` ONLY — a `read` action never reaches this pattern at all.
513
+ * Because `fsCheck` runs first in the same `??` chain, this pattern is
514
+ * reached only when the path already passed `writeScope`/`deny` — exactly the
515
+ * residual case (an in-fence write/edit whose path still carries a `.git` or
516
+ * `node_modules` segment; `.git` itself is never IN a signed fence in the
517
+ * first place, `scopeContained` in src/validate.js refuses that at
518
+ * declaration time — this is the belt for a validator regression). Matched
519
+ * whole-segment (a look-alike like `.github`/`.gitignore`/`my.git`/
520
+ * `node_modules_util` stays admitted). Safe to match the WHOLE serialized
521
+ * action (not just the `path` field in isolation) because bareloop's own
522
+ * action shape (`toolAction`, src/tools.js) never carries a write's byte
523
+ * CONTENT in the action object — `args.bytes` is a length, not the text — so
524
+ * there is no written-file-content collision risk the way there would be for
525
+ * bareguard's own `content.denyPatterns` primitive (which explicitly strips
526
+ * payload fields for exactly that reason).
527
+ * @type {RegExp}
528
+ */
529
+ export const FORBIDDEN_WRITE_SEGMENT_PATTERN = /"path":"(?:(?:[^"\\]|\\.)*\/)?(?:\.git|node_modules)(?:\/|")/;
530
+
483
531
  /**
484
532
  * The recorded bound-reason's ceiling in the WORKER-facing note. The reason is a
485
533
  * provider string (`denied:<tool>` today, ~20 bytes), not a gap, so this is a
@@ -2250,6 +2298,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2250
2298
  readScope: [workdir],
2251
2299
  deny: [auditPath, ...ARBITER_BOOK_STORES.map((s) => join(workdir, s))],
2252
2300
  },
2301
+ // F178/F177's runtime belt: `.git` and `node_modules` WRITES only — never
2302
+ // fs.deny, which blocks reads too (see FORBIDDEN_WRITE_SEGMENT_PATTERN's
2303
+ // own doc). Reached only when a write/edit path already cleared
2304
+ // fs.writeScope/deny above (step 3's fsCheck runs first in the SAME `??`
2305
+ // chain, node_modules/bareguard/src/gate.js) — i.e. only the residual
2306
+ // case of an in-fence path that still carries one of these segments.
2307
+ tools: { denyArgPatterns: { write: [FORBIDDEN_WRITE_SEGMENT_PATTERN], edit: [FORBIDDEN_WRITE_SEGMENT_PATTERN] } },
2253
2308
  budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
2254
2309
  limits: { maxTurns },
2255
2310
  audit: { path: auditPath },
@@ -2756,6 +2811,30 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2756
2811
  err.lib = 'bare-agent';
2757
2812
  throw err;
2758
2813
  }
2814
+ // F184 — bare-agent 0.43.0 (BA-27) returns a malformed final-turn tool
2815
+ // call PRICED rather than throwing: `toolCalls: []`, `text: ''`,
2816
+ // `error: null`, and its own `malformedToolCall: {name, error}`
2817
+ // marker. Nothing below this point can tell that apart from the model
2818
+ // genuinely producing no useful work — `middle()` reads `r.text` into
2819
+ // `lastText`, which comes back empty either way, and the ordinary
2820
+ // `needs_revision`/`exit-eval` gap is what reaches the spine. That is
2821
+ // NOT changed here — no retry, no new cause on `attemptBounded`, no
2822
+ // effect on strike/ladder/attempt counting or verdict routing (all
2823
+ // arbiter-adjacent, none of this finding's scope). What was missing is
2824
+ // VISIBILITY: this is the one place bareloop code ever sees the
2825
+ // marker before `ask()` returns it unexamined, so a distinct spine
2826
+ // record names the transport class here — the same honesty bar
2827
+ // F179/F180 set for the authoring path, applied to the worker path.
2828
+ if (r.malformedToolCall) {
2829
+ const rawErr = String(r.malformedToolCall.error ?? '');
2830
+ const scrubbedErr = scrub(rawErr);
2831
+ const shownErr = scrubbedErr.length > BOUND_REASON_MAX
2832
+ ? `${scrubbedErr.slice(0, BOUND_REASON_MAX)} [${GAP_TRIM_MARKER} ${scrubbedErr.length - BOUND_REASON_MAX} of ${scrubbedErr.length} characters withheld — the cap is ${BOUND_REASON_MAX}]`
2833
+ : scrubbedErr;
2834
+ emit('worker-malformed-tool-call', {
2835
+ phase, iteration: roundIteration, name: scrub(String(r.malformedToolCall.name ?? '')), error: shownErr,
2836
+ });
2837
+ }
2759
2838
  return r;
2760
2839
  };
2761
2840
  return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
@@ -76,6 +76,14 @@ export const PROMPT_REGISTERS = Object.freeze([
76
76
 
77
77
  // src/planrun.js — the native-surface read-truncation strategy line
78
78
  Object.freeze({ file: 'src/planrun.js', name: 'NATIVE_READ_STRATEGY' }),
79
+
80
+ // src/authoring.js — the types-genre close template, interpolated verbatim
81
+ // into the authoring prompt (src/authorflow.js's assembled system message,
82
+ // "THE GENRE TEMPLATE" block) — model-facing law for a types-genre
83
+ // declaration, not merely internal genre-table data. Found missing from
84
+ // this inventory 2026-09-15 (item 6 close-out); no other exported const or
85
+ // inline template literal in this file carries prompt/instruction text.
86
+ Object.freeze({ file: 'src/authoring.js', name: 'TYPES_GENRE_TEMPLATE' }),
79
87
  ]);
80
88
 
81
89
  /** The distinct file paths carrying prompt registers, repo-root-relative with
package/src/providers.js CHANGED
@@ -201,6 +201,42 @@ export function resolveProvider(providerName) {
201
201
  return entry;
202
202
  }
203
203
 
204
+ /**
205
+ * Report whether a resolved API key VALUE (not its presence — every call
206
+ * site already presence-checks separately) carries a shape an HTTP header
207
+ * value cannot: F181 (`docs/logs/FINDINGS.md`) — a two-line secret-store
208
+ * entry put the key plus a trailing metadata line into `OPENAI_API_KEY`
209
+ * (`"<key>\nmeta"`), the presence-only check at every door passed, and Node
210
+ * crashed `TypeError [ERR_INVALID_CHAR]` inside its own header-encode at the
211
+ * FIRST paid call — after the run had already announced itself as spending,
212
+ * inside the paid span. curl tolerated the same value (HTTP 200 twice), so a
213
+ * curl probe is a blind instrument for this defect class.
214
+ *
215
+ * NEVER TRIMS OR REPAIRS — this reports a reason, it does not fix the value
216
+ * (hamr's retry-never-repair rule, in spirit: refuse loud, don't guess at
217
+ * what the operator's secret store meant). Every call site that gets a
218
+ * non-null reason back refuses at $0, in the SAME place and the SAME way it
219
+ * already refuses a missing key — this function only adds a second shape of
220
+ * refusal to a door that already had one.
221
+ *
222
+ * The checked shape is deliberately wider than what Node's own header
223
+ * encoder rejects (Node tolerates a bare tab in a header value): CR, LF, TAB,
224
+ * every other C0/DEL control character, and leading/trailing whitespace all
225
+ * refuse, because every one of them is evidence the resolved value is not a
226
+ * clean single-line secret — the same class of "reads as one plausible thing,
227
+ * is actually stale/wrong/multi-line" this finding named.
228
+ * @param {string} value the resolved key value — never logged, never echoed by any caller
229
+ * @returns {string|null} a plain-English reason class, or `null` when the value is clean
230
+ */
231
+ export function apiKeyProblem(value) {
232
+ if (/[\r\n]/.test(value)) return 'contains a line break';
233
+ if (/\t/.test(value)) return 'contains a tab character';
234
+ // eslint-disable-next-line no-control-regex -- deliberately scanning C0/DEL controls
235
+ if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(value)) return 'contains a control character';
236
+ if (value !== value.trim()) return 'has leading or trailing whitespace';
237
+ return null;
238
+ }
239
+
204
240
  /**
205
241
  * Construct ONE provider instance for `providerName`/`model`, applying
206
242
  * `exposeErrorBody: true` (F153 — every constructed provider, unconditionally)
package/src/source.js CHANGED
@@ -23,7 +23,7 @@
23
23
  // on rather than catch.
24
24
 
25
25
  import { createHash } from 'node:crypto';
26
- import { existsSync, lstatSync } from 'node:fs';
26
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
27
27
  import { mkdir, readdir, readFile, writeFile, lstat, stat, chmod, access, copyFile, cp, open, rm, realpath, readlink, symlink, constants as fsConstants } from 'node:fs/promises';
28
28
  import { dirname, join, relative, resolve, sep, basename } from 'node:path';
29
29
  import { git } from './kinds.js';
@@ -76,6 +76,43 @@ const MAX_SAME_DAY = 99;
76
76
  /** @param {string} code @param {string} stop @returns {SourceRefusal} */
77
77
  const refuse = (code, stop) => ({ stop, code });
78
78
 
79
+ /**
80
+ * F177 / hamr's ruling (2026-09-14, option A): a `kind: 'repo'` copy's own
81
+ * `node_modules/` stays hidden from `changedSet` (src/kinds.js) even when the
82
+ * SOURCE repo's tracked `.gitignore` does not mention it — the person
83
+ * installs packages into the copy themselves (`missingDependencies`,
84
+ * `source-deps-missing`), and an unignored `node_modules` then reads as 247
85
+ * files of "the worker's own writes" (live-verified against a real copy of
86
+ * pulselog). The fix writes into the copy's PRIVATE exclude file
87
+ * (`.git/info/exclude`, resolved via `--git-path` rather than assumed —
88
+ * worktree layouts keep it in the common dir, not the per-worktree one) —
89
+ * never the tracked `.gitignore` — so the source repo's own files, including
90
+ * its `.gitignore` bytes, are never touched. Idempotent: an existing
91
+ * `node_modules/` line is left alone rather than duplicated.
92
+ *
93
+ * Plain folder/file/URL sources never call this — they have no `.git` of
94
+ * their own to carry a private exclude, and (M2b fix 6, above) their
95
+ * `.gitignore` is deliberately powerless over what the seed holds at all.
96
+ * @param {string} treeDir
97
+ * @returns {Promise<SourceRefusal|null>}
98
+ */
99
+ async function hideNodeModulesInCopy(treeDir) {
100
+ const gp = await git(treeDir, ['rev-parse', '--git-path', 'info/exclude']);
101
+ if (!gp.ok) return refuse('source-git-failed', `git rev-parse --git-path info/exclude failed in ${treeDir}: ${gp.err}`);
102
+ const excludeRel = gp.out.trim().split('\n')[0].trim();
103
+ if (!excludeRel) return refuse('source-git-failed', `git rev-parse --git-path info/exclude returned nothing in ${treeDir}`);
104
+ const excludePath = join(treeDir, excludeRel);
105
+ await mkdir(dirname(excludePath), { recursive: true });
106
+ let existing = '';
107
+ try { existing = await readFile(excludePath, 'utf8'); } catch { /* no exclude file yet — created fresh below */ }
108
+ const hasLine = existing.split('\n').some((l) => l.trim() === 'node_modules/');
109
+ if (!hasLine) {
110
+ const sep = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
111
+ await writeFile(excludePath, `${existing}${sep}node_modules/\n`);
112
+ }
113
+ return null;
114
+ }
115
+
79
116
  /**
80
117
  * Walk from `startDir` upward — inclusive — to the NEAREST ancestor whose own
81
118
  * directory carries a `.git` entry, whether that entry is a real repo
@@ -611,6 +648,10 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
611
648
  // strip the copied hooks (see NO_HOOKS above) — `force: true` because a
612
649
  // repo with no hooks configured has no `hooks/` to remove at all
613
650
  await rm(join(treeDir, '.git', 'hooks'), { recursive: true, force: true });
651
+ // F177: hide the copy's own future `node_modules/` before the seed is
652
+ // committed below — see `hideNodeModulesInCopy`'s docstring
653
+ const hideStop = await hideNodeModulesInCopy(treeDir);
654
+ if (hideStop !== null) return hideStop;
614
655
  }
615
656
  await mkdir(outputDir, { recursive: true });
616
657
  // git tracks no empty directories — a `.gitkeep` is what makes `output/`
@@ -712,6 +753,102 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
712
753
  return { stop: null, into: intoAbs, tree: treeDir, manifestPath, manifest };
713
754
  }
714
755
 
756
+ /**
757
+ * The install command a lockfile in `dir` implies — checked in a fixed order
758
+ * so a tree carrying more than one lockfile still picks exactly one command,
759
+ * deterministically. `npm install` (no lockfile match) is the fallback, never
760
+ * a refusal — a repo can legally have dependencies with no committed
761
+ * lockfile.
762
+ * @type {ReadonlyArray<{file: string, command: string}>}
763
+ */
764
+ const LOCKFILE_COMMANDS = Object.freeze([
765
+ Object.freeze({ file: 'package-lock.json', command: 'npm ci' }),
766
+ Object.freeze({ file: 'npm-shrinkwrap.json', command: 'npm ci' }),
767
+ Object.freeze({ file: 'pnpm-lock.yaml', command: 'pnpm install --frozen-lockfile' }),
768
+ Object.freeze({ file: 'yarn.lock', command: 'yarn install --frozen-lockfile' }),
769
+ Object.freeze({ file: 'bun.lockb', command: 'bun install --frozen-lockfile' }),
770
+ Object.freeze({ file: 'bun.lock', command: 'bun install --frozen-lockfile' }),
771
+ ]);
772
+
773
+ /**
774
+ * THE INSTALL-GAP DETECTOR (PRD item 33 close-out, `docs/product/PRD.md:450-452`,
775
+ * hamr's ruling 2026-09-14, option A): `prepareSource` copies ONLY git-tracked
776
+ * files (hamr's ruling, `docs/product/ITEM33-BUILD.md:225-239` — unchanged by
777
+ * this function), so a JS/TS repo's prepared copy never carries `node_modules`,
778
+ * and every close stage needing a tool (`tsc`, a test runner) instrument-stops
779
+ * with nothing telling the person why. bareloop NEVER runs an install itself —
780
+ * it only ever touches gated primitives, and a shell-out to `npm`/etc from
781
+ * inside this module would be exactly the verb hamr's law reserves for the
782
+ * worker's own granted tools, never the arbiter-side machinery that decides
783
+ * whether a run even starts. This is a pure, $0 DETECTOR: it names the gap and
784
+ * the exact command a person runs THEMSELVES in the copy, then returns.
785
+ *
786
+ * JS/TS ONLY for now — a repo in another detected language returns `null`
787
+ * here unconditionally (no `package.json` to find), which is the correct
788
+ * "nothing to report" reading, not a gap in this function; per-language
789
+ * dependency-gap data for other languages is M3b's job
790
+ * (`docs/product/ITEM33-BUILD.md`, "M3b"), not this one's.
791
+ *
792
+ * SUBFOLDER-AS-REPO (ruling 2 addendum, commit 2a020d8): a subfolder job
793
+ * freezes the WHOLE repo, so `package.json` may sit at the repo root, at the
794
+ * subfolder itself, or anywhere between — the SAME nearest-manifest-walking-up
795
+ * rule `detectLanguage` (`src/detectlang.js`) applies, done independently here
796
+ * (not by importing `detectLanguage`) to avoid a `source.js` ⇄ `detectlang.js`
797
+ * import cycle, since `detectlang.js` already imports `nearestGitAncestor`
798
+ * from this module.
799
+ *
800
+ * @param {string} treeDir the prepared tree's root (`prepareSource`'s
801
+ * returned `tree`, e.g. `<into>/tree` — a REPO source sits directly at this
802
+ * root; every other kind sits under `input/` inside it and has no repo
803
+ * dependency story yet, so callers only need this for a `kind: 'repo'`
804
+ * manifest)
805
+ * @param {string} [sourceSubdir] the manifest's own `sourceSubdir` ('' or
806
+ * undefined when Source IS the repo root)
807
+ * @returns {{manager: string, command: string, reason: string}|null} `null`
808
+ * when there is nothing to report: no JS/TS manifest found walking up from
809
+ * `sourceSubdir` to `treeDir`, the nearest one found has no dependencies,
810
+ * `node_modules` is already there, or the manifest cannot even be read as
811
+ * JSON (an invalid `package.json` is "cannot tell", never a false alarm).
812
+ */
813
+ export function missingDependencies(treeDir, sourceSubdir = '') {
814
+ const root = resolve(treeDir);
815
+ let dir = sourceSubdir ? resolve(root, sourceSubdir) : root;
816
+ /** @type {string[]} */
817
+ const chain = [];
818
+ for (;;) {
819
+ chain.push(dir);
820
+ if (dir === root) break;
821
+ const parent = dirname(dir);
822
+ if (parent === dir) break; // filesystem root — safety only, never reachable under `root`
823
+ dir = parent;
824
+ }
825
+
826
+ for (const d of chain) {
827
+ const pkgPath = join(d, 'package.json');
828
+ if (!existsSync(pkgPath)) continue;
829
+ let pkg;
830
+ try {
831
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
832
+ } catch {
833
+ return null; // cannot tell — an invalid package.json is not a fabricated gap
834
+ }
835
+ if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) return null;
836
+ const depCount = Object.keys(pkg.dependencies ?? {}).length + Object.keys(pkg.devDependencies ?? {}).length;
837
+ if (depCount === 0) return null; // a manifest with nothing to install is not a gap
838
+ if (existsSync(join(d, 'node_modules'))) return null;
839
+
840
+ const lock = LOCKFILE_COMMANDS.find((l) => existsSync(join(d, l.file)));
841
+ const installCmd = lock ? lock.command : 'npm install';
842
+ const rel = relative(root, d).split(sep).join('/');
843
+ return {
844
+ manager: installCmd.split(' ')[0],
845
+ command: rel === '' ? installCmd : `cd ${rel} && ${installCmd}`,
846
+ reason: `${rel === '' ? 'package.json' : `${rel}/package.json`} lists dependencies but the copy has no node_modules`,
847
+ };
848
+ }
849
+ return null;
850
+ }
851
+
715
852
  /**
716
853
  * The delivered name (M2b fix 4, kept under D3): a destination FILE of
717
854
  * `/home/me/profile.md` lands as `/home/me/profile-2026-09-12.md`, and a
package/src/text.js CHANGED
@@ -8,8 +8,12 @@
8
8
  // record, for the same reason again — a second writer that forgets the scrub is
9
9
  // a leak on the very trail the scrub exists to protect.
10
10
 
11
+ import { createRequire } from 'node:module';
11
12
  import { redactSecrets } from './validate.js';
12
13
 
14
+ const require = createRequire(import.meta.url);
15
+ const { HaltError } = require('bare-agent');
16
+
13
17
  // A fence counts as the artifact's WRAPPER only when it opens within this many
14
18
  // lines of the response — the chatty-preamble shape ("Here's the fix:\n```…").
15
19
  // A fence buried deeper inside an unfenced reply is the artifact's OWN content
@@ -149,6 +153,47 @@ export function capStop({ ceilingUsd, knownUsd, spendComplete }) {
149
153
  return null;
150
154
  }
151
155
 
156
+ /**
157
+ * THE ONE SPELLING of "was this throw a call casualty, or something else" —
158
+ * F179/F180's seam. `authorscout.js`'s `settled` narrows a rejected
159
+ * `loop.run()` to this one class: swallowing every throw would launder a
160
+ * budget `HaltError`, a programming bug, or any other named terminal into
161
+ * "the call failed" — the exact class of dishonesty this repo refuses (an
162
+ * unknown reported as a known).
163
+ *
164
+ * bare-agent 0.42.0's `OpenAIProvider.generate` used to throw a raw
165
+ * `SyntaxError` from an unguarded `JSON.parse(tc.function.arguments)` after
166
+ * a billed HTTP round (F179/BA-27) — this predicate used to admit that class
167
+ * too. bare-agent 0.43.0 fixed it upstream (`parseToolCalls`, shared by
168
+ * OpenAIProvider and Ollama): a malformed tool-call arguments string now
169
+ * returns a priced round with `toolCalls: []` plus its own `malformedToolCall`
170
+ * marker instead of throwing. Verified against 0.43.0's installed source that
171
+ * no provider bareloop constructs (`AnthropicProvider`, `OpenAIProvider`,
172
+ * `GeminiProvider` — `src/providers.js`) can still throw a JSON `SyntaxError`
173
+ * out of `generate()` after a billed round: Anthropic and Gemini read tool-call
174
+ * arguments as already-parsed objects off their own response shape (nothing to
175
+ * `JSON.parse`), and every provider's raw-HTTP-body parse is already
176
+ * try/catch-wrapped inside `_request`, converted to a plain `Error` before it
177
+ * can reach here. bareloop never streams a provider response. So the
178
+ * SyntaxError branch this predicate used to carry is dead code and was
179
+ * removed in the same change that bumped to bare-agent 0.43.0 — re-add it (or
180
+ * a narrower successor) only if a real provider is found to reintroduce the
181
+ * class.
182
+ *
183
+ * A `HaltError` is deliberately excluded — it is bare-agent's own governance
184
+ * exit (a budget/turn cap), never a transport casualty, and this predicate
185
+ * must never launder one into a mere "the call failed".
186
+ * @param {any} err
187
+ * @returns {string|null} a short reason when `err` is an admitted casualty class,
188
+ * `null` for everything else (including `HaltError`) — the caller re-throws on `null`.
189
+ */
190
+ export function callCasualty(err) {
191
+ if (err instanceof HaltError) return null;
192
+ const e = /** @type {any} */ (err);
193
+ if (e?.code === 'ETIMEDOUT' || e?.name === 'TimeoutError') return String(e?.message ?? e);
194
+ return null;
195
+ }
196
+
152
197
  // ── THE RAW MODEL OUTPUT, AS A PERSISTED RECORD ─────────────────────────────
153
198
  //
154
199
  // Run mslhn707 died on a survey that broke JSON at position 1339 and left NO
package/src/validate.js CHANGED
@@ -41,11 +41,34 @@ export function globToPrefix(scope) {
41
41
  return p;
42
42
  }
43
43
 
44
+ /**
45
+ * Path segments a fence/scope may never contain, checked WHOLE-SEGMENT
46
+ * (`prefix.split('/')`), never substring — a look-alike (`.github`,
47
+ * `.gitignore`, `my.git`, `node_modules_util`) is a DIFFERENT name and stays
48
+ * admitted. hamr's ruling (option B, 2026-09-14, block both): F178 — a
49
+ * signed fence naming `.git` could let a worker write git's own files (refs,
50
+ * the seed, `info/exclude`) and hide those writes from `changedSet`. F177's
51
+ * follow-up — a signed fence naming `node_modules` directly is the same class
52
+ * of gap (an installed/worker-written file inside it is invisible to
53
+ * `changedSet` the same way, and installs are the person's job now, F177).
54
+ * This catches only a fence that NAMES one of these directly; a fence like
55
+ * `packages/api/**` that later reaches a NESTED `node_modules` beneath it is a
56
+ * distinct, only partially closeable gap — see the runtime belt in
57
+ * planrun.js for `node_modules` (`.git` has no such gap: there is exactly one
58
+ * `.git` per repo, always at the workdir root, so its own runtime belt is a
59
+ * single `fs.deny` entry). Both are inexpressible at declaration time, the
60
+ * same law #1 already applies to `..`.
61
+ * @type {readonly string[]}
62
+ */
63
+ export const FORBIDDEN_SCOPE_SEGMENTS = Object.freeze(['.git', 'node_modules']);
64
+
44
65
  /**
45
66
  * A scope may never reach the arbiter's inputs (design law #1): it must
46
67
  * resolve to a PROPER subdirectory of the run directory. Absolute paths and
47
68
  * ".." segments escape it; "." / "./**" cover the whole run directory — where
48
69
  * the close suite lives. Windows spellings ("..\", "C:\") count as escapes.
70
+ * A `.git` or `node_modules` segment anywhere in the prefix is refused too
71
+ * (F178/F177, see {@link FORBIDDEN_SCOPE_SEGMENTS}).
49
72
  * Exported for the job validator: the operator's outer fence obeys the SAME
50
73
  * law through the same code — two containment transforms would be the F9
51
74
  * red-class one level up.
@@ -59,7 +82,8 @@ export function scopeContained(s) {
59
82
  // that normalizes to something absolute can never pass (defense in depth
60
83
  // against a future globToPrefix regression; the un-gameable gate, law #1).
61
84
  if (prefix === '' || prefix === '.' || prefix.startsWith('/') || /^[a-zA-Z]:/.test(prefix)) return false;
62
- return prefix.split('/').every((seg) => seg !== '..');
85
+ const segments = prefix.split('/');
86
+ return segments.every((seg) => seg !== '..' && !FORBIDDEN_SCOPE_SEGMENTS.includes(seg));
63
87
  }
64
88
 
65
89
  /** Shared with the job validator — one definition of "a JSON object" for both