bareloop 0.25.1 → 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/providers.js CHANGED
@@ -45,10 +45,17 @@ export const ANTHROPIC_TIER_MODELS = Object.freeze({
45
45
  * of a single-model provider, not a second model masquerading as `haiku`.
46
46
  * If DeepSeek (or a future openai-api entrant) ever ships a genuinely
47
47
  * cheaper tier model, this is where it would earn its own `haiku` id.
48
+ *
49
+ * MODEL SWAP (F171, item 34 L16, hamr 2026-09-13): `deepseek-chat` is no
50
+ * longer served — a live `/models` call returned only `deepseek-flash` and
51
+ * `deepseek-v4-pro`. Switched to `deepseek-flash` (DeepSeek V4.1). F149's
52
+ * `legacyMaxTokens` measurement (asked 64 output tokens, got 665/608) was
53
+ * taken on `deepseek-chat` and is UNVERIFIED on `deepseek-flash` — kept on
54
+ * below as the fail-safe (over-capping direction is safe; see F171).
48
55
  */
49
56
  export const OPENAI_TIER_MODELS = Object.freeze({
50
- sonnet: 'deepseek-chat',
51
- haiku: 'deepseek-chat',
57
+ sonnet: 'deepseek-flash',
58
+ haiku: 'deepseek-flash',
52
59
  });
53
60
 
54
61
  /**
@@ -97,9 +104,14 @@ export const PROBE_STATUS = Object.freeze({
97
104
  *
98
105
  * A model with no entry here gets `{}` — bare-agent's own default
99
106
  * (`max_completion_tokens`, GPT-5-safe) applies, unmodified.
107
+ *
108
+ * F171 (2026-09-13): `deepseek-chat` was retired and swapped for
109
+ * `deepseek-flash` (item 34 L16). This gating was measured on
110
+ * `deepseek-chat` only and carried forward onto `deepseek-flash`
111
+ * UNVERIFIED — re-verify before any paid DeepSeek run relies on it.
100
112
  */
101
113
  const OPENAI_MODEL_OPTIONS = Object.freeze({
102
- 'deepseek-chat': Object.freeze({ legacyMaxTokens: true }),
114
+ 'deepseek-flash': Object.freeze({ legacyMaxTokens: true }),
103
115
  });
104
116
 
105
117
  /**
@@ -189,6 +201,42 @@ export function resolveProvider(providerName) {
189
201
  return entry;
190
202
  }
191
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
+
192
240
  /**
193
241
  * Construct ONE provider instance for `providerName`/`model`, applying
194
242
  * `exposeErrorBody: true` (F153 — every constructed provider, unconditionally)
package/src/source.js CHANGED
@@ -23,9 +23,9 @@
23
23
  // on rather than catch.
24
24
 
25
25
  import { createHash } from 'node:crypto';
26
- import { existsSync } from 'node:fs';
27
- import { mkdir, readdir, readFile, writeFile, lstat, stat, access, copyFile, cp, open, rm, realpath, readlink, symlink, constants as fsConstants } from 'node:fs/promises';
28
- import { dirname, join, resolve, sep, basename } from 'node:path';
26
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
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
+ import { dirname, join, relative, resolve, sep, basename } from 'node:path';
29
29
  import { git } from './kinds.js';
30
30
  import { MAX_BUFFER } from './kinds.js';
31
31
  import { PROVIDER_TIMEOUT_MS } from './clock.js';
@@ -76,6 +76,106 @@ 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
+
116
+ /**
117
+ * Walk from `startDir` upward — inclusive — to the NEAREST ancestor whose own
118
+ * directory carries a `.git` entry, whether that entry is a real repo
119
+ * directory or a linked-worktree/submodule FILE (the caller decides what a
120
+ * file means for it). `null` when no ancestor up to the filesystem root has
121
+ * one. ONE helper for "find the repo boundary walking up", shared by
122
+ * {@link looksLikeRepoSource} and `prepareSource`'s own authoritative routing
123
+ * below (PRD item 33 M3 ruling 2 addendum, 2026-09-13: a Source that is a
124
+ * SUBFOLDER inside a git repo is a repo job, not a plain-folder one — the
125
+ * boundary is the nearest ancestor's `.git`, never only a `.git` sitting
126
+ * directly inside Source itself) and reused by `src/detectlang.js`'s own
127
+ * upward walk (`walkChain`), which only needs the boundary directory, not
128
+ * which kind of `.git` it is — never three copies of this rule.
129
+ * @param {string} startDir an absolute directory to start the walk FROM
130
+ * @returns {{dir: string, isFile: boolean}|null}
131
+ */
132
+ export function nearestGitAncestor(startDir) {
133
+ let dir = resolve(startDir);
134
+ for (;;) {
135
+ const dotGit = join(dir, '.git');
136
+ if (existsSync(dotGit)) {
137
+ let isFile = false;
138
+ try { isFile = !lstatSync(dotGit).isDirectory(); } catch { /* raced away between exists and lstat — treated as a directory below, the pre-existing walk's own behaviour */ }
139
+ return { dir, isFile };
140
+ }
141
+ const parent = dirname(dir);
142
+ if (parent === dir) return null; // reached the filesystem root
143
+ dir = parent;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * A CHEAP peek (a stat, never a read) at whether `source` is going to turn
149
+ * out to be a REPO source — the ONE rule for that question, shared by
150
+ * `prepareSource` (which routes the destination check below before the real
151
+ * walk further down decides authoritatively) and `scripts/run-interview.mjs`
152
+ * (PRD item 33 M3 ruling 2), which has to know BEFORE Source is even frozen
153
+ * whether Destination fills the write fence or a delivery directory. Never a
154
+ * second, hand-typed copy of this test: a URL is never a repo; a local path
155
+ * is a repo source only when it is a DIRECTORY, and the NEAREST ancestor
156
+ * (Source itself, or an ancestor above it — ruling 2's 2026-09-13 addendum)
157
+ * carries a `.git` DIRECTORY. A nearest `.git` that is a FILE (a linked
158
+ * worktree or a submodule) is NOT repo-like here — the real walk below
159
+ * refuses `source-is-linked-worktree` for that case, and this peek must
160
+ * agree closely enough to route the destination question correctly rather
161
+ * than offer the write-fence wording for a source that is about to be
162
+ * refused. An unreadable path is reported properly by the real walk; this
163
+ * peek only has to agree with that walk closely enough to route correctly,
164
+ * never to replace it.
165
+ * @param {string} source
166
+ * @returns {boolean}
167
+ */
168
+ export function looksLikeRepoSource(source) {
169
+ if (/^https?:\/\//i.test(source)) return false;
170
+ try {
171
+ const abs = resolve(source);
172
+ const st = lstatSync(abs);
173
+ if (!st.isDirectory()) return false;
174
+ const found = nearestGitAncestor(abs);
175
+ return found !== null && !found.isFile;
176
+ } catch { return false; }
177
+ }
178
+
79
179
  /** @param {Buffer} buf @returns {boolean} */
80
180
  function hasNulByte(buf) {
81
181
  // "first 8 KB" (PRD/M2 text) — a NUL anywhere further in a huge log-shaped
@@ -351,22 +451,14 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
351
451
  return refuse('into-exists', `${intoAbs} already exists — a source door writes a FRESH tree, never reuses one (the export worktree rule)`);
352
452
  }
353
453
  const isUrl = /^https?:\/\//i.test(source);
354
- // a CHEAP peek (a stat, never a read) at whether this source is going to
355
- // turn out to be a repo — needed here, before the source is actually
356
- // walked below, because a repo source's destination takes a DIFFERENT path
357
- // (the write-fence recording above) than every other kind's (the directory
358
- // proof). The real walk below makes the authoritative kind determination
359
- // (and refuses source-is-linked-worktree when `.git` is a file); this peek
454
+ // needed here, before the source is actually walked below, because a repo
455
+ // source's destination takes a DIFFERENT path (the write-fence recording
456
+ // above) than every other kind's (the directory proof). The real walk
457
+ // below makes the authoritative kind determination (and refuses
458
+ // source-is-linked-worktree when `.git` is a file); `looksLikeRepoSource`
360
459
  // only has to agree with it closely enough to route the destination check
361
460
  // correctly, and it does — both read the same `.git` existence test.
362
- let looksLikeRepoSource = false;
363
- if (!isUrl) {
364
- try {
365
- const peekAbs = resolve(source);
366
- const peekStat = await lstat(peekAbs);
367
- looksLikeRepoSource = peekStat.isDirectory() && existsSync(join(peekAbs, '.git'));
368
- } catch { /* an unreadable source is reported properly by the real walk below */ }
369
- }
461
+ const isRepoLike = looksLikeRepoSource(source);
370
462
  // hamr's ruling (PRD item 33): source AND destination are proven at job
371
463
  // start, $0 — and review finding #2 sharpens WHEN: before `into` is
372
464
  // created, never after, so a refused destination leaves nothing on disk
@@ -374,7 +466,7 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
374
466
  // its result). Order matters here: this runs before any source is read,
375
467
  // fetched, or walked.
376
468
  if (destination !== undefined) {
377
- if (looksLikeRepoSource) {
469
+ if (isRepoLike) {
378
470
  if (typeof destination !== 'string' || destination.length === 0) {
379
471
  return refuse('destination-invalid', 'destination must be a non-empty value — a repo source records it as the declared write fence, wired into writeScope in a later milestone');
380
472
  }
@@ -384,7 +476,7 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
384
476
  }
385
477
  }
386
478
 
387
- /** @type {{kind: 'url'|'file'|'folder'|'repo', files: {rel: string, abs?: string, buf?: Buffer, symlinkTarget?: string}[], root?: string, finalUrl?: string}} */
479
+ /** @type {{kind: 'url'|'file'|'folder'|'repo', files: {rel: string, abs?: string, buf?: Buffer, symlinkTarget?: string}[], root?: string, finalUrl?: string, sourceSubdir?: string}} */
388
480
  let frozen;
389
481
 
390
482
  if (isUrl) {
@@ -429,22 +521,54 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
429
521
  // patients-are-copies rule), and gets the same `output/`, manifest,
430
522
  // destination and copy-out every other source gets. Its own guards
431
523
  // (the input stays untouched) arrive with M4.
432
- const dotGit = join(sourceAbs, '.git');
433
- let isRepo = false;
434
- if (existsSync(dotGit)) {
524
+ //
525
+ // RULING 2 ADDENDUM (hamr, 2026-09-13, option A): a Source that is a
526
+ // SUBFOLDER inside a git repo IS a repo job — the boundary is the
527
+ // NEAREST ancestor's `.git`, found by walking UP from Source, never
528
+ // only a `.git` sitting directly inside Source itself (a monorepo
529
+ // package like `myrepo/packages/api` used to fall to the plain-folder
530
+ // path below, which has no checks yet and would hit untracked
531
+ // `node_modules` symlinks as a refusal, F164's class). The WHOLE
532
+ // repo's tracked files are frozen — `listRepoFiles(repoRoot)`, not just
533
+ // the subfolder — exactly as for a repo-ROOT source today.
534
+ const found = nearestGitAncestor(sourceAbs);
535
+ if (found !== null && found.isFile) {
435
536
  // a `.git` FILE (a linked worktree or a submodule) points its gitdir
436
537
  // somewhere else entirely — copying it would aim every git command,
437
538
  // the seed commit included, at the ORIGINAL repo. Refused, never
438
- // followed, for the same reason a symlink is.
439
- if (!(await lstat(dotGit)).isDirectory()) {
440
- return refuse('source-is-linked-worktree', `${source}/.git is a file, not a directory — this is a linked worktree or a submodule, whose real git directory lives elsewhere; point the door at the repo itself (a copy would silently commit into the original)`);
441
- }
442
- isRepo = true;
539
+ // followed, for the same reason a symlink is. `found.dir` is the
540
+ // ancestor that actually carries the file — Source itself when
541
+ // Source IS that ancestor, an ancestor ABOVE Source otherwise.
542
+ return refuse('source-is-linked-worktree', `${found.dir}/.git is a file, not a directory — this is a linked worktree or a submodule, whose real git directory lives elsewhere; point the door at the repo itself (a copy would silently commit into the original)`);
443
543
  }
544
+ const isRepo = found !== null;
444
545
  if (isRepo) {
445
- const walked = await listRepoFiles(sourceAbs);
546
+ const repoRoot = /** @type {{dir: string}} */ (found).dir;
547
+ const walked = await listRepoFiles(repoRoot);
446
548
  if (walked.stop !== null) return walked;
447
- frozen = { kind: 'repo', root: sourceAbs, files: walked.files };
549
+ // Source's own path relative to the repo root — '' when Source IS
550
+ // the repo root, a real subpath (e.g. "packages/api") when it is a
551
+ // subfolder. Recorded in the manifest (below) for later use; nothing
552
+ // downstream reads it yet — the fence/scope wiring is a later piece.
553
+ const sourceSubdir = relative(repoRoot, sourceAbs).split(sep).join('/');
554
+ // hamr's ruling A (PRD item 34 loose end, 2026-09-13, live-proven at
555
+ // $0): the ruling-2 addendum above freezes the WHOLE repo whenever
556
+ // Source is a subfolder, but `listRepoFiles` only ever returns
557
+ // TRACKED paths — when the subfolder itself is gitignored or simply
558
+ // untracked, NONE of its own files are among them, and the freeze
559
+ // silently succeeds with a `kind: 'repo'` tree that holds nothing the
560
+ // person actually pointed Source at. Refused here, before `into` is
561
+ // created and before a single byte is scanned or written — a repo
562
+ // ROOT source (`sourceSubdir === ''`) is exempt (it always has the
563
+ // whole tracked tree by definition), and a subfolder with AT LEAST
564
+ // ONE tracked file underneath it is unaffected (that is the existing,
565
+ // unchanged "partially-ignored files inside a tracked folder" case).
566
+ if (sourceSubdir !== '' && !walked.files.some((f) => f.rel === sourceSubdir || f.rel.startsWith(`${sourceSubdir}/`))) {
567
+ return refuse('source-untracked-in-repo', `${sourceAbs} is inside the git repo at ${repoRoot}, but git does not track anything in it, so a repo job would not see its files — commit the files, or point Source at a folder outside the repo`);
568
+ }
569
+ frozen = {
570
+ kind: 'repo', root: repoRoot, files: walked.files, sourceSubdir,
571
+ };
448
572
  } else {
449
573
  const walked = await walkFolder(sourceAbs);
450
574
  if (walked.stop !== null) return walked;
@@ -524,17 +648,47 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
524
648
  // strip the copied hooks (see NO_HOOKS above) — `force: true` because a
525
649
  // repo with no hooks configured has no `hooks/` to remove at all
526
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;
527
655
  }
528
656
  await mkdir(outputDir, { recursive: true });
529
657
  // git tracks no empty directories — a `.gitkeep` is what makes `output/`
530
658
  // actually exist in the seed commit the close measures changes against
531
659
  await writeFile(join(outputDir, '.gitkeep'), '');
532
- for (const f of frozen.files) {
660
+ // TOCTOU close (item 34 L1, fix-ledger `src/source.js:506,537`): a folder/
661
+ // repo file's bytes were hashed and secret-scanned ABOVE, from one read —
662
+ // then re-read a SECOND time here via `copyFile`, off disk, to freeze into
663
+ // the tree. A file swapped on disk between the two reads would have its
664
+ // secret scan run against bytes different from the ones actually frozen —
665
+ // exactly the gap the hard line (secrets never enter the tree) exists to
666
+ // close. Fixed by re-verifying, not by caching every buffer: caching would
667
+ // hold a whole repo's bytes in memory at once (the streaming design above
668
+ // this loop exists precisely to avoid that for a repo source "far larger
669
+ // than memory"). Instead, this second read is hashed again and compared
670
+ // against `fileMeta`'s scan-time `sha256` (same index, same file order as
671
+ // the scan loop above) — a match means the bytes frozen ARE the bytes
672
+ // scanned; a mismatch refuses rather than silently freezing content the
673
+ // scan never saw. `copyFile` also preserved the source file's mode bits
674
+ // (verified: 0o755 in, 0o755 out; a plain `writeFile` defaults to 0o644),
675
+ // so the replacement chmod's the destination from a fresh `stat` to keep
676
+ // that behaviour unchanged.
677
+ for (let i = 0; i < frozen.files.length; i += 1) {
678
+ const f = frozen.files[i];
533
679
  const dest = join(treeDir, treeRel(f.rel));
534
680
  await mkdir(dirname(dest), { recursive: true });
535
- if (f.buf) await writeFile(dest, f.buf);
536
- else if (f.symlinkTarget !== undefined) await symlink(f.symlinkTarget, dest);
537
- else await copyFile(/** @type {string} */ (f.abs), dest);
681
+ if (f.buf) { await writeFile(dest, f.buf); continue; }
682
+ if (f.symlinkTarget !== undefined) { await symlink(f.symlinkTarget, dest); continue; }
683
+ const [freshBuf, srcStat] = await Promise.all([
684
+ readFile(/** @type {string} */ (f.abs)),
685
+ stat(/** @type {string} */ (f.abs)),
686
+ ]);
687
+ if (sha256Hex(freshBuf) !== fileMeta[i].sha256) {
688
+ return refuse('source-changed-after-scan', `${f.rel} changed on disk after its secret scan — refused rather than freezing bytes the scan never saw (hard line #3)`);
689
+ }
690
+ await writeFile(dest, freshBuf);
691
+ await chmod(dest, srcStat.mode & 0o777);
538
692
  }
539
693
 
540
694
  if (frozen.kind !== 'repo') {
@@ -580,6 +734,13 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
580
734
  const manifest = {
581
735
  kind: frozen.kind,
582
736
  source,
737
+ // Source's own path relative to the repo root (ruling 2 addendum,
738
+ // 2026-09-13) — '' when Source IS the repo root, a real subpath (e.g.
739
+ // "packages/api") when it is a subfolder frozen as part of the WHOLE
740
+ // repo. A NEW field beside the existing ones: `source` above keeps its
741
+ // existing meaning (the original Source path, untouched), and nothing
742
+ // downstream reads `sourceSubdir` yet — recorded here, wired later.
743
+ ...(frozen.sourceSubdir === undefined ? {} : { sourceSubdir: frozen.sourceSubdir }),
583
744
  ...(frozen.finalUrl === undefined ? {} : { finalUrl: frozen.finalUrl }),
584
745
  fetchedAt: new Date().toISOString(),
585
746
  files: fileMeta,
@@ -592,6 +753,102 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
592
753
  return { stop: null, into: intoAbs, tree: treeDir, manifestPath, manifest };
593
754
  }
594
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
+
595
852
  /**
596
853
  * The delivered name (M2b fix 4, kept under D3): a destination FILE of
597
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