bareloop 0.21.0 → 0.23.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.
@@ -0,0 +1,165 @@
1
+ // Close timeout — PRD item 27/M3, `docs/product/CLOSE-INTEGRITY-BUILD.md`.
2
+ //
3
+ // hamr's ruling (2026-09-06, verbatim): "both … autoset and can be override,
4
+ // same like api pricing" — the guesstimate-plus-loud-sign-plus-customer-
5
+ // override shape, never a hidden knob. Today `runClose`'s own default
6
+ // (`src/ralph.js:221`, `timeoutMs = 120_000`) is a defaulted cap with no
7
+ // operator-set floor — the same shape the wall's no-default rule already
8
+ // forbids for `maxWallMs`. This module is the fix: measure the close's own
9
+ // stages once at $0, before any provider call, and derive a per-stage ceiling
10
+ // from what was actually measured, unless the signed spec names one itself.
11
+ //
12
+ // CONSTANTS BELOW ARE ARBITER CONSTANTS — hamr set them 2026-09-07, verbatim:
13
+ // "floor = 120,000 ms; K = 5 ('2 and 5 are fine')." Never agent- or
14
+ // adopter-settable; a build changing them is arbiter territory, not a tuning
15
+ // knob a drafted spec or a runner script can reach for.
16
+
17
+ import { runClose } from './ralph.js';
18
+
19
+ /** hamr, 2026-09-07: the floor, verbatim. Also the value `runClose`'s own
20
+ * un-autoset default has always used — kept as its own named constant here
21
+ * (not re-exported from ralph.js) because the two are different CLAIMS: one is
22
+ * "what runClose does if nobody says otherwise", the other is "the arbiter's
23
+ * chosen floor for autoset" — they happen to be the same number today, and a
24
+ * future change to either must not silently move the other. */
25
+ export const CLOSE_TIMEOUT_FLOOR_MS = 120_000;
26
+ /** hamr, 2026-09-07, verbatim: "K = 5 ('2 and 5 are fine')." */
27
+ export const CLOSE_TIMEOUT_K = 5;
28
+ /** `runClose`'s own shipped default when nobody passes a timeout at all
29
+ * (`src/ralph.js:221`) — the ceiling the $0 timing PASS ITSELF runs each
30
+ * stage under is a multiple of this, never of the floor above (the two are
31
+ * the same number today; named separately on purpose, see the constant
32
+ * above). */
33
+ export const LIBRARY_DEFAULT_CLOSE_TIMEOUT_MS = 120_000;
34
+ /**
35
+ * The generous PROVISIONAL ceiling the timing preflight runs every stage
36
+ * under, so "a genuinely hung stage still ends" (build spec, M3 §2) rather
37
+ * than blocking the timing pass forever. Deliberately the library default × K
38
+ * (600s) — NOT the autoset/override ceiling this module computes, which is
39
+ * only known after the pass completes.
40
+ */
41
+ export const TIMING_PREFLIGHT_CEILING_MS = LIBRARY_DEFAULT_CLOSE_TIMEOUT_MS * CLOSE_TIMEOUT_K;
42
+
43
+ /**
44
+ * The $0 timing preflight (PRD item 27, M3 §2): run every close STAGE once,
45
+ * ignoring verdicts — first-red-wins stays for the real verdict precheck
46
+ * (`src/planrun.js`'s `check-preflight`); this is a separate, sequential pass
47
+ * (F131/F68: never two closes in flight against one tree) whose only purpose
48
+ * is TIMING. A declared-close stage (no `.cmd`, a kind executor) has nothing
49
+ * a command runner can spawn and is skipped — it contributes no reading, not
50
+ * a zero one (a silent zero would understate the real close's later cost).
51
+ *
52
+ * @param {any[]} stages a close stage chain (`runStages`'s own shape)
53
+ * @param {{cwd?: string, redact?: (s: string) => string, ceilingMs?: number}} [opts]
54
+ * `ceilingMs` (test seam only — never wired to any spec/runner surface):
55
+ * overrides `TIMING_PREFLIGHT_CEILING_MS` so a hang-detection test does not
56
+ * have to wait 10 real minutes. Production callers never pass it.
57
+ * @returns {Promise<{
58
+ * perStage: {name: string, ms: number, exitCode: number|null, timedOut: boolean}[],
59
+ * slowestMs: number, slowestName: string|null, anyTimedOut: boolean,
60
+ * }>}
61
+ */
62
+ export async function timeCloseStages(stages, { cwd, redact = (s) => s, ceilingMs = TIMING_PREFLIGHT_CEILING_MS } = {}) {
63
+ /** @type {{name: string, ms: number, exitCode: number|null, timedOut: boolean}[]} */
64
+ const perStage = [];
65
+ for (const st of Array.isArray(stages) ? stages : []) {
66
+ if (!st || typeof st !== 'object' || typeof st.cmd !== 'string') continue; // declared/kind stage — nothing to spawn
67
+ const start = Date.now();
68
+ // Sequential by construction (F68), same discipline as `runStages`: a
69
+ // stage's own timing pass is never in flight beside another close.
70
+ // eslint-disable-next-line no-await-in-loop
71
+ const r = await runClose(st.cmd.trim().split(/\s+/), redact, {
72
+ timeoutMs: ceilingMs, cwd, expect: st.expect, judged: st.judged, gapKeep: st.gapKeep,
73
+ });
74
+ const ms = Date.now() - start;
75
+ // `runClose` only reports `exitCode` on a NON-satisfied verdict (a
76
+ // satisfied close's exit code is implied by `expect`, never re-stated) —
77
+ // this timing pass records it either way, since "which exit code did
78
+ // this stage return" is a cheap, honest thing to keep for `close-timing`.
79
+ const exitCode = typeof r.exitCode === 'number' ? r.exitCode
80
+ : (r.verdict === 'satisfied' && typeof st.expect === 'number' ? st.expect : null);
81
+ perStage.push({
82
+ name: typeof st.name === 'string' ? st.name : st.cmd,
83
+ ms,
84
+ exitCode,
85
+ timedOut: r.verdict === 'timed-out',
86
+ });
87
+ }
88
+ let slowest = null;
89
+ for (const s of perStage) if (slowest === null || s.ms > slowest.ms) slowest = s;
90
+ return {
91
+ perStage,
92
+ slowestMs: slowest ? slowest.ms : 0,
93
+ slowestName: slowest ? slowest.name : null,
94
+ anyTimedOut: perStage.some((s) => s.timedOut),
95
+ };
96
+ }
97
+
98
+ /**
99
+ * The ceiling formula (PRD item 27, M3): `max(FLOOR, K × slowest measured
100
+ * stage)`, unless an explicit override is given — the override is the
101
+ * OPERATOR'S number (the rates-passthrough shape, F113: guesstimate + loud
102
+ * sign + customer override), so it may legally sit ABOVE or BELOW the
103
+ * autoset estimate; this is not tighten-only.
104
+ * @param {{slowestMs: number, overrideMs?: number|null}} args
105
+ * @returns {{ ceilingMs: number, source: 'estimated'|'override' }}
106
+ */
107
+ export function computeCloseTimeoutCeiling({ slowestMs, overrideMs = null }) {
108
+ if (typeof overrideMs === 'number' && Number.isFinite(overrideMs)) {
109
+ return { ceilingMs: overrideMs, source: 'override' };
110
+ }
111
+ return { ceilingMs: Math.max(CLOSE_TIMEOUT_FLOOR_MS, CLOSE_TIMEOUT_K * slowestMs), source: 'estimated' };
112
+ }
113
+
114
+ /**
115
+ * ONE resolver for "what per-stage close timeout does this job get", shared
116
+ * by `src/planrun.js` (the in-run path) and `scripts/run-u.mjs` (which must
117
+ * know the answer BEFORE it spawns the outside watchdog — F67 — so the
118
+ * watchdog's own stale/grace windows are sized off the real ceiling rather
119
+ * than a hardcoded guess). Two callers computing this independently is
120
+ * exactly the class of drift this whole rung exists to close (F129's own
121
+ * lesson, one level up): this function is the only place the precedence is
122
+ * spelled out.
123
+ *
124
+ * Precedence: a signed spec override (`job.closeTimeoutMs`) wins outright,
125
+ * with NO timing pass run (the pass would waste real machine time and, on a
126
+ * deliberately slow/hung stage, block for up to `TIMING_PREFLIGHT_CEILING_MS`
127
+ * for no purpose). Absent that, every stage is timed once (F131's autoset
128
+ * design) and the ceiling is `max(FLOOR, K × slowest)`.
129
+ * @param {{job: any, stages: any[], cwd: string, redact?: (s: string) => string, ceilingMs?: number}} args
130
+ * `ceilingMs` — see `timeCloseStages`; test seam only.
131
+ * @returns {Promise<{
132
+ * closeTimeoutMs: number|null, source: 'estimated'|'override',
133
+ * timing: Awaited<ReturnType<typeof timeCloseStages>>|null, timedOut: boolean,
134
+ * }>} `closeTimeoutMs: null, timedOut: true` means a stage never finished the
135
+ * timing pass — the caller reds `close-timing-red` rather than trusting any
136
+ * number here.
137
+ */
138
+ export async function resolveCloseTimeoutMs({ job, stages, cwd, redact = (s) => s, ceilingMs }) {
139
+ const signedOverride = typeof job?.closeTimeoutMs === 'number' ? job.closeTimeoutMs : null;
140
+ if (signedOverride !== null) {
141
+ return { closeTimeoutMs: signedOverride, source: 'override', timing: null, timedOut: false };
142
+ }
143
+ const timing = await timeCloseStages(stages, { cwd, redact, ...(ceilingMs !== undefined ? { ceilingMs } : {}) });
144
+ if (timing.anyTimedOut) return { closeTimeoutMs: null, source: 'estimated', timing, timedOut: true };
145
+ const computed = computeCloseTimeoutCeiling({ slowestMs: timing.slowestMs });
146
+ return { closeTimeoutMs: computed.ceilingMs, source: computed.source, timing, timedOut: false };
147
+ }
148
+
149
+ /**
150
+ * The printed banner (build spec M3 §2's exact wording) — ONE spelling, so
151
+ * the run-u tail and the bundle CLI can never drift into two announcements of
152
+ * the same number. `source` accepts 'explicit' too: a caller-passed
153
+ * `closeTimeoutMs` runtime option (the pre-M3 shell/test knob, kept for
154
+ * backward compatibility and test control — never the signed spec field) is
155
+ * a real override but not a SIGNED one, so it earns its own honest word
156
+ * rather than being folded into 'signed override'.
157
+ * @param {{ceilingMs: number, source: 'estimated'|'override'|'explicit', slowestMs?: number, slowestName?: string|null}} args
158
+ * @returns {string}
159
+ */
160
+ export function closeTimeoutBanner({ ceilingMs, source, slowestMs, slowestName }) {
161
+ const seconds = Math.round(ceilingMs / 1000);
162
+ if (source === 'override') return `close timeout: ${seconds}s per stage (signed override)`;
163
+ if (source === 'explicit') return `close timeout: ${seconds}s per stage (explicit runner override)`;
164
+ return `close timeout: ${seconds}s per stage (estimated from seed timing: slowest ${slowestName ?? '<none>'} ${slowestMs ?? 0}ms × ${CLOSE_TIMEOUT_K}, floor ${Math.round(CLOSE_TIMEOUT_FLOOR_MS / 1000)}s)`;
165
+ }
@@ -567,13 +567,14 @@ function translate(r, redact) {
567
567
  * seedTrees?: any, gapCap?: number, maxBuffer?: number, baselineMode?: 'auto'|'worktree',
568
568
  * humanRuling?: {decision: string, text?: string|null}|null,
569
569
  * judgeLoop?: ((o: {system: string}) => any)|null,
570
- * onJudgeCost?: ((c: any) => void)|null}} [opts]
570
+ * onJudgeCost?: ((c: any) => void)|null,
571
+ * callBounds?: (() => {timeoutMs?: number, deadlineMs?: number})|null}} [opts]
571
572
  * @returns {Promise<any>}
