bareloop 0.26.0 → 0.28.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 +271 -0
- package/bareloop.context.md +308 -38
- package/package.json +4 -3
- package/src/authorflow.js +255 -33
- package/src/authorjob.js +12 -9
- package/src/authorreadout.js +323 -0
- package/src/authorrun.js +1337 -0
- package/src/authorscout.js +16 -11
- package/src/cli.js +130 -8
- package/src/index.js +14 -2
- package/src/interviewrun.js +842 -0
- package/src/job.js +42 -26
- package/src/planrun.js +82 -3
- package/src/promptregisters.js +8 -0
- package/src/providers.js +36 -0
- package/src/replayio.js +153 -0
- package/src/reuse.js +1 -1
- package/src/run.js +1 -1
- package/src/source.js +138 -1
- package/src/text.js +45 -0
- package/src/u-patient.js +73 -0
- package/src/u-readout.js +451 -0
- package/src/userrun.js +2273 -0
- package/src/validate.js +25 -1
- package/types/authorflow.d.ts +49 -7
- package/types/authorreadout.d.ts +172 -0
- package/types/authorrun.d.ts +37 -0
- package/types/index.d.ts +3 -2
- package/types/interviewrun.d.ts +46 -0
- package/types/job.d.ts +21 -0
- package/types/planrun.d.ts +43 -3
- package/types/providers.d.ts +28 -0
- package/types/replayio.d.ts +93 -0
- package/types/reuse.d.ts +2 -2
- package/types/run.d.ts +2 -2
- package/types/source.d.ts +45 -0
- package/types/text.d.ts +35 -0
- package/types/u-patient.d.ts +39 -0
- package/types/u-readout.d.ts +247 -0
- package/types/userrun.d.ts +134 -0
- package/types/validate.d.ts +22 -0
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.
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
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
|
-
|
|
342
|
-
|
|
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
|
|
@@ -884,7 +932,7 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
884
932
|
* it the deterministic name would collide with that very branch and the collision walk
|
|
885
933
|
* would mint a `-2` beside the progress the resume exists to keep. Absent is the cold
|
|
886
934
|
* path. A recorded branch that no longer exists is a STOP, never a fresh start.
|
|
887
|
-
* @param {{decision: string, text?: string}|null} [opts.humanRuling] N4 — the SIGNER's
|
|
935
|
+
* @param {{decision: string, text?: string|null}|null} [opts.humanRuling] N4 — the SIGNER's
|
|
888
936
|
* answer at a hitl pause (2026-08-12 §1, re-cut 2026-08-17: accept | rerun <text> | pause), carried in
|
|
889
937
|
* by the runner on the leg that RESUMES a paused run. Absent on every ordinary run and
|
|
890
938
|
* on the leg that pauses; it is never authored, never defaulted, and never inferred.
|
|
@@ -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 };
|
package/src/promptregisters.js
CHANGED
|
@@ -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/replayio.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// PANEL-BUILD.md P0 — the spine/gate-audit READ side. Lifted out of
|
|
2
|
+
// `scripts/run-replay.mjs` (verbatim logic, moved so it is callable
|
|
3
|
+
// in-process rather than only as a script) plus one more reader
|
|
4
|
+
// (`readHistoryLog`) for `bareloop history`'s bundle-registry file. This
|
|
5
|
+
// module is the IO layer ONLY: it resolves paths, parses JSONL tolerantly
|
|
6
|
+
// (a malformed line is counted and skipped, never thrown on), and hands
|
|
7
|
+
// parsed records to `src/replay.js`'s pure `replayRun`/`formatReplay`/
|
|
8
|
+
// `summarizeForAllLine`/`formatAllLines` — it never recomputes anything
|
|
9
|
+
// those already derive (PANEL-BUILD.md P0: "feed replay.js, never
|
|
10
|
+
// duplicate it").
|
|
11
|
+
//
|
|
12
|
+
// NAME-AGNOSTIC spine detection (coordinator fix, 2026-08-25, carried over
|
|
13
|
+
// verbatim from the script this replaces): the patient corpus does not agree
|
|
14
|
+
// on a filename convention — `u-<id>.jsonl` (run-u), `battery-A1-<id>.jsonl`
|
|
15
|
+
// / `l2accept-L1-<id>.jsonl` (batteries), `layer-r-probe-P1-<id>.jsonl`,
|
|
16
|
+
// `types-screen-C-<id>.jsonl`, `reuse-<id>.jsonl` (first record
|
|
17
|
+
// `reuse-start`), `job2-<id>.jsonl` all measured across the real archive. A
|
|
18
|
+
// spine is therefore identified by CONTENT, never by a naming pattern: any
|
|
19
|
+
// `*.jsonl` that is not a gate-audit/lag sidecar BY NAME
|
|
20
|
+
// (`*-gate-audit.jsonl`, `*.lag.jsonl` — the one convention that IS
|
|
21
|
+
// universal, because every spine resolves its own audit sibling this same
|
|
22
|
+
// way) and whose records include at least one `job-start` or `run-start` is
|
|
23
|
+
// a spine. A `.jsonl` that matches neither test is reported as
|
|
24
|
+
// `<name> not-a-spine` — never silently dropped, never silently misread as
|
|
25
|
+
// an empty/unknown run.
|
|
26
|
+
|
|
27
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
28
|
+
import { basename, dirname, join } from 'node:path';
|
|
29
|
+
import { replayRun, summarizeForAllLine } from './replay.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse one JSONL file into records, tolerant of a malformed line (counted
|
|
33
|
+
* in `skipped`, never thrown on). Mirrors `scripts/behaviour-readout.mjs`'s
|
|
34
|
+
* own parse.
|
|
35
|
+
* @param {string} file
|
|
36
|
+
* @returns {{records: any[], skipped: number}}
|
|
37
|
+
*/
|
|
38
|
+
export function parseJsonl(file) {
|
|
39
|
+
const raw = readFileSync(file, 'utf8');
|
|
40
|
+
const lines = raw.split('\n').filter((l) => l.trim() !== '');
|
|
41
|
+
/** @type {any[]} */
|
|
42
|
+
const records = [];
|
|
43
|
+
let skipped = 0;
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
try {
|
|
46
|
+
records.push(JSON.parse(line));
|
|
47
|
+
} catch {
|
|
48
|
+
skipped += 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { records, skipped };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `true` when `name` (a bare filename, not a path) is a gate-audit or lag
|
|
56
|
+
* sidecar by NAME convention — the one filename rule that holds across the
|
|
57
|
+
* whole archive.
|
|
58
|
+
* @param {string} name
|
|
59
|
+
*/
|
|
60
|
+
export function isSidecarByName(name) {
|
|
61
|
+
return name.endsWith('-gate-audit.jsonl') || name.endsWith('.lag.jsonl');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `true` when `records` carries at least one `job-start` (present on every
|
|
66
|
+
* real spine checked, first record or not — a reuse/battery run nests one or
|
|
67
|
+
* more ordinary `runJob` calls inside it) or `run-start` (ralph's own inner
|
|
68
|
+
* loop marker, present on most but not all real spines).
|
|
69
|
+
* @param {any[]} records
|
|
70
|
+
*/
|
|
71
|
+
export function looksLikeSpine(records) {
|
|
72
|
+
return records.some((r) => r && (r.type === 'job-start' || r.type === 'run-start'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `<name>.jsonl` → `<name>-gate-audit.jsonl` in the same directory, and the
|
|
77
|
+
* bare run id for display — the run id lives only in the filename (no spine
|
|
78
|
+
* record carries it; see `src/replay.js`'s header). A leading `u-` (the
|
|
79
|
+
* run-u convention) is stripped; every other prefix (`battery-A1-`,
|
|
80
|
+
* `reuse-`, …) is kept verbatim, since it is the only thing distinguishing
|
|
81
|
+
* that run from its siblings in the same directory.
|
|
82
|
+
* @param {string} spinePath
|
|
83
|
+
* @returns {{runId: string, auditPath: string|null}}
|
|
84
|
+
*/
|
|
85
|
+
export function resolveSiblings(spinePath) {
|
|
86
|
+
const dir = dirname(spinePath);
|
|
87
|
+
const base = basename(spinePath);
|
|
88
|
+
const stem = base.replace(/\.jsonl$/, '');
|
|
89
|
+
const runId = stem.startsWith('u-') ? stem.slice(2) : stem;
|
|
90
|
+
const auditPath = join(dir, `${stem}-gate-audit.jsonl`);
|
|
91
|
+
return { runId, auditPath: existsSync(auditPath) ? auditPath : null };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read one run's spine (+ its gate-audit sidecar, when present) off disk and
|
|
96
|
+
* hand both to {@link replayRun}. Read-only, $0, mints no verdict.
|
|
97
|
+
* @param {string} spinePath absolute or cwd-relative path to a `.jsonl` spine
|
|
98
|
+
* @param {{preParsedSpine?: {records: any[], skipped: number}, skipAudit?: boolean}} [opts]
|
|
99
|
+
* `preParsedSpine`: avoids a second parse of the same file when the caller
|
|
100
|
+
* ({@link listSpines}) already read it once to run {@link looksLikeSpine}.
|
|
101
|
+
* `skipAudit` (PR #23 review item 5, 2026-08-26, carried over): a
|
|
102
|
+
* directory listing never opens the gate-audit sidecar at all —
|
|
103
|
+
* `summarizeForAllLine` reads none of the fields that sidecar feeds inside
|
|
104
|
+
* `replayRun`, so reading every run's sidecar in a directory listing was
|
|
105
|
+
* pure unused I/O, multiplied by every spine found.
|
|
106
|
+
* @returns {ReturnType<typeof replayRun>}
|
|
107
|
+
*/
|
|
108
|
+
export function replayOne(spinePath, opts = {}) {
|
|
109
|
+
const { preParsedSpine, skipAudit = false } = opts;
|
|
110
|
+
const { runId, auditPath } = resolveSiblings(spinePath);
|
|
111
|
+
const spine = preParsedSpine ?? parseJsonl(spinePath);
|
|
112
|
+
const audit = (!skipAudit && auditPath) ? parseJsonl(auditPath) : { records: [], skipped: 0 };
|
|
113
|
+
const summary = replayRun(spine.records, audit.records, { runId });
|
|
114
|
+
summary.skipped += spine.skipped + audit.skipped;
|
|
115
|
+
return summary;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* List every spine found directly in `dir` (not recursive — same as the
|
|
120
|
+
* script this replaces): one entry per `.jsonl` file that is not a sidecar
|
|
121
|
+
* by name, each either `{kind:'spine', row}` ({@link summarizeForAllLine}'s
|
|
122
|
+
* row) or `{kind:'not-a-spine', name}` when the file's content doesn't look
|
|
123
|
+
* like a spine. Feeds `formatAllLines` (`src/replay.js`) for the printed
|
|
124
|
+
* table — this function does no formatting of its own, only IO + detection.
|
|
125
|
+
* @param {string} dir
|
|
126
|
+
* @returns {Array<{kind: 'spine', row: ReturnType<typeof summarizeForAllLine>}|{kind: 'not-a-spine', name: string}>}
|
|
127
|
+
*/
|
|
128
|
+
export function listSpines(dir) {
|
|
129
|
+
const candidates = readdirSync(dir)
|
|
130
|
+
.filter((f) => f.endsWith('.jsonl') && !isSidecarByName(f))
|
|
131
|
+
.sort();
|
|
132
|
+
return candidates.map((f) => {
|
|
133
|
+
const path = join(dir, f);
|
|
134
|
+
const spine = parseJsonl(path);
|
|
135
|
+
if (!looksLikeSpine(spine.records)) return { kind: /** @type {const} */ ('not-a-spine'), name: f };
|
|
136
|
+
const summary = replayOne(path, { preParsedSpine: spine, skipAudit: true });
|
|
137
|
+
return { kind: /** @type {const} */ ('spine'), row: summarizeForAllLine(summary) };
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Read a bundle's `history.jsonl` (one `bareloop run` row per line, written
|
|
143
|
+
* by `appendHistory` in `src/bundle.js`) — the single reader for this file,
|
|
144
|
+
* replacing `src/cli.js`'s own hand-rolled `readFileSync`/split. Tolerant of
|
|
145
|
+
* a malformed line the same way {@link parseJsonl} is (counted in
|
|
146
|
+
* `skipped`, never thrown on).
|
|
147
|
+
* @param {string} file absolute path to `history.jsonl`
|
|
148
|
+
* @returns {{rows: any[], skipped: number}}
|
|
149
|
+
*/
|
|
150
|
+
export function readHistoryLog(file) {
|
|
151
|
+
const { records, skipped } = parseJsonl(file);
|
|
152
|
+
return { rows: records, skipped };
|
|
153
|
+
}
|
package/src/reuse.js
CHANGED
|
@@ -805,7 +805,7 @@ function readGradeSeed(seen) {
|
|
|
805
805
|
* abandoned attempt twice in a chain of resumes.
|
|
806
806
|
*
|
|
807
807
|
* @param {any[]} events the dead spine's events, parsed, in file order
|
|
808
|
-
* @param {{deathAt?: number|null, direct?: boolean, resumableOutcomes?: string[]}} [opts]
|
|
808
|
+
* @param {{deathAt?: number|null, direct?: boolean, resumableOutcomes?: readonly string[]}} [opts]
|
|
809
809
|
* `deathAt`: when the process is judged to have died (the watchdog's kill record,
|
|
810
810
|
* which is later and better evidence than the last event). Defaults to the last
|
|
811
811
|
* event's own timestamp — the last sign of life there is.
|
package/src/run.js
CHANGED
|
@@ -204,7 +204,7 @@ async function primitiveSmoke(workdir) {
|
|
|
204
204
|
* a manufactured-fixation probe). `true` is the ON/experimental arm; the
|
|
205
205
|
* default is the OFF arm. (Shell-owned seam, same doctrine as the provider
|
|
206
206
|
* binding.)
|
|
207
|
-
* @param {{decision: string, text?: string}|null} [opts.humanRuling] N4 — the SIGNER's
|
|
207
|
+
* @param {{decision: string, text?: string|null}|null} [opts.humanRuling] N4 — the SIGNER's
|
|
208
208
|
* answer at a hitl pause (accept | rerun <text> | pause), carried by the leg that
|
|
209
209
|
* RESUMES a paused run and forwarded verbatim to the plan flow. Absent on every
|
|
210
210
|
* ordinary run; never authored and never defaulted (a defaulted answer to "is this
|
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
|