bareloop 0.10.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 +351 -0
- package/README.md +17 -7
- package/bareloop.context.md +179 -16
- package/package.json +3 -3
- package/src/authorflow.js +114 -16
- package/src/authoring.js +100 -33
- package/src/authorjob.js +50 -6
- package/src/authorscout.js +13 -1
- package/src/bridges.js +1 -1
- package/src/clock.js +21 -3
- package/src/declaredclose.js +88 -11
- package/src/index.js +8 -1
- package/src/job.js +7 -2
- package/src/kinds.js +190 -9
- package/src/ledger.js +24 -1
- package/src/planrun.js +195 -5
- package/src/ralph.js +9 -0
- package/src/reuse.js +195 -7
- package/src/root.js +1 -1
- package/src/run.js +10 -4
- package/src/selection.js +1 -1
- package/types/authorflow.d.ts +86 -12
- package/types/authoring.d.ts +49 -35
- package/types/authorjob.d.ts +9 -1
- package/types/authorscout.d.ts +7 -1
- 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 +76 -4
- package/types/planrun.d.ts +18 -2
- package/types/reuse.d.ts +74 -0
- package/types/run.d.ts +12 -2
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
|
*/
|
|
@@ -1323,12 +1360,117 @@ async function runFilesChanged(stage, ctx) {
|
|
|
1323
1360
|
});
|
|
1324
1361
|
}
|
|
1325
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
|
+
|
|
1326
1467
|
/** @type {Record<string, (stage: any, ctx: Ctx) => Promise<StageResult>>} */
|
|
1327
1468
|
const RUNNERS = {
|
|
1328
1469
|
'command-exit': runCommandExit,
|
|
1329
1470
|
'count-not-worse': runCountNotWorse,
|
|
1330
1471
|
'pattern-absent-in-diff': runPatternAbsentInDiff,
|
|
1331
1472
|
'files-changed': runFilesChanged,
|
|
1473
|
+
'human-confirms': runHumanConfirms,
|
|
1332
1474
|
};
|
|
1333
1475
|
|
|
1334
1476
|
/**
|
|
@@ -1387,10 +1529,41 @@ export async function seedRead(declaration, ctx) {
|
|
|
1387
1529
|
const owned = !ctx.seedTrees;
|
|
1388
1530
|
const seedTrees = ctx.seedTrees ?? makeSeedTrees();
|
|
1389
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
|
+
};
|
|
1390
1543
|
try {
|
|
1391
1544
|
/** @type {StageResult[]} */
|
|
1392
1545
|
const rows = [];
|
|
1393
|
-
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
|
+
}
|
|
1394
1567
|
return rows;
|
|
1395
1568
|
} finally {
|
|
1396
1569
|
if (owned) await seedTrees.cleanup();
|
|
@@ -1412,7 +1585,7 @@ export async function seedRead(declaration, ctx) {
|
|
|
1412
1585
|
* room would break a documented adopter contract for nothing; the new name says
|
|
1413
1586
|
* exactly what differs. `src/declaredclose.js` is what bridges the two shapes.
|
|
1414
1587
|
* @param {any} declaration @param {Ctx} ctx
|
|
1415
|
-
* @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[]}>}
|
|
1416
1589
|
*/
|
|
1417
1590
|
export async function runDeclaredClose(declaration, ctx) {
|
|
1418
1591
|
const owned = !ctx.seedTrees;
|
|
@@ -1431,12 +1604,20 @@ export async function runDeclaredClose(declaration, ctx) {
|
|
|
1431
1604
|
stages.push(r);
|
|
1432
1605
|
if (r.verdict !== 'green') ended = true;
|
|
1433
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).
|
|
1434
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;
|
|
1435
1614
|
return {
|
|
1436
|
-
verdict: /** @type {'green'|'red'|'instrument-stop'} */ (deciding ? deciding.verdict : 'green'),
|
|
1437
|
-
exitCode: deciding ? /** @type {number} */ (deciding.exitCode) : EXIT_GREEN,
|
|
1438
|
-
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),
|
|
1439
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,
|
|
1440
1621
|
stages,
|
|
1441
1622
|
};
|
|
1442
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([
|
|
@@ -82,6 +83,28 @@ const EXCLUDED_ESCALATIONS = new Set([
|
|
|
82
83
|
'gate-red',
|
|
83
84
|
'smoke-red', // already counted via primitive-smoke
|
|
84
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',
|
|
85
108
|
'close-unsupported', // honest refusal, by design
|
|
86
109
|
// Close authoring (M4, D13) — the interview refusing a job we cannot close: a
|
|
87
110
|
// genre we do not run, a job with no seed to measure against, a locked kind.
|
package/src/planrun.js
CHANGED
|
@@ -33,8 +33,11 @@ import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
|
|
|
33
33
|
import { validateBridge, loadGate } from './bridges.js';
|
|
34
34
|
import { extractArtifact } from './text.js';
|
|
35
35
|
import { createClock, isWallTimeout } from './clock.js';
|
|
36
|
-
import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade } from './declaredclose.js';
|
|
37
|
-
import {
|
|
36
|
+
import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
|
|
37
|
+
import {
|
|
38
|
+
seedAtHead, seedListing, changedSet, GAP_TRIM_MARKER, GATE_AUDIT_FILE, ARBITER_BOOK_STORES,
|
|
39
|
+
HUMAN_KIND, HUMAN_DECISIONS, normalizeHumanRuling,
|
|
40
|
+
} from './kinds.js';
|
|
38
41
|
import { workBranchName, prepareWorkBranch } from './workbranch.js';
|
|
39
42
|
|
|
40
43
|
const require = createRequire(import.meta.url);
|
|
@@ -65,6 +68,11 @@ const NATIVE_READ_STRATEGY = '\nINTERFACE LIMIT: on this surface a whole-file re
|
|
|
65
68
|
const SCOUT_MIN_BYTES = 200;
|
|
66
69
|
/** feed-forward artifact bound per step (prompt ingredient, spine-bound) */
|
|
67
70
|
const ARTIFACT_MAX = 2000;
|
|
71
|
+
/** how many changed paths ride the hitl pause's evidence package. Bounded like
|
|
72
|
+
* every other list this repo puts on a spine, and the trim ANNOUNCED (F28): the
|
|
73
|
+
* person needs to see WHAT moved, and a run that touched a thousand files is
|
|
74
|
+
* telling them something a thousand lines would not. */
|
|
75
|
+
const PAUSE_CHANGED_CAP = 50;
|
|
68
76
|
/** the wallet floor below which a replan is a stop, not an adaptation (review
|
|
69
77
|
* #5): a money-gate halt drains the wallet to ~0, so replanning against dust
|
|
70
78
|
* just burns another draft and mislabels the money-cut as "exits still red"
|
|
@@ -581,12 +589,24 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
581
589
|
* it the deterministic name would collide with that very branch and the collision walk
|
|
582
590
|
* would mint a `-2` beside the progress the resume exists to keep. Absent is the cold
|
|
583
591
|
* path. A recorded branch that no longer exists is a STOP, never a fresh start.
|
|
592
|
+
* @param {{decision: string, text?: string}|null} [opts.humanRuling] N4 — the SIGNER's
|
|
593
|
+
* answer at a hitl pause (2026-08-12 §1, re-cut 2026-08-17: accept | rerun <text> | pause), carried in
|
|
594
|
+
* by the runner on the leg that RESUMES a paused run. Absent on every ordinary run and
|
|
595
|
+
* on the leg that pauses; it is never authored, never defaulted, and never inferred.
|
|
596
|
+
*
|
|
597
|
+
* It is spent ONCE, on this leg's close readings up to the moment the fix loop opens:
|
|
598
|
+
* `accept` greens the human stage on fresh evidence (OPEN-3 — the mechanical stages
|
|
599
|
+
* re-run first, because the tree can change while a run is paused), `rerun` reds it
|
|
600
|
+
* with the human's own words as the gap, and `pause` keeps the checkpoint exactly as it
|
|
601
|
+
* is, before anything is read at all. After the fix loop opens the ruling is gone, so once the mechanical
|
|
602
|
+
* stages pass again the run PAUSES for a second review rather than converting the same
|
|
603
|
+
* sentence forever.
|
|
584
604
|
* @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
|
|
585
605
|
* 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
|
|
586
606
|
* 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
|
|
587
|
-
* 'step-stalled' | `step-red:<id>`
|
|
607
|
+
* 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
|
|
588
608
|
*/
|
|
589
|
-
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null }) {
|
|
609
|
+
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null }) {
|
|
590
610
|
workdir = resolve(workdir);
|
|
591
611
|
// ONE spelling of the redaction, housed next to the inventory it reads
|
|
592
612
|
// (src/validate.js) — the same helper the isolate verbs scrub the litectx store
|
|
@@ -638,6 +658,79 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
638
658
|
return 'close-unsupported';
|
|
639
659
|
}
|
|
640
660
|
|
|
661
|
+
// ── THE SIGNER'S ANSWER (N4 §1.4), read before anything costs anything.
|
|
662
|
+
//
|
|
663
|
+
// Three gates, and each refuses rather than guesses. A decision that is not one
|
|
664
|
+
// of the three doors — or a `rerun` whose text is empty — is refused at the
|
|
665
|
+
// seam that ACCEPTS it, because the whole of what a rerun gives the fix worker
|
|
666
|
+
// is the human's words, and an empty gap re-runs the worker as though nothing
|
|
667
|
+
// had been said (the POC's negative control measured exactly that). A decision
|
|
668
|
+
// handed to a close with no human stage is refused too: there is nothing for it
|
|
669
|
+
// to rule on, and silently ignoring it would leave a person believing they had
|
|
670
|
+
// answered. It is a terminal of its own rather than an `interpreter-red` — no
|
|
671
|
+
// library failed, the operator's own input is what could not be read.
|
|
672
|
+
const hasHumanStage = stagedClose.some((/** @type {any} */ s) => s?.kind === HUMAN_KIND);
|
|
673
|
+
const ruling = normalizeHumanRuling(humanRuling);
|
|
674
|
+
const decisionRed = (/** @type {string} */ detail) => {
|
|
675
|
+
emit('escalation', {
|
|
676
|
+
category: HITL_DECISION_RED, decisionReady: true,
|
|
677
|
+
decision: 'The decision handed to this run could not be applied, so nothing was run and nothing was spent.',
|
|
678
|
+
options: [
|
|
679
|
+
`re-run the resume with one of: ${HUMAN_DECISIONS.join(' | ')} (a rerun carries the text the worker converts)`,
|
|
680
|
+
'abandon the task',
|
|
681
|
+
],
|
|
682
|
+
detail: scrub(detail),
|
|
683
|
+
});
|
|
684
|
+
return HITL_DECISION_RED;
|
|
685
|
+
};
|
|
686
|
+
if (!ruling.ok) return decisionRed(/** @type {string} */ (ruling.why));
|
|
687
|
+
if (ruling.ruling !== null && !hasHumanStage) {
|
|
688
|
+
return decisionRed(`this job's close has no ${HUMAN_KIND} stage, so a signer's "${ruling.ruling.decision}" has `
|
|
689
|
+
+ 'nothing to rule on — the verdict class is a promise about the CLOSE, and only a close that asks a person '
|
|
690
|
+
+ 'can be answered by one');
|
|
691
|
+
}
|
|
692
|
+
/** the ruling still to be spent. Cleared the moment the fix loop opens: the
|
|
693
|
+
* human's words are what OPENS that loop, and once the worker holds them the
|
|
694
|
+
* next machine-clean tree is a new question for the person, not the old one. */
|
|
695
|
+
let liveRuling = ruling.ruling;
|
|
696
|
+
|
|
697
|
+
// ── PAUSE: the third door, and it costs nothing at all (2026-08-12 §1, doors
|
|
698
|
+
// re-cut 2026-08-17). Before the branch, the seed, the precheck and the clock,
|
|
699
|
+
// because a person who answered "not now" has asked for nothing to be done: no
|
|
700
|
+
// gap, no continuation, no worker round. It is deliberately NOT routed through
|
|
701
|
+
// the close — running four stages to arrive back at the question the person has
|
|
702
|
+
// just declined to answer is wall time spent on an answer already in hand.
|
|
703
|
+
//
|
|
704
|
+
// hamr's ruling, verbatim: *"what's the point of cancel anyways? pause can
|
|
705
|
+
// resume — that would be more honest"*. So this mints the SAME checkpoint
|
|
706
|
+
// terminal the machine-side pause mints, which is what keeps it resumable under
|
|
707
|
+
// the existing TTL: the run re-enters at the start of its last step whenever
|
|
708
|
+
// somebody comes back, and if nobody does, the checkpoint expires on its own.
|
|
709
|
+
// That expiry IS the case cancel used to serve, without forcing a person into a
|
|
710
|
+
// forever-decision at the moment they least want to make one.
|
|
711
|
+
//
|
|
712
|
+
// The record is EXPLICIT (`humanDecision`) rather than a silent re-pause: "a
|
|
713
|
+
// person looked and kept it" and "nobody has looked yet" are two different
|
|
714
|
+
// facts, and one record spelling both is how a reader comes to confuse them.
|
|
715
|
+
// The evidence package is not re-assembled here — it is already on the spine of
|
|
716
|
+
// the leg that asked, and this leg measured nothing, so it states nothing (F6:
|
|
717
|
+
// absent, never an empty list dressed as a reading).
|
|
718
|
+
if (liveRuling?.decision === 'pause') {
|
|
719
|
+
emit(HITL_PAUSE, {
|
|
720
|
+
stage: stagedClose.find((/** @type {any} */ s) => s?.kind === HUMAN_KIND)?.name ?? null,
|
|
721
|
+
humanDecision: 'pause',
|
|
722
|
+
decisionReady: true,
|
|
723
|
+
// EXPLICITLY null, never absent: every gap consumer guards with `if (gap)`,
|
|
724
|
+
// and the field states the ruling rather than leaving it to be inferred.
|
|
725
|
+
gap: null,
|
|
726
|
+
decision: 'The signer looked and paused: nothing was asked for, so nothing was run and nothing was spent. The '
|
|
727
|
+
+ 'checkpoint stands exactly as it was — the work, the plan and the money are where the paused leg left them.',
|
|
728
|
+
options: [...HUMAN_DECISIONS],
|
|
729
|
+
meaning: 'not a verdict — the run is still paused, the clock is still stopped, and the same three doors are open',
|
|
730
|
+
});
|
|
731
|
+
return HITL_PAUSE;
|
|
732
|
+
}
|
|
733
|
+
|
|
641
734
|
// ── native wiring: a clipipe-subscription job needs the native provider
|
|
642
735
|
// FACTORY (native governance is constructor-time + per-worker, so the runner
|
|
643
736
|
// cannot reuse one injected instance). A missing factory is an adopter wiring
|
|
@@ -827,7 +920,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
827
920
|
* @param {any[]} stages
|
|
828
921
|
*/
|
|
829
922
|
const runCloseStages = (stages) => (declared
|
|
830
|
-
? runDeclaredStages(stages, scrub, {
|
|
923
|
+
? runDeclaredStages(stages, scrub, {
|
|
924
|
+
...closeOpts,
|
|
925
|
+
seedRef: /** @type {any} */ (declaredCtx).seedRef,
|
|
926
|
+
// the signer's answer reaches the human stage the same way every other
|
|
927
|
+
// arbiter-owned fact reaches a stage: through the ctx, never through the
|
|
928
|
+
// declaration. A command close has no human stage to hand it to.
|
|
929
|
+
humanRuling: liveRuling,
|
|
930
|
+
})
|
|
831
931
|
: runStages(stages, scrub, closeOpts));
|
|
832
932
|
/** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
|
|
833
933
|
let closeStage;
|
|
@@ -884,6 +984,41 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
884
984
|
...extra,
|
|
885
985
|
});
|
|
886
986
|
};
|
|
987
|
+
/**
|
|
988
|
+
* THE PAUSE (N4 §1.3/§1.4, rulings 1 and 2) — a decision-ready checkpoint, and
|
|
989
|
+
* never a bare "approve?". What the person is shown is assembled here rather
|
|
990
|
+
* than by whatever surface renders it: every mechanical stage's own result, the
|
|
991
|
+
* question the close itself declared, and the set of files this run changed.
|
|
992
|
+
*
|
|
993
|
+
* The changed set is the "before/after" half, at the granularity a library can
|
|
994
|
+
* state honestly — WHICH files moved. Rendering the lines belongs to the
|
|
995
|
+
* surface that has a screen; carrying the list is what makes the surface's job
|
|
996
|
+
* possible without it re-deriving the run's own facts.
|
|
997
|
+
* @param {any} v the close verdict that paused @returns {Promise<void>}
|
|
998
|
+
*/
|
|
999
|
+
const emitHitlPause = async (v) => {
|
|
1000
|
+
const cs = declaredCtx ? await changedSet(workdir, declaredCtx.seedRef) : { stop: 'no seed', paths: [] };
|
|
1001
|
+
const paths = cs.stop === null ? cs.paths : [];
|
|
1002
|
+
const shown = paths.slice(0, PAUSE_CHANGED_CAP);
|
|
1003
|
+
emit(HITL_PAUSE, {
|
|
1004
|
+
stage: v.stage ?? null,
|
|
1005
|
+
ask: v.ask ?? null,
|
|
1006
|
+
decisionReady: true,
|
|
1007
|
+
// ruling 2: the whole evidence package, never a bare "approve?"
|
|
1008
|
+
stages: v.stages ?? [],
|
|
1009
|
+
changed: {
|
|
1010
|
+
paths: shown,
|
|
1011
|
+
// the trim is ANNOUNCED (F28), and an unreadable set is UNKNOWN, never
|
|
1012
|
+
// an empty list dressed as "nothing changed" (F6)
|
|
1013
|
+
...(paths.length > shown.length ? { more: paths.length - shown.length } : {}),
|
|
1014
|
+
...(cs.stop === null ? {} : { unreadable: scrub(String(cs.stop)) }),
|
|
1015
|
+
},
|
|
1016
|
+
decision: `The close reached its human stage and is waiting on you: ${v.ask ?? 'review the result'}`,
|
|
1017
|
+
options: [...HUMAN_DECISIONS],
|
|
1018
|
+
meaning: 'not a verdict — the run is paused, the clock is stopped, and the last thing it did stands until you answer',
|
|
1019
|
+
});
|
|
1020
|
+
};
|
|
1021
|
+
|
|
887
1022
|
/** the close, as ONE verdict: every stage in order, first red wins */
|
|
888
1023
|
const judgeClose = async () => {
|
|
889
1024
|
const v = await runCloseStages(stagedClose);
|
|
@@ -1057,6 +1192,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1057
1192
|
const pre = await judgeClose();
|
|
1058
1193
|
emit('close-precheck', { ...pre });
|
|
1059
1194
|
if (pre.verdict === 'satisfied') return 'already-green';
|
|
1195
|
+
// …and the pause's own arm of the same gate. Reaching a human stage HERE means
|
|
1196
|
+
// every mechanical stage already passes on the untouched tree: the machine half
|
|
1197
|
+
// of this job is done and the only thing left is a person. Pausing costs $0 and
|
|
1198
|
+
// is the honest answer; running a plan first would spend a budget to arrive at
|
|
1199
|
+
// exactly this question. No `planExecuted` — there is no plan yet, and an empty
|
|
1200
|
+
// one on the spine would be a record of work nobody did.
|
|
1201
|
+
if (pre.verdict === HUMAN_PAUSE) { await emitHitlPause(pre); return HITL_PAUSE; }
|
|
1060
1202
|
const preFault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
|
|
1061
1203
|
if (preFault) {
|
|
1062
1204
|
emit('escalation', { category: preFault.category, decisionReady: true, decision: preFault.decision, options: preFault.options, detail: pre.detail });
|
|
@@ -2579,6 +2721,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2579
2721
|
planExecuted();
|
|
2580
2722
|
return 'green';
|
|
2581
2723
|
}
|
|
2724
|
+
// ── THE THIRD BRANCH (N4 §1.3): between `satisfied` and the fix loop, because
|
|
2725
|
+
// a pause is neither. It is not green (nobody said so), not a red (nobody has
|
|
2726
|
+
// written a gap), and not a CLOSE_FAULT (nothing broke) — so it must be caught
|
|
2727
|
+
// before the fix loop, which would otherwise buy a worker round to convert a
|
|
2728
|
+
// wall that does not exist yet.
|
|
2729
|
+
if (post.verdict === HUMAN_PAUSE) {
|
|
2730
|
+
await emitHitlPause(post);
|
|
2731
|
+
planExecuted();
|
|
2732
|
+
return HITL_PAUSE;
|
|
2733
|
+
}
|
|
2582
2734
|
const postFault = Object.hasOwn(CLOSE_FAULTS, post.verdict) ? CLOSE_FAULTS[post.verdict] : undefined;
|
|
2583
2735
|
if (postFault) {
|
|
2584
2736
|
emit('escalation', { category: postFault.category, decisionReady: true, decision: postFault.decision, options: postFault.options, detail: post.detail });
|
|
@@ -2586,6 +2738,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2586
2738
|
return 'close-red';
|
|
2587
2739
|
}
|
|
2588
2740
|
emit('fix-loop', { gapBytes: Buffer.byteLength(post.gap ?? '') });
|
|
2741
|
+
// ── THE RULING IS SPENT HERE (N4 §1.5). The human's words are what OPENS this
|
|
2742
|
+
// loop; the worker now holds them. Leaving the ruling live would make the human
|
|
2743
|
+
// stage red on every judgment for the rest of the leg, so the loop would keep
|
|
2744
|
+
// converting one sentence until the governor or the money stopped it. Cleared,
|
|
2745
|
+
// the next machine-clean tree pauses for a SECOND review — which is the honest
|
|
2746
|
+
// cycle: a person asked for a change, the change happened, the person looks
|
|
2747
|
+
// again. A mechanical red in between is still converted normally, because
|
|
2748
|
+
// first-red-wins reaches it before the person's stage.
|
|
2749
|
+
liveRuling = null;
|
|
2589
2750
|
let fixOutcome;
|
|
2590
2751
|
/** @type {object|null} W-2 — set when the WALL stopped the fix loop BETWEEN
|
|
2591
2752
|
* iterations. It carries that stop's own record fields, and its presence is what
|
|
@@ -2667,6 +2828,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2667
2828
|
const fixGovernor = {
|
|
2668
2829
|
record: (/** @type {{iteration: number, gap?: string}} */ o) => {
|
|
2669
2830
|
fixIterationsUsed = o.iteration;
|
|
2831
|
+
// A PAUSE IS NEVER GRADED. ralph records every non-satisfied iteration,
|
|
2832
|
+
// and a pause is one — but it carries no gap and no number, so feeding it
|
|
2833
|
+
// to the trend would mint a strike out of a non-verdict and could strike
|
|
2834
|
+
// the loop out into a `cap-halt` that never happened. The reading says
|
|
2835
|
+
// what it is and the meter is left untouched; `middle` stops the loop on
|
|
2836
|
+
// the next iteration, before anything is bought.
|
|
2837
|
+
if (lastCloseVerdict?.verdict === HUMAN_PAUSE) {
|
|
2838
|
+
return { governor: 'close-trend', trend: 'unknown', reading: 'the close is waiting on a person — not a grade', iteration: o.iteration, paused: true };
|
|
2839
|
+
}
|
|
2670
2840
|
return { governor: 'close-trend', ...fixTrend.record({ gap: o.gap }), iteration: o.iteration };
|
|
2671
2841
|
},
|
|
2672
2842
|
struckOut: fixTrend.struckOut,
|
|
@@ -2708,6 +2878,19 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2708
2878
|
// until the next iteration is about to start. The close is never bounded — a
|
|
2709
2879
|
// wall that kills grading leaves the run unreadable after the money is spent
|
|
2710
2880
|
// (the F45 class), so the one thing this must never do is stop a close.
|
|
2881
|
+
// ── THE CLOSE IS WAITING ON A PERSON. Read here, in the same place and for
|
|
2882
|
+
// the same reason the wall is: past this point the iteration is guaranteed
|
|
2883
|
+
// to be worthless, because the machine half of the close already passes and
|
|
2884
|
+
// nothing a worker writes can answer a question only a person can. So the
|
|
2885
|
+
// loop stops ON that reading — the run pauses again, with everything it has
|
|
2886
|
+
// done kept, exactly as the first pause did.
|
|
2887
|
+
if (lastCloseVerdict?.verdict === HUMAN_PAUSE) {
|
|
2888
|
+
const err = /** @type {CategorizedError} */ (new Error(
|
|
2889
|
+
`the close is waiting on the signer at stage "${lastCloseVerdict.stage ?? 'unknown'}" — every mechanical `
|
|
2890
|
+
+ `stage passes, so fix attempt ${iteration} could not change the answer`));
|
|
2891
|
+
err.category = HITL_PAUSE;
|
|
2892
|
+
throw err;
|
|
2893
|
+
}
|
|
2711
2894
|
if (clock.expired()) {
|
|
2712
2895
|
// ONE progress instrument, shared with the money halt (PRD v1.46 §2/§4).
|
|
2713
2896
|
// This site used to run its own `gapTrend`: byte equality of the last two
|
|
@@ -2789,6 +2972,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2789
2972
|
return relay(e, 'fix');
|
|
2790
2973
|
}
|
|
2791
2974
|
planExecuted();
|
|
2975
|
+
// The fix loop ran into the person again (the `middle` stop above). Same
|
|
2976
|
+
// terminal as the first pause, and never `escalated`: nothing failed, and the
|
|
2977
|
+
// work this loop did is exactly what the signer is now being asked about.
|
|
2978
|
+
if (fixOutcome !== 'green' && lastEscalation?.category === HITL_PAUSE) {
|
|
2979
|
+
await emitHitlPause(lastCloseVerdict);
|
|
2980
|
+
return HITL_PAUSE;
|
|
2981
|
+
}
|
|
2792
2982
|
if (fixOutcome !== 'green' && lastEscalation?.category === 'wall-halt') {
|
|
2793
2983
|
// F64 in the close-fix loop: the same governance stop, and it must not ride out
|
|
2794
2984
|
// as a bare `escalated` either — that reads as "the fix failed" when the fix was
|
package/src/ralph.js
CHANGED
|
@@ -629,6 +629,15 @@ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redac
|
|
|
629
629
|
// interpreter" would aim a human at the one component that did nothing wrong.
|
|
630
630
|
'step-stalled': ['The model stopped producing rounds and reissuing the call did not recover it — a stall, not a fault.',
|
|
631
631
|
['let the planner re-allocate what is left (replan)', 'retry the run', 'check provider status', 'abandon the task']],
|
|
632
|
+
// N4 — the close is WAITING ON A PERSON. Nothing failed and nothing is
|
|
633
|
+
// discarded: every mechanical stage passes and the one stage left is a
|
|
634
|
+
// question no worker can answer, so the loop stopped rather than buying
|
|
635
|
+
// an attempt that could not change the answer. The caller renders the
|
|
636
|
+
// real decision-ready package (`hitl-pause`, with the evidence); this
|
|
637
|
+
// entry exists so the passthrough never narrates a paused run as a
|
|
638
|
+
// broken middle.
|
|
639
|
+
'hitl-pause': ['The close reached its human stage — the run is paused, decision-ready, and the clock is stopped.',
|
|
640
|
+
['accept', 'rerun (your words become the gap the worker converts)', 'pause (the checkpoint keeps, and it resumes)']],
|
|
632
641
|
};
|
|
633
642
|
// Object.hasOwn, not bare lookup: a category named after an
|
|
634
643
|
// Object.prototype member ("toString") would return the inherited
|