572
573
  */
573
574
  export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
574
575
  const {
575
576
  timeoutMs, cwd, seedRef, seedTrees: shared, gapCap, maxBuffer, baselineMode, humanRuling = null,
576
- judgeLoop = null, onJudgeCost = null,
577
+ judgeLoop = null, onJudgeCost = null, callBounds = null,
577
578
  } = opts;
578
579
  if (!isNonEmptyString(cwd) || !isNonEmptyString(seedRef)) {
579
580
  // Refuse rather than guess. A declared close measures against a SEED; a
@@ -611,6 +612,12 @@ export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
611
612
  // until this kind, and a budget must fund the attempt PLUS its close.
612
613
  judgeLoop,
613
614
  onJudgeCost,
615
+ // F152/PRD 30.4 — the SAME per-call time bounds every worker round already
616
+ // carries (`callBounds()`, src/planrun.js:1518), so the judged stage's own
617
+ // locate call cannot hang the process on a live silent endpoint. `null` is
618
+ // the honest absence (an adopter/test that wires no clock through), never a
619
+ // defaulted number invented here.
620
+ callBounds,
614
621
  seedTrees,
615
622
  };
616
623
  try {
package/src/index.js CHANGED
@@ -262,6 +262,20 @@ export { PROMPT_REGISTERS, isPromptFile } from './promptregisters.js';
262
262
  export {
263
263
  exportBundle, bundleHash, readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
264
264
  } from './bundle.js';
265
+ // Close integrity (PRD item 27 / M1, `docs/product/CLOSE-INTEGRITY-BUILD.md`)
266
+ // — the ONE `close-absolute-path` detector shared by `exportBundle` (above)
267
+ // and `runPlan`'s run-start precheck. `readCloseScripts` is the pure reader
268
+ // M2's sha256 fingerprint will reuse.
269
+ export { readCloseScripts, checkCloseAbsolutePaths, absolutePathLiteralsOf } from './close-integrity.js';
270
+ // Close integrity M2 (PRD item 27, `docs/product/CLOSE-INTEGRITY-BUILD.md`) —
271
+ // the close-BYTES half. `hashCloseScriptBytes`/`checkCloseByteSignature` are
272
+ // the fingerprint the run-start precheck and `exportBundle` both compare
273
+ // against; `checkStageByteSignature` is the narrower re-verify `runCloseStages`
274
+ // (src/planrun.js) runs before every close run; `signCloseScripts` is the
275
+ // pure minting helper `scripts/sign-close.mjs` wraps (never writes itself).
276
+ export {
277
+ hashCloseScriptBytes, checkCloseByteSignature, checkStageByteSignature, signCloseScripts,
278
+ } from './close-integrity.js';
265
279
  // Export M2 (`docs/product/EXPORT-BUILD.md`) — the CLI's one entry point,
266
280
  // exported as `cliMain` (never `main`, a name generic enough to collide with
267
281
  // an adopter's own). `bin/bareloop.mjs` is the only other caller; this export
package/src/job.js CHANGED
@@ -14,8 +14,9 @@
14
14
  // judgment call. Minting policy is product doctrine, not job-authorable.
15
15
 
16
16
  import { createHash } from 'node:crypto';
17
- import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier } from './validate.js';
17
+ import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier, closeScriptCandidateToken } from './validate.js';
18
18
  import { validateCloseDecl, DECLARED_CLOSE_CLASSES } from './declaredclose.js';
