bareloop 0.11.0 → 0.12.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 +159 -0
- package/README.md +3 -2
- package/bareloop.context.md +282 -8
- package/package.json +1 -1
- package/src/authorflow.js +176 -33
- package/src/authoring.js +170 -25
- package/src/authorjob.js +340 -26
- package/src/bridges.js +237 -10
- package/src/calibrate.js +597 -0
- package/src/cardauthor.js +479 -0
- package/src/declaredclose.js +108 -10
- package/src/index.js +84 -8
- package/src/job.js +27 -13
- package/src/judged.js +765 -0
- package/src/kinds.js +390 -6
- package/src/planrun.js +328 -15
- package/src/reuse.js +319 -89
- package/src/reviewdoor.js +246 -0
- package/src/run.js +73 -6
- package/src/selection.js +22 -6
- package/types/authorflow.d.ts +147 -9
- package/types/authoring.d.ts +54 -8
- package/types/authorjob.d.ts +36 -6
- package/types/bridges.d.ts +95 -1
- package/types/calibrate.d.ts +191 -0
- package/types/cardauthor.d.ts +352 -0
- package/types/declaredclose.d.ts +34 -5
- package/types/index.d.ts +10 -6
- package/types/job.d.ts +22 -9
- package/types/judged.d.ts +280 -0
- package/types/kinds.d.ts +151 -4
- package/types/planrun.d.ts +56 -2
- package/types/reuse.d.ts +144 -3
- package/types/reviewdoor.d.ts +81 -0
- package/types/run.d.ts +49 -1
package/src/reuse.js
CHANGED
|
@@ -46,7 +46,7 @@ import { existsSync } from 'node:fs';
|
|
|
46
46
|
import { join } from 'node:path';
|
|
47
47
|
import { createHash } from 'node:crypto';
|
|
48
48
|
import { createRequire } from 'node:module';
|
|
49
|
-
import { loadRegistry, saveBridge, appendGreen, appendRed, mintBridge, registryExists } from './bridges.js';
|
|
49
|
+
import { loadRegistry, loadBridge, saveBridge, appendGreen, appendRed, mintBridge, registryExists, reuseEligibility, recordDoor, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
|
|
50
50
|
import { renderListing, selectionPrompt } from './selection.js';
|
|
51
51
|
import { closeStagesOf } from './plan.js';
|
|
52
52
|
import { MIN_WALL_MS, jobSpecHash } from './job.js';
|
|
@@ -59,7 +59,7 @@ import { readGrade } from './trend.js';
|
|
|
59
59
|
* list, and the TTL gate below cannot drift from each other */
|
|
60
60
|
import { HITL_PAUSE, HUMAN_CHECKPOINTS } from './declaredclose.js';
|
|
61
61
|
import { extractArtifact, priceOf } from './text.js';
|
|
62
|
-
import { runJob as shippedRunJob } from './run.js';
|
|
62
|
+
import { runJob as shippedRunJob, ACCOUNTED_ROUND_TYPES } from './run.js';
|
|
63
63
|
|
|
64
64
|
const require = createRequire(import.meta.url);
|
|
65
65
|
const { Loop } = require('bare-agent');
|
|
@@ -338,7 +338,8 @@ export function reuseSpecHash(resolved) {
|
|
|
338
338
|
* @param {boolean} [opts.forceCold]
|
|
339
339
|
* @param {string[]} [opts.exclude] names already tried this run
|
|
340
340
|
* @returns {Promise<any>} `{ choice, reason, called, refused, forcedCold, red, costUsd,
|
|
341
|
-
* spendComplete, candidates, listing }`
|
|
341
|
+
* spendComplete, candidates, quarantineSkips, listing }` — `quarantineSkips` names every
|
|
342
|
+
* workflow held back for want of a signer's accept, with its reason (module 6)
|
|
342
343
|
*/
|
|
343
344
|
export async function selectBridge({ registry, job, ask, provider, pinned = null, shortlist = null, forceCold = false, exclude = [] }) {
|
|
344
345
|
const r = /** @type {any} */ (registry);
|
|
@@ -346,22 +347,48 @@ export async function selectBridge({ registry, job, ask, provider, pinned = null
|
|
|
346
347
|
const skipReds = Array.isArray(r?.reds) ? r.reds : [];
|
|
347
348
|
const excluded = new Set(exclude);
|
|
348
349
|
const shortSet = Array.isArray(shortlist) ? new Set(shortlist) : null;
|
|
349
|
-
const
|
|
350
|
+
const offered = all.filter((/** @type {any} */ b) => !excluded.has(b.name) && (!shortSet || shortSet.has(b.name)));
|
|
351
|
+
|
|
352
|
+
// SOFTGREEN MODULE 6 — a HELD workflow is not offered, and the skip is stated.
|
|
353
|
+
// Its green was rendered by the judged floor and no signer has accepted it yet,
|
|
354
|
+
// so it has earned no reuse; handing it to the picker would spend the credit
|
|
355
|
+
// before it was granted. The reason travels TWO ways — into the listing the
|
|
356
|
+
// picker reads (as a distinct note, never dressed as an unreadable file) and out
|
|
357
|
+
// to the caller — because a shelf that quietly shrinks teaches the operator that
|
|
358
|
+
// the registry is empty when it is merely waiting on them.
|
|
359
|
+
/** @type {{name: string|null, reason: string}[]} */
|
|
360
|
+
const quarantineSkips = [];
|
|
361
|
+
const candidates = offered.filter((/** @type {any} */ b) => {
|
|
362
|
+
const e = reuseEligibility(b);
|
|
363
|
+
if (!e.ok) quarantineSkips.push({ name: isNonEmptyString(b.name) ? b.name : null, reason: e.reason });
|
|
364
|
+
return e.ok;
|
|
365
|
+
});
|
|
350
366
|
|
|
351
367
|
/** the shape every return here holds, so a caller never branches on field presence */
|
|
352
|
-
const base = { choice: null, reason: '', called: false, refused: false, forcedCold: false, red: null, costUsd: 0, spendComplete: true, candidates: candidates.map((/** @type {any} */ b) => b.name), listing: null, pinned };
|
|
368
|
+
const base = { choice: null, reason: '', called: false, refused: false, forcedCold: false, red: null, costUsd: 0, spendComplete: true, candidates: candidates.map((/** @type {any} */ b) => b.name), quarantineSkips, listing: null, pinned };
|
|
353
369
|
|
|
354
370
|
if (forceCold) {
|
|
355
371
|
return { ...base, forcedCold: true, reason: 'the operator forced a cold draft — no workflow was offered and no selection call was made' };
|
|
356
372
|
}
|
|
357
373
|
if (candidates.length === 0) {
|
|
358
|
-
return {
|
|
374
|
+
return {
|
|
375
|
+
...base,
|
|
376
|
+
// "nothing to reuse" and "everything is waiting on YOU" are different facts,
|
|
377
|
+
// and the empty-shelf sentence must not be said about the second
|
|
378
|
+
reason: quarantineSkips.length
|
|
379
|
+
? `every workflow this run could have started from is HELD: ${quarantineSkips.map((s) => `${s.name} (${s.reason})`).join('; ')}`
|
|
380
|
+
: (exclude.length || shortSet ? 'no workflow remains to offer (every candidate was excluded or outside the shortlist)' : 'the registry holds no reusable workflow'),
|
|
381
|
+
};
|
|
359
382
|
}
|
|
360
383
|
|
|
361
384
|
// the listing the MODEL reads is the listing a human reads — same function, same text,
|
|
362
385
|
// and the skip reds travel with it so a picker is never handed a set shorter than the
|
|
363
386
|
// directory without being told
|
|
364
|
-
const listing = renderListing({
|
|
387
|
+
const listing = renderListing({
|
|
388
|
+
ok: skipReds.length === 0 && quarantineSkips.length === 0,
|
|
389
|
+
reds: [...skipReds, ...quarantineSkips.map((s) => ({ code: QUARANTINED_CODE, path: s.name, detail: s.reason }))],
|
|
390
|
+
bridges: candidates,
|
|
391
|
+
});
|
|
365
392
|
const prompt = selectionPrompt(listing, ask, pinned);
|
|
366
393
|
|
|
367
394
|
let result;
|
|
@@ -567,6 +594,46 @@ function readWorkBranch(seen) {
|
|
|
567
594
|
return null;
|
|
568
595
|
}
|
|
569
596
|
|
|
597
|
+
/**
|
|
598
|
+
* F102 — the SIGNER'S ANSWER this window received and never paid for.
|
|
599
|
+
*
|
|
600
|
+
* Two records, two questions, and the pairing is the whole reading:
|
|
601
|
+
* `human-decision` — a decision reached this leg (with the person's own
|
|
602
|
+
* words, and when THEY said them)
|
|
603
|
+
* `human-decision-spent` — work was bought for it (a fix round, an accept that
|
|
604
|
+
* greened, a pause honoured)
|
|
605
|
+
*
|
|
606
|
+
* A decision with no spend after it is still owed to the person. That is the state
|
|
607
|
+
* F102 found unrepresentable: the resumed leg re-ran the close, arrived at the human
|
|
608
|
+
* stage and asked the byte-identical question, because "a decision is pending" and
|
|
609
|
+
* "nobody has looked yet" were the same empty checkpoint.
|
|
610
|
+
*
|
|
611
|
+
* The LAST unspent one wins — a leg emits at most one, and a window that reaches
|
|
612
|
+
* across a resume must act on the newest. `receivedAt` prefers the carried stamp (the
|
|
613
|
+
* moment the PERSON answered, forwarded leg to leg) and falls back to the record's own
|
|
614
|
+
* `ts`, which is that same moment on the leg that first heard it.
|
|
615
|
+
* @param {any[]} seen the window's own events, in file order
|
|
616
|
+
* @returns {{decision: string, text: string|null, receivedAt: string|null, source: string|null}|null}
|
|
617
|
+
*/
|
|
618
|
+
function readPendingDecision(seen) {
|
|
619
|
+
for (let i = seen.length - 1; i >= 0; i -= 1) {
|
|
620
|
+
const e = seen[i];
|
|
621
|
+
if (!isObj(e)) continue;
|
|
622
|
+
// a spend seen while walking backwards settles everything older: the decision it
|
|
623
|
+
// paid for is the newest one there is, and nothing before it can still be owed
|
|
624
|
+
if (e.type === 'human-decision-spent') return null;
|
|
625
|
+
if (e.type !== 'human-decision' || !isNonEmptyString(e.decision)) continue;
|
|
626
|
+
const at = isNonEmptyString(e.receivedAt) ? String(e.receivedAt) : (isNonEmptyString(e.ts) ? String(e.ts) : null);
|
|
627
|
+
return {
|
|
628
|
+
decision: String(e.decision),
|
|
629
|
+
text: typeof e.text === 'string' ? e.text : null,
|
|
630
|
+
receivedAt: at,
|
|
631
|
+
source: isNonEmptyString(e.source) ? String(e.source) : null,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
570
637
|
/**
|
|
571
638
|
* The dead leg's CLOSE GRADES, in order — the trend baselines a resumed leg
|
|
572
639
|
* inherits (PRD v1.46 §3, and hamr's approved #2: *a resumed run's trend judges the
|
|
@@ -653,8 +720,11 @@ function readGradeSeed(seen) {
|
|
|
653
720
|
* 4. **Which envelope this spine belongs to** — the `reuse-start` record's own
|
|
654
721
|
* approval hash, so a resume can prove it is continuing the SAME signed run.
|
|
655
722
|
*
|
|
656
|
-
* **F45 governs the arithmetic here.** A window's spend is summed from
|
|
657
|
-
* ONLY — the
|
|
723
|
+
* **F45 governs the arithmetic here.** A window's spend is summed from
|
|
724
|
+
* `ACCOUNTED_ROUND_TYPES` ONLY — the ledger's OWN list (src/run.js), imported rather
|
|
725
|
+
* than re-spelled, so a record `runJob` charges and this reader has never heard of
|
|
726
|
+
* cannot silently widen a resumed run's ceiling by exactly that amount. It was one
|
|
727
|
+
* event (`worker-round`) until the close itself could spend (`judge-round`). The selection calls
|
|
658
728
|
* are real money and are counted, but as the RUN's cost and never as a try's: a
|
|
659
729
|
* picker's tokens are not a worker's, and attributing them to a try would misstate
|
|
660
730
|
* both the fold and the row. `worker-turn` (native attribution, cost null by design)
|
|
@@ -815,7 +885,7 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
|
|
|
815
885
|
// a priced worker round outside every try window: nothing in this runner emits
|
|
816
886
|
// one, so it is a writer this reader does not model. It is COUNTED and reported
|
|
817
887
|
// rather than dropped — the blind-instrument class is what F45 is about.
|
|
818
|
-
if (ev.type
|
|
888
|
+
if (ACCOUNTED_ROUND_TYPES.includes(ev.type)) {
|
|
819
889
|
strayRounds += 1;
|
|
820
890
|
if (typeof ev.costUsd === 'number' && Number.isFinite(ev.costUsd)) strayCostUsd += ev.costUsd;
|
|
821
891
|
else selectionComplete = false;
|
|
@@ -823,8 +893,11 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
|
|
|
823
893
|
continue;
|
|
824
894
|
}
|
|
825
895
|
open.seen.push(ev);
|
|
826
|
-
if (ev.type
|
|
827
|
-
// the
|
|
896
|
+
if (ACCOUNTED_ROUND_TYPES.includes(ev.type)) {
|
|
897
|
+
// the events the live ledger accounts (src/run.js), read from ITS OWN list rather
|
|
898
|
+
// than re-spelled here: the fold is what stops a resume silently widening a signed
|
|
899
|
+
// budget, so a record the ledger charges and this reader does not know about is a
|
|
900
|
+
// ceiling that grows by exactly the close's own spend (`judge-round`, softgreen)
|
|
828
901
|
if (typeof ev.costUsd === 'number' && Number.isFinite(ev.costUsd)) open.roundsUsd += ev.costUsd;
|
|
829
902
|
else open.roundsComplete = false;
|
|
830
903
|
} else if (ev.type === 'job-end') {
|
|
@@ -1012,6 +1085,25 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
|
|
|
1012
1085
|
priorRounds: open.declaredRounds !== null && typeof windowRounds === 'number'
|
|
1013
1086
|
? open.declaredRounds + windowRounds
|
|
1014
1087
|
: null,
|
|
1088
|
+
// F102 — the DECISION this leg was given and never paid for. A person's
|
|
1089
|
+
// answer is state, and state that lives only in a process dies with it: the
|
|
1090
|
+
// paused run `msx7a3rj` took the rerun door, stopped with `iterationsUsed: 0`,
|
|
1091
|
+
// and its resume asked the byte-identical question again because the
|
|
1092
|
+
// checkpoint could not tell "pending" from "nobody has looked yet".
|
|
1093
|
+
//
|
|
1094
|
+
// The two records are the run's own (`human-decision` / `human-decision-spent`,
|
|
1095
|
+
// src/planrun.js) and the reading is the plain one: the last decision received
|
|
1096
|
+
// with nothing after it that bought it. Deliberately NOT re-derived from a
|
|
1097
|
+
// terminal or a phase — the writer says both facts, and a reader inventing a
|
|
1098
|
+
// third rule is how two instruments come to disagree.
|
|
1099
|
+
//
|
|
1100
|
+
// THIS WINDOW ONLY, unlike the plan seed and the work branch beside it. Those
|
|
1101
|
+
// are facts about the TREE and survive an attempt that died before recording
|
|
1102
|
+
// anything; a decision is a fact about a PERSON and about the tree they were
|
|
1103
|
+
// shown, and reaching back into an abandoned attempt to re-apply one could
|
|
1104
|
+
// hand a leg an answer given about a tree two attempts ago. Stated as the
|
|
1105
|
+
// narrower rule rather than discovered as a mis-applied accept.
|
|
1106
|
+
pendingDecision: readPendingDecision(open.seen),
|
|
1015
1107
|
};
|
|
1016
1108
|
if (open.bridge) tried.push(open.bridge);
|
|
1017
1109
|
}
|
|
@@ -1494,6 +1586,10 @@ export async function runReuse(opts) {
|
|
|
1494
1586
|
runid: `${runid}-t${n}`, patient, at: new Date(now()).toISOString(),
|
|
1495
1587
|
costUsd: read.spentUsd, spendComplete: read.spendComplete, wallMs, rounds,
|
|
1496
1588
|
...(trySpecHash ? { specHash: trySpecHash } : {}),
|
|
1589
|
+
// the CLASS this run was signed under, carried to the one place a green is
|
|
1590
|
+
// built (softgreen module 6). It decides whether the minted version is HELD;
|
|
1591
|
+
// `appendRed` ignores it, because a red minted nothing to hold.
|
|
1592
|
+
verdictType: job.verdictType,
|
|
1497
1593
|
};
|
|
1498
1594
|
if (outcome === 'already-green') {
|
|
1499
1595
|
// A GREEN THAT PREDATES THE RUN mints nothing, and the guard keys on the
|
|
@@ -1531,7 +1627,13 @@ export async function runReuse(opts) {
|
|
|
1531
1627
|
closeStageNames: (closeStagesOf(job) ?? []).map((/** @type {any} */ s) => s.name),
|
|
1532
1628
|
toolsUsed: [...new Set((read.plan.steps ?? []).flatMap((/** @type {any} */ s) => s.tools ?? []))],
|
|
1533
1629
|
};
|
|
1534
|
-
|
|
1630
|
+
// ONE spelling of what a green writes, shared with the U runner (2B):
|
|
1631
|
+
// `writeGreenRow` builds the write record and saves it; this leg still owns
|
|
1632
|
+
// the reporting, exactly as `commit` did when the logic lived in here.
|
|
1633
|
+
const w = writeGreenRow({ registryDir, bridge, meta, record: { ...record, plan: read.plan } });
|
|
1634
|
+
bridgeWrites.push(w);
|
|
1635
|
+
emit('bridge-write', w);
|
|
1636
|
+
row.bridgeWrite = w;
|
|
1535
1637
|
} else if (HUMAN_CHECKPOINTS.includes(outcome)) {
|
|
1536
1638
|
// A PAUSE IS NOT A RESULT, so the box gets nothing — not a casualty row, not a red,
|
|
1537
1639
|
// and the workflow is left exactly as it was. The recipe has not been judged: the
|
|
@@ -1565,89 +1667,18 @@ export async function runReuse(opts) {
|
|
|
1565
1667
|
return row;
|
|
1566
1668
|
};
|
|
1567
1669
|
|
|
1568
|
-
/** save a bridge result and record the write, whatever produced it
|
|
1670
|
+
/** save a bridge result and record the write, whatever produced it — the record
|
|
1671
|
+
* itself is built by `commitWrite` (the one spelling of the write-record shape,
|
|
1672
|
+
* shared with `writeGreenRow`); this closure only adds the reporting
|
|
1569
1673
|
* @param {{ok: boolean, reds: any[], bridge: any}} r @param {string|null} name @param {string} action
|
|
1570
1674
|
* @param {object} [extra] fields that make the write's own story visible on the record */
|
|
1571
1675
|
const commit = (r, name, action, extra = {}) => {
|
|
1572
|
-
|
|
1573
|
-
const w = { name, action: 'none', file: null, reds: r.reds, ...extra };
|
|
1574
|
-
bridgeWrites.push(w);
|
|
1575
|
-
emit('bridge-write', w);
|
|
1576
|
-
return w;
|
|
1577
|
-
}
|
|
1578
|
-
const s = saveBridge(registryDir, r.bridge);
|
|
1579
|
-
const w = { name: r.bridge.name, action: s.ok ? action : 'none', file: s.file, reds: s.reds, ...extra };
|
|
1580
|
-
bridgeWrites.push(w);
|
|
1581
|
-
emit('bridge-write', w);
|
|
1582
|
-
return w;
|
|
1583
|
-
};
|
|
1584
|
-
|
|
1585
|
-
/** a refused write, recorded exactly like a committed one so a reader never has to
|
|
1586
|
-
* branch on presence @param {string} name @param {any} red @param {object} [extra] */
|
|
1587
|
-
const refuseWrite = (name, red, extra = {}) => {
|
|
1588
|
-
const w = { name, action: 'none', file: null, reds: [red], ...extra };
|
|
1676
|
+
const w = commitWrite(registryDir, r, name, action, extra);
|
|
1589
1677
|
bridgeWrites.push(w);
|
|
1590
1678
|
emit('bridge-write', w);
|
|
1591
1679
|
return w;
|
|
1592
1680
|
};
|
|
1593
1681
|
|
|
1594
|
-
/**
|
|
1595
|
-
* KNOWN LIMIT, named rather than discovered later: `appendGreen` carries the entry's
|
|
1596
|
-
* LABEL (`goal`, `closeStageNames`, `toolsUsed`) forward untouched, so a new version
|
|
1597
|
-
* whose plan uses a verb the founding green did not is not reflected in `toolsUsed`.
|
|
1598
|
-
* The load gate reads that field, so the gate's verb check goes slightly stale across
|
|
1599
|
-
* versions. It is not a safety hole — the gate only ever gets more permissive there,
|
|
1600
|
-
* and the TWEAKED plan still passes the full `validatePlan` against the job's signed
|
|
1601
|
-
* menu at draft time, which is the check that actually decides. Fixing it properly
|
|
1602
|
-
* means `appendGreen` taking a label update, which is module 1's contract and hamr's
|
|
1603
|
-
* call, not something to change from the caller.
|
|
1604
|
-
* @param {any|null} bridge @param {any} meta @param {any} record
|
|
1605
|
-
*/
|
|
1606
|
-
function writeGreen(bridge, meta, record) {
|
|
1607
|
-
// the BRIDGE leg needs no shape check: `runJob`'s own load gate already proved this
|
|
1608
|
-
// entry is the same kind of recipe as this job, at the door, before a token was spent
|
|
1609
|
-
// (a mismatch never gets here — it returns `recipe-stale`).
|
|
1610
|
-
if (bridge) return commit(appendGreen(bridge, record), bridge.name, 'appendGreen');
|
|
1611
|
-
|
|
1612
|
-
// the COLD leg's green. `mintBridge` is for a name nobody holds; a name that already
|
|
1613
|
-
// holds greens APPENDS, because a cold run of a job that already has a bridge is
|
|
1614
|
-
// another version of that workflow, and overwriting it would destroy the green that
|
|
1615
|
-
// minted it.
|
|
1616
|
-
//
|
|
1617
|
-
// But the name is the JOB's slug, and a job's CLOSE can be re-signed under that same
|
|
1618
|
-
// slug. Appending across two close shapes does two wrong things at once: it counts a
|
|
1619
|
-
// green another close rendered toward this entry's distinct-patient status (a false
|
|
1620
|
-
// `proven`), and it hands the load gate — which matches on exactly these stage names
|
|
1621
|
-
// — a plan that satisfied a different verification. So the shape is checked here with
|
|
1622
|
-
// the gate's own predicate, and a mismatch neither appends NOR discards the green: it
|
|
1623
|
-
// forks to a deterministic derived name, with the fork ON the record.
|
|
1624
|
-
const stageNames = Array.isArray(meta.closeStageNames) ? meta.closeStageNames : [];
|
|
1625
|
-
const entries = loadRegistry(registryDir).bridges;
|
|
1626
|
-
const existing = entries.find((/** @type {any} */ b) => b.name === meta.name);
|
|
1627
|
-
if (existing && sameCloseShape(existing, stageNames)) return commit(appendGreen(existing, record), meta.name, 'appendGreen');
|
|
1628
|
-
|
|
1629
|
-
const forked = existing !== undefined;
|
|
1630
|
-
const target = forked ? shapeForkName(meta.name, stageNames) : meta.name;
|
|
1631
|
-
/** the fork's own story, carried on every write record it produces */
|
|
1632
|
-
const mark = forked ? { shapeForked: true, forkedFrom: meta.name, closeStageNames: [...stageNames] } : {};
|
|
1633
|
-
const held = forked ? entries.find((/** @type {any} */ b) => b.name === target) : undefined;
|
|
1634
|
-
if (held) {
|
|
1635
|
-
// the derived name is derived FROM the shape, so a readable entry there matches by
|
|
1636
|
-
// construction; a mismatch would mean two different closes hashed the same, and
|
|
1637
|
-
// that is refused rather than appended blind
|
|
1638
|
-
return sameCloseShape(held, stageNames)
|
|
1639
|
-
? commit(appendGreen(held, record), target, 'appendGreen-shape-forked', mark)
|
|
1640
|
-
: refuseWrite(target, { code: 'shape-fork-collision', path: `${target}.json`, detail: `the derived name for close [${stageNames.join(' → ')}] is already held by an entry of a different shape — refusing to append this green to a recipe another close greened` }, mark);
|
|
1641
|
-
}
|
|
1642
|
-
// A file of that name that `loadRegistry` could not READ is not an absent entry — it
|
|
1643
|
-
// is an entry nobody can see, and minting over it destroys whatever greens it held.
|
|
1644
|
-
// `loadRegistry` skips-and-reports for that reason; the mint must not undo the skip.
|
|
1645
|
-
if (existsSync(join(registryDir, `${target}.json`))) {
|
|
1646
|
-
return refuseWrite(target, { code: 'mint-collision', path: `${target}.json`, detail: 'a file of this name exists but could not be read as a bridge — refusing to mint over it, because whatever greens it holds are not recoverable once overwritten. Repair or move it, then re-run.' }, mark);
|
|
1647
|
-
}
|
|
1648
|
-
return commit(mintBridge({ ...meta, name: target }, record), target, forked ? 'mint-shape-forked' : 'mint', mark);
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
1682
|
/**
|
|
1652
1683
|
* The stops no further try can change. A spec nobody signed, a spec that will not
|
|
1653
1684
|
* validate, and a degraded primitive are properties of the RUN, not of the recipe — the
|
|
@@ -1908,7 +1939,10 @@ export async function runReuse(opts) {
|
|
|
1908
1939
|
});
|
|
1909
1940
|
selection.push({ n, ...sel, listing: undefined });
|
|
1910
1941
|
account(sel.costUsd, sel.spendComplete);
|
|
1911
|
-
|
|
1942
|
+
// `quarantineSkips` rides the spine beside the candidates for the same reason it rides
|
|
1943
|
+
// the listing: a shelf that quietly shrank is indistinguishable from an empty one, and
|
|
1944
|
+
// "held, waiting on you" is the fact the operator needs to see (softgreen module 6)
|
|
1945
|
+
emit('selection-result', { n, choice: sel.choice, reason: sel.reason, called: sel.called, refused: sel.refused, costUsd: sel.costUsd, candidates: sel.candidates, quarantineSkips: sel.quarantineSkips });
|
|
1912
1946
|
|
|
1913
1947
|
if (sel.refused) {
|
|
1914
1948
|
return done('selection-refused', {
|
|
@@ -1962,3 +1996,199 @@ export async function runReuse(opts) {
|
|
|
1962
1996
|
+ ` close ${t.closeReached ? 'reached' : 'NEVER REACHED'}`).join('\n'),
|
|
1963
1997
|
});
|
|
1964
1998
|
}
|
|
1999
|
+
|
|
2000
|
+
// ── the GREEN's registry write, shared by both runners ──────────────────────
|
|
2001
|
+
|
|
2002
|
+
/** save a bridge result and BUILD the write record — the ONE spelling of the
|
|
2003
|
+
* write-record shape (`{name, action, file, reds, …extra}`), used by `writeGreenRow`
|
|
2004
|
+
* below AND by `runReuse`'s own red/casualty `commit`, so a field added to one can
|
|
2005
|
+
* never silently miss the other. Reporting (push + emit) stays each caller's.
|
|
2006
|
+
* @param {string} registryDir @param {{ok: boolean, reds: any[], bridge: any}} r
|
|
2007
|
+
* @param {string|null} name @param {string} action @param {object} [extra] */
|
|
2008
|
+
function commitWrite(registryDir, r, name, action, extra = {}) {
|
|
2009
|
+
if (!r.ok) return { name, action: 'none', file: null, reds: r.reds, ...extra };
|
|
2010
|
+
const s = saveBridge(registryDir, r.bridge);
|
|
2011
|
+
return { name: r.bridge.name, action: s.ok ? action : 'none', file: s.file, reds: s.reds, ...extra };
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
/**
|
|
2015
|
+
* WHAT A GREEN WRITES, in ONE place. Lifted verbatim out of `runReuse`'s own
|
|
2016
|
+
* closure when the U runner needed it too (2B) — two spellings of "what a green
|
|
2017
|
+
* writes" would be the two-transforms class applied to the ledger itself, which is
|
|
2018
|
+
* the failure `greenParts` exists one level down to prevent.
|
|
2019
|
+
*
|
|
2020
|
+
* Two legs, and the split is which caller already holds an entry:
|
|
2021
|
+
* - A BRIDGE IN HAND (the reuse leg) APPENDS. No shape check is needed: `runJob`'s
|
|
2022
|
+
* load gate already proved at the door, before a token, that this entry is the
|
|
2023
|
+
* same kind of recipe as this job (a mismatch returns `recipe-stale` and never
|
|
2024
|
+
* gets here).
|
|
2025
|
+
* - NO BRIDGE (the cold leg, and every U run) mints — unless a name already holds
|
|
2026
|
+
* greens, in which case it APPENDS, because a cold run of a job that already has
|
|
2027
|
+
* a bridge is another version of that workflow and overwriting it would destroy
|
|
2028
|
+
* the green that minted it.
|
|
2029
|
+
*
|
|
2030
|
+
* But the name is the JOB's slug, and a job's CLOSE can be re-signed under that same
|
|
2031
|
+
* slug. Appending across two close shapes does two wrong things at once: it counts a
|
|
2032
|
+
* green another close rendered toward this entry's distinct-patient status (a false
|
|
2033
|
+
* `proven`), and it hands the load gate — which matches on exactly these stage names
|
|
2034
|
+
* — a plan that satisfied a different verification. So the shape is checked with the
|
|
2035
|
+
* gate's own predicate, and a mismatch neither appends NOR discards the green: it
|
|
2036
|
+
* forks to a deterministic derived name, with the fork ON the record.
|
|
2037
|
+
*
|
|
2038
|
+
* THE HOLD IS NOT DECIDED HERE. `quarantined` comes off `greenParts` reading the
|
|
2039
|
+
* class the run was signed under (`record.verdictType`, module 6), so a caller that
|
|
2040
|
+
* forgets to pass it mints an UNHELD green rather than a held one — which is why
|
|
2041
|
+
* both callers pass the spec's own field and never a literal.
|
|
2042
|
+
*
|
|
2043
|
+
* KNOWN LIMIT, named rather than discovered later: `appendGreen` carries the entry's
|
|
2044
|
+
* LABEL (`goal`, `closeStageNames`, `toolsUsed`) forward untouched, so a new version
|
|
2045
|
+
* whose plan uses a verb the founding green did not is not reflected in `toolsUsed`.
|
|
2046
|
+
* The load gate reads that field, so the gate's verb check goes slightly stale across
|
|
2047
|
+
* versions. It is not a safety hole — the gate only ever gets more permissive there,
|
|
2048
|
+
* and the TWEAKED plan still passes the full `validatePlan` against the job's signed
|
|
2049
|
+
* menu at draft time, which is the check that actually decides. Fixing it properly
|
|
2050
|
+
* means `appendGreen` taking a label update, which is module 1's contract and hamr's
|
|
2051
|
+
* call, not something to change from the caller.
|
|
2052
|
+
*
|
|
2053
|
+
* Returns the WRITE RECORD both runners report — never throws, and a refusal is a
|
|
2054
|
+
* record with `action: 'none'` and named reds rather than an absence a reader has to
|
|
2055
|
+
* branch on.
|
|
2056
|
+
* @param {{registryDir: string, bridge?: any|null, meta: any, record: any}} o
|
|
2057
|
+
* @returns {{name: string|null, action: string, file: string|null, reds: any[], [k: string]: any}}
|
|
2058
|
+
*/
|
|
2059
|
+
export function writeGreenRow({ registryDir, bridge = null, meta, record }) {
|
|
2060
|
+
/** the shared record builder, with this write's registry bound in
|
|
2061
|
+
* @param {{ok: boolean, reds: any[], bridge: any}} r @param {string|null} name
|
|
2062
|
+
* @param {string} action @param {object} [extra] */
|
|
2063
|
+
const commit = (r, name, action, extra = {}) => commitWrite(registryDir, r, name, action, extra);
|
|
2064
|
+
/** a refused write, recorded exactly like a committed one @param {string} name
|
|
2065
|
+
* @param {any} red @param {object} [extra] */
|
|
2066
|
+
const refuseWrite = (name, red, extra = {}) => ({ name, action: 'none', file: null, reds: [red], ...extra });
|
|
2067
|
+
|
|
2068
|
+
if (bridge) return commit(appendGreen(bridge, record), bridge.name, 'appendGreen');
|
|
2069
|
+
|
|
2070
|
+
const stageNames = Array.isArray(meta.closeStageNames) ? meta.closeStageNames : [];
|
|
2071
|
+
const entries = loadRegistry(registryDir).bridges;
|
|
2072
|
+
const existing = entries.find((/** @type {any} */ b) => b.name === meta.name);
|
|
2073
|
+
if (existing && sameCloseShape(existing, stageNames)) return commit(appendGreen(existing, record), meta.name, 'appendGreen');
|
|
2074
|
+
|
|
2075
|
+
const forked = existing !== undefined;
|
|
2076
|
+
const target = forked ? shapeForkName(meta.name, stageNames) : meta.name;
|
|
2077
|
+
/** the fork's own story, carried on every write record it produces */
|
|
2078
|
+
const mark = forked ? { shapeForked: true, forkedFrom: meta.name, closeStageNames: [...stageNames] } : {};
|
|
2079
|
+
const held = forked ? entries.find((/** @type {any} */ b) => b.name === target) : undefined;
|
|
2080
|
+
if (held) {
|
|
2081
|
+
// the derived name is derived FROM the shape, so a readable entry there matches by
|
|
2082
|
+
// construction; a mismatch would mean two different closes hashed the same, and
|
|
2083
|
+
// that is refused rather than appended blind
|
|
2084
|
+
return sameCloseShape(held, stageNames)
|
|
2085
|
+
? commit(appendGreen(held, record), target, 'appendGreen-shape-forked', mark)
|
|
2086
|
+
: refuseWrite(target, { code: 'shape-fork-collision', path: `${target}.json`, detail: `the derived name for close [${stageNames.join(' → ')}] is already held by an entry of a different shape — refusing to append this green to a recipe another close greened` }, mark);
|
|
2087
|
+
}
|
|
2088
|
+
// A file of that name that `loadRegistry` could not READ is not an absent entry — it
|
|
2089
|
+
// is an entry nobody can see, and minting over it destroys whatever greens it held.
|
|
2090
|
+
// `loadRegistry` skips-and-reports for that reason; the mint must not undo the skip.
|
|
2091
|
+
if (existsSync(join(registryDir, `${target}.json`))) {
|
|
2092
|
+
return refuseWrite(target, { code: 'mint-collision', path: `${target}.json`, detail: 'a file of this name exists but could not be read as a bridge — refusing to mint over it, because whatever greens it holds are not recoverable once overwritten. Repair or move it, then re-run.' }, mark);
|
|
2093
|
+
}
|
|
2094
|
+
return commit(mintBridge({ ...meta, name: target }, record), target, forked ? 'mint-shape-forked' : 'mint', mark);
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
/**
|
|
2098
|
+
* THE U RUNNER'S TERMINAL WRITE (2B) — does the run that just ended earn a registry
|
|
2099
|
+
* row, and if so, write it.
|
|
2100
|
+
*
|
|
2101
|
+
* `scripts/run-u.mjs` has always kept standalone bridge FILES and never a registry,
|
|
2102
|
+
* so its review door could describe a held learning credit it had no row to release:
|
|
2103
|
+
* `--decide accept` reached `recordDoor`, found no green row for the runid and died
|
|
2104
|
+
* `no-row-for-run`. This is the row, and STORAGE IS ALL IT IS — selection, promotion
|
|
2105
|
+
* and reuse execution stay parked (layer-3-reuse). Nothing here reads a row back.
|
|
2106
|
+
*
|
|
2107
|
+
* FOUR REFUSALS, each one a guard that already existed somewhere and is kept:
|
|
2108
|
+
* - NO REGISTRY, no row. The registry path is operator-supplied and never
|
|
2109
|
+
* conjured; without one the door says, in words, that nothing was released.
|
|
2110
|
+
* - ALREADY-GREEN mints nothing. The close was satisfied before this run did
|
|
2111
|
+
* anything, so nothing here earned a version — the reuse-credit leak block, from
|
|
2112
|
+
* the writing side rather than the door's.
|
|
2113
|
+
* - A GREEN WITH NO PLAN mints nothing. The artifact that inherits is the one that
|
|
2114
|
+
* RAN, and there is none (R1).
|
|
2115
|
+
* - A CLASS WHOSE CREDIT IS NOT HELD mints nothing HERE. That is `green` today, and
|
|
2116
|
+
* it is main's behaviour kept rather than a new rule: this runner has never
|
|
2117
|
+
* written a registry row for a green-class run, and widening what enters the
|
|
2118
|
+
* registry is a reuse-rung question, not a side effect of fixing a door. The
|
|
2119
|
+
* predicate is `quarantinesCredit` — the ONE spelling of "is this class held" —
|
|
2120
|
+
* so the row this runner mints and the row a door releases can never disagree
|
|
2121
|
+
* about which classes they are talking about.
|
|
2122
|
+
*
|
|
2123
|
+
* The row is born HELD for exactly the reason any judged green is: `greenParts` reads
|
|
2124
|
+
* `verdictType` off the record. Nothing here sets the flag.
|
|
2125
|
+
* @param {{registryDir: string|null|undefined, job: any, name: string|null,
|
|
2126
|
+
* outcome: string, plan: any, record: any}} o
|
|
2127
|
+
* @returns {{minted: boolean, reason: string|null, write: any|null}}
|
|
2128
|
+
*/
|
|
2129
|
+
export function writeRunGreenRow({ registryDir, job, name, outcome, plan, record }) {
|
|
2130
|
+
if (!isNonEmptyString(registryDir)) return { minted: false, reason: 'no-registry', write: null };
|
|
2131
|
+
if (outcome !== 'green') {
|
|
2132
|
+
return { minted: false, reason: outcome === 'already-green' ? 'green-predates-run' : 'not-green', write: null };
|
|
2133
|
+
}
|
|
2134
|
+
if (!isObj(plan)) return { minted: false, reason: 'no-plan-executed', write: null };
|
|
2135
|
+
if (!quarantinesCredit(job?.verdictType)) return { minted: false, reason: 'credit-not-held', write: null };
|
|
2136
|
+
|
|
2137
|
+
const target = isNonEmptyString(name) ? name : job.job;
|
|
2138
|
+
const meta = {
|
|
2139
|
+
name: target,
|
|
2140
|
+
goal: job.goal,
|
|
2141
|
+
specHash: record?.specHash ?? null,
|
|
2142
|
+
closeStageNames: (closeStagesOf(job) ?? []).map((/** @type {any} */ s) => s.name),
|
|
2143
|
+
toolsUsed: [...new Set((plan.steps ?? []).flatMap((/** @type {any} */ s) => s.tools ?? []))],
|
|
2144
|
+
};
|
|
2145
|
+
const write = writeGreenRow({
|
|
2146
|
+
registryDir,
|
|
2147
|
+
bridge: null,
|
|
2148
|
+
meta,
|
|
2149
|
+
// the class the run was SIGNED under, from the spec — never a literal, so the
|
|
2150
|
+
// hold and the class can never drift apart
|
|
2151
|
+
record: { ...record, plan, verdictType: job.verdictType },
|
|
2152
|
+
});
|
|
2153
|
+
return { minted: write.action !== 'none', reason: write.action === 'none' ? 'write-refused' : null, write };
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// ── the REVIEW DOOR's registry half (softgreen module 6) ────────────────────
|
|
2157
|
+
|
|
2158
|
+
/**
|
|
2159
|
+
* Apply a signer's door decision to the workflow a run wrote its row against:
|
|
2160
|
+
* record the disposition, and — on `accept` over a HELD judged green — release the
|
|
2161
|
+
* learning credit that green has been carrying unspent (PRD v1.71 §3).
|
|
2162
|
+
*
|
|
2163
|
+
* This is the ONE seam between a person's answer and the box. It is deliberately
|
|
2164
|
+
* separate from `runReuse`: the door opens AFTER the run has ended (a green run is
|
|
2165
|
+
* over, non-blocking, and the person may answer minutes or days later), so the
|
|
2166
|
+
* decision cannot ride the run's own return path. The runner calls this with the
|
|
2167
|
+
* runid it printed at the door.
|
|
2168
|
+
*
|
|
2169
|
+
* Never throws and never conjures: a missing registry, a missing entry, a
|
|
2170
|
+
* malformed one and a runid with no green row all come back as named reds. The
|
|
2171
|
+
* loop's verdict is untouched in every branch — this writes a disposition, never a
|
|
2172
|
+
* result.
|
|
2173
|
+
*
|
|
2174
|
+
* @param {object} opts
|
|
2175
|
+
* @param {string} opts.registryDir the operator-supplied registry directory
|
|
2176
|
+
* @param {string} opts.name the workflow the run wrote to
|
|
2177
|
+
* @param {string} opts.runid the RUN's id, exactly as the bridge row records it
|
|
2178
|
+
* @param {string} opts.decision one of the three doors
|
|
2179
|
+
* @param {string|null} [opts.at] when the person answered
|
|
2180
|
+
* @returns {{ok: boolean, reds: any[], released: boolean, file: string|null}}
|
|
2181
|
+
*/
|
|
2182
|
+
export function applyDoorDecision({ registryDir, name, runid, decision, at = null }) {
|
|
2183
|
+
if (!isNonEmptyString(registryDir) || !registryExists(registryDir)) {
|
|
2184
|
+
return { ok: false, reds: [{ code: 'registry-missing', path: String(registryDir), detail: 'the registry path is operator-supplied and is never conjured' }], released: false, file: null };
|
|
2185
|
+
}
|
|
2186
|
+
if (!isNonEmptyString(name)) return { ok: false, reds: [{ code: 'invalid-value', path: 'name', detail: 'the workflow this run wrote its row against' }], released: false, file: null };
|
|
2187
|
+
const file = join(registryDir, `${name}.json`);
|
|
2188
|
+
const loaded = loadBridge(file);
|
|
2189
|
+
if (!loaded.ok) return { ok: false, reds: loaded.reds, released: false, file: null };
|
|
2190
|
+
const r = recordDoor(loaded.bridge, { runid, decision, at });
|
|
2191
|
+
if (!r.ok) return { ok: false, reds: r.reds, released: false, file: null };
|
|
2192
|
+
const s = saveBridge(registryDir, r.bridge);
|
|
2193
|
+
return { ok: s.ok, reds: s.reds, released: s.ok && r.released, file: s.file };
|
|
2194
|
+
}
|