bareloop 0.9.0 → 0.11.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 +555 -0
- package/README.md +111 -117
- package/bareloop.context.md +314 -24
- package/package.json +3 -3
- package/src/authorflow.js +300 -35
- package/src/authoring.js +290 -33
- package/src/authorjob.js +59 -7
- package/src/authorscout.js +83 -10
- package/src/bridges.js +15 -2
- package/src/clock.js +21 -3
- package/src/declaredclose.js +88 -11
- package/src/index.js +11 -1
- package/src/job.js +7 -2
- package/src/kinds.js +292 -20
- package/src/ledger.js +29 -2
- package/src/planrun.js +404 -29
- package/src/ralph.js +14 -0
- package/src/reuse.js +247 -9
- package/src/root.js +1 -1
- package/src/run.js +12 -5
- package/src/selection.js +1 -1
- package/src/text.js +79 -0
- package/src/tools.js +11 -0
- package/src/validate.js +168 -11
- package/types/authorflow.d.ts +135 -17
- package/types/authoring.d.ts +161 -35
- package/types/authorjob.d.ts +11 -2
- package/types/authorscout.d.ts +22 -3
- package/types/clock.d.ts +5 -2
- package/types/declaredclose.d.ts +64 -6
- package/types/index.d.ts +4 -4
- package/types/job.d.ts +5 -0
- package/types/kinds.d.ts +134 -10
- package/types/planrun.d.ts +61 -2
- package/types/reuse.d.ts +75 -1
- package/types/run.d.ts +12 -2
- package/types/text.d.ts +67 -0
- package/types/validate.d.ts +74 -9
package/src/kinds.js
CHANGED
|
@@ -64,7 +64,7 @@ import { realpathSync } from 'node:fs';
|
|
|
64
64
|
import { readFile, stat, mkdtemp, rm, symlink } from 'node:fs/promises';
|
|
65
65
|
import { tmpdir } from 'node:os';
|
|
66
66
|
import { join, sep } from 'node:path';
|
|
67
|
-
import { globToPrefix } from './validate.js';
|
|
67
|
+
import { globToPrefix, isNonEmptyString } from './validate.js';
|
|
68
68
|
import { CLOSE_ENV_DENY } from './ralph.js';
|
|
69
69
|
|
|
70
70
|
/** the close's two judgment exits — a stage's verdict, in the shape the
|
|
@@ -136,19 +136,56 @@ export const SIGNS = Object.freeze([1, -1]);
|
|
|
136
136
|
* are catalogue entries and are deliberately absent here: declaring one is
|
|
137
137
|
* counted demand (the request-red path), and reaching one at runtime is a
|
|
138
138
|
* stop — never a red, and never a silent pass. */
|
|
139
|
-
export const LIVE_KINDS = Object.freeze(['command-exit', 'count-not-worse', 'pattern-absent-in-diff', 'files-changed']);
|
|
139
|
+
export const LIVE_KINDS = Object.freeze(['command-exit', 'count-not-worse', 'pattern-absent-in-diff', 'files-changed', 'human-confirms']);
|
|
140
|
+
/** RULING 8 — the stages the seed-verdict read (D12) must NOT run. A judged
|
|
141
|
+
* number at seed is unstable and a person at seed is an interview, not a
|
|
142
|
+
* measurement; their bar comes from calibration (soft-green) or from the person
|
|
143
|
+
* themselves (hitl), never from the seed. The skip is RECORDED by name rather
|
|
144
|
+
* than taken in silence (F59: absent is not empty), so `seedRead` still returns
|
|
145
|
+
* one row per declared stage. */
|
|
146
|
+
export const SEED_EXEMPT_KINDS = Object.freeze(['judged-floor', 'human-confirms']);
|
|
147
|
+
/** the one kind a PERSON renders — named once, because the runner keys three
|
|
148
|
+
* decisions on it (is there a door to answer, which stage paused, whose ruling
|
|
149
|
+
* this is) and three literals is how they come to disagree */
|
|
150
|
+
export const HUMAN_KIND = 'human-confirms';
|
|
151
|
+
/** the three doors the signer is offered at a hitl pause (2026-08-12 §1, doors
|
|
152
|
+
* re-cut 2026-08-17). No fourth door, and no free-text-only variant: a red that
|
|
153
|
+
* is not one of these two is not a ruling the run can act on.
|
|
154
|
+
*
|
|
155
|
+
* `cancel` was the third door and is DELETED as a concept (hamr: *"what's the
|
|
156
|
+
* point of cancel anyways? pause can resume — that would be more honest"*). The
|
|
157
|
+
* case it served — a person who looks and does not want to carry on — is a pause
|
|
158
|
+
* nobody returns to, which the 60-day checkpoint TTL retires on its own. Nothing
|
|
159
|
+
* here forces a forever-decision at the one moment the person has least reason
|
|
160
|
+
* to make one. */
|
|
161
|
+
export const HUMAN_DECISIONS = Object.freeze(['accept', 'rerun', 'pause']);
|
|
140
162
|
|
|
141
163
|
/**
|
|
142
164
|
* @typedef {{code: string, path: string, detail: string}} Red
|
|
143
165
|
* @typedef {{workdir: string, seedRef: string, gapKeep: string,
|
|
144
166
|
* timeoutMsDefault?: number, baselineMode?: 'auto'|'worktree',
|
|
145
|
-
* gapCap?: number, maxBuffer?: number, seedTrees?: SeedTrees
|
|
167
|
+
* gapCap?: number, maxBuffer?: number, seedTrees?: SeedTrees,
|
|
168
|
+
* humanRuling?: {decision: string, text?: string|null}|null,
|
|
169
|
+
* onStage?: (row: {stage: string|null, kind: string|null, verdict: string, durationMs: number}) => void}} Ctx
|
|
146
170
|
* The arbiter's half of a stage run. Every field here is operator/runner
|
|
147
171
|
* territory: a declaration parameterises kinds, never the contracts.
|
|
148
|
-
*
|
|
172
|
+
* `humanRuling` is the SIGNER's answer at a hitl pause, carried in from the
|
|
173
|
+
* runner — never authored, never defaulted, and absent on every ordinary run.
|
|
174
|
+
* `onStage` is a REPORTING seam and nothing else: the seed read spawns a real
|
|
175
|
+
* toolchain per stage (a suite is the slow one) and a whole read is one opaque
|
|
176
|
+
* await, so the shell gets told as each stage lands. It decides nothing, it is
|
|
177
|
+
* never consulted, and no verdict depends on it — the house pattern is
|
|
178
|
+
* `runJob`'s `emit` (the library reports, the shell prints).
|
|
179
|
+
* @typedef {{verdict: 'green'|'red'|'instrument-stop'|'not-reached'|'pause'|'skipped',
|
|
149
180
|
* exitCode: number|null, value: number|null, baseline: number|null,
|
|
150
181
|
* baselineSource: string|null, gapLines: string[], judged: boolean,
|
|
151
182
|
* stage: string|null, kind: string|null, detail: Record<string, any>}} StageResult
|
|
183
|
+
* Six outcomes, and only two of them are verdicts. `pause` is N4's fifth: the
|
|
184
|
+
* close reached a stage a machine cannot render and is WAITING on a person —
|
|
185
|
+
* neither green nor red, and never graded (the F17 forbidden-zone rule applied
|
|
186
|
+
* to a non-verdict). `skipped` is the sixth and says a stage was deliberately
|
|
187
|
+
* not run at THIS reading (ruling 8's seed exemption), which is a different
|
|
188
|
+
* fact from `not-reached` (an earlier stage decided first).
|
|
152
189
|
* @typedef {{ensure: (workdir: string, seedRef: string) => Promise<{stop: string, fault: string}|{stop: null, dir: string}>,
|
|
153
190
|
* cleanup: () => Promise<{ok: boolean, leaked: string[], detail: string|null}>}} SeedTrees
|
|
154
191
|
*/
|
|
@@ -346,24 +383,55 @@ export async function seedListing(workdir, seedRef) {
|
|
|
346
383
|
// ── contract (c): the changed-set primitive ──────────────────────────────────
|
|
347
384
|
|
|
348
385
|
/**
|
|
349
|
-
* The
|
|
386
|
+
* The arbiter books, and the shape of each exclusion is the whole point.
|
|
350
387
|
*
|
|
351
|
-
* `.litectx/`
|
|
352
|
-
* arbiter. `gate-audit.jsonl` is an EXACT repo-relative path and
|
|
353
|
-
* basename or a pattern: the gate audit is written to
|
|
388
|
+
* `.litectx/` and `.smoke/` are PREFIXES — everything beneath either store
|
|
389
|
+
* belongs to the arbiter. `gate-audit.jsonl` is an EXACT repo-relative path and
|
|
390
|
+
* never a basename or a pattern: the gate audit is written to
|
|
354
391
|
* `join(workdir, 'gate-audit.jsonl')` and nowhere else, so a worker-authored
|
|
355
392
|
* `src/gate-audit.jsonl` is NOT an arbiter book and must still count as work.
|
|
356
393
|
* Matching by basename would swallow it — and swallowing a worker's real
|
|
357
|
-
* write is the failure direction that reads as a clean tree.
|
|
394
|
+
* write is the failure direction that reads as a clean tree. `src/smoke.js` and
|
|
395
|
+
* `tests/smoke.test.js` are that same decoy for the store below.
|
|
396
|
+
*
|
|
397
|
+
* `.smoke/` — F98's parked residual, closed here. The fence has always denied
|
|
398
|
+
* `join(workdir, '.smoke')` on every worker (src/planrun.js, src/authorscout.js)
|
|
399
|
+
* and the persona now states the rule; this reader, which decides what COUNTS AS
|
|
400
|
+
* WORK, was the one place that never knew the name. On patients whose .gitignore
|
|
401
|
+
* default-denies dot-directories the store never reaches `ls-files --others` and
|
|
402
|
+
* the gap is masked — by the PATIENT's config, not by anything here. Incidental
|
|
403
|
+
* masking is not an exclusion: on a patient without that line the arbiter's own
|
|
404
|
+
* store would enter the changed set as the worker's writing.
|
|
358
405
|
* @param {string} rel a repo-relative POSIX path
|
|
359
406
|
* @returns {string|null} the book it belongs to, or null
|
|
360
407
|
*/
|
|
361
408
|
export function isArbiterBook(rel) {
|
|
362
|
-
|
|
363
|
-
|
|
409
|
+
for (const store of ARBITER_BOOK_STORES) {
|
|
410
|
+
if (rel === store || rel.startsWith(`${store}/`)) return `${store}/`;
|
|
411
|
+
}
|
|
412
|
+
if (rel === GATE_AUDIT_FILE) return GATE_AUDIT_FILE;
|
|
364
413
|
return null;
|
|
365
414
|
}
|
|
366
415
|
|
|
416
|
+
/**
|
|
417
|
+
* The arbiter's book NAMES, one home. F98's mechanism was exactly this list
|
|
418
|
+
* living as independent literals: the fence deny arrays (src/planrun.js,
|
|
419
|
+
* src/authorscout.js) knew `.smoke` while `isArbiterBook` — the reader that
|
|
420
|
+
* decides what COUNTS AS WORK — did not, and the drift was masked by the
|
|
421
|
+
* patient's own .gitignore. Every consumer (the fence deny lists, this file's
|
|
422
|
+
* reader, the persona's stated law, run.js's smoke-store root) now spells the
|
|
423
|
+
* names from here, so adding or renaming a book is one edit that cannot leave
|
|
424
|
+
* a site behind.
|
|
425
|
+
*
|
|
426
|
+
* `ARBITER_BOOK_STORES` are directory PREFIXES; `GATE_AUDIT_FILE` is an EXACT
|
|
427
|
+
* repo-relative path (see `isArbiterBook`'s docstring for why never a
|
|
428
|
+
* basename). Order is the fence deny-list order and the persona's prose order.
|
|
429
|
+
*/
|
|
430
|
+
export const SMOKE_STORE = '.smoke';
|
|
431
|
+
export const LITECTX_STORE = '.litectx';
|
|
432
|
+
export const GATE_AUDIT_FILE = 'gate-audit.jsonl';
|
|
433
|
+
export const ARBITER_BOOK_STORES = Object.freeze([SMOKE_STORE, LITECTX_STORE]);
|
|
434
|
+
|
|
367
435
|
/**
|
|
368
436
|
* Everything this run changed against the seed: the tracked diff PLUS
|
|
369
437
|
* untracked files (an untracked file has no diff — the case a naive
|
|
@@ -699,13 +767,36 @@ export function normalizeParser(parser, at = 'parser') {
|
|
|
699
767
|
* that matched BEFORE the filter judged them — kept, unattributable and dropped
|
|
700
768
|
* alike — and it is the only evidence of liveness a caller has once the scope
|
|
701
769
|
* has removed the whole population. It is never the number being read.
|
|
770
|
+
*
|
|
771
|
+
* `matched` is the KEPT lines themselves, PER TERM and index-aligned with
|
|
772
|
+
* `breakdown`: entry i holds, in output order, exactly the lines term i's
|
|
773
|
+
* subtotal was computed FROM. Run u-msn227nq paid for the echo — the executor
|
|
774
|
+
* counted 8 real `error TS…` lines and told the worker "8 match(es)", discarding
|
|
775
|
+
* every address, and a worker with a number and no file to open went looking for
|
|
776
|
+
* the arbiter's own books instead.
|
|
777
|
+
*
|
|
778
|
+
* TWO EXCLUSIONS, both of them "this line is not what the count is made of":
|
|
779
|
+
* - a scope-DROPPED line is never in here. It was never counted, so aiming the
|
|
780
|
+
* worker at it would aim it outside its own population.
|
|
781
|
+
* - under `first`, only the line the aggregate actually READ. The later matches
|
|
782
|
+
* are real matches (`breakdown[i].matches` still counts them) but the value
|
|
783
|
+
* never contained them, and echoing them points the worker at a number that
|
|
784
|
+
* is not in the reading.
|
|
785
|
+
*
|
|
786
|
+
* And the GROUPING is the third: a term's sign lives in `breakdown[i]`, so lines
|
|
787
|
+
* pooled flat across terms lose the one fact that says what they DID to the
|
|
788
|
+
* number. A subtracted term's line rendered beside an added one reads as a wall
|
|
789
|
+
* to go fix, and fixing it moves the count the wrong way. Deduplication is
|
|
790
|
+
* therefore within a term only — one line matched by two terms plays two
|
|
791
|
+
* arithmetic roles and is named under each. The count is computed from `values`,
|
|
792
|
+
* never from this list.
|
|
702
793
|
* @param {string} output @param {any[]} terms normalised terms
|
|
703
794
|
* Every stop here is `crashed` in the arbiter's vocabulary — the command RAN,
|
|
704
795
|
* came back, and the number could not be read out of it. Nothing in this
|
|
705
796
|
* function can observe a spawn failure, a timeout or a signal, so it never
|
|
706
797
|
* claims one.
|
|
707
798
|
* @param {{scope?: any, workdir?: string}} [o]
|
|
708
|
-
* @returns {{stop: string, fault: string, notes?: string[]}|{stop: null, value: number, breakdown: any[], notes: string[], preScopeMatches: number}}
|
|
799
|
+
* @returns {{stop: string, fault: string, notes?: string[]}|{stop: null, value: number, breakdown: any[], notes: string[], preScopeMatches: number, matched: string[][]}}
|
|
709
800
|
*/
|
|
710
801
|
export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
|
|
711
802
|
let value = 0;
|
|
@@ -715,6 +806,10 @@ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
|
|
|
715
806
|
const breakdown = [];
|
|
716
807
|
/** @type {string[]} */
|
|
717
808
|
const notes = [];
|
|
809
|
+
// the KEPT lines PER TERM, in output order and deduplicated within the term —
|
|
810
|
+
// see the note above
|
|
811
|
+
/** @type {string[][]} */
|
|
812
|
+
const matched = [];
|
|
718
813
|
const filtering = scopeFilters(scope);
|
|
719
814
|
// one resolver (and one realpath cache) per call, built only when a filter
|
|
720
815
|
// will actually read it
|
|
@@ -739,6 +834,9 @@ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
|
|
|
739
834
|
try { re = new RegExp(t.lineMatch, 'g'); } catch (e) { return { stop: `INSTRUMENT: term ${i} lineMatch /${t.lineMatch}/ is not a valid regex: ${String(/** @type {any} */ (e)?.message ?? e)}`, fault: STOP_FAULTS.CRASHED }; }
|
|
740
835
|
/** @type {number[]} */
|
|
741
836
|
const values = [];
|
|
837
|
+
/** @type {string[]} */
|
|
838
|
+
const kept = [];
|
|
839
|
+
const seen = new Set();
|
|
742
840
|
let dropped = 0;
|
|
743
841
|
let unattributable = 0;
|
|
744
842
|
for (const line of region.split('\n')) {
|
|
@@ -750,6 +848,10 @@ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
|
|
|
750
848
|
if (p === null) unattributable += 1;
|
|
751
849
|
else if (!inScope(p, scope, phys)) { dropped += 1; continue; }
|
|
752
850
|
}
|
|
851
|
+
// past the filter, so this line is a CANDIDATE for the harvest — kept here
|
|
852
|
+
// rather than at aggregation because this is the only place the raw line
|
|
853
|
+
// still exists, and narrowed to what the aggregate actually read below
|
|
854
|
+
if (!seen.has(line)) { seen.add(line); kept.push(line); }
|
|
753
855
|
for (const h of hits) {
|
|
754
856
|
if (t.capture === null) { values.push(1); continue; }
|
|
755
857
|
const rawCapture = h[t.capture];
|
|
@@ -789,10 +891,14 @@ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
|
|
|
789
891
|
}
|
|
790
892
|
subtotal = values[0];
|
|
791
893
|
}
|
|
894
|
+
// `first` read ONE line — `kept[0]`, since `values[0]` is the first hit on
|
|
895
|
+
// the first kept line. The rest matched (and `matches` below still says so)
|
|
896
|
+
// but are not what this subtotal is made of.
|
|
897
|
+
matched.push(t.aggregate === 'first' ? kept.slice(0, 1) : kept);
|
|
792
898
|
breakdown.push({ term: i, lineMatch: t.lineMatch, aggregate: t.aggregate, sign: t.sign, matches: values.length, subtotal, contribution: t.sign * subtotal });
|
|
793
899
|
value += t.sign * subtotal;
|
|
794
900
|
}
|
|
795
|
-
return { stop: null, value, breakdown, notes, preScopeMatches };
|
|
901
|
+
return { stop: null, value, breakdown, notes, preScopeMatches, matched };
|
|
796
902
|
}
|
|
797
903
|
|
|
798
904
|
// ── D12: the baseline is MEASURED at this run's own seed, every run ──────────
|
|
@@ -1084,7 +1190,7 @@ async function runCountNotWorse(stage, ctx) {
|
|
|
1084
1190
|
notes: [],
|
|
1085
1191
|
};
|
|
1086
1192
|
}
|
|
1087
|
-
return { stop: null, value: v.value, breakdown: v.breakdown, notes: v.notes, exit: r.code, dropped: r.dropped, fault: STOP_FAULTS.FAILED };
|
|
1193
|
+
return { stop: null, value: v.value, breakdown: v.breakdown, notes: v.notes, matched: v.matched, exit: r.code, dropped: r.dropped, fault: STOP_FAULTS.FAILED };
|
|
1088
1194
|
};
|
|
1089
1195
|
|
|
1090
1196
|
const now = await measure(ctx.workdir, 'the current tree');
|
|
@@ -1126,8 +1232,30 @@ async function runCountNotWorse(stage, ctx) {
|
|
|
1126
1232
|
if (worse) {
|
|
1127
1233
|
gap.push(`${stage.name}: ${value} against a baseline of ${baseline} (${p.direction}) — worse`);
|
|
1128
1234
|
gap.push(` baseline ${baselineSource}`);
|
|
1129
|
-
|
|
1235
|
+
// …each term stating itself, and THEN the lines that term's subtotal is made
|
|
1236
|
+
// of, nested under it. A count with no addresses is the semantic genre F38
|
|
1237
|
+
// measured as inert: run u-msn227nq's worker was handed "8 match(es)", had
|
|
1238
|
+
// nowhere to open, and probed the arbiter's books until the deny guard ended
|
|
1239
|
+
// the run. The instrument NAMED these lines — echoing what it named is the
|
|
1240
|
+
// same licence `pattern-absent-in-diff` already runs under, not the barred
|
|
1241
|
+
// move of naming a culprit the instrument never reported.
|
|
1242
|
+
//
|
|
1243
|
+
// NESTED, not pooled, because a line's arithmetic role is the term's and only
|
|
1244
|
+
// the term's row carries it. `value` is a SIGNED sum: a term declared
|
|
1245
|
+
// `sign: -1` subtracts, so its lines are ones the count went DOWN for, and a
|
|
1246
|
+
// worker handed them flat beside the added ones reads every line as a wall
|
|
1247
|
+
// and "fixes" one that moves the number the wrong way. The row directly above
|
|
1248
|
+
// each block already says `contributes +n` / `-n`; sitting the lines under it
|
|
1249
|
+
// is what makes that statement reach them. Nothing is withheld — under
|
|
1250
|
+
// `higher-is-better` the subtracted term is exactly where the news usually is
|
|
1251
|
+
// — and nothing is relabelled: the lines are still verbatim, one indent in.
|
|
1252
|
+
//
|
|
1253
|
+
// They ride the Gap like every other line: the stage prefix (Layer R's
|
|
1254
|
+
// redKeep derives from it), the cap, and the trim marker on overflow.
|
|
1255
|
+
const perTerm = /** @type {string[][]} */ (now.matched ?? []);
|
|
1256
|
+
for (const [i, b] of /** @type {any[]} */ (now.breakdown).entries()) {
|
|
1130
1257
|
gap.push(` term ${b.term} /${b.lineMatch}/ ${b.aggregate}: ${b.matches} match(es), subtotal ${b.subtotal}, contributes ${b.contribution >= 0 ? '+' : ''}${b.contribution}`);
|
|
1258
|
+
for (const l of perTerm[i] ?? []) gap.push(` ${l}`);
|
|
1131
1259
|
}
|
|
1132
1260
|
}
|
|
1133
1261
|
return result(stage, {
|
|
@@ -1232,12 +1360,117 @@ async function runFilesChanged(stage, ctx) {
|
|
|
1232
1360
|
});
|
|
1233
1361
|
}
|
|
1234
1362
|
|
|
1363
|
+
/**
|
|
1364
|
+
* THE SIGNER'S ANSWER, normalised at the seam that accepts it — the library half
|
|
1365
|
+
* of the three-button gate (2026-08-12 §1).
|
|
1366
|
+
*
|
|
1367
|
+
* `null` is not a refusal: no ruling at all IS the pause path, which is how every
|
|
1368
|
+
* ordinary run and every first leg of a hitl run arrives here. What is refused is
|
|
1369
|
+
* an answer that is not one of the three doors, and — the case the POC's negative
|
|
1370
|
+
* control measured — a `rerun` with no text. Ruling 3 makes the human's words the
|
|
1371
|
+
* gap the worker converts from; an empty one launches a fix worker that re-runs
|
|
1372
|
+
* as though nothing had been said, which is the failure this refusal exists for.
|
|
1373
|
+
*
|
|
1374
|
+
* It never throws and never repairs: a decision that cannot be read is handed
|
|
1375
|
+
* back as a refusal with its reason, and the caller decides what to do about it.
|
|
1376
|
+
* @param {any} ruling
|
|
1377
|
+
* @returns {{ok: boolean, ruling: {decision: string, text: string|null}|null, why: string|null}}
|
|
1378
|
+
*/
|
|
1379
|
+
export function normalizeHumanRuling(ruling) {
|
|
1380
|
+
if (ruling === null || ruling === undefined) return { ok: true, ruling: null, why: null };
|
|
1381
|
+
if (typeof ruling !== 'object' || Array.isArray(ruling)) {
|
|
1382
|
+
return { ok: false, ruling: null, why: `a decision is an object {decision, text?} — one of ${HUMAN_DECISIONS.join(' | ')}` };
|
|
1383
|
+
}
|
|
1384
|
+
const decision = String(/** @type {any} */ (ruling).decision);
|
|
1385
|
+
if (!HUMAN_DECISIONS.includes(decision)) {
|
|
1386
|
+
return {
|
|
1387
|
+
ok: false,
|
|
1388
|
+
ruling: null,
|
|
1389
|
+
why: `"${decision}" is not one of the three doors (${HUMAN_DECISIONS.join(' | ')}). There is no fourth, and no `
|
|
1390
|
+
+ 'free-text-only variant: a red that is not one of these two is not a ruling the run can act on',
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
const text = /** @type {any} */ (ruling).text;
|
|
1394
|
+
if (decision === 'rerun') {
|
|
1395
|
+
if (typeof text !== 'string' || text.trim().length === 0) {
|
|
1396
|
+
return {
|
|
1397
|
+
ok: false,
|
|
1398
|
+
ruling: null,
|
|
1399
|
+
why: 'a "rerun" decision needs TEXT: the human is the gap author (ruling 3), and their words are the whole of '
|
|
1400
|
+
+ 'what the fix worker is given. An empty gap re-runs the worker as though nothing had been said',
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
return { ok: true, ruling: { decision, text }, why: null };
|
|
1404
|
+
}
|
|
1405
|
+
return { ok: true, ruling: { decision, text: null }, why: null };
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* THE HUMAN STAGE (N4 slice 1) — the one kind that measures nothing.
|
|
1410
|
+
*
|
|
1411
|
+
* With no ruling in hand it does not RUN, it PAUSES: the fifth outcome, neither
|
|
1412
|
+
* green nor red, so nothing downstream grades it. With the signer's ruling in
|
|
1413
|
+
* hand it renders exactly what the person said — `accept` is a green a PERSON
|
|
1414
|
+
* judged, and `rerun` is a red whose gap is the human's own words, verbatim,
|
|
1415
|
+
* through the same bounded/announced channel every other stage's gap uses.
|
|
1416
|
+
*
|
|
1417
|
+
* It spawns nothing, reads nothing and touches no tree, which is why it can
|
|
1418
|
+
* render both without a workdir that exists.
|
|
1419
|
+
*
|
|
1420
|
+
* `pause` is deliberately NOT a case here. It is not a judgment about the tree
|
|
1421
|
+
* — it is the signer saying "not now" — and it is consumed above this stage, by
|
|
1422
|
+
* the runner and by `runPlan` before the close is ever asked (a `pause` that
|
|
1423
|
+
* reached this far has bypassed that seam, so it renders NO verdict rather than
|
|
1424
|
+
* being guessed into one). With no ruling in hand this kind pauses anyway, which
|
|
1425
|
+
* is the same place the person is left; what the door adds is the RECORD that
|
|
1426
|
+
* they looked and chose to keep the checkpoint.
|
|
1427
|
+
* @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>}
|
|
1428
|
+
*/
|
|
1429
|
+
async function runHumanConfirms(stage, ctx) {
|
|
1430
|
+
const ask = stage?.params?.ask;
|
|
1431
|
+
const norm = normalizeHumanRuling(ctx.humanRuling ?? null);
|
|
1432
|
+
if (!norm.ok) {
|
|
1433
|
+
return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" was handed a decision it cannot read — ${norm.why}`,
|
|
1434
|
+
{}, STOP_FAULTS.CRASHED);
|
|
1435
|
+
}
|
|
1436
|
+
if (norm.ruling === null) {
|
|
1437
|
+
// THE PAUSE. `judged: false` and `exitCode: null` together are the whole
|
|
1438
|
+
// contract: nothing was measured and no process ran, so a zero here would be
|
|
1439
|
+
// F6 in an exit code's coat.
|
|
1440
|
+
return result(stage, {
|
|
1441
|
+
verdict: 'pause',
|
|
1442
|
+
exitCode: null,
|
|
1443
|
+
judged: false,
|
|
1444
|
+
detail: { ask: isNonEmptyString(ask) ? ask : null },
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
if (norm.ruling.decision === 'accept') {
|
|
1448
|
+
return result(stage, { verdict: 'green', exitCode: EXIT_GREEN, judged: true, detail: { ask: ask ?? null, decision: 'accept' } });
|
|
1449
|
+
}
|
|
1450
|
+
if (norm.ruling.decision === 'rerun') {
|
|
1451
|
+
const gap = new Gap(ctx.gapKeep, ctx.gapCap);
|
|
1452
|
+
gap.push(`${stage.name}: the signer reviewed the result and asked for another pass. Their words:`);
|
|
1453
|
+
gap.push(/** @type {string} */ (norm.ruling.text));
|
|
1454
|
+
return result(stage, {
|
|
1455
|
+
verdict: 'red',
|
|
1456
|
+
exitCode: EXIT_RED,
|
|
1457
|
+
judged: true,
|
|
1458
|
+
gapLines: gap.render(),
|
|
1459
|
+
detail: { ask: ask ?? null, decision: 'rerun' },
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" was handed a "${norm.ruling.decision}" decision, which `
|
|
1463
|
+
+ 'is the signer ending the run and is never a verdict about the tree — the runner consumes it before the close',
|
|
1464
|
+
{}, STOP_FAULTS.CRASHED);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1235
1467
|
/** @type {Record<string, (stage: any, ctx: Ctx) => Promise<StageResult>>} */
|
|
1236
1468
|
const RUNNERS = {
|
|
1237
1469
|
'command-exit': runCommandExit,
|
|
1238
1470
|
'count-not-worse': runCountNotWorse,
|
|
1239
1471
|
'pattern-absent-in-diff': runPatternAbsentInDiff,
|
|
1240
1472
|
'files-changed': runFilesChanged,
|
|
1473
|
+
'human-confirms': runHumanConfirms,
|
|
1241
1474
|
};
|
|
1242
1475
|
|
|
1243
1476
|
/**
|
|
@@ -1296,10 +1529,41 @@ export async function seedRead(declaration, ctx) {
|
|
|
1296
1529
|
const owned = !ctx.seedTrees;
|
|
1297
1530
|
const seedTrees = ctx.seedTrees ?? makeSeedTrees();
|
|
1298
1531
|
const inner = { ...ctx, seedTrees };
|
|
1532
|
+
/** AFTER the stage completes, never before: a line saying a stage STARTED and
|
|
1533
|
+
* a line saying what it RETURNED are two different facts, and only the second
|
|
1534
|
+
* one can carry a verdict. The duration is measured around the call rather
|
|
1535
|
+
* than reported by it, so a stage that stops still gets a real number.
|
|
1536
|
+
* @param {StageResult} row @param {number} startedAt */
|
|
1537
|
+
const told = (row, startedAt) => {
|
|
1538
|
+
if (typeof ctx.onStage === 'function') {
|
|
1539
|
+
ctx.onStage({ stage: row.stage, kind: row.kind, verdict: row.verdict, durationMs: Date.now() - startedAt });
|
|
1540
|
+
}
|
|
1541
|
+
return row;
|
|
1542
|
+
};
|
|
1299
1543
|
try {
|
|
1300
1544
|
/** @type {StageResult[]} */
|
|
1301
1545
|
const rows = [];
|
|
1302
|
-
for (const stage of declaration?.stages ?? [])
|
|
1546
|
+
for (const stage of declaration?.stages ?? []) {
|
|
1547
|
+
// RULING 8 — judged and human stages are EXEMPT from the seed read, and
|
|
1548
|
+
// the exemption is a ROW, not an absence (F59). A person asked at the seed
|
|
1549
|
+
// is being interviewed, not measured, and there is nothing to baseline.
|
|
1550
|
+
const startedAt = Date.now();
|
|
1551
|
+
if (SEED_EXEMPT_KINDS.includes(stage?.kind)) {
|
|
1552
|
+
rows.push(told(result(stage, {
|
|
1553
|
+
verdict: 'skipped',
|
|
1554
|
+
exitCode: null,
|
|
1555
|
+
judged: false,
|
|
1556
|
+
detail: {
|
|
1557
|
+
skipped: true,
|
|
1558
|
+
why: 'ruling 8 (2026-08-07): judged and human stages skip the seed-verdict read — a judged number at seed '
|
|
1559
|
+
+ 'is unstable and a person at seed is an interview, not a measurement. This stage has no seed baseline '
|
|
1560
|
+
+ 'and contributes none.',
|
|
1561
|
+
},
|
|
1562
|
+
}), startedAt));
|
|
1563
|
+
continue;
|
|
1564
|
+
}
|
|
1565
|
+
rows.push(told(await runStageIn(stage, inner), startedAt));
|
|
1566
|
+
}
|
|
1303
1567
|
return rows;
|
|
1304
1568
|
} finally {
|
|
1305
1569
|
if (owned) await seedTrees.cleanup();
|
|
@@ -1321,7 +1585,7 @@ export async function seedRead(declaration, ctx) {
|
|
|
1321
1585
|
* room would break a documented adopter contract for nothing; the new name says
|
|
1322
1586
|
* exactly what differs. `src/declaredclose.js` is what bridges the two shapes.
|
|
1323
1587
|
* @param {any} declaration @param {Ctx} ctx
|
|
1324
|
-
* @returns {Promise<{verdict: 'green'|'red'|'instrument-stop', exitCode: number, judged: boolean, firstRed: string|null, stages: StageResult[]}>}
|
|
1588
|
+
* @returns {Promise<{verdict: 'green'|'red'|'instrument-stop'|'pause', exitCode: number|null, judged: boolean, firstRed: string|null, pausedAt: string|null, stages: StageResult[]}>}
|
|
1325
1589
|
*/
|
|
1326
1590
|
export async function runDeclaredClose(declaration, ctx) {
|
|
1327
1591
|
const owned = !ctx.seedTrees;
|
|
@@ -1340,12 +1604,20 @@ export async function runDeclaredClose(declaration, ctx) {
|
|
|
1340
1604
|
stages.push(r);
|
|
1341
1605
|
if (r.verdict !== 'green') ended = true;
|
|
1342
1606
|
}
|
|
1607
|
+
// A PAUSE stops the close exactly like a red does, and is DELIBERATELY not
|
|
1608
|
+
// in this list: `deciding` names the stage that rendered a VERDICT, and a
|
|
1609
|
+
// pause rendered none. Reading it as one would fall through to green — the
|
|
1610
|
+
// close would mint a verdict nobody gave (the defect this line exists to
|
|
1611
|
+
// make impossible).
|
|
1343
1612
|
const deciding = stages.find((s) => s.verdict === 'red' || s.verdict === 'instrument-stop');
|
|
1613
|
+
const paused = deciding ? null : stages.find((s) => s.verdict === 'pause') ?? null;
|
|
1344
1614
|
return {
|
|
1345
|
-
verdict: /** @type {'green'|'red'|'instrument-stop'} */ (deciding ? deciding.verdict : 'green'),
|
|
1346
|
-
exitCode: deciding ? /** @type {number} */ (deciding.exitCode) : EXIT_GREEN,
|
|
1347
|
-
judged: deciding ? deciding.judged : true,
|
|
1615
|
+
verdict: /** @type {'green'|'red'|'instrument-stop'|'pause'} */ (deciding ? deciding.verdict : (paused ? 'pause' : 'green')),
|
|
1616
|
+
exitCode: deciding ? /** @type {number} */ (deciding.exitCode) : (paused ? null : EXIT_GREEN),
|
|
1617
|
+
judged: deciding ? deciding.judged : (paused ? false : true),
|
|
1348
1618
|
firstRed: deciding ? deciding.stage : null,
|
|
1619
|
+
/** the stage the close is WAITING on, when it is waiting on a person */
|
|
1620
|
+
pausedAt: paused ? paused.stage : null,
|
|
1349
1621
|
stages,
|
|
1350
1622
|
};
|
|
1351
1623
|
} finally {
|
package/src/ledger.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The upstream ledger (module 4) — reactive lib/primitive incident monitoring.
|
|
2
|
-
// Design record: docs/
|
|
2
|
+
// Design record: docs/02-features/2026-07-11-upstream-ledger-design.md (adaptlearn
|
|
3
3
|
// POC'd spec) + its 2026-07-13 addendum: the bareloop event mapping. One
|
|
4
4
|
// append-only JSONL both the consumer (the panel, N6) and the maintainer read;
|
|
5
5
|
// the A1/A2/A3 upstream-ask flow, mechanized — evidence in, human judgment out.
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { readFileSync, appendFileSync, existsSync } from 'node:fs';
|
|
23
23
|
import { basename } from 'node:path';
|
|
24
24
|
import { createHash } from 'node:crypto';
|
|
25
|
+
import { HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
|
|
25
26
|
|
|
26
27
|
/** severity order, worst-first (the fold's display order; frequency ranks within) */
|
|
27
28
|
export const LEDGER_CLASSES = Object.freeze([
|
|
@@ -75,9 +76,35 @@ const EXCLUDED_ESCALATIONS = new Set([
|
|
|
75
76
|
// real upstream ask would be sourced from anyway.
|
|
76
77
|
'step-stalled',
|
|
77
78
|
'step-variance', // A's replan trigger: a planning story, never a lib bug
|
|
78
|
-
|
|
79
|
+
// Governance working as intended — and no longer minted anywhere in this tree
|
|
80
|
+
// (a denial streak is a bounded attempt, not a terminal). Kept listed because
|
|
81
|
+
// this set is the EXECUTABLE excluded-set: dropping a name does not delete the
|
|
82
|
+
// category, it re-files any future emission of it as a counted capability gap.
|
|
83
|
+
'gate-red',
|
|
79
84
|
'smoke-red', // already counted via primitive-smoke
|
|
80
85
|
'hitl-close', // by design: a human is the close
|
|
86
|
+
// N4 slice 1 — the hitl class's own terminals, and each is here for its own
|
|
87
|
+
// reason rather than by family resemblance. `hitl-pause` is a CHECKPOINT (the
|
|
88
|
+
// cap-halt story with a person instead of a budget: the run stopped because it
|
|
89
|
+
// reached the one stage a machine cannot render). `hitl-decision-red` is an
|
|
90
|
+
// honest refusal of the OPERATOR's own input, exactly like `close-unsupported`
|
|
91
|
+
// below, and the one direction that would be wrong is filing either of them as
|
|
92
|
+
// a capability gap against a library that did nothing.
|
|
93
|
+
//
|
|
94
|
+
// MINTED, never borrowed: the legacy `hitl-close` above is the deleted path's
|
|
95
|
+
// close TYPE and keeps its own meaning. Reusing that name would have made two
|
|
96
|
+
// different facts one number (the `step-stalled` lesson).
|
|
97
|
+
HITL_PAUSE,
|
|
98
|
+
HITL_DECISION_RED,
|
|
99
|
+
// LEGACY, read-only, and a bare STRING on purpose (the `gate-red` precedent
|
|
100
|
+
// above): `hitl-cancel` was the third door's terminal until 2026-08-17, when
|
|
101
|
+
// cancel was deleted in favour of a pause that can resume. Nothing in this tree
|
|
102
|
+
// mints it any more — there is no constant left to import — but this set is the
|
|
103
|
+
// EXECUTABLE excluded-set, and dropping a name does not delete the category: it
|
|
104
|
+
// re-files any spine that still CARRIES one as a counted capability gap against
|
|
105
|
+
// a library that did nothing. A reader must keep recognising what a writer can
|
|
106
|
+
// no longer write.
|
|
107
|
+
'hitl-cancel',
|
|
81
108
|
'close-unsupported', // honest refusal, by design
|
|
82
109
|
// Close authoring (M4, D13) — the interview refusing a job we cannot close: a
|
|
83
110
|
// genre we do not run, a job with no seed to measure against, a locked kind.
|