19
+ import { CLOSE_TIMEOUT_FLOOR_MS } from './closetimeout.js';
19
20
 
20
21
  // The menus below ARE the close-authoring hierarchy (PRD §7) and ship frozen:
21
22
  // they are read at call time, so a mutable export would let adopter code
@@ -132,7 +133,11 @@ export const CADENCE_UNITS = Object.freeze(['hour', 'day', 'week']);
132
133
  * subscription — its costUsd axis is NOTIONAL (API-equivalent value, not
133
134
  * billed), so it is a DISTINCT condition: rows never pool with anthropic-api
134
135
  * rows on the cost axis, and budgets sized for one do not transfer. */
135
- export const PROVIDERS = Object.freeze(['anthropic-api', 'clipipe-subscription']);
136
+ /** PRD item 28 (2026-09-06/09-09): `openai-api` — an OpenAI-shaped provider,
137
+ * constructed via `src/providers.js`'s factory. hamr's ruling (PRD 30.7):
138
+ * `deepseek-chat` is THE secondary provider (one, not a menu of half-tested
139
+ * models); the factory's tier table is where that lives, not this menu. */
140
+ export const PROVIDERS = Object.freeze(['anthropic-api', 'openai-api', 'clipipe-subscription']);
136
141
  /** V3 environment label: declared keys only — every field is a lineage-key
137
142
  * candidate at N3. `provider` is part of the key by definition (top-level,
138
143
  * not duplicated here). */
@@ -140,7 +145,7 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
140
145
  // `steps` stays in the field list ONLY so a retired spec reds by name
141
146
  // (`shape-retired`) instead of falling through to a generic unknown-field —
142
147
  // the operator gets told what happened, not just that something is wrong.
143
- const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'model', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
148
+ const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
144
149
  /** the four-field plan shape's core (decision 5) — presence of any of these
145
150
  * declares the shape; `tools` (the ceiling) rides the shape but alone does not
146
151
  * declare it, so a legacy spec carrying it gets a pointed red, not a conflict */
@@ -148,7 +153,7 @@ const PLAN_CORE_FIELDS = ['goal', 'verdictType', 'close', 'closeDecl', 'checks']
148
153
  /** exact field set per close type — anything else is an unknown-field red
149
154
  * (freeform code, script bodies, and minting claims all land there) */
150
155
  const CLOSE_FIELDS = {
151
- predicate: ['type', 'cmd', 'expect', 'judged', 'gapKeep'],
156
+ predicate: ['type', 'cmd', 'expect', 'judged', 'gapKeep', 'sha256'],
152
157
  gold: ['type', 'expected', 'compare'],
153
158
  rubric: ['type', 'criteria'],
154
159
  hitl: ['type', 'prompt'],
@@ -240,6 +245,21 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
240
245
  `integer milliseconds >= ${MIN_WALL_MS} (one close timeout). Enforcement is a between-round deadline, measured as maxWallMs + closeStages x closeTimeoutMs (design addendum 1: loop.stop() cannot cut an in-flight call; W5: every close stage runs under the full timeout), so a budget under one close cannot fund its own close and the advertised number would be more wrong than right`);
241
246
  }
242
247
 
248
+ // CLOSE TIMEOUT (PRD item 27/M3, hamr 2026-09-06: "both … autoset and can be
249
+ // override, same like api pricing"). OPTIONAL and WITH NO CEILING: absent
250
+ // means the runner autosets it from a $0 seed timing pass
251
+ // (`src/closetimeout.js`); present is the OPERATOR's own number and — like a
252
+ // rates-passthrough override (F113) — it may legally sit ABOVE the autoset
253
+ // estimate, so this is not tighten-only. The one bound that IS enforced is
254
+ // the floor every close timeout has always had (`CLOSE_TIMEOUT_FLOOR_MS`,
255
+ // hamr's arbiter constant, 2026-09-07): a value below it could never fund
256
+ // even a fast close, exactly the "advertised number more wrong than right"
257
+ // failure `maxWallMs` already guards above.
258
+ if (spec.closeTimeoutMs !== undefined
259
+ && !(typeof spec.closeTimeoutMs === 'number' && Number.isInteger(spec.closeTimeoutMs) && spec.closeTimeoutMs >= CLOSE_TIMEOUT_FLOOR_MS)) {
260
+ red('bounds', 'closeTimeoutMs', `integer milliseconds >= ${CLOSE_TIMEOUT_FLOOR_MS} (the arbiter's floor) — a signed override may sit above the autoset estimate, never below the floor every close timeout has always had`);
261
+ }
262
+
243
263
  // MODEL — the worker's provider model id (build-list #3, hamr's GO
244
264
  // 2026-08-30). OPTIONAL, exactly like budgetUsd/maxWallMs before it: absent
245
265
  // means today's behaviour (the runner's own default), present it is part
@@ -251,6 +271,37 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
251
271
  red('invalid-value', 'model', 'non-empty string — exact provider model id (e.g. "claude-sonnet-5")');
252
272
  }
253
273
 
274
+ // BASE URL — PRD item 28, ruling (d), 2026-09-09: admitted in v1 of
275
+ // openai-api (an OpenAI-compatible gateway/endpoint), OPTIONAL and part of
276
+ // the signed hash like `model` above. This validator has no network
277
+ // access, so it checks SHAPE only: `https://` for a real endpoint, or
278
+ // `http://` restricted to a loopback host (a local OpenAI-compatible
279
+ // server, the only legitimate reason to skip TLS) — and never a
280
+ // credential embedded IN the URL itself. A URL travels into logs and
281
+ // spine records far more casually than a header ever would, and secrets
282
+ // never enter the tree/spine (hard line #3) — a bareUrl carrying
283
+ // `user:pass@host` is exactly that leak, just spelled differently.
284
+ if (spec.baseUrl !== undefined) {
285
+ if (!isNonEmptyString(spec.baseUrl)) {
286
+ red('invalid-value', 'baseUrl', 'non-empty string — an https:// URL (http:// admitted only to 127.0.0.1/localhost/::1)');
287
+ } else {
288
+ /** @type {URL|null} */
289
+ let u = null;
290
+ try { u = new URL(spec.baseUrl); } catch { /* u stays null — caught below */ }
291
+ if (!u) {
292
+ red('invalid-value', 'baseUrl', 'must be a parseable URL');
293
+ } else if (u.username !== '' || u.password !== '') {
294
+ red('invalid-value', 'baseUrl', 'no embedded credentials (user:pass@host) — secrets never enter a signed spec');
295
+ } else if (u.protocol === 'https:') {
296
+ // admitted
297
+ } else if (u.protocol === 'http:' && (u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1')) {
298
+ // admitted — a local OpenAI-compatible endpoint, the one case that legitimately skips TLS
299
+ } else {
300
+ red('invalid-value', 'baseUrl', 'https:// required (http:// admitted only to 127.0.0.1/localhost/::1)');
301
+ }
302
+ }
303
+ }
304
+
254
305
  // 3. the outer write fence — operator law (interview decision #4), same
255
306
  // containment rules as the workflow layer THROUGH THE SAME CODE (F9: two
256
307
  // transforms would let a fence validate green and mean something else).
@@ -356,6 +407,34 @@ function predicateBody(o, at, red) {
356
407
  // refusal beats silent misparse (N2 design default)
357
408
  else if (/["']/.test(o.cmd)) red('invalid-value', `${at}.cmd`, 'quote characters are inexpressible: cmd runs as whitespace-split argv, no shell');
358
409
  else if (o.cmd !== o.cmd.trim()) red('invalid-value', `${at}.cmd`, 'leading/trailing whitespace — argv splits on whitespace and an empty argv[0] cannot spawn; honest refusal beats a silent misparse');
410
+ // Close-bytes signature (PRD item 27/M2, F129's general fix's own general
411
+ // fix — N4): the signature must cover the close SCRIPT'S CONTENT, not only
412
+ // its path (`cmd`). Demanded exactly where a byte-exact fingerprint can be
413
+ // minted against something: `cmd` names an addressable script by the
414
+ // SAME shape test `src/close-integrity.js`'s detectors use
415
+ // (`closeScriptCandidateToken`, src/validate.js — `node <path> …`, OR a
416
+ // bare directly-executable absolute path such as a `.sh` wrapper; widened
417
+ // 2026-09-06 after an orchestrator audit found the bare form silently
418
+ // invisible to both this demand and the byte/path detectors — a blind
419
+ // instrument). A cmd naming no such script (most test-fixture closes:
420
+ // `true`, `npx`, `pytest`, a relative bare executable — or a
421
+ // missing/malformed cmd, already reded above) carries no demand — a stage
422
+ // with nothing to hash gets no `missing-required` for a field that could
423
+ // never be minted honestly. `validateJob` stays pure (no fs): this checks
424
+ // the FIELD'S SHAPE only, never the bytes on disk (that is the runner's
425
+ // job, `checkCloseByteSignature`/`checkStageByteSignature`).
426
+ const isScriptCmd = closeScriptCandidateToken(o.cmd) !== null;
427
+ if (isScriptCmd) {
428
+ if (o.sha256 === undefined) {
429
+ red('missing-required', `${at}.sha256`, 'hex sha256 of the script file bytes `cmd` names — mint it with signCloseScripts (src/close-integrity.js) / scripts/sign-close.mjs, never hand-typed');
430
+ } else if (typeof o.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(o.sha256)) {
431
+ red('invalid-value', `${at}.sha256`, '64 lowercase hex chars — sha256 of the script file bytes, never the spec, never the path');
432
+ }
433
+ } else if (o.sha256 !== undefined && (typeof o.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(o.sha256))) {
434
+ // not demanded for a non-script cmd, but a PRESENT value is still
435
+ // shape-checked — a malformed string is a typo either way
436
+ red('invalid-value', `${at}.sha256`, '64 lowercase hex chars — sha256 of the script file bytes, never the spec, never the path');
437
+ }
359
438
  if (!Number.isInteger(o.expect)) red('invalid-value', `${at}.expect`, 'integer exit code');
360
439
  // The judgment-rendered signal (PRD v1.11, optional). Exit code alone
361
440
  // cannot separate "the suite ran and failed" from "the suite crashed at
@@ -492,7 +571,7 @@ export function checkMenu(close) {
492
571
  }
493
572
 
494
573
  /** the fields a close STAGE may carry — anything else is a smuggle channel */
495
- const STAGE_FIELDS = ['name', 'cmd', 'expect', 'judged', 'gapKeep', 'offer', 'needs', 'direction'];
574
+ const STAGE_FIELDS = ['name', 'cmd', 'expect', 'judged', 'gapKeep', 'offer', 'needs', 'direction', 'sha256'];
496
575
 
497
576
  /**
498
577
  * Validate the staged close. Every stage is a predicate BODY under the same
package/src/judged.js CHANGED
@@ -565,6 +565,43 @@ export function validateFacts(facts) {
565
565
 
566
566
  // ── THE LOCATE PROMPT ───────────────────────────────────────────────────────
567
567
 
568
+ /**
569
+ * THE ONE SHAPE INVENTORY (PRD 30.8), stated ONCE and never twice. It drives
570
+ * BOTH the LOCATE prompt's prose ("declared with ...") AND the completeness
571
+ * detector's regex — the same standing rule the secret-shape scrubber
572
+ * follows (one inventory drives detection, never two spellings that can
573
+ * drift). A shape added here rides both sides in the same commit; a shape
574
+ * added to only a hand-typed prompt string or only a hand-typed regex is
575
+ * exactly the blind-instrument class this repo keeps paying for.
576
+ *
577
+ * Two BASE shapes, each carrying its own independent `export`/`async`
578
+ * modifiers (the modifiers sit in different POSITIONS in the two shapes —
579
+ * `async function foo(` vs `const foo = async () =>` — so they cannot be
580
+ * factored into one shared prefix):
581
+ * - `function name(...)` — optionally `export`, optionally `async`,
582
+ * in either order before `function`.
583
+ * - `const name = (...) =>` — optionally `export` before `const`,
584
+ * optionally `async` after `=`.
585
+ *
586
+ * Restricted to column-0 lines only, in both the prompt's prose and the
587
+ * detector: a nested function or a function expression returned from one is
588
+ * indented, and excluding it is the SAME cure PROMPT_HEAD already states
589
+ * (the POC's nested-function cure).
590
+ * @type {ReadonlyArray<{prose: string, match: (line: string) => string|null}>}
591
+ */
592
+ const FN_SHAPES = Object.freeze([
593
+ {
594
+ prose: '`function name(...)` (optionally `async`, optionally `export`, in either order)',
595
+ match: (/** @type {string} */ line) =>
596
+ line.match(/^(?:(?:export|async)\s+){0,2}function\s+([A-Za-z_$][\w$]*)\s*\(/)?.[1] ?? null,
597
+ },
598
+ {
599
+ prose: '`const name = (...) =>` (optionally `export` before `const`, optionally `async` after `=`)',
600
+ match: (/** @type {string} */ line) =>
601
+ line.match(/^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/)?.[1] ?? null,
602
+ },
603
+ ]);
604
+
568
605
  /** The spine every card shares. Both POC cures live here rather than on a rule,
569
606
  * because both are properties of ENUMERATING functions and not of any one
570
607
  * question about them: the nested-function clause (the POC counted a returned
@@ -573,8 +610,7 @@ export function validateFacts(facts) {
573
610
  const PROMPT_HEAD =
574
611
  'You LOCATE FACTS in a source file. You never decide whether anything passes, and you never judge — '
575
612
  + 'another system does that.\n'
576
- + 'For EVERY top-level function in the FILE (declared with `function name(...)`, `const name = (...) =>`, '
577
- + 'or `export function name(...)`), report:\n'
613
+ + `For EVERY top-level function in the FILE (declared with ${FN_SHAPES.map((s) => s.prose).join(', or ')}), report:\n`
578
614
  + 'Count ONLY functions declared at the top level of the module — never a function nested inside '
579
615
  + 'another function, and never a function expression returned from one.\n'
580
616
  + ' "name": the function name\n'
@@ -596,6 +632,43 @@ export function locatePrompt(card) {
596
632
  return `${PROMPT_HEAD}${asks}\n${PROMPT_TAIL}`;
597
633
  }
598
634
 
635
+ /**
636
+ * THE COMPLETENESS MIRROR of `PROMPT_HEAD`'s own contract (PRD 30.8): which
637
+ * top-level function declarations the artifact ACTUALLY holds, read off the
638
+ * SAME `FN_SHAPES` inventory the prompt's prose is built from, restricted to
639
+ * column-0 lines.
640
+ *
641
+ * This is a HEURISTIC, never a parser, and it is used for exactly one thing:
642
+ * telling `decide()` when LOCATE's `functions` list is short of what the file
643
+ * holds. It never grades a rule and it never adds a fact `decide()` acts on
644
+ * beyond "this name exists at the top level" — the missing function's rule
645
+ * violations, if any, stay unknown, which is exactly why the miss is UNSURE
646
+ * and not a manufactured red on a specific rule.
647
+ *
648
+ * TWO DIRECTIONS, both handled but not symmetrically by design:
649
+ * - a shape THIS inventory does not enumerate is a FALSE NEGATIVE — the
650
+ * check stays silent about that function, same as it stays silent with
651
+ * no `artifactText` at all. Widening `FN_SHAPES` closes the hole; nothing
652
+ * here manufactures a claim about a shape it was never told to look for.
653
+ * - a column-0 line that only LOOKS like a declaration (e.g. inside a
654
+ * template literal) is a FALSE POSITIVE and reds a real judged close —
655
+ * that is the SAFE side of the fail-safe tiebreak (unsure is red), and it
656
+ * is never silently swallowed the way a false negative would be.
657
+ * @param {string} artifactText @returns {string[]}
658
+ */
659
+ function topLevelFunctionNames(artifactText) {
660
+ /** @type {string[]} */
661
+ const names = [];
662
+ for (const line of artifactText.split('\n')) {
663
+ if (/^\s/.test(line)) continue; // indented: nested, or a returned function expression — never top level
664
+ for (const shape of FN_SHAPES) {
665
+ const name = shape.match(line);
666
+ if (name) { names.push(name); break; }
667
+ }
668
+ }
669
+ return names;
670
+ }
671
+
599
672
  /** the default toolless seam — one spelling of how this repo drives a judge.
600
673
  * A `Loop`, NOT `provider.generate()`: the POC measured that a bare provider
601
674
  * result carries no `costUsd` at all, and under F6 an unpriced call is a red,
@@ -628,12 +701,23 @@ export const defaultJudgeLoop = ({ provider, system }) => {
628
701
  * On every route out, red or clean, `onCost` fires exactly once with the honest
629
702
  * read: a paid call that leaves no meter record is F12 wearing a judge's coat.
630
703
  * @param {{artifactText: string, card: any, loopFactory: (o: {provider?: any, system: string}) => any,
631
- * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void}} o
704
+ * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void,
705
+ * callBounds?: {timeoutMs?: number, deadlineMs?: number}}} o
632
706
  * @returns {Promise<{ok: boolean, facts: any|null, red: {axis: string, detail: string}|null,
633
707
  * costUsd: number|null, unpricedRounds: number, truncated: boolean, parseError: boolean,
634
708
  * raw: ReturnType<typeof scrubRaw>}>}
635
709
  */
636
- export async function runLocate({ artifactText, card, loopFactory, maxTokens = JUDGE_MAX_TOKENS, attempt = 1, onCost = () => {} }) {
710
+ export async function runLocate({
711
+ artifactText, card, loopFactory, maxTokens = JUDGE_MAX_TOKENS, attempt = 1, onCost = () => {},
712
+ // F152/PRD 30.4 — the SAME per-call TIME bounds the worker's own rounds carry
713
+ // (`callBounds()`, src/planrun.js:1518), threaded through by the caller that
714
+ // owns the clock. Defaults to `{}` (unbounded) so every existing caller and
715
+ // test is unchanged: an adopter that never wires a clock through gets the same
716
+ // behaviour as before this field existed. A live endpoint that accepts and
717
+ // never answers otherwise HANGS the process forever (measured, F152) — an open
718
+ // socket is an active handle, so node never drains and no backstop can fire.
719
+ callBounds = {},
720
+ }) {
637
721
  // the param-guard class: a caller's own broken input THROWS. These are
638
722
  // programmer errors with no safe direction to degrade toward — a locate over
639
723
  // an absent artifact would grade an empty string and report it as facts.
@@ -662,7 +746,7 @@ export async function runLocate({ artifactText, card, loopFactory, maxTokens = J
662
746
  let r = null;
663
747
  try {
664
748
  const loop = loopFactory({ system });
665
- r = await loop.run([{ role: 'user', content: `FILE (untrusted data):\n${artifactText}\n\nReturn the JSON.` }], [], { maxTokens });
749
+ r = await loop.run([{ role: 'user', content: `FILE (untrusted data):\n${artifactText}\n\nReturn the JSON.` }], [], { maxTokens, ...callBounds });
666
750
  } catch (e) {
667
751
  return out({ axis: LOCATE_AXES.PROVIDER, detail: `the locate call failed: ${String(/** @type {any} */ (e)?.message ?? e)}` }, null, null, false, false);
668
752
  }
@@ -743,6 +827,25 @@ export function decide(facts, card, { artifactText = null } = {}) {
743
827
  return unsure('locate found nothing in the artifact — unsure, and unsure is red');
744
828
  }
745
829
 
830
+ // COMPLETENESS (PRD 30.8): a locate emission can be well-formed AND non-empty
831
+ // AND still have silently DROPPED a top-level function the artifact actually
832
+ // holds — every route above this one only asks "is `fns` usable", never "is
833
+ // `fns` the WHOLE set". Graded rules only ever see what LOCATE reported, so
834
+ // an omission never surfaces as a rule red; it surfaces as a PASS on the
835
+ // subset, which is the fail-safe direction inverted. With `artifactText` in
836
+ // hand (every real caller has it — see the note below), the omission is
837
+ // checkable and is unsure, and unsure is red, same as every other route in.
838
+ // Without it, this says nothing it cannot know, same as the quote checks.
839
+ if (typeof artifactText === 'string') {
840
+ const present = topLevelFunctionNames(artifactText);
841
+ const reported = new Set(fns.map((f) => String(f?.name ?? '')));
842
+ const missing = present.filter((n) => !reported.has(n));
843
+ if (missing.length > 0) {
844
+ return unsure(`locate omitted top-level function(s) the artifact contains: ${missing.join(', ')} — a pass on `
845
+ + 'the reported subset is not a pass on the file, and unsure is red');
846
+ }
847
+ }
848
+
746
849
  const lines = typeof artifactText === 'string'
747
850
  ? new Set(artifactText.split('\n').map((l) => l.trim()).filter(Boolean))
748
851
  : null;
package/src/kinds.js CHANGED
@@ -260,6 +260,7 @@ export function mechanicalStages(stages) {
260
260
  * judgeLoop?: (o: {system: string}) => any,
261
261
  * onJudgeCost?: (c: {stage: string|null, kind: string|null, path: string, attempt: number,
262
262
  * label: string, model: string, costUsd: number|null, unpricedRounds: number}) => void,
263
+ * callBounds?: () => {timeoutMs?: number, deadlineMs?: number},
263
264
  * onStage?: (row: {stage: string|null, kind: string|null, verdict: string, durationMs: number}) => void}} Ctx
264
265
  * The arbiter's half of a stage run. Every field here is operator/runner
265
266
  * territory: a declaration parameterises kinds, never the contracts.
@@ -275,6 +276,11 @@ export function mechanicalStages(stages) {
275
276
  * funded honestly: this executor cannot reach the run's ledger, so every locate
276
277
  * call's cost is reported OUT through it, once per call, on every route including
277
278
  * the red ones — a paid call that leaves no meter record is F12 in a judge's coat.
279
+ * `callBounds` is F152/PRD 30.4's per-call TIME bound, read fresh before every
280
+ * locate attempt — the same derivation the run's own worker rounds carry
281
+ * (`callBounds()`, src/planrun.js). Absent is the honest unbounded default:
282
+ * an adopter or test that wires no clock through behaves exactly as before
283
+ * this field existed.
278
284
  * `onStage` is a REPORTING seam and nothing else: the seed read spawns a real
279
285
  * toolchain per stage (a suite is the slow one) and a whole read is one opaque
280
286
  * await, so the shell gets told as each stage lands. It decides nothing, it is
@@ -1781,6 +1787,12 @@ async function runJudgedFloor(stage, ctx) {
1781
1787
  card: p.card,
1782
1788
  loopFactory,
1783
1789
  attempt,
1790
+ // F152/PRD 30.4 — read fresh per attempt, the same way `planrun.js`'s own
1791
+ // worker rounds re-read `callBounds()` at every call site rather than
1792
+ // once: the remaining time shrinks between attempts. Absent (no clock
1793
+ // wired through) is `{}`, the honest unbounded default `runLocate` itself
1794
+ // already carries.
1795
+ callBounds: typeof ctx.callBounds === 'function' ? ctx.callBounds() : {},
1784
1796
  onCost: (c) => {
1785
1797
  if (typeof ctx.onJudgeCost !== 'function') return;
1786
1798
  ctx.onJudgeCost({
package/src/plan.js CHANGED
@@ -177,7 +177,11 @@ export { hasNestedQuantifier };
177
177
  export function stageClose(close) {
178
178
  if (Array.isArray(close)) return close;
179
179
  if (!isObj(close) || close.type !== 'predicate') return null;
180
- return [{ name: 'close', cmd: close.cmd, expect: close.expect, judged: close.judged, gapKeep: close.gapKeep }];
180
+ // `sha256` (M2, PRD item 27) rides through exactly like every other
181
+ // predicate-body field — a single-object predicate close is byte-signable
182
+ // the same way a staged close's stages are, and `checkCloseByteSignature`
183
+ // reads this field off whatever `closeStagesOf` hands back.
184
+ return [{ name: 'close', cmd: close.cmd, expect: close.expect, judged: close.judged, gapKeep: close.gapKeep, sha256: close.sha256 }];
181
185
  }
182
186
 
183
187
  /**