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/CHANGELOG.md +525 -0
- package/bareloop.context.md +384 -84
- package/package.json +4 -3
- package/src/authorflow.js +947 -104
- package/src/authoring.js +33 -15
- package/src/authorjob.js +161 -5
- package/src/authorscout.js +16 -11
- package/src/cardauthor.js +28 -18
- package/src/cli.js +10 -1
- package/src/detectlang.js +200 -0
- package/src/index.js +26 -30
- package/src/job.js +45 -28
- package/src/planrun.js +81 -2
- package/src/promptregisters.js +14 -0
- package/src/providers.js +51 -3
- package/src/source.js +290 -33
- package/src/text.js +45 -0
- package/src/validate.js +25 -1
- package/types/authorflow.d.ts +476 -105
- package/types/authoring.d.ts +23 -8
- package/types/authorjob.d.ts +22 -4
- package/types/cardauthor.d.ts +7 -3
- package/types/detectlang.d.ts +38 -0
- package/types/index.d.ts +16 -16
- package/types/job.d.ts +24 -2
- package/types/planrun.d.ts +41 -1
- package/types/providers.d.ts +37 -2
- package/types/source.d.ts +87 -0
- package/types/text.d.ts +35 -0
- package/types/validate.d.ts +22 -0
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-
|
|
51
|
-
haiku: 'deepseek-
|
|
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-
|
|
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
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
|
546
|
+
const repoRoot = /** @type {{dir: string}} */ (found).dir;
|
|
547
|
+
const walked = await listRepoFiles(repoRoot);
|
|
446
548
|
if (walked.stop !== null) return walked;
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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
|