bareloop 0.1.0 → 0.2.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 CHANGED
@@ -5,7 +5,87 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
- ## [Unreleased]
8
+ ## [0.2.0] — 2026-07-12
9
+
10
+ ### Added
11
+ - **N1 — the job/close schema (rung 2 of the ladder).** `validateJob` (`src/job.js`):
12
+ the operator-owned `job-v1` spec — the arbiter's rulebook as pure declarative data
13
+ (close chain, budget, outer write fence, environment label, escalation), validated
14
+ reds-before-tokens with pinned `code:path` reds. The arbiter split is guarded from
15
+ both sides by inexpressibility (workflow config can't say `close`/`provider`; job spec
16
+ can't say `hooks`/`loop`/`memory`, minting claims, or the shell-owned retry cap).
17
+ Close-authoring hierarchy (PRD §7) enforced as a class menu keyed by close type —
18
+ verdict-class laundering (`rubric` claiming `hard`) is a named red `close-hierarchy`.
19
+ `jobSpecHash` + `checkApproval`: the pure half of human-signs-always (sha256 over
20
+ canonical JSON; an edited spec is unapproved by construction; the N2 runner enforces).
21
+ Design record: `docs/plans/2026-07-12-n1-job-close-schema-design.md`; POC verdict: F4.
22
+ - **Two-layer write fence.** `validateConfig` accepts `jobWriteScope` (the job spec's
23
+ operator-owned outer fence); every workflow scope must fit inside it — path-boundary
24
+ aware (`src2` is not inside `src`) — or it reds `scope-escape`. Same containment law,
25
+ same code, both layers (the F9 lesson).
26
+ - **Reserved spine vocabulary: `coordination-red`** (V7, PRD v1.7 #1) — documented in
27
+ `bareloop.context.md`; no machinery until job #1 surfaces one.
28
+
29
+ ### Changed
30
+ - **`validateConfig` returns `{ ok, reds, config }`** — the parsed config on ok, `null`
31
+ on any red; kills the interpreter's double-parse (N2+ queue item absorbed). Additive
32
+ for callers reading `ok`/`reds`.
33
+
34
+ ### Fixed
35
+ - **Review hardening (post-build /code-review, 8 findings fixed + 6 sub-cap cleanups;
36
+ all fixes negative-tested and mutation-checked, zero feature regressions):**
37
+ cadence/escalation red unknown keys (the last smuggling level in a signed spec is
38
+ closed); the `jobWriteScope` fence opt fails CLOSED — a malformed fence is its own
39
+ `fence-invalid` red, never silently skipped, and each escaping scope reds at its own
40
+ indexed path (`gate.writeScope.N`); scope normalization moved into the shared
41
+ `globToPrefix` (leading `./`, interior `/./`, `//`, trailing `/` collapse) so a
42
+ validateJob-green fence like `src/` no longer deadlocks contained workflow configs;
43
+ `canon()` follows JSON semantics (undefined-valued keys dropped) so approvals survive
44
+ a disk round-trip, and `checkApproval` never throws (non-JSON spec → `false`);
45
+ `SECRET_RE` gained a left boundary (`flask-sqlalchemy` no longer reds) and the sweep
46
+ is shared by BOTH validators — the agent-authored workflow config is now swept too;
47
+ `interpret` accepts `jobWriteScope` and enforces the fence at the choke point (entry
48
+ + revision candidates); revision candidates are judged and installed on their PARSED
49
+ form (a JSON-string candidate no longer false-reds arbiter-touch); exported arbiter
50
+ menus are frozen; `isObj`/`isNonEmptyString` single-copied in `validate.js`.
51
+ - **Second-round review (self-review of the hardening commit found a regression IT
52
+ introduced — all fixes TDD'd, mutation-checked, zero feature regressions):**
53
+ **critical containment escape** — the fence-normalization added to `globToPrefix`
54
+ stripped a leading `./` before collapsing `//`, so `.//src/**` minted the ABSOLUTE
55
+ prefix `/src`, validated green, and resolved outside the run directory at enforcement
56
+ (design law #1); fixed by collapsing `//`+`/./` first (so `.//src/**` → `src`, safe),
57
+ a belt in `scopeContained` rejecting any normalized-absolute prefix, and an enforcement
58
+ belt in `interpret` that refuses to build a Gate whose resolved scope escapes the
59
+ workdir. `canon()` now honors `toJSON` (a `Date` no longer hashes as `{}`; distinct
60
+ values no longer collide) and `jobSpecHash` never throws (the minting path the runner
61
+ calls directly is now crash-free on `BigInt`/cycles). `SECRET_RE` left boundary extended
62
+ to `-`/`_` (`pipeline-sk-transform-utils-v2` no longer false-reds; real keys still red).
63
+ `jobWriteScope: null`/`undefined` are the legitimate no-fence spellings (no more deadlock
64
+ on every config); a malformed fence reds `fence-invalid` at path `jobWriteScope`, not the
65
+ innocent workflow field (no ledger misattribution), with the detail bounded. Shared
66
+ `legalScopeEntry` gives the scope-legality law one home across all three call sites.
67
+ - **Secrets never enter the spine (hard line), enforced at the source.** `runClose`
68
+ (`src/ralph.js`) scrubs close-command output the moment it is captured, so a secret a
69
+ checked command echoes (a 401 dumping a `Bearer …`/`sk-…` header) never reaches the
70
+ append-only spine or the next worker prompt. The redactor is injected (the shell stays
71
+ stdlib-only); `interpret` wires bareguard's exported `redact`. A benign gap is returned
72
+ byte-identical — the failure still reaches the human, just without the token (design
73
+ law #7 / V4 intact: the redactor is a fixed shell primitive, not an emergent component).
74
+ - `NOTICE` ships in the tarball (npm auto-includes LICENSE/README but not NOTICE; Apache-2.0
75
+ wants both) — found validating the installed 0.1.0 artifact.
76
+ - **Release-gate review (fresh `/security` + `/diff-review` on the whole release diff;
77
+ every finding execution-verified, every fix TDD'd):** the spine redactor now scrubs
78
+ **every shape the validator reds** — bareguard's defaults cover only `Bearer`/`sk-`,
79
+ so a git close echoing a `ghp_`/`github_pat_`/`AKIA`/`xox` token passed unredacted
80
+ into the append-only spine (the most plausible leak for job #1, a GitHub PR workflow);
81
+ `SECRET_PATTERNS` is now the one shape inventory shared by detection and redaction.
82
+ `interpret` normalizes `workdir` once at entry — a trailing-slash or relative spelling
83
+ made the enforcement belt false-red every legal scope — and the belt now treats a scope
84
+ resolving to the run dir itself as escaped. `checkApproval` no longer routes through the
85
+ un-hashable sentinel (two distinct un-hashable specs cross-approved each other; now
86
+ un-hashable = unapproved). The secrets sweep tests object **keys**, not just values (a
87
+ token could ride a key in a `gold` `expected` onto the spine through a green spec). A
88
+ non-object `cadence` reds once at `cadence`, not twice at paths that don't exist.
9
89
 
10
90
  ## [0.1.0] — 2026-07-11
11
91
 
@@ -119,6 +199,6 @@ feature lands, **patch** = docs, fixes, scaffolding.
119
199
  `docs/UPSTREAM-ASKS.md`, guardrails pre-tool hook (local), `.gitignore`.
120
200
  - Public GitHub repo `hamr0/bareloop`, `main` branch.
121
201
 
122
- [Unreleased]: https://github.com/hamr0/bareloop/compare/v0.1.0...HEAD
202
+ [0.2.0]: https://github.com/hamr0/bareloop/compare/v0.1.0...v0.2.0
123
203
  [0.1.0]: https://github.com/hamr0/bareloop/compare/v0.0.1...v0.1.0
124
204
  [0.0.1]: https://github.com/hamr0/bareloop/releases/tag/v0.0.1
package/NOTICE ADDED
@@ -0,0 +1,13 @@
1
+ bareloop
2
+ Copyright 2026 hamr0
3
+
4
+ This product includes software developed by hamr0
5
+ (https://github.com/hamr0/bareloop).
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License").
8
+ You may obtain a copy of the License at:
9
+
10
+ http://www.apache.org/licenses/LICENSE-2.0
11
+
12
+ This NOTICE file is required by Section 4(d) of the License.
13
+ Forks and derivative works MUST preserve this attribution.
@@ -1,10 +1,9 @@
1
1
  # bareloop — Integration Guide
2
2
 
3
- > **DRAFT — pre-code.** bareloop is at the name-reservation stage; the API sections below
4
- > fill in as build-ladder rungs land (PRD §10). What is already settled — the boundary,
5
- > the architecture, the refusals, the constraints — is settled for good and recorded now.
6
- > Per LIBRARY_CONVENTIONS §3 this file ships with the package and is the complete adopter
7
- > contract; the README is only the pitch.
3
+ > **Current through N1** (job/close schema); API sections fill in as build-ladder rungs
4
+ > land (PRD §10). What is settled — the boundary, the architecture, the refusals, the
5
+ > constraints — is settled for good. Per LIBRARY_CONVENTIONS §3 this file ships with the
6
+ > package and is the complete adopter contract; the README is only the pitch.
8
7
 
9
8
  ## What this is
10
9
 
@@ -30,8 +29,42 @@ checkpoints + verdict classes, budget, cadence, provider) → run → watch the
30
29
 
31
30
  ## All options
32
31
 
33
- *TBD at N1 (job/close schema + validator lands). The schema is the option surface;
34
- config-red before tokens burn.*
32
+ Two documents, two validators — the schema is the option surface; config-red before
33
+ tokens burn. The arbiter split is guarded from both sides by INEXPRESSIBILITY: the
34
+ workflow config cannot say `close`/`provider`; the job spec cannot say
35
+ `hooks`/`loop`/`memory`, any minting claim, or the shell-owned retry cap — all
36
+ unknown-field reds.
37
+
38
+ **Job spec (`schema: "job-v1"`, operator-owned — the arbiter's rulebook):**
39
+
40
+ | field | shape | notes |
41
+ |---|---|---|
42
+ | `job` | kebab-case slug | |
43
+ | `description` | non-empty string | |
44
+ | `provider` | `anthropic-api` | menu; part of the lineage key by definition |
45
+ | `conditions` | `{ providerPath?, closeVerbosity?, taskFraming?, scaffold? }` | declared keys only, string values — the environment label (consumed by the N3 lineage key; recorded on spines from run one) |
46
+ | `cadence` | `{ unit: hour\|day\|week, every: 1..30 }` | validated now, consumed at N5 (Scheduler) |
47
+ | `budgetUsd` | `0 < n <= shell cap` | ceiling chain: workflow ≤ job ≤ shell — each layer may tighten, never exceed |
48
+ | `writeScope` | array of contained globs | the operator's outer fence; same containment law as the workflow layer, same code |
49
+ | `steps` | array of `{ id, close, class }` | unique slug ids; every step names its close |
50
+ | `escalation` | `{ mode: "decision-ready" }` | the pain channel is not optional |
51
+
52
+ **Close types and the hierarchy** (a close is data, never code; verdict-class laundering
53
+ is a named red `close-hierarchy`):
54
+
55
+ | type | fields (exact — extras red) | legal class |
56
+ |---|---|---|
57
+ | `predicate` | `cmd`, `expect` (int exit code) | `hard` |
58
+ | `gold` | `expected`, `compare: exact\|json-equal` | `hard` |
59
+ | `rubric` | `criteria` | `soft` only — can never mint automatically |
60
+ | `hitl` | `prompt` | `hitl` — a human IS the close |
61
+
62
+ Red vocabulary (both validators): `parse-error`, `unknown-field`, `missing-required`,
63
+ `invalid-value`, `bounds`, `duplicate-id`, `close-type`, `close-hierarchy`,
64
+ `secret-literal`, `scope-escape`, `fence-invalid` (a malformed `jobWriteScope` fence — attributed to `jobWriteScope`, never the workflow config), plus the workflow-side verb reds (`verb-illegal`,
65
+ `verb-placement`, `verb-params`, `slot-overflow`). The `secret-literal` sweep is
66
+ defense-in-depth against known token shapes — env-only loading remains the law, not the
67
+ sweep.
35
68
 
36
69
  ## Public API
37
70
 
@@ -51,23 +84,57 @@ The dumb outer shell: `while close-red and under-cap: run the middle`. `close` i
51
84
  whose exit code is truth (`runClose` is also exported); the red gap text feeds the next
52
85
  iteration. Escalations are decision-ready (category, options, spend); cap-halt is its own
53
86
  category, never merged with "wrong". A thrown middle is relayed by its `category`
54
- property (`cap-halt`, `gate-red`, …); an unnamed throw is `interpreter-red`.
87
+ property (`cap-halt`, `gate-red`, …); an unnamed throw is `interpreter-red`. Close output
88
+ is scrubbed at capture (an injected `redact`, wired to bareguard by `interpret` with the
89
+ validators' full secret-shape inventory — Bearer/sk-/ghp_/github_pat_/AKIA/xox) so a
90
+ secret a checked command echoes never enters the append-only spine or a worker prompt —
91
+ a benign gap is byte-identical (secrets hard line; design law #7 intact).
55
92
 
56
- ### `validateConfig(input, { shellCapUsd? })` → `{ ok, reds }` — `src/validate.js`
93
+ ### `validateConfig(input, { shellCapUsd?, jobWriteScope? })` → `{ ok, reds, config }` — `src/validate.js`
57
94
 
58
95
  Deterministic schema-v1 predicate; never throws. Every failure is a named red
59
- `{ code, path, detail? }` — reds before tokens burn. Verb vocabulary bound from litectx
96
+ `{ code, path, detail? }` — reds before tokens burn. Returns the parsed config on ok
97
+ (single parse; `null` on any red). Pass the job spec's `writeScope` as `jobWriteScope`
98
+ and every workflow scope must fit inside the fence (path-boundary aware: `src2` is not
99
+ inside `src`) or it reds `scope-escape`; pass `min(shell cap, job budgetUsd)` as
100
+ `shellCapUsd` to complete the ceiling chain. Verb vocabulary bound from litectx
60
101
  (`LOOP_SHAPES`, `SLOTS`, `VERBS` exported). `diffPaths(a, b)` returns changed JSON paths —
61
102
  the one-knob mutation checker.
62
103
 
104
+ ### `validateJob(input, { shellCapUsd? })` → `{ ok, reds, job }` — `src/job.js`
105
+
106
+ The operator-owned sibling (never an extension) of `validateConfig`: validates a
107
+ `job-v1` spec — see **All options** for the full schema, close types, and hierarchy.
108
+ Never throws on JSON text or plain parsed data (the ingest contract); returns the
109
+ parsed spec on ok, `null` on any red. Menus exported:
110
+ `CLOSE_TYPES`, `CLASSES`, `CLASS_BY_CLOSE`, `GOLD_COMPARE`, `CADENCE_UNITS`,
111
+ `PROVIDERS`, `CONDITION_KEYS`.
112
+
113
+ ### `jobSpecHash(job)` / `checkApproval(job, approvals)` — `src/job.js`
114
+
115
+ The pure half of **human-signs-always**: an agent may draft a job spec, but no job runs
116
+ until a human approves that exact version. `jobSpecHash` is sha256 over canonical JSON
117
+ (key-order independent) — any edit changes the hash, so an edited spec is unapproved by
118
+ construction. `checkApproval(job, approvals)` is a pure predicate over
119
+ `{ specHash, signer, ts }` records; the approval record lives OUTSIDE the document it
120
+ signs and is shell/human territory, never agent-writable. The N2 runner enforces it.
121
+ Reserved spine vocabulary (V7, machinery-free until job #1 surfaces one):
122
+ `coordination-red` — a failure between units (scope contention, step order, store
123
+ races), never to be folded into worker/interpreter reds.
124
+
63
125
  ### `interpret(configRaw, opts)` → `'green' | 'escalated' | 'config-red'` — `src/interpret.js`
64
126
 
65
127
  The only code that reads a config. Composes bareguard `Gate` (write scopes, USD budget),
66
128
  litectx (recall/compress/stash/remember hook ops), and the bareagent `Loop` under `ralph`.
67
129
  The provider and the close arrive from the shell, never the config. Optional `revisor`
68
130
  seam fires once after `STALL_REDS` consecutive close reds; the interpreter owns acceptance
69
- (arbiter-touch / cap-touch / validation reds). Emits `config-final` — the run-as-executed
70
- config (design law #2) — on every run.
131
+ (arbiter-touch / cap-touch / validation reds), judged and installed on the candidate's
132
+ PARSED form. Emits `config-final` — the run-as-executed config (design law #2) — on every
133
+ run. Pass the job spec's fence as `jobWriteScope` (or `null`/omit for no fence): it is
134
+ enforced HERE — the one choke point where a config becomes a Gate — on entry AND on every
135
+ revision candidate, so a workflow scope outside the operator's fence reds before tokens.
136
+ An enforcement belt resolves every scope and refuses to build a Gate that escapes the
137
+ workdir, independent of validator correctness (law #1).
71
138
 
72
139
  ### `extractRules({ config, provider, priorRules, revisionDiff? })` — `src/extract.js`
73
140
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -39,6 +39,7 @@
39
39
  "files": [
40
40
  "src/",
41
41
  "types/",
42
+ "NOTICE",
42
43
  "bareloop.context.md",
43
44
  "CHANGELOG.md"
44
45
  ],
package/src/index.js CHANGED
@@ -1,9 +1,13 @@
1
1
  // bareloop public surface — grows one rung at a time (PRD §10). N0: the
2
2
  // token-free spine. The five modules compose bottom-up: makeSpine feeds ralph,
3
3
  // interpret is the only config reader, extractRules distills a green run.
4
+ // N1: the operator-owned job spec (job-v1) — validateJob is validate.js's
5
+ // sibling (never an extension); jobSpecHash/checkApproval are the pure half
6
+ // of human-signs-always (the N2 runner enforces).
4
7
 
5
8
  export { makeSpine } from './spine.js';
6
9
  export { ralph, runClose } from './ralph.js';
7
10
  export { validateConfig, diffPaths, globToPrefix, LOOP_SHAPES, SLOTS, VERBS } from './validate.js';
11
+ export { validateJob, jobSpecHash, checkApproval, CLOSE_TYPES, CLASSES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS } from './job.js';
8
12
  export { interpret, STALL_REDS } from './interpret.js';
9
13
  export { extractRules, MAX_RULES, MAX_RULE_CHARS } from './extract.js';
package/src/interpret.js CHANGED
@@ -12,10 +12,10 @@
12
12
 
13
13
  import { createRequire } from 'node:module';
14
14
  import { writeFileSync, readFileSync } from 'node:fs';
15
- import { join, resolve } from 'node:path';
16
- import { Gate } from 'bareguard';
15
+ import { join, resolve, sep } from 'node:path';
16
+ import { Gate, redact } from 'bareguard';
17
17
  import { LiteCtx, compress } from 'litectx';
18
- import { validateConfig, diffPaths, globToPrefix } from './validate.js';
18
+ import { validateConfig, diffPaths, globToPrefix, SECRET_PATTERNS } from './validate.js';
19
19
  import { ralph } from './ralph.js';
20
20
 
21
21
  /** @typedef {Error & {category?: string}} CategorizedError the failure map's carrier: ralph relays by `category` */
@@ -45,6 +45,10 @@ const PERSONA = 'You are a senior engineer. Reply with ONLY the complete content
45
45
  * @param {(type: string, data?: object) => object} opts.emit spine emitter
46
46
  * @param {object} opts.provider a bareagent provider — SHELL-owned binding (adaptlearn F8: an unsealed binding is a gate bypass)
47
47
  * @param {number} [opts.shellCapUsd=2] the shell's USD cap; config budgetUsd is clamped by validation
48
+ * @param {string[]} [opts.jobWriteScope] the job spec's outer write fence (operator law,
49
+ * job-v1) — enforced HERE, the one choke point where a config becomes a Gate:
50
+ * every workflow scope must fit inside it (scope-escape config-red otherwise),
51
+ * on entry validation and on every revision candidate alike
48
52
  * @param {(o: {config: object, gaps: string[], policy: any, onLlmResult: any}) => Promise<{candidate: object|null, parseError?: string|null, costUsd?: number}>} [opts.revisor]
49
53
  * optional mid-run revision seam. Fires ONCE per run after STALL_REDS consecutive
50
54
  * close reds. The interpreter — never the revisor — owns acceptance: the candidate
@@ -54,22 +58,40 @@ const PERSONA = 'You are a senior engineer. Reply with ONLY the complete content
54
58
  * the worker.
55
59
  * @returns {Promise<'green'|'escalated'|'config-red'>}
56
60
  */
57
- export async function interpret(configRaw, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd = 2, revisor }) {
58
- const v = validateConfig(configRaw, { shellCapUsd });
61
+ export async function interpret(configRaw, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd = 2, jobWriteScope, revisor }) {
62
+ // Normalize ONCE: a trailing slash or a relative spelling must mean the same
63
+ // directory everywhere below — the enforcement belt compares string prefixes,
64
+ // and "/run/" vs "/run" would false-red every legal scope (release review).
65
+ workdir = resolve(workdir);
66
+ const v = validateConfig(configRaw, { shellCapUsd, jobWriteScope });
59
67
  emit('config-validate', { ok: v.ok, reds: v.reds });
60
68
  if (!v.ok) {
61
69
  for (const r of v.reds) emit('config-red', r);
62
70
  emit('run-end', { outcome: 'config-red', iterations: 0 });
63
71
  return 'config-red';
64
72
  }
65
- let config = typeof configRaw === 'string' ? JSON.parse(configRaw) : configRaw;
73
+ let config = /** @type {any} */ (v.config); // single parse — validateConfig returns the parsed config on ok
66
74
 
67
75
  const lc = new LiteCtx({ root: workdir });
76
+ // Enforcement belt (law #1, un-gameable gate): resolve every scope and prove
77
+ // it stays under workdir BEFORE building the Gate. validateConfig already
78
+ // rejects escaping scopes, so this is defense in depth — a future globToPrefix
79
+ // regression (a spelling that normalizes to an absolute path) can never reach
80
+ // a live Gate fence. The interpreter and the validator must never disagree (F9).
81
+ const resolvedScopes = config.gate.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g)));
82
+ // equality counts as escaped: no legal scope resolves to workdir itself (the
83
+ // close lives there), so a scope normalizing to ''/'.' is a regression, not a grant
84
+ const escaped = resolvedScopes.filter((/** @type {string} */ abs) => !abs.startsWith(workdir + sep));
85
+ if (escaped.length) {
86
+ for (const abs of escaped) emit('config-red', { code: 'scope-escape', path: 'gate.writeScope', detail: `resolved scope ${abs} escapes the run directory` });
87
+ emit('run-end', { outcome: 'config-red', iterations: 0 });
88
+ return 'config-red';
89
+ }
68
90
  const gate = new Gate({
69
91
  // bareguard fs.writeScope is prefix-containment, not glob (adaptlearn F4); globToPrefix
70
92
  // is the ONE transform shared with the validator's legality rule — mid-path wildcards
71
93
  // and workdir-escaping scopes were already rejected up front (adaptlearn F9, law #1).
72
- fs: { writeScope: config.gate.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g))) },
94
+ fs: { writeScope: resolvedScopes },
73
95
  budget: { maxCostUsd: config.gate.budgetUsd },
74
96
  limits: { maxTurns: 8 * (capRuns + 1) },
75
97
  audit: { path: join(workdir, 'gate-audit.jsonl') },
@@ -139,17 +161,22 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
139
161
  // already snapshotted, so gate/escalation (arbiter) and loop.maxIterations
140
162
  // (cap) must be byte-identical; anything else that validates is a legal
141
163
  // free-axis revision.
142
- /** @param {any} candidate */
164
+ /** @param {any} candidate
165
+ * @returns {{ red: {code: string, reds?: object[]} } | { red?: undefined, config: any }} */
143
166
  const acceptRevision = (candidate) => {
144
- if (!candidate) return { code: 'parse-error' };
145
- const cv = validateConfig(candidate, { shellCapUsd });
146
- if (!cv.ok) return { code: 'validation', reds: cv.reds };
147
- if (JSON.stringify(candidate.gate) !== JSON.stringify(config.gate)
148
- || JSON.stringify(candidate.escalation) !== JSON.stringify(config.escalation)) {
149
- return { code: 'arbiter-touch' };
167
+ if (!candidate) return { red: { code: 'parse-error' } };
168
+ const cv = validateConfig(candidate, { shellCapUsd, jobWriteScope });
169
+ if (!cv.ok) return { red: { code: 'validation', reds: cv.reds } };
170
+ // judged and installed on the PARSED form (single-parse contract) — a
171
+ // string candidate compared raw would false-red arbiter-touch (its .gate
172
+ // is undefined), and installing it raw would crash every later read
173
+ const cand = /** @type {any} */ (cv.config);
174
+ if (JSON.stringify(cand.gate) !== JSON.stringify(config.gate)
175
+ || JSON.stringify(cand.escalation) !== JSON.stringify(config.escalation)) {
176
+ return { red: { code: 'arbiter-touch' } };
150
177
  }
151
- if (candidate.loop?.maxIterations !== config.loop.maxIterations) return { code: 'cap-touch' };
152
- return null;
178
+ if (cand.loop?.maxIterations !== config.loop.maxIterations) return { red: { code: 'cap-touch' } };
179
+ return { config: cand };
153
180
  };
154
181
 
155
182
  /** @type {string[]} */
@@ -174,12 +201,12 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
174
201
  if (e instanceof HaltError) /** @type {CategorizedError} */ (e).category = 'cap-halt';
175
202
  throw e;
176
203
  }
177
- const red = acceptRevision(rv.candidate);
178
- if (red) {
179
- emit('revision-red', { iteration, ...red, detail: rv.parseError ?? undefined, costUsd: rv.costUsd ?? 0 });
204
+ const rr = acceptRevision(rv.candidate);
205
+ if (rr.red) {
206
+ emit('revision-red', { iteration, ...rr.red, detail: rv.parseError ?? undefined, costUsd: rv.costUsd ?? 0 });
180
207
  } else {
181
- emit('revision-accepted', { iteration, changedPaths: diffPaths(config, rv.candidate), costUsd: rv.costUsd ?? 0 });
182
- config = rv.candidate;
208
+ emit('revision-accepted', { iteration, changedPaths: diffPaths(config, rr.config), costUsd: rv.costUsd ?? 0 });
209
+ config = rr.config;
183
210
  }
184
211
  }
185
212
  if (gap) await runOps('after-red', { iteration, gap, context: {} });
@@ -208,7 +235,13 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
208
235
 
209
236
  // the config may tighten the shell's iteration budget, never exceed it (mirrors budgetUsd)
210
237
  const effectiveCap = Math.min(capRuns, config.loop.maxIterations ?? capRuns);
211
- const outcome = await ralph({ middle, close, capRuns: effectiveCap, emit });
238
+ // Secrets never enter the spine (hard line): the shell scrubs close output at
239
+ // the source with bareguard's redactor — BG-1 defaults (Bearer/sk-…) PLUS the
240
+ // validator's whole shape inventory (SECRET_PATTERNS), so redaction can never
241
+ // pass a shape detection reds (a git close echoing a ghp_ token was the gap).
242
+ // Injected here (the layer that owns bareguard) so ralph stays stdlib-only and
243
+ // the scrub is a fixed shell primitive, not an emergent component (V4 holds).
244
+ const outcome = await ralph({ middle, close, capRuns: effectiveCap, emit, redact: (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS }) });
212
245
  if (outcome === 'green') {
213
246
  // The close already passed — a retention hiccup must not un-green a real
214
247
  // green (it would corrupt the learning curve). It degrades loudly:
package/src/job.js ADDED
@@ -0,0 +1,252 @@
1
+ // Job-spec validator — the OPERATOR-owned half of the config story (N1,
2
+ // design record: docs/plans/2026-07-12-n1-job-close-schema-design.md).
3
+ // A job spec is the arbiter's rulebook: the close chain, the budget, the
4
+ // outer write fence, the escalation route. It is pure declarative data —
5
+ // no freeform code is expressible anywhere in it — and it validates
6
+ // reds-before-tokens like its sibling validate.js (workflow, agent-authored).
7
+ // The arbiter split is guarded from both sides by INEXPRESSIBILITY: the
8
+ // workflow config cannot say close/provider (validate.js, shipped at N0);
9
+ // the job spec cannot say hooks/loop/memory, any minting claim, or the
10
+ // shell-owned retry cap — all unknown-field reds here.
11
+ //
12
+ // Close-authoring hierarchy (PRD §7) as a class menu keyed by close type:
13
+ // verdict-class laundering (a rubric claiming hard) is a named red, never a
14
+ // judgment call. Minting policy is product doctrine, not job-authorable.
15
+
16
+ import { createHash } from 'node:crypto';
17
+ import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals } from './validate.js';
18
+
19
+ // The menus below ARE the close-authoring hierarchy (PRD §7) and ship frozen:
20
+ // they are read at call time, so a mutable export would let adopter code
21
+ // enable verdict-class laundering process-wide with one push().
22
+ export const CLOSE_TYPES = Object.freeze(['predicate', 'gold', 'rubric', 'hitl']);
23
+ export const CLASSES = Object.freeze(['hard', 'soft', 'hitl']);
24
+ /** the hierarchy: which verdict classes a close type may claim (PRD §7) */
25
+ export const CLASS_BY_CLOSE = Object.freeze({ predicate: Object.freeze(['hard']), gold: Object.freeze(['hard']), rubric: Object.freeze(['soft']), hitl: Object.freeze(['hitl']) });
26
+ export const GOLD_COMPARE = Object.freeze(['exact', 'json-equal']);
27
+ export const CADENCE_UNITS = Object.freeze(['hour', 'day', 'week']);
28
+ export const PROVIDERS = Object.freeze(['anthropic-api']); // SP-2: API-first; local deferred (PRD §5/§8)
29
+ /** V3 environment label: declared keys only — every field is a lineage-key
30
+ * candidate at N3. `provider` is part of the key by definition (top-level,
31
+ * not duplicated here). */
32
+ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', 'taskFraming', 'scaffold']);
33
+ const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'writeScope', 'steps', 'escalation'];
34
+ /** exact field set per close type — anything else is an unknown-field red
35
+ * (freeform code, script bodies, and minting claims all land there) */
36
+ const CLOSE_FIELDS = {
37
+ predicate: ['type', 'cmd', 'expect'],
38
+ gold: ['type', 'expected', 'compare'],
39
+ rubric: ['type', 'criteria'],
40
+ hitl: ['type', 'prompt'],
41
+ };
42
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
43
+
44
+ /** @typedef {{code: string, path: string, detail?: string}} Red */
45
+
46
+ /**
47
+ * Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
48
+ * JSON text or plain parsed data — the ingest contract; a live object with a
49
+ * hostile accessor is outside it. Every failure is a named red. Returns the
50
+ * parsed spec on ok (single parse), null on any red.
51
+ * @param {object|string} input parsed spec, or raw JSON text (parse failures are a red)
52
+ * @param {{ shellCapUsd?: number }} [opts] the shell's hard ceiling — the job
53
+ * budget may tighten it, never exceed it (ceiling chain: workflow ≤ job ≤ shell)
54
+ * @returns {{ ok: boolean, reds: Red[], job: object|null }}
55
+ */
56
+ export function validateJob(input, { shellCapUsd = 2 } = {}) {
57
+ /** @type {Red[]} */
58
+ const reds = [];
59
+ /** @type {(code: string, path: string, detail?: string) => void} */
60
+ const red = (code, path, detail) => { reds.push(detail ? { code, path, detail } : { code, path }); };
61
+
62
+ let j = input;
63
+ if (typeof j === 'string') {
64
+ try { j = JSON.parse(j); } catch (e) {
65
+ return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }], job: null };
66
+ }
67
+ }
68
+ if (!isObj(j)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'job spec must be a JSON object' }], job: null };
69
+ const spec = /** @type {Record<string, any>} */ (j);
70
+
71
+ // 1. shape — unknown fields red here: hooks/loop/memory (agent domain),
72
+ // capRuns (shell domain), minting claims — inexpressibility is the guard.
73
+ for (const key of Object.keys(spec)) {
74
+ if (!JOB_FIELDS.includes(key)) red('unknown-field', key);
75
+ }
76
+ if (spec.schema === undefined) red('missing-required', 'schema');
77
+ else if (spec.schema !== 'job-v1') red('invalid-value', 'schema', `expected "job-v1", got ${JSON.stringify(spec.schema)}`);
78
+ if (!isNonEmptyString(spec.job) || !SLUG_RE.test(spec.job)) red('invalid-value', 'job', 'kebab-case slug');
79
+ if (!isNonEmptyString(spec.description)) red('missing-required', 'description');
80
+
81
+ if (spec.provider === undefined) red('missing-required', 'provider');
82
+ else if (!PROVIDERS.includes(spec.provider)) red('invalid-value', 'provider', `menu: ${PROVIDERS.join('|')}`);
83
+
84
+ // 2. the environment label (V3, design law #5): declared keys only
85
+ if (spec.conditions !== undefined) {
86
+ if (!isObj(spec.conditions)) red('invalid-value', 'conditions', 'flat object of declared condition keys');
87
+ else {
88
+ for (const [key, value] of Object.entries(spec.conditions)) {
89
+ if (!CONDITION_KEYS.includes(key)) red('unknown-field', `conditions.${key}`, `declared keys only: ${CONDITION_KEYS.join('|')} (every field is a lineage-key dimension, V3)`);
90
+ else if (!isNonEmptyString(value)) red('invalid-value', `conditions.${key}`, 'non-empty string');
91
+ }
92
+ }
93
+ }
94
+
95
+ if (spec.cadence === undefined) red('missing-required', 'cadence');
96
+ else if (!isObj(spec.cadence)) red('invalid-value', 'cadence', 'object of {unit, every}');
97
+ else {
98
+ const cad = spec.cadence;
99
+ for (const key of Object.keys(cad)) {
100
+ if (key !== 'unit' && key !== 'every') red('unknown-field', `cadence.${key}`, 'nested objects red unknown keys too — no smuggling level exists in a signed spec');
101
+ }
102
+ if (!CADENCE_UNITS.includes(cad.unit)) red('invalid-value', 'cadence.unit', CADENCE_UNITS.join('|'));
103
+ if (!(Number.isInteger(cad.every) && cad.every >= 1 && cad.every <= 30)) red('bounds', 'cadence.every', '1..30');
104
+ }
105
+
106
+ if (spec.budgetUsd === undefined) red('missing-required', 'budgetUsd');
107
+ else if (!(typeof spec.budgetUsd === 'number' && spec.budgetUsd > 0 && spec.budgetUsd <= shellCapUsd)) {
108
+ red('bounds', 'budgetUsd', `0 < budget <= shell cap ${shellCapUsd} (cap-not-estimate; no self-adjusted budgets, ever)`);
109
+ }
110
+
111
+ // 3. the outer write fence — operator law (interview decision #4), same
112
+ // containment rules as the workflow layer THROUGH THE SAME CODE (F9: two
113
+ // transforms would let a fence validate green and mean something else).
114
+ if (spec.writeScope === undefined) red('missing-required', 'writeScope', 'the fence is operator law — the workflow scope must fit inside it');
115
+ else if (!(Array.isArray(spec.writeScope) && spec.writeScope.length > 0
116
+ && spec.writeScope.every((/** @type {unknown} */ s) => isNonEmptyString(s)))) {
117
+ red('invalid-value', 'writeScope', 'non-empty array of glob strings');
118
+ } else if (!spec.writeScope.every((/** @type {string} */ s) => !globToPrefix(s).includes('*'))) {
119
+ red('invalid-value', 'writeScope', 'wildcards only as a trailing "/**" or "/*" (enforcement is prefix-containment, adaptlearn F9)');
120
+ } else if (!spec.writeScope.every(scopeContained)) {
121
+ red('invalid-value', 'writeScope', 'must be a proper subdirectory of the run directory — no absolute paths, no ".." segments, not the run dir itself (design law #1)');
122
+ }
123
+
124
+ if (spec.escalation === undefined) red('missing-required', 'escalation.mode', 'the pain channel is not optional (law #7)');
125
+ else {
126
+ const esc = isObj(spec.escalation) ? spec.escalation : {};
127
+ for (const key of Object.keys(esc)) {
128
+ if (key !== 'mode') red('unknown-field', `escalation.${key}`, 'nested objects red unknown keys too — no smuggling level exists in a signed spec');
129
+ }
130
+ if (esc.mode !== 'decision-ready') red('invalid-value', 'escalation.mode', 'must be "decision-ready"');
131
+ }
132
+
133
+ // 4. steps + the close chain
134
+ if (!Array.isArray(spec.steps) || spec.steps.length === 0) {
135
+ red('missing-required', 'steps', 'non-empty array — a job without closes is ungated spend');
136
+ } else {
137
+ const seen = new Set();
138
+ spec.steps.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
139
+ const at = `steps.${i}`;
140
+ if (!isObj(s)) { red('invalid-value', at, 'step must be an object'); return; }
141
+ for (const key of Object.keys(s)) {
142
+ if (!['id', 'close', 'class'].includes(key)) red('unknown-field', `${at}.${key}`);
143
+ }
144
+ if (!isNonEmptyString(s.id) || !SLUG_RE.test(s.id)) red('invalid-value', `${at}.id`, 'kebab-case slug');
145
+ else if (seen.has(s.id)) red('duplicate-id', `${at}.id`, s.id);
146
+ else seen.add(s.id);
147
+
148
+ if (s.class === undefined) red('missing-required', `${at}.class`);
149
+ else if (!CLASSES.includes(s.class)) red('invalid-value', `${at}.class`, CLASSES.join('|'));
150
+
151
+ if (!isObj(s.close)) { red('missing-required', `${at}.close`, 'every step names its close — a step without one is ungated spend'); return; }
152
+ const close = s.close;
153
+ if (!CLOSE_TYPES.includes(close.type)) {
154
+ red('close-type', `${at}.close.type`, `menu: ${CLOSE_TYPES.join('|')} — a close is data, never code`);
155
+ return;
156
+ }
157
+ // per-type contracts — fixed menus only
158
+ if (close.type === 'predicate') {
159
+ if (!isNonEmptyString(close.cmd)) red('missing-required', `${at}.close.cmd`);
160
+ if (!Number.isInteger(close.expect)) red('invalid-value', `${at}.close.expect`, 'integer exit code');
161
+ } else if (close.type === 'gold') {
162
+ if (close.expected === undefined) red('missing-required', `${at}.close.expected`);
163
+ if (!GOLD_COMPARE.includes(close.compare)) red('invalid-value', `${at}.close.compare`, GOLD_COMPARE.join('|'));
164
+ } else if (close.type === 'rubric') {
165
+ if (!isNonEmptyString(close.criteria)) red('missing-required', `${at}.close.criteria`);
166
+ } else if (close.type === 'hitl') {
167
+ if (!isNonEmptyString(close.prompt)) red('missing-required', `${at}.close.prompt`);
168
+ }
169
+ for (const key of Object.keys(close)) {
170
+ if (!CLOSE_FIELDS[close.type].includes(key)) {
171
+ red('unknown-field', `${at}.close.${key}`, `not a ${close.type} field (script bodies and minting claims land here)`);
172
+ }
173
+ }
174
+ // the hierarchy: one check covers both directions — a class outside the
175
+ // close type's menu is laundering (rubric-as-hard) or delegation
176
+ // (hitl-class on a script close); hitl ⇔ hitl falls out of the menus.
177
+ const legal = CLASS_BY_CLOSE[close.type];
178
+ if (CLASSES.includes(s.class) && !legal.includes(s.class)) {
179
+ red('close-hierarchy', `${at}.class`, `${close.type} close admits class ${legal.join('|')} only (a rubric can never be hard; a human IS the hitl close — PRD §7)`);
180
+ }
181
+ });
182
+ }
183
+
184
+ // 5. secrets sweep — the SAME shared guard the workflow validator runs
185
+ // (defense-in-depth against known token shapes; env-only loading is the law)
186
+ sweepSecretLiterals(spec, red);
187
+
188
+ return { ok: reds.length === 0, reds, job: reds.length === 0 ? spec : null };
189
+ }
190
+
191
+ /**
192
+ * Canonical JSON — recursive key sort, so the hash binds to CONTENT, not to
193
+ * the accident of key order a drafting UX or an editor produced.
194
+ * @param {any} v
195
+ * @returns {string}
196
+ */
197
+ function canon(v) {
198
+ // Follow JSON.stringify semantics — the hash must equal the hash of the
199
+ // disk round-trip, or an approval minted in memory fails after save/reload
200
+ // (human-signs-always rejecting an unedited spec). That means honoring toJSON
201
+ // (a Date serializes to its ISO string, not walked as {}) and dropping
202
+ // undefined-valued keys, both of which JSON.stringify does. One divergence:
203
+ // a function/symbol-VALUED key canonizes as null where stringify drops it —
204
+ // reachable only on non-JSON specs, where a round-trip mismatch is correct.
205
+ if (v !== null && typeof v === 'object' && typeof (/** @type {any} */ (v).toJSON) === 'function') {
206
+ return canon(/** @type {any} */ (v).toJSON());
207
+ }
208
+ if (Array.isArray(v)) return `[${v.map((x) => (x === undefined ? 'null' : canon(x))).join(',')}]`;
209
+ if (isObj(v)) {
210
+ const obj = /** @type {Record<string, any>} */ (v);
211
+ return `{${Object.keys(obj).sort().filter((k) => obj[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canon(obj[k])}`).join(',')}}`;
212
+ }
213
+ const s = JSON.stringify(v);
214
+ return s === undefined ? 'null' : s; // undefined/function/symbol leaf: not JSON-representable — 'null', never throw
215
+ }
216
+
217
+ /**
218
+ * Content hash of a job spec — what an approval record binds to. Any edit to
219
+ * the spec changes the hash, so an edited spec is unapproved by construction
220
+ * (interview decision #1: human signs, always, once per spec VERSION).
221
+ * @param {object} job
222
+ * @returns {string} sha256 hex
223
+ */
224
+ export function jobSpecHash(job) {
225
+ // NEVER throws: the N2 runner mints {specHash, signer, ts} by calling this
226
+ // directly on the raw job object, so a non-JSON value (BigInt, a cycle, a
227
+ // throwing getter) must yield a hash, not crash the signer. canon handles
228
+ // JSON-representable inputs; the guard covers cycles and hostile getters.
229
+ let c;
230
+ try { c = canon(job); } catch { c = '\u0000unhashable'; }
231
+ return createHash('sha256').update(c).digest('hex');
232
+ }
233
+
234
+ /**
235
+ * Does an approval record cover this exact spec version? Pure predicate — the
236
+ * N2 runner refuses to run without it; nothing here writes or prompts. The
237
+ * approval record lives OUTSIDE the document it signs (a spec never contains
238
+ * its own signature) and is shell/human territory, never agent-writable.
239
+ * @param {object} job
240
+ * @param {unknown} approvals array of { specHash, signer, ts } records
241
+ * @returns {boolean}
242
+ */
243
+ export function checkApproval(job, approvals) {
244
+ if (!Array.isArray(approvals)) return false;
245
+ // NOT jobSpecHash: its un-hashable sentinel would make every un-hashable
246
+ // spec hash-equal — an approval minted for one would authorize any other.
247
+ // Here un-hashable simply means unapproved, full stop.
248
+ let c;
249
+ try { c = canon(job); } catch { return false; }
250
+ const h = createHash('sha256').update(c).digest('hex');
251
+ return approvals.some((a) => isObj(a) && /** @type {Record<string, unknown>} */ (a).specHash === h);
252
+ }
package/src/ralph.js CHANGED
@@ -9,9 +9,19 @@ import { spawnSync } from 'node:child_process';
9
9
  /**
10
10
  * Run a close command; its exit code is the verdict (hard green, PRD §4).
11
11
  * 0 → satisfied; nonzero → needs_revision (gap fed back); spawn error → failed (terminal).
12
+ *
13
+ * `redact` scrubs the close's own output at the SOURCE — before the gap becomes
14
+ * a spine event or a worker prompt — so a secret a checked command echoes
15
+ * (a 401 dumping an `Authorization: Bearer …` header) never enters the
16
+ * append-only spine (the hard line). It is INJECTED, not imported: the shell
17
+ * stays stdlib-only and un-gameable, and the redactor is a fixed shell
18
+ * primitive (bareguard's, wired by the interpreter), never an emergent
19
+ * component — so V4 holds. The shell's canonical emission IS the redacted text;
20
+ * a benign gap is returned byte-identical (default: identity).
12
21
  * @param {string[]} close argv, e.g. ['node', '--test', 'close/']
22
+ * @param {(s: string) => string} [redact] source scrubber; identity by default
13
23
  */
14
- export function runClose(close) {
24
+ export function runClose(close, redact = (s) => s) {
15
25
  const env = { ...process.env };
16
26
  delete env.NODE_TEST_CONTEXT; // a `node --test` close inherits this from a test runner and silently no-ops — a fake green
17
27
  const r = spawnSync(close[0], close.slice(1), { env, encoding: 'utf8', timeout: 120_000 });
@@ -20,7 +30,8 @@ export function runClose(close) {
20
30
  // The gap must never be falsy: every consumer guards with `if (gap)` — an
21
31
  // empty-output red would silently kill gap feedback, after-red hooks, AND
22
32
  // stall detection, leaving the worker re-prompted byte-identically to the cap.
23
- return { verdict: 'needs_revision', gap: (r.stderr || r.stdout || '').slice(0, 2000) || '(close exited nonzero with no output)', exitCode: r.status };
33
+ // Redact BEFORE slicing so a token straddling the 2000-char bound can't survive.
34
+ return { verdict: 'needs_revision', gap: redact(r.stderr || r.stdout || '').slice(0, 2000) || '(close exited nonzero with no output)', exitCode: r.status };
24
35
  }
25
36
 
26
37
  /**
@@ -42,9 +53,11 @@ export function runClose(close) {
42
53
  * @param {string[]} opts.close argv whose exit code is truth
43
54
  * @param {number} opts.capRuns budget: max middle runs
44
55
  * @param {(type: string, data?: object) => object} opts.emit a spine emitter
56
+ * @param {(s: string) => string} [opts.redact] source scrubber for close output
57
+ * (secrets never enter the spine); injected so the shell stays stdlib-only
45
58
  * @returns {Promise<'green'|'escalated'>}
46
59
  */
47
- export async function ralph({ middle, close, capRuns, emit }) {
60
+ export async function ralph({ middle, close, capRuns, emit, redact }) {
48
61
  emit('run-start', { capRuns, close: close.join(' ') });
49
62
  const verdicts = [];
50
63
  let gap;
@@ -77,7 +90,7 @@ export async function ralph({ middle, close, capRuns, emit }) {
77
90
  return 'escalated';
78
91
  }
79
92
  emit('middle-done', { iteration });
80
- const v = runClose(close);
93
+ const v = runClose(close, redact);
81
94
  verdicts.push(v.verdict);
82
95
  emit('close-verdict', { iteration, ...v });
83
96
  if (v.verdict === 'satisfied') {
package/src/validate.js CHANGED
@@ -11,9 +11,9 @@
11
11
 
12
12
  import { COMPRESS_LEVELS, KINDS, WRITE_KINDS } from 'litectx';
13
13
 
14
- export const LOOP_SHAPES = ['refine', 'plan'];
15
- export const SLOTS = ['before-attempt', 'after-red', 'on-green'];
16
- export const VERBS = ['recall', 'compress', 'stash', 'remember'];
14
+ export const LOOP_SHAPES = Object.freeze(['refine', 'plan']);
15
+ export const SLOTS = Object.freeze(['before-attempt', 'after-red', 'on-green']);
16
+ export const VERBS = Object.freeze(['recall', 'compress', 'stash', 'remember']);
17
17
  const TOP_FIELDS = ['schema', 'loop', 'memory', 'hooks', 'gate', 'escalation'];
18
18
  const MAX_OPS_PER_SLOT = 2;
19
19
 
@@ -40,7 +40,23 @@ const VERB_PARAMS = {
40
40
  * @param {string} scope
41
41
  */
42
42
  export function globToPrefix(scope) {
43
- return scope.replace(/\/\*\*?$/, '');
43
+ // One canonical spelling for equivalent paths — leading "./", interior "/./",
44
+ // doubled "//", trailing "/" all collapse — so the legality rule, the fence
45
+ // comparison, and bareguard enforcement agree on what a scope NAMES. Without
46
+ // this, a validateJob-green fence like "src/" would reject every workflow
47
+ // scope inside it (the F9 red-class across two documents).
48
+ //
49
+ // Order is load-bearing: "//" collapses and interior "/./" drops BEFORE the
50
+ // leading "./" strip, so ".//src" → "src" and never "/src". Stripping first
51
+ // would MINT an absolute prefix from a relative scope — a design-law-#1
52
+ // containment escape (resolve(workdir, "/src") ignores workdir entirely).
53
+ //
54
+ // Deliberately NOT path.posix.normalize: it resolves ".." segments
55
+ // (normalize("src/../etc") === "etc"), which must stay VISIBLE so
56
+ // scopeContained can reject them — the whole point of the containment law.
57
+ let p = scope.replace(/\/\*\*?$/, '');
58
+ p = p.replace(/\/{2,}/g, '/').replace(/\/\.(?=\/|$)/g, '').replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
59
+ return p;
44
60
  }
45
61
 
46
62
  /**
@@ -48,15 +64,35 @@ export function globToPrefix(scope) {
48
64
  * resolve to a PROPER subdirectory of the run directory. Absolute paths and
49
65
  * ".." segments escape it; "." / "./**" cover the whole run directory — where
50
66
  * the close suite lives. Windows spellings ("..\", "C:\") count as escapes.
67
+ * Exported for the job validator: the operator's outer fence obeys the SAME
68
+ * law through the same code — two containment transforms would be the F9
69
+ * red-class one level up.
51
70
  * @param {string} s
52
71
  */
53
- function scopeContained(s) {
72
+ export function scopeContained(s) {
54
73
  if (s.startsWith('/') || s.includes('\\') || /^[a-zA-Z]:/.test(s)) return false;
55
74
  const prefix = globToPrefix(s);
56
- if (prefix === '' || prefix === '.') return false;
75
+ // Belt: reject a prefix that is empty, the run dir, absolute, or a drive —
76
+ // checked on the NORMALIZED prefix, not just the raw string, so a spelling
77
+ // that normalizes to something absolute can never pass (defense in depth
78
+ // against a future globToPrefix regression; the un-gameable gate, law #1).
79
+ if (prefix === '' || prefix === '.' || prefix.startsWith('/') || /^[a-zA-Z]:/.test(prefix)) return false;
57
80
  return prefix.split('/').every((seg) => seg !== '..');
58
81
  }
59
82
 
83
+ /**
84
+ * The per-entry write-scope legality law, one home for all three call sites
85
+ * (workflow gate.writeScope, the job fence, the operator's job-v1 writeScope):
86
+ * a non-empty string, no mid-path wildcard (enforcement is prefix-containment,
87
+ * F9), contained under the run dir (law #1). The chains that need per-check
88
+ * reds still spell the three steps; fenceOk uses this so a fence can never be
89
+ * blessed that the job validator would reject.
90
+ * @param {unknown} s
91
+ */
92
+ function legalScopeEntry(s) {
93
+ return isNonEmptyString(s) && !globToPrefix(s).includes('*') && scopeContained(s);
94
+ }
95
+
60
96
  // Each verb is legal ONLY in the slot where it has effect: recall/compress
61
97
  // build the attempt context (consumed by before-attempt only), stash parks the
62
98
  // gap (which exists only after-red), remember is verdict-gated retention
@@ -69,17 +105,72 @@ const VERB_SLOT = { recall: 'before-attempt', compress: 'before-attempt', stash:
69
105
  function isKinds(v) {
70
106
  return Array.isArray(v) && v.length > 0 && v.every((k) => KINDS.includes(k));
71
107
  }
72
- function isObj(v) {
108
+ /** Shared with the job validator — one definition of "a JSON object" for both
109
+ * documents (the sibling-validator drift class).
110
+ * @param {unknown} v */
111
+ export function isObj(v) {
73
112
  return v !== null && typeof v === 'object' && !Array.isArray(v);
74
113
  }
114
+ /** @param {unknown} v @returns {v is string} */
115
+ export function isNonEmptyString(v) {
116
+ return typeof v === 'string' && v.length > 0;
117
+ }
118
+
119
+ // Secrets never enter the tree/spine/configs (hard line) — BOTH config
120
+ // documents get the same sweep: the agent-authored workflow config is the
121
+ // riskier entry point (machine-written), the operator's job spec the other.
122
+ // Known token shapes only, left-bounded so hyphenated words ("flask-sqlalchemy")
123
+ // never red — defense-in-depth; env-only loading is the law, not this regex.
124
+ // ONE shape inventory for the whole system: detection (this sweep) and the
125
+ // spine redactor (interpret wires these into bareguard's redact) must never
126
+ // disagree on what a secret looks like — a shape the validator reds but the
127
+ // redactor passes is a leak on the very output the validator was guarding.
128
+ export const SECRET_PATTERNS = [
129
+ /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{16,}/,
130
+ /(?<![A-Za-z0-9_-])ghp_[A-Za-z0-9]{20,}/,
131
+ /(?<![A-Za-z0-9_-])github_pat_[A-Za-z0-9_]{20,}/,
132
+ /(?<![A-Za-z0-9_-])AKIA[0-9A-Z]{16}/,
133
+ /(?<![A-Za-z0-9_-])xox[bap]-[A-Za-z0-9-]{10,}/,
134
+ ];
135
+ const SECRET_RE = new RegExp(SECRET_PATTERNS.map((r) => r.source).join('|'));
75
136
 
76
137
  /**
77
- * Validate a workflow config against schema v1.
138
+ * Red every string in a config tree that carries a known secret-token shape.
139
+ * @param {any} root
140
+ * @param {(code: string, path: string, detail?: string) => void} red
141
+ */
142
+ export function sweepSecretLiterals(root, red) {
143
+ (/** @type {(node: any, at: string) => void} */
144
+ function sweep(node, at) {
145
+ if (typeof node === 'string') {
146
+ if (SECRET_RE.test(node)) red('secret-literal', at, 'secrets load from the environment; an append-only record that captures a key captures it forever');
147
+ return;
148
+ }
149
+ if (Array.isArray(node)) node.forEach((v, i) => sweep(v, `${at}.${i}`));
150
+ else if (isObj(node)) {
151
+ for (const [k, v] of Object.entries(node)) {
152
+ const path = at ? `${at}.${k}` : k;
153
+ // keys too: inside free-form values (a gold expected, a conditions map)
154
+ // a key is unconstrained, so a token can ride a KEY onto the spine
155
+ if (SECRET_RE.test(k)) red('secret-literal', path, 'secrets load from the environment; an append-only record that captures a key captures it forever');
156
+ sweep(v, path);
157
+ }
158
+ }
159
+ })(root, '');
160
+ }
161
+
162
+ /**
163
+ * Validate a workflow config against schema v1. Returns the parsed config on
164
+ * ok (single parse — callers never re-parse; null on any red).
78
165
  * @param {object|string} input parsed config, or raw JSON text (parse failures are a red)
79
- * @param {{ shellCapUsd?: number }} [opts] the shell's cap — a config may tighten it, never exceed it
80
- * @returns {{ ok: boolean, reds: Array<{code: string, path: string, detail?: string}> }}
166
+ * @param {{ shellCapUsd?: number, jobWriteScope?: string[] }} [opts] the shell's
167
+ * ceilings — a config may tighten either, never exceed. `shellCapUsd`: at N2+
168
+ * the shell passes min(shell cap, job budgetUsd) — the ceiling chain
169
+ * workflow ≤ job ≤ shell. `jobWriteScope`: the job spec's outer fence
170
+ * (operator law, job-v1) — every workflow scope must fit inside it.
171
+ * @returns {{ ok: boolean, reds: Array<{code: string, path: string, detail?: string}>, config: object|null }}
81
172
  */
82
- export function validateConfig(input, { shellCapUsd = 2 } = {}) {
173
+ export function validateConfig(input, { shellCapUsd = 2, jobWriteScope } = {}) {
83
174
  /** @type {Array<{code: string, path: string, detail?: string}>} */
84
175
  const reds = [];
85
176
  /** @type {(code: string, path: string, detail?: string) => void} */
@@ -88,10 +179,10 @@ export function validateConfig(input, { shellCapUsd = 2 } = {}) {
88
179
  let c = input;
89
180
  if (typeof c === 'string') {
90
181
  try { c = JSON.parse(c); } catch (e) {
91
- return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }] };
182
+ return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }], config: null };
92
183
  }
93
184
  }
94
- if (!isObj(c)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'config must be a JSON object' }] };
185
+ if (!isObj(c)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'config must be a JSON object' }], config: null };
95
186
 
96
187
  // 1. shape — unknown top-level fields red here (smuggled close/provider included)
97
188
  for (const key of Object.keys(c)) {
@@ -128,7 +219,7 @@ export function validateConfig(input, { shellCapUsd = 2 } = {}) {
128
219
  }
129
220
  if (gate.writeScope === undefined) red('missing-required', 'gate.writeScope');
130
221
  else if (!(Array.isArray(gate.writeScope) && gate.writeScope.length > 0
131
- && gate.writeScope.every((s) => typeof s === 'string' && s.length > 0))) {
222
+ && gate.writeScope.every(isNonEmptyString))) {
132
223
  red('invalid-value', 'gate.writeScope', 'non-empty array of glob strings');
133
224
  } else if (!gate.writeScope.every((s) => !globToPrefix(s).includes('*'))) {
134
225
  // adaptlearn F9: enforcement (bareguard fs.writeScope) is prefix-containment — a wildcard
@@ -139,6 +230,29 @@ export function validateConfig(input, { shellCapUsd = 2 } = {}) {
139
230
  // A scope that can reach the arbiter's inputs is the config-level
140
231
  // fit-to-pass surface (design law #1). Reds-before-tokens.
141
232
  red('invalid-value', 'gate.writeScope', 'must be a proper subdirectory of the run directory — no absolute paths, no ".." segments, not the run dir itself (design law #1)');
233
+ } else if (jobWriteScope !== undefined && jobWriteScope !== null) {
234
+ // Two-layer fence: the job spec's writeScope is operator law; the authored
235
+ // config may tighten it, never exceed it (the budget pattern, on paths).
236
+ // null/undefined are the legitimate "no fence" spellings (job optional at
237
+ // N2, and the only forms that survive JSON transit) — the layer is skipped,
238
+ // never a deadlock. A PRESENT-but-malformed fence fails CLOSED with its own
239
+ // red, attributed to jobWriteScope (NOT the innocent workflow field — the
240
+ // ledger's contrast attribution must not charge the agent's config for the
241
+ // operator's broken spec).
242
+ const fenceOk = Array.isArray(jobWriteScope) && jobWriteScope.length > 0 && jobWriteScope.every(legalScopeEntry);
243
+ if (!fenceOk) {
244
+ const shown = JSON.stringify(jobWriteScope).slice(0, 200); // bound: a malformed fence is untrusted caller input, and this rides the append-only spine (ralph's 2000-char precedent)
245
+ red('fence-invalid', 'jobWriteScope', `not a validateJob-green fence (${shown}) — validate the job spec before threading its fence`);
246
+ } else {
247
+ // Boundary-aware: "src2" is not inside "src" — prefix means PATH prefix.
248
+ const fence = jobWriteScope.map(globToPrefix);
249
+ const inside = (/** @type {string} */ p) => fence.some((f) => p === f || p.startsWith(f + '/'));
250
+ gate.writeScope.forEach((/** @type {string} */ s, /** @type {number} */ i) => {
251
+ if (!inside(globToPrefix(s))) {
252
+ red('scope-escape', `gate.writeScope.${i}`, `"${s}" is outside the job's fence [${jobWriteScope.join(', ')}] — the workflow may tighten the operator's bound, never exceed it`);
253
+ }
254
+ });
255
+ }
142
256
  }
143
257
 
144
258
  const escalation = isObj(c.escalation) ? c.escalation : {};
@@ -173,7 +287,11 @@ export function validateConfig(input, { shellCapUsd = 2 } = {}) {
173
287
  }
174
288
  }
175
289
 
176
- return { ok: reds.length === 0, reds };
290
+ // 5. secrets sweep — same guard as the job spec (the agent-authored document
291
+ // is the riskier one; a green config is a ledger fact and inherits forever)
292
+ sweepSecretLiterals(c, red);
293
+
294
+ return { ok: reds.length === 0, reds, config: reds.length === 0 ? c : null };
177
295
  }
178
296
 
179
297
  /**
package/types/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { makeSpine } from "./spine.js";
2
2
  export { ralph, runClose } from "./ralph.js";
3
3
  export { validateConfig, diffPaths, globToPrefix, LOOP_SHAPES, SLOTS, VERBS } from "./validate.js";
4
+ export { validateJob, jobSpecHash, checkApproval, CLOSE_TYPES, CLASSES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS } from "./job.js";
4
5
  export { interpret, STALL_REDS } from "./interpret.js";
5
6
  export { extractRules, MAX_RULES, MAX_RULE_CHARS } from "./extract.js";
@@ -11,6 +11,10 @@
11
11
  * @param {(type: string, data?: object) => object} opts.emit spine emitter
12
12
  * @param {object} opts.provider a bareagent provider — SHELL-owned binding (adaptlearn F8: an unsealed binding is a gate bypass)
13
13
  * @param {number} [opts.shellCapUsd=2] the shell's USD cap; config budgetUsd is clamped by validation
14
+ * @param {string[]} [opts.jobWriteScope] the job spec's outer write fence (operator law,
15
+ * job-v1) — enforced HERE, the one choke point where a config becomes a Gate:
16
+ * every workflow scope must fit inside it (scope-escape config-red otherwise),
17
+ * on entry validation and on every revision candidate alike
14
18
  * @param {(o: {config: object, gaps: string[], policy: any, onLlmResult: any}) => Promise<{candidate: object|null, parseError?: string|null, costUsd?: number}>} [opts.revisor]
15
19
  * optional mid-run revision seam. Fires ONCE per run after STALL_REDS consecutive
16
20
  * close reds. The interpreter — never the revisor — owns acceptance: the candidate
@@ -20,7 +24,7 @@
20
24
  * the worker.
21
25
  * @returns {Promise<'green'|'escalated'|'config-red'>}
22
26
  */
23
- export function interpret(configRaw: object | string, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd, revisor }: {
27
+ export function interpret(configRaw: object | string, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd, jobWriteScope, revisor }: {
24
28
  task: string;
25
29
  target: string;
26
30
  close: string[];
@@ -29,6 +33,7 @@ export function interpret(configRaw: object | string, { task, target, close, wor
29
33
  emit: (type: string, data?: object) => object;
30
34
  provider: object;
31
35
  shellCapUsd?: number | undefined;
36
+ jobWriteScope?: string[] | undefined;
32
37
  revisor?: ((o: {
33
38
  config: object;
34
39
  gaps: string[];
package/types/job.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ /** @typedef {{code: string, path: string, detail?: string}} Red */
2
+ /**
3
+ * Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
4
+ * JSON text or plain parsed data — the ingest contract; a live object with a
5
+ * hostile accessor is outside it. Every failure is a named red. Returns the
6
+ * parsed spec on ok (single parse), null on any red.
7
+ * @param {object|string} input parsed spec, or raw JSON text (parse failures are a red)
8
+ * @param {{ shellCapUsd?: number }} [opts] the shell's hard ceiling — the job
9
+ * budget may tighten it, never exceed it (ceiling chain: workflow ≤ job ≤ shell)
10
+ * @returns {{ ok: boolean, reds: Red[], job: object|null }}
11
+ */
12
+ export function validateJob(input: object | string, { shellCapUsd }?: {
13
+ shellCapUsd?: number;
14
+ }): {
15
+ ok: boolean;
16
+ reds: Red[];
17
+ job: object | null;
18
+ };
19
+ /**
20
+ * Content hash of a job spec — what an approval record binds to. Any edit to
21
+ * the spec changes the hash, so an edited spec is unapproved by construction
22
+ * (interview decision #1: human signs, always, once per spec VERSION).
23
+ * @param {object} job
24
+ * @returns {string} sha256 hex
25
+ */
26
+ export function jobSpecHash(job: object): string;
27
+ /**
28
+ * Does an approval record cover this exact spec version? Pure predicate — the
29
+ * N2 runner refuses to run without it; nothing here writes or prompts. The
30
+ * approval record lives OUTSIDE the document it signs (a spec never contains
31
+ * its own signature) and is shell/human territory, never agent-writable.
32
+ * @param {object} job
33
+ * @param {unknown} approvals array of { specHash, signer, ts } records
34
+ * @returns {boolean}
35
+ */
36
+ export function checkApproval(job: object, approvals: unknown): boolean;
37
+ export const CLOSE_TYPES: readonly string[];
38
+ export const CLASSES: readonly string[];
39
+ /** the hierarchy: which verdict classes a close type may claim (PRD §7) */
40
+ export const CLASS_BY_CLOSE: Readonly<{
41
+ predicate: readonly string[];
42
+ gold: readonly string[];
43
+ rubric: readonly string[];
44
+ hitl: readonly string[];
45
+ }>;
46
+ export const GOLD_COMPARE: readonly string[];
47
+ export const CADENCE_UNITS: readonly string[];
48
+ export const PROVIDERS: readonly string[];
49
+ /** V3 environment label: declared keys only — every field is a lineage-key
50
+ * candidate at N3. `provider` is part of the key by definition (top-level,
51
+ * not duplicated here). */
52
+ export const CONDITION_KEYS: readonly string[];
53
+ export type Red = {
54
+ code: string;
55
+ path: string;
56
+ detail?: string;
57
+ };
package/types/ralph.d.ts CHANGED
@@ -1,9 +1,19 @@
1
1
  /**
2
2
  * Run a close command; its exit code is the verdict (hard green, PRD §4).
3
3
  * 0 → satisfied; nonzero → needs_revision (gap fed back); spawn error → failed (terminal).
4
+ *
5
+ * `redact` scrubs the close's own output at the SOURCE — before the gap becomes
6
+ * a spine event or a worker prompt — so a secret a checked command echoes
7
+ * (a 401 dumping an `Authorization: Bearer …` header) never enters the
8
+ * append-only spine (the hard line). It is INJECTED, not imported: the shell
9
+ * stays stdlib-only and un-gameable, and the redactor is a fixed shell
10
+ * primitive (bareguard's, wired by the interpreter), never an emergent
11
+ * component — so V4 holds. The shell's canonical emission IS the redacted text;
12
+ * a benign gap is returned byte-identical (default: identity).
4
13
  * @param {string[]} close argv, e.g. ['node', '--test', 'close/']
14
+ * @param {(s: string) => string} [redact] source scrubber; identity by default
5
15
  */
6
- export function runClose(close: string[]): {
16
+ export function runClose(close: string[], redact?: (s: string) => string): {
7
17
  verdict: string;
8
18
  detail: string;
9
19
  gap?: undefined;
@@ -38,11 +48,14 @@ export function runClose(close: string[]): {
38
48
  * @param {string[]} opts.close argv whose exit code is truth
39
49
  * @param {number} opts.capRuns budget: max middle runs
40
50
  * @param {(type: string, data?: object) => object} opts.emit a spine emitter
51
+ * @param {(s: string) => string} [opts.redact] source scrubber for close output
52
+ * (secrets never enter the spine); injected so the shell stays stdlib-only
41
53
  * @returns {Promise<'green'|'escalated'>}
42
54
  */
43
- export function ralph({ middle, close, capRuns, emit }: {
55
+ export function ralph({ middle, close, capRuns, emit, redact }: {
44
56
  middle: (iteration: number, gap?: string) => void | Promise<void>;
45
57
  close: string[];
46
58
  capRuns: number;
47
59
  emit: (type: string, data?: object) => object;
60
+ redact?: ((s: string) => string) | undefined;
48
61
  }): Promise<"green" | "escalated">;
@@ -9,13 +9,42 @@
9
9
  */
10
10
  export function globToPrefix(scope: string): string;
11
11
  /**
12
- * Validate a workflow config against schema v1.
12
+ * A scope may never reach the arbiter's inputs (design law #1): it must
13
+ * resolve to a PROPER subdirectory of the run directory. Absolute paths and
14
+ * ".." segments escape it; "." / "./**" cover the whole run directory — where
15
+ * the close suite lives. Windows spellings ("..\", "C:\") count as escapes.
16
+ * Exported for the job validator: the operator's outer fence obeys the SAME
17
+ * law through the same code — two containment transforms would be the F9
18
+ * red-class one level up.
19
+ * @param {string} s
20
+ */
21
+ export function scopeContained(s: string): boolean;
22
+ /** Shared with the job validator — one definition of "a JSON object" for both
23
+ * documents (the sibling-validator drift class).
24
+ * @param {unknown} v */
25
+ export function isObj(v: unknown): boolean;
26
+ /** @param {unknown} v @returns {v is string} */
27
+ export function isNonEmptyString(v: unknown): v is string;
28
+ /**
29
+ * Red every string in a config tree that carries a known secret-token shape.
30
+ * @param {any} root
31
+ * @param {(code: string, path: string, detail?: string) => void} red
32
+ */
33
+ export function sweepSecretLiterals(root: any, red: (code: string, path: string, detail?: string) => void): void;
34
+ /**
35
+ * Validate a workflow config against schema v1. Returns the parsed config on
36
+ * ok (single parse — callers never re-parse; null on any red).
13
37
  * @param {object|string} input parsed config, or raw JSON text (parse failures are a red)
14
- * @param {{ shellCapUsd?: number }} [opts] the shell's cap — a config may tighten it, never exceed it
15
- * @returns {{ ok: boolean, reds: Array<{code: string, path: string, detail?: string}> }}
38
+ * @param {{ shellCapUsd?: number, jobWriteScope?: string[] }} [opts] the shell's
39
+ * ceilings — a config may tighten either, never exceed. `shellCapUsd`: at N2+
40
+ * the shell passes min(shell cap, job budgetUsd) — the ceiling chain
41
+ * workflow ≤ job ≤ shell. `jobWriteScope`: the job spec's outer fence
42
+ * (operator law, job-v1) — every workflow scope must fit inside it.
43
+ * @returns {{ ok: boolean, reds: Array<{code: string, path: string, detail?: string}>, config: object|null }}
16
44
  */
17
- export function validateConfig(input: object | string, { shellCapUsd }?: {
45
+ export function validateConfig(input: object | string, { shellCapUsd, jobWriteScope }?: {
18
46
  shellCapUsd?: number;
47
+ jobWriteScope?: string[];
19
48
  }): {
20
49
  ok: boolean;
21
50
  reds: Array<{
@@ -23,6 +52,7 @@ export function validateConfig(input: object | string, { shellCapUsd }?: {
23
52
  path: string;
24
53
  detail?: string;
25
54
  }>;
55
+ config: object | null;
26
56
  };
27
57
  /**
28
58
  * Changed JSON paths between two configs — the one-knob mutation checker
@@ -33,6 +63,7 @@ export function validateConfig(input: object | string, { shellCapUsd }?: {
33
63
  * @returns {string[]} sorted changed paths, dot-notation with array indices
34
64
  */
35
65
  export function diffPaths(a: object, b: object): string[];
36
- export const LOOP_SHAPES: string[];
37
- export const SLOTS: string[];
38
- export const VERBS: string[];
66
+ export const LOOP_SHAPES: readonly string[];
67
+ export const SLOTS: readonly string[];
68
+ export const VERBS: readonly string[];
69
+ export const SECRET_PATTERNS: RegExp[];