bareloop 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
+ ## [0.47.0] - 2026-10-24
9
+
10
+ ### Added
11
+
12
+ - **`bareloop` is a launcher (PANEL-BUILD.md addendum L, hamr 2026-10-24).** Bare `bareloop` shows a menu (`1 start 2 stop 3 status 4 install q quit`) under two header lines (installed or not, panel running or not, `runs: N running · N need you · N in all`); the same four work as `bareloop start | stop | status | install`. No pid file: the panel is found by asking ports 4700-4709 for the new read-only `GET /api/whoami`. `start` runs the panel detached on the first free port (log: `~/.config/bareloop/panel.log`) and opens the page; `stop` stops every panel it finds (one panel at a time; no `--port`, no which-one question), warns about open drafts summed over them and asks (`--yes` skips); `status` prints `Panel not running.` and exits 3 when no panel runs (0 from the menu); `install` creates only what is missing. The old `1 export / 2 run / 3 history` menu is gone (the sub-commands stay). `bareloop panel [--port N]` is unchanged. Coloured on a terminal (off when piped, `NO_COLOR` set or `TERM=dumb`). `status` and the header show the running panel's version, with a stop-and-start note when it differs from the CLI's. The header's `need you` count matches the panel's Needs-you list, schedules that gave up included. `GET /api/whoami` (app, version, pid, port, drafts, schedules) sits behind the Host guard and needs no token. The panel address has one owner (`panelUrl`); no address literal lives in the launcher, and the OS opener is shared with Settings.
13
+
14
+ ### Changed
15
+
16
+ - **A rubric run's lessons count at run end, like a deterministic run's (hamr 2026-10-24, option A).** `foldRunIntoLedger` no longer holds a `rubric` run's cards in `pending` until the human Accept: they go through `countLedger` now, FIXED and NEVER FIXED alike, with `trust: 'rubric'` kept on new rows; `held` is always 0. A hold already in a ledger file (written by v0.46.x) still releases on the door Accept. Reuse and bridge credit is unchanged and still waits for the Accept. Noise runs stay dropped.
17
+
18
+ ### Fixed
19
+
20
+ - **A resumed leg updates its own run's ledger row.** `countLedger` skipped a row that already held the run id, so a NEVER FIXED lesson from leg 1 stayed NEVER FIXED when leg 2 (same run id) fixed it. The row is now refreshed from the new card (sample, lesson, key, lastSeen, hash, place) while its count, model tally and run ids stay one per run; an older run's cards re-folded later never step a row back. Found by fwdloop.
21
+
22
+ ## [0.46.1] - 2026-10-24
23
+
24
+ ### Fixed
25
+
26
+ - **A blank Base URL in the New job readiness line reads "Type a Base URL."** It showed `[✗] no-base-url — may be flaky, not blocking`; it now shows the server's own sentence (owned by `src/modellist.js`).
27
+
28
+ - **The Audit learnings header counts distinct hints across the draft and the first replan.** It counted only the draft block (3 at draft, 5 at replan read "3 hints"). A hint handed at both draft and replan counts once (identity = ledger row id).
29
+
30
+ - **A scheduled fire refused at the run-start fetch shows its status word on the schedule card.** The card read `last 10:30 · run-4` with no word because the run list's events were not passed to `summarizeRow`; the refused row now carries its `failed` status.
31
+
32
+ - **Every drafting stop reads in plain words from one table.** `confirm-abandoned` and `confirm-restart` printed `Stopped: <code>`, and a code outside an 8-entry table fell back to `Drafting stopped (<code>).`. `STOP_HEADS` (with `STOP_TODO` where "draft again" is wrong) in `src/authorreadout.js` now covers about 20 stop codes and `authorsession` routes every stop through `draftStopText`; an unknown code gets a generic sentence, never the raw code. The confirm-abandoned stop now ends "nothing more was spent" (it promised a figure that reads "unknown" when spend is incomplete). Wording signed by hamr 2026-10-23.
33
+
34
+ - **One `isReusableGreen` decides every Reuse-eligibility check.** The picker, `getStartFrom`, the start gate and prefill flag in `authorroutes`, and the Ended block each spelled it their own way, and the Ended block still treated an already-green as not reusable. An already-green now ends "Nothing to do." like a green, and a scheduled already-green run is held for Accept like a green. Scheduling itself still needs a real green (`greenFor` in `src/schedules.js` is unchanged).
35
+
36
+ - **A watched-folder file whose name holds a line break or ends in whitespace is refused.** A newline in the name became extra Inputs lines in the fire's card text, and a trailing space trimmed to a path that does not exist. `perFireSpec` now refuses it through the ordinary refused-fire path ("Rename the file. Nothing started."; one refused row, no session) and never rewrites the name. A leading space is harmless and not refused.
37
+
38
+ - **`scripts/hermetic.mjs` refuses a test path that does not exist.** A mistyped path was skipped and the run exited 0, so a green count could miss a file. A path-looking argument that does not exist now exits 1 with a plain message and runs nothing; `npm test` with no arguments, flags and globs are untouched. The value of a value-taking flag (`--test-name-pattern "a/b"`, `--test-reporter`, `--import`, `-r`, ...) is never checked as a path; a boolean flag followed by a mistyped path is still refused.
39
+
40
+ ### Changed
41
+
42
+ - **Linear-time trailing trims in the reuse-preprobe `normPath` (`scripts/`, not shipped).** The `[.,;:)\]}'"`]+$` and `\/+$` trims were the quadratic class (about 1.2 s CPU on 30000 trailing dots); same results on 1.5M random strings, 0 mismatches.
43
+
44
+ - **`stageQuestionText`'s JSDoc now sits on the function, not on `GENRE_WHAT`.** Comments and declaration order only.
45
+
46
+ - **Tests strengthened (test-only, no source change).** A seeded differential test pins the linear `stringLiteralsOf` and `staticImportsOf` scanners against the old regexes; `buildCards`' card-level secret mask, the judged-strike trend number (read from the real `runDeclaredStages`), the Revise-at-menu spend figure (waits for the unwind, runs a second draft), and money-as-read in symbol-first and far-apart layouts each have a test that fails on a mutant.
47
+
8
48
  ## [0.46.0] - 2026-10-23
9
49
 
10
50
  ### Added
package/README.md CHANGED
@@ -68,6 +68,8 @@ building it.
68
68
  npm install bareloop
69
69
  ```
70
70
 
71
+ Quick start: `npm install -g bareloop`, then `bareloop` — a menu to start the panel, stop it, see its status, and set up `~/.config/bareloop/`.
72
+
71
73
  ## Usage
72
74
 
73
75
  A signed job spec goes in. A verdict comes out.
@@ -627,7 +627,7 @@ a step, or test it without a provider.
627
627
  `error: 'truncated:max_tokens'`, neutral `stopReason: 'max_tokens'`) is a FAILED TRY on `askStructured`'s retry ladder with the
628
628
  reason "the AI's answer was cut off at the output limit" (`axis: 'output-truncated'`) — NOT `provider-red`; all attempts stay
629
629
  booked, `AUTHOR_MAX_TOKENS` is never raised, and only a real transport failure stays `provider-red`. `draftStopText`
630
- (`src/authorreadout.js`) words any drafting stop that has no wording of its own; the panel puts it on the failed step. (2)
630
+ (`src/authorreadout.js`) words EVERY drafting stop from one table (`STOP_HEADS` for the sentence, `STOP_TODO` where "draft again" is the wrong advice; a code the table lacks reads one generic sentence, never the raw code); the panel puts it on the failed step. (2)
631
631
  `authorClose({ offerQuestions })` (default `false`; the CLI never sets it) offers the optional `questions` property on the LAST
632
632
  try of the full ladder only, and only when the two tries before it both failed — validation OR the seed read (hamr ruled 2026-10-10; this DIFFERS
633
633
  from fwdloop's validation-only rule): a seed-read failure is a work stage that instrument-stopped or a close with no work stage
@@ -3074,7 +3074,9 @@ Enforcement is **local only** — wired into `npm test` (see `package.json`'s `t
3074
3074
  script), never into `.github/workflows/*` (CI already runs `npm test`, so the rule is
3075
3075
  enforced there without an ask-first CI edit). `npm test` itself now runs `node --test`
3076
3076
  through `scripts/hermetic.mjs`, which strips this machine's `HOME`/git identity so a
3077
- test that leans on either fails locally instead of only on CI (F136). The check itself lives outside the
3077
+ test that leans on either fails locally instead of only on CI (F136). It also refuses a
3078
+ test path that does not exist (`node --test` skips one silently, so a mistyped file could hide
3079
+ behind a green count); the separate value of a value-taking flag is never read as a path. The check itself lives outside the
3078
3080
  published package, at `scripts/prompt-commit-check.mjs` (pure decision logic in
3079
3081
  `scripts/promptcommitlib.mjs`, `scripts/` is not in `package.json`'s `files`, so none of
3080
3082
  this ships):
@@ -3289,16 +3291,35 @@ real key check is skipped entirely — a scripted provider *is* the run, the
3289
3291
  same seam `tests/planrun-*.test.js` uses. `deps` also accepts `env`, `stdout`, `stderr`, `cwd`,
3290
3292
  `now`, `stdin` — omit any of them and the real `process.*` equivalent is used.
3291
3293
 
3292
- Bare `bareloop` (no sub-command) prints a numbered menu — `1 export 2 run 3 history q
3293
- quit` — and asks each sub-command's arguments one line at a time over the SAME code paths
3294
- below. `run-u`, `interview` and `author` (below) are not on this menu (PANEL-BUILD.md P0
3295
- tasks 2/4-4/4): each one's own flag grammar is too wide for a line-at-a-time wizard, and the
3296
- menu's job is to ask the SAME questions the sub-command below already answers, never to
3297
- invent a new interview — this is doubly true for `interview`/`author`, which already ARE
3298
- interviews of their own. Choice `2 run` is handed the SAME resolved keys home `bareloop run <bundle>` gets, so the
3299
- monthly limit, your price and the key-row choice apply exactly as on the sub-command (before this it
3300
- got none, and a run started from the menu skipped the limit). All three are dispatched by name only: `bareloop run-u <flags…>`,
3301
- `bareloop interview <flags…>`, `bareloop author <flags…>`.
3294
+ Bare `bareloop` (no sub-command) is the **launcher menu** (PANEL-BUILD.md addendum L, `src/launcher.js`): two header lines, then
3295
+ `1 start 2 stop 3 status 4 install q quit`. The header is printed first by EVERY call (menu and each of
3296
+ `bareloop start | stop | status | install`), from one function:
3297
+
3298
+ ```
3299
+ bareloop 0.47.0 · installed ✓ · panel running at http://127.0.0.1:4701
3300
+ runs: 1 running · 2 need you · 375 in all
3301
+ ```
3302
+
3303
+ Colour (`src/tty-colour.js`): on only when stdout is a terminal, `NO_COLOR` is empty/unset and `TERM` is not `dumb`; it wraps words and never
3304
+ changes them, and a piped or `NO_COLOR` run prints the plain words above (the thin `─` rules around the header appear only with colour).
3305
+
3306
+ - `installed ✓` = `~/.config/bareloop/` exists, `config.json` is there and reads with no problem, `.env` exists with mode 600, node >= 22;
3307
+ otherwise `not installed — <first missing thing>, pick 4 install`. The run counts use the panel's own status words and Needs-you rule over the
3308
+ run list file ($0, works with the panel stopped).
3309
+ - **No pid file.** The panel is found by asking `127.0.0.1:4700..4709` for `GET /api/whoami` (300 ms each, in parallel); a reply with `app: "bareloop"` is a panel.
3310
+ `GET /api/whoami` → `{ app: "bareloop", version, pid, port, drafts, schedules }` (open drafting sessions, jobs with a schedule), read-only, same Host guard as every
3311
+ route, no token.
3312
+ - **`bareloop start`** → already running: says so, opens the page, starts nothing (if the running panel's version differs from this bareloop's it adds `It is version X; this bareloop is Y. Run bareloop stop, then bareloop start, to use Y.`). Else starts `bareloop panel --port N` DETACHED (`setsid`; log appended to
3313
+ `~/.config/bareloop/panel.log`) on the first free port from 4700 (a taken port is skipped and named), waits up to 10 s for it to answer, opens the page with
3314
+ `xdg-open`/`open` (no opener: prints the URL). `bareloop panel [--port N]` is unchanged (foreground, fixed port).
3315
+ - **`bareloop stop [--yes]`** → SIGTERM to the pid of EVERY panel the probe finds (one panel at a time; no which-one question), waits until each port is quiet. Open drafts (in memory, summed over all found panels) are warned about and a y/N is asked;
3316
+ `--yes` skips it. Not running: `Panel not running.`, exit 0. Several found: all stopped, `N panels stopped.`. Any other option (e.g. `--port`) is refused in plain words. Runs are separate processes and keep going.
3317
+ - **`bareloop status`** → header plus `pid N · version X · N draft(s) open · N schedule(s) on`. Exit 0 running, 3 not running. A panel whose version differs from the CLI's shows `(version X — stop and start to use Y)` after its URL in the header (yellow on a terminal).
3318
+ - **`bareloop install`** → creates only what is missing (folder mode 700, `.env` with the keys-file comment lines, mode 600, `config.json`); never reads a key value;
3319
+ `Already installed, nothing to do.` when there is nothing to add. Needs node >= 22.
3320
+
3321
+ The old `1 export 2 run 3 history` menu is gone; `bareloop export|run|history` and the other sub-commands are unchanged. `run-u`, `interview` and `author` are dispatched
3322
+ by name only: `bareloop run-u <flags…>`, `bareloop interview <flags…>`, `bareloop author <flags…>`.
3302
3323
 
3303
3324
  When two key rows share one provider and endpoint, a CLI run uses the FIRST row in the keys list, so put the
3304
3325
  key you want first (the panel picks the row by the Model menu).
@@ -3888,8 +3909,8 @@ No model is called anywhere in learnings (a test walks the import closure). Pure
3888
3909
  - **Run end:** `execute` (`src/userrun.js`, shared by the panel, the CLI and the bundle runner) calls `learnAtRunEnd({ home, runid, events, auditFiles, spec, specHash, workdir, trust })` once, right after the claim settles, in its own try/catch: a failure prints one WARNING and never changes the verdict, exit code or spend. It writes nothing when the engine has no config home.
3889
3910
  - **Cards:** `buildCards({ runid, events, audit, workdir, spec, model, questions })` → `{ cards, suspect, thin, noise }`. FIXED = a check red then the same check passes later in the run; NEVER FIXED = still red at run end. Excluded and counted: noise runs (provider-red, close-crashed, worker-crash, no close spoke), CHECK-SUSPECT (a no-suppressions hit on a prose comment), TOO-THIN (no error kind and no fix files). Written files come from `attempt-diff` events and the gate audit's allow write/edit rows relative to the tree.
3890
3911
  - **Fold:** `cardsMatch` / `foldCards`: same job + check, a shared error kind, Jaccard >= 60% on flagged-text tokens (a fit on known cards, not a proof). Code-less cards (kind `other`) fold on the text rule alone, only when both token sets are non-empty (R1, 2026-10-23: UNTESTED on real data; the 60% was fitted on cards with codes). `kindAgrees` is the one kind test.
3891
- - **Ledger** `<config home>/learn/ledger.json` (mode 600, atomic, secrets masked): rows `{ id, lesson, count, sample, models, workflow, place, level, trust, runIds, firstSeen, lastSeen, specHash }`, plus `pending` (held rubric cards), `suspect`, `health`. There is no check-wide set-aside (R2, 2026-10-23): a lesson seen in several jobs climbs by the climb rules and is read at its level; all-jobs hints come last. `countLedger` (+1 per run per row), `holdPending` / `releasePending` (rubric cards count only after the human Accept; `releaseRun` is called from the door's accept path), `climb` (the level of a lesson, derived at read time by `readableRows` from the rows not decayed at that moment: it can go back down when siblings age past 56 days; the file stores `workflow` only and ignores an older stored level). The ledger keeps EVERY row (nothing is deleted, no `expired` flag is stored; hamr 2026-10-23). Memory is the filter: `readableRows` / `renderHints` skip a row last seen more than 56 days ago (`isDecayed`) and a workflow row of another signed hash (`otherJobVersion`); a lesson seen again counts +1 on its old row. Old files with `status`/`expiredBy` still load; the fields are ignored and dropped at the next write.
3892
- - **Reading:** `hintsForRun({ home, spec, specHash })` renders once at run start; `runJob(..., { learnHints: { text, ids } })` → `runPlan` hands it to the PLANNER at the draft and the first replan only, and emits a `learnings` spine record with the exact text. `renderHints(ledger, { workflow, place, specHash, now })` is the one render (this job, then this place, then all-jobs, highest count first, 2,000 characters, a hint whole or not at all; all-jobs hints last); a FIXED hint adds `What changed:` (first 3 `+`/`-` lines of the stored diff, 150 chars each, then `(+N more)`; never on NEVER FIXED) and a repeated path in a hint reads as its basename (R3, R5, 2026-10-23); the spine's recorded text is byte-equal to a fresh `renderHints(...).text` from the ledger as it stood. The Audit run detail carries `learnings` (read-only).
3912
+ - **Ledger** `<config home>/learn/ledger.json` (mode 600, atomic, secrets masked): rows `{ id, lesson, count, sample, models, workflow, place, level, trust, runIds, firstSeen, lastSeen, specHash }`, plus `pending` (holds an older version wrote for rubric runs; new runs write none), `suspect`, `health`. There is no check-wide set-aside (R2, 2026-10-23): a lesson seen in several jobs climbs by the climb rules and is read at its level; all-jobs hints come last. `countLedger` (+1 per run per row), `releasePending` (an OLD hold, written by v0.46.x, counts on the human Accept; `releaseRun` is called from the door's accept path; a rubric run's lessons now count at run end like a deterministic run's, trust `rubric` kept, and `foldRunIntoLedger` returns `held: 0`; reuse/bridge credit still waits for the Accept). A row already holding the run id (a resumed leg, one id) is refreshed from the new card (sample, lesson, key, lastSeen, hash, place) but its count, model tally and run ids do not move, `climb` (the level of a lesson, derived at read time by `readableRows` from the rows not decayed at that moment: it can go back down when siblings age past 56 days; the file stores `workflow` only and ignores an older stored level). The ledger keeps EVERY row (nothing is deleted, no `expired` flag is stored; hamr 2026-10-23). Memory is the filter: `readableRows` / `renderHints` skip a row last seen more than 56 days ago (`isDecayed`) and a workflow row of another signed hash (`otherJobVersion`); a lesson seen again counts +1 on its old row. Old files with `status`/`expiredBy` still load; the fields are ignored and dropped at the next write.
3913
+ - **Reading:** `hintsForRun({ home, spec, specHash })` renders once at run start; `runJob(..., { learnHints: { text, ids } })` → `runPlan` hands it to the PLANNER at the draft and the first replan only, and emits a `learnings` spine record with the exact text. `renderHints(ledger, { workflow, place, specHash, now })` is the one render (this job, then this place, then all-jobs, highest count first, 2,000 characters, a hint whole or not at all; all-jobs hints last); a FIXED hint adds `What changed:` (first 3 `+`/`-` lines of the stored diff, 150 chars each, then `(+N more)`; never on NEVER FIXED) and a repeated path in a hint reads as its basename (R3, R5, 2026-10-23); the spine's recorded text is byte-equal to a fresh `renderHints(...).text` from the ledger as it stood. The Audit run detail carries `learnings` (read-only); its header counts DISTINCT hints (the same ledger row handed at the draft and the first replan counts once).
3893
3914
 
3894
3915
  ### Settings — the keys file, `config.json`, the monthly $ limit, the Providers tab (PANEL-BUILD.md P4a, `src/keysfile.js`, `src/config.js`, `src/monthly.js`, `src/providerrows.js`, `src/panel/settingsroutes.js`)
3895
3916
 
@@ -3976,7 +3997,7 @@ above 0, or `null`/blank to clear) — an empty or key-less body is a `400`, nev
3976
3997
  only the shell holds when config already names it (`keys.<ENV>` or `models.<ENV>`; its status reads `found in shell`) — nothing is hardcoded, there is no add-row and no remove-row control. A missing `.env`
3977
3998
  is created (mode 600, dir 700) on the first Providers read with two comment lines and no key. A value of `null` counts as SET and means "no real key" (the provider is handed the placeholder
3978
3999
  `no-key-needed`, never the word `null`; the row shows `set in file`). A key whose value has a space or a line break shows `The key has a space or a line break in it. Fix it in your keys file, then
3979
- reload keys.` Each row: the env NAME + status, an **API shape** select (Anthropic / OpenAI-compatible / Gemini), the typed **Base URL** (empty = no run, no list, `type the Base URL`), the **Old max_tokens**
4000
+ reload keys.` Each row: the env NAME + status, an **API shape** select (Anthropic / OpenAI-compatible / Gemini), the typed **Base URL** (empty = no run, no list, `type the Base URL`; the Test line reads the server's own sentence `Type a Base URL.`), the **Old max_tokens**
3980
4001
  tick (applies to `openai-api` only), **Tokens** (the key's spend), `[ Test ] [ ⟳ Refresh ]`, and its model lines: a **Use** tick, the model name, **In / Out $/1M** boxes, tokens (`—` when no spend row names the model).
3981
4002
  A listed id from Refresh is shown unticked and is not saved until touched; `+ type a model name` adds a line unticked. Saves are on change; success reads `saved — applies from the next model call`;
3982
4003
  every refusal is a plain sentence in `say` (`Fill both prices to tick this model.`, `Fill both price boxes or clear both.`, `A price must be a number, 0 or more.`, …). **Open keys folder** (in the KEYS strip,
@@ -4439,12 +4460,12 @@ Check what the person asked for, not what the worker chose to write. bareloop ne
4439
4460
  - **Only the panel fires.** `createPanelServer` runs one 500 ms unref'd scheduler (poll only, never `fs.watch`); the CLI never fires. Panel off = nothing fires; slots missed collapse to ONE catch-up (marked when more than 2 minutes late). `createPanelServer` test seams: `scheduleNow`, `schedulerIntervalMs` (0 = no timer), `sessionDeps`, `acceptWaitMs`.
4440
4461
  - **Files live under `<config home>/triggers/<job>/`**: `schedule.json` (the current signed schedule) and ONE append-only `ledger.jsonl` (every row carries `sig`, the spec hash it was written under). A run that a schedule started carries `trigger.json` beside its `resolved-spec.json`.
4441
4462
  - **Setting one is two clicks, both $0** (`POST /api/author/schedule-prepare`, then `/api/author/schedule` with the hash). It needs a real green run of the job's CURRENT workflow (a rubric job also an honoured Accept). A file trigger is for plain-files jobs only. Cancel is never refused. A reuse never clears a schedule: Reuse workflow (the one door, v1.101) keeps the origin's name while no job box is changed and switches the Job name to the next free `<base>-N` once one is; a changed job is a new workflow that needs its own green before it can be scheduled.
4442
- - **A fire goes busy, then monthly limit, then `wouldAccept`, then the one starter** (the same session start and `signRun` as a Reuse the person clicked, with `--review-door`). Busy, monthly and refused fires consume nothing and are told once per `key|outcome|why`, so they retry. A run is busy while its pid is alive AND its process started no later than 5 s after its run row was written (a recycled pid is not that run). A run waiting at its review door is not busy.
4463
+ - **A fire goes busy, then monthly limit, then `wouldAccept`, then the one starter** (the same session start and `signRun` as a Reuse the person clicked, with `--review-door`). Busy, monthly and refused fires consume nothing and are told once per `key|outcome|why`, so they retry. A file fire whose name holds a line break or ends in whitespace is refused at `perFireSpec` (the card text is `label: value` lines, so it would read back as another Inputs line); the person is told to rename the file. A run is busy while its pid is alive AND its process started no later than 5 s after its run row was written (a recycled pid is not that run). A run waiting at its review door is not busy.
4443
4464
  - **An install-needed refusal backs off, then gives up.** When a fire's copy needs packages, that job's next attempt waits 1, 2, 3, 5, 8, 13, then 21 minutes (in memory: a restart begins at the first wait); a wait that would pass 60 minutes in total is not taken: the schedule writes ONE `gave-up` row and is paused (nothing fires; files still queue as `arrived`), and the job shows in Needs you with the pill `Check` (`listGaveUp`). Signing the schedule again writes `resumed` and clears it. Constants: `INSTALL_RETRY_STEPS_MS`, `INSTALL_GIVE_UP_MS` (`src/panel/schedules.js`).
4444
4465
  - **A scheduled green is held until accepted**: `getStartFrom` reads its outcome as `green-held` and the Reuse list skips it, until the spine holds an honoured accept.
4445
4466
  - **Accept** (`POST /api/runs/<id>/accept`) runs the engine's own door path (`run-u --spec <signed spec> --door <id> --decide accept --approve <hash>`): the mechanical stages re-run, a red is `door-accept-red`, the run's verdict is untouched. It shows only while `doorWaiting(events)` (`src/reviewdoor.js`) is true; the same answer fills the runs list's Needs-you `Accept`.
4446
4467
  - **Every word the page shows** comes from `src/schedulewords.js` (`SCHEDULE_WORDS` is the one button table the server sends).
4447
- - **The schedule is shown on three tabs** (v1.102): the Run tab's one summary block (ENDED dated, NEXT, SCHEDULE with `last`/`next`; row and frame red when the last fire started nothing), the Job tab's Started and Schedule rows (`getRunJob`), and the Audit's own folded `schedule` section on every run of a scheduled job (v1.104: `detail.scheduleSection` `{title, sign, word, head, rows}`, drawn `[sign] word schedule · head` with the sign and word both from the status table, v1.106 Part 4; a run a cancelled schedule started keeps `detail.startedAudit`). `/api/runs` `scheduled[]` and `gaveUp[]` carry `runNo` so the small lists read `job · run-N`. `trigger.json` carries `triggerKind`, `sessionId`, `originRunid`, `sig`, `lateMs`; each signing appends a `signed` ledger row naming its origin run.
4468
+ - **The schedule is shown on three tabs** (v1.102): the Run tab's one summary block (ENDED dated, NEXT, SCHEDULE with `last`/`next`; row and frame red when the last fire started nothing; `last` wears the run's status word even for a fire refused at the run-start fetch, which has no spine, only a settled event), the Job tab's Started and Schedule rows (`getRunJob`), and the Audit's own folded `schedule` section on every run of a scheduled job (v1.104: `detail.scheduleSection` `{title, sign, word, head, rows}`, drawn `[sign] word schedule · head` with the sign and word both from the status table, v1.106 Part 4; a run a cancelled schedule started keeps `detail.startedAudit`). `/api/runs` `scheduled[]` and `gaveUp[]` carry `runNo` so the small lists read `job · run-N`. `trigger.json` carries `triggerKind`, `sessionId`, `originRunid`, `sig`, `lateMs`; each signing appends a `signed` ledger row naming its origin run.
4448
4469
 
4449
4470
 
4450
4471
  ## The Audit's tries, checks and answered questions (PRD v1.106 Part 5)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -656,6 +656,50 @@ export function proposalStopText({ stop, reds, cases = null, spend = null, sourc
656
656
  /** the reason inside a `scout-absent` red's detail: `the survey is ABSENT (<reason>) — an absent facts object ...` (src/authorflow.js) */
657
657
  const SCOUT_REASON_RE = /^the survey is [^(]*\((.*)\) — an absent facts/s;
658
658
 
659
+ /**
660
+ * ONE owner of the plain sentence for every drafting stop code (hamr 2026-10-09: "a user should get a reason they can
661
+ * understand and do something about"). What happened; `STOP_TODO` below overrides the default "draft again" where that is wrong.
662
+ * Used by `draftStopText` for every stop the panel and the CLI show; a code not listed reads `STOP_UNKNOWN`, never the raw code.
663
+ * @type {Record<string, string>}
664
+ */
665
+ export const STOP_HEADS = {
666
+ 'provider-red': 'The AI provider failed before a usable plan came back — a connection or service fault, not a problem with your job.',
667
+ 'artifact-red': 'The AI answered, but its answer could not be used as a plan.',
668
+ 'cap-halt': 'Drafting stopped on your cost cap.',
669
+ 'pricing-red': 'Drafting stopped: the cost of a call could not be priced, so the cap cannot be enforced.',
670
+ 'max-revisions': 'The AI used all its tries without a plan that passed the checks.',
671
+ precheck: 'Drafting stopped before any call: something it needs was missing.',
672
+ unchanged: 'Drafting stopped: the AI sent the same plan twice.',
673
+ 'scout-absent': 'Drafting stopped: the first look at your repo or input did not complete.',
674
+ refused: 'Drafting stopped: something in the job or its inputs is not allowed.',
675
+ 'interview-incomplete': 'Drafting stopped: the job form was not complete, so there was nothing to draft from.',
676
+ 'calibration-missing': 'Drafting stopped: the practice cases that prove the rubric were missing, so the check could not be trusted.',
677
+ 'invalid-set': 'Drafting stopped: the rubric and its practice cases do not fit together, so they could not be graded.',
678
+ 'broken-close': 'Drafting stopped: the final check the model wrote could not run.',
679
+ 'listing-absent': 'Drafting stopped: the list of your files was not available, so the checks could not name any.',
680
+ 'listing-conflict': 'Drafting stopped: the list of your files was given twice and the two did not agree.',
681
+ 'listing-unreadable': 'Drafting stopped: your repo\'s file list could not be read.',
682
+ 'non-code-source': 'Drafting stopped: this source is not a code repo, and this kind of job only works on code.',
683
+ 'authoring-failed': 'Drafting stopped for a reason that was not recorded.',
684
+ 'confirm-abandoned': 'Drafting stopped: the confirm step ended before you answered, so nothing was signed.',
685
+ 'confirm-restart': 'Drafting stopped: you chose to start over, so nothing was signed.',
686
+ };
687
+
688
+ /** the "What to do" for the stops where "draft again" is not the right advice */
689
+ const STOP_TODO = /** @type {Record<string, string>} */ ({
690
+ refused: 'change what the message above names, then draft again.',
691
+ 'interview-incomplete': 'fill in every line of the job form, then draft again.',
692
+ 'calibration-missing': 'draft again; if it stops the same way twice, make your PASS/FAIL examples more concrete.',
693
+ 'invalid-set': 'draft again; if it stops the same way twice, make your PASS/FAIL examples more concrete.',
694
+ 'non-code-source': 'point the job at a code repo, or use a folder or file job instead.',
695
+ 'listing-unreadable': 'check the repo is a readable git repo with at least one commit, then draft again.',
696
+ 'confirm-abandoned': 'start a new draft when you are ready; nothing more was spent.',
697
+ 'confirm-restart': 'change the answers you want and start the draft again.',
698
+ });
699
+
700
+ /** a stop code this table does not know: still plain words, never the code */
701
+ const STOP_UNKNOWN = 'Drafting stopped for a reason this screen has no wording for.';
702
+
659
703
  /**
660
704
  * @param {{stop: string, reds?: PlainRed[]|null, refusal?: {detail?: string}|null,
661
705
  * spend?: {knownUsd?: number|null, spendComplete?: boolean|null}|null, wholeJob?: boolean}} o `wholeJob` - a rubric job on the person's
@@ -674,23 +718,13 @@ export function draftStopText({ stop, reds = null, refusal = null, spend = null,
674
718
  const detail = (Array.isArray(reds) ? reds : []).find((r) => r?.code === 'fit-stopped')?.detail;
675
719
  if (detail) return `Stopped before drafting: the page does not show everything the job asks for: ${redactSecrets(String(detail))}. Change the job or the page and draft again. Spent so far: ${spent ?? 'unknown'}.`;
676
720
  }
677
- /** @type {Record<string, string>} */
678
- const heads = {
679
- 'provider-red': 'The AI provider failed before a usable plan came back — a connection or service fault, not a problem with your job.',
680
- 'artifact-red': 'The AI answered, but its answer could not be used as a plan.',
681
- 'cap-halt': 'Drafting stopped on your cost cap.',
682
- 'pricing-red': 'Drafting stopped: the cost of a call could not be priced, so the cap cannot be enforced.',
683
- 'max-revisions': 'The AI used all its tries without a plan that passed the checks.',
684
- precheck: 'Drafting stopped before any call: something it needs was missing.',
685
- unchanged: 'Drafting stopped: the AI sent the same plan twice.',
686
- 'scout-absent': 'Drafting stopped: the first look at your repo or input did not complete.',
687
- };
721
+ const heads = STOP_HEADS;
688
722
  const redList = Array.isArray(reds) ? reds : [];
689
723
  // F217: the first look at the repo failing (a dropped connection, a timeout) arrives as a `precheck` stop with a
690
724
  // `scout-absent` red. That is NOT "something it needs was missing", and its reason sits past the 60-char quote bound
691
725
  // behind a fixed lead-in, so the person saw neither. Word it as the scout's stop and quote the reason in full (scrubbed).
692
726
  const scoutRed = stop === 'precheck' ? redList.find((r) => r?.code === 'scout-absent') : undefined;
693
- const head = scoutRed ? heads['scout-absent'] : (heads[stop] ?? `Drafting stopped (${safeText(stop)}).`);
727
+ const head = scoutRed ? heads['scout-absent'] : (Object.hasOwn(heads, stop) ? heads[stop] : STOP_UNKNOWN);
694
728
  /** @type {string[]} */
695
729
  const said = scoutRed
696
730
  ? [SCOUT_REASON_RE.exec(String(scoutRed.detail ?? ''))?.[1]
@@ -705,6 +739,7 @@ export function draftStopText({ stop, reds = null, refusal = null, spend = null,
705
739
  }
706
740
  lines.push(stop === 'cap-halt' || stop === 'pricing-red'
707
741
  ? 'What to do: raise the cap or fix the price in Settings, then draft again.'
742
+ : Object.hasOwn(STOP_TODO, stop) ? `What to do: ${STOP_TODO[stop]}`
708
743
  : (wholeJob ? 'What to do: review the whole job - every line and its ~ rules, and the inputs - and submit it again.' : 'What to do: draft again.'));
709
744
  return lines.join('\n');
710
745
  }
package/src/cli.js CHANGED
@@ -36,6 +36,8 @@ import { bundleMain } from './bundlerun.js';
36
36
  // rather than re-parsing argv a second time here keeps exactly one code
37
37
  // path for the flag grammar; `src/cli.js` only supplies the deps shape
38
38
  // (`out`/`err`/`env`) this command's own callers already use.
39
+ import { startCmd, stopCmd, statusCmd, installCmd, menuCmd } from './launcher.js';
40
+ import { makePaint } from './tty-colour.js';
39
41
  import { main as runUMain } from './userrun.js';
40
42
  // PANEL-BUILD.md P0 task 3/4 — `bareloop interview` and `bareloop author`
41
43
  // wrap the close-authoring interview and authoring-pipeline flows' own
@@ -272,50 +274,30 @@ function doRuns(args, { out, err, cwd }) {
272
274
  }
273
275
 
274
276
  /**
275
- * The bare `bareloop` menu: `1 export 2 run 3 history q quit`, then the
276
- * SAME code path as the sub-command, asking its args line by line. `run-u`,
277
- * `interview`, `author` and `replay` are NOT wizarded here — a line-at-a-time
278
- * prompt for `run-u`'s 13 flags (or the others' own argv shapes) would be
279
- * inventing new interactive UX, not wiring, and that judgement stands. This
280
- * menu is fixed instead: it now says those four commands exist and how to
281
- * reach them, rather than implying only three commands do at all.
282
- * `keysHome` is the home `main` already resolved (`keysForDoor`), handed on so choice 2 reads the
283
- * SAME config.json (monthly limit, price, key row) as `bareloop run <bundle>` — never resolved twice.
284
- * @param {any} deps @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string, env: any, now: () => number }} ctx @param {string|undefined} keysHome
277
+ * The launcher doors (L, PANEL-BUILD.md addendum 2026-10-24): the bare `bareloop` menu and `start | stop | status | install`. All logic is
278
+ * `src/launcher.js`; this only supplies the home (the SAME one `main` resolved for the keys file and config.json) and the line reader.
279
+ * `deps.launcher` is the test seam for every OS touch (ports, spawn, kill, opener, clock).
280
+ * @param {string} cmd 'menu' | 'start' | 'stop' | 'status' | 'install' @param {string[]} rest
281
+ * @param {any} deps @param {{ out: (s: string) => void, err: (s: string) => void }} io @param {string|undefined} home
282
+ * @returns {Promise<number>}
285
283
  */
286
- async function runMenu(deps, ctx, keysHome) {
287
- const stdin = deps.stdin;
288
- const stdout = deps.stdout;
289
- const rl = createInterface({ input: stdin, output: stdout });
284
+ async function runLauncher(cmd, rest, deps, io, home) {
285
+ const rl = createInterface({ input: deps.stdin ?? process.stdin, output: deps.stdout ?? process.stdout });
286
+ let closed = false;
287
+ rl.once('close', () => { closed = true; });
288
+ // a closed stdin answers '' (= the safe default: quit / "no"), never hangs
289
+ const ask = (/** @type {string} */ q) => (closed ? Promise.resolve('') : Promise.race([
290
+ rl.question(q),
291
+ new Promise((resolve) => { rl.once('close', () => resolve('')); }),
292
+ ]).then(String));
293
+ const rawEnv = deps.processEnv ?? process.env;
294
+ const d = { home, ask, paint: makePaint(deps.stdout ?? process.stdout, rawEnv), paintErr: makePaint(deps.stderr ?? process.stderr, rawEnv), ...(deps.launcher ?? {}) };
290
295
  try {
291
- ctx.out('1 export 2 run 3 history q quit');
292
- ctx.out('(also on the command line, not wizarded here: run-u, interview, author, replay, runs, panel — run `bareloop <name>` with its own flags)');
293
- const choice = (await rl.question('> ')).trim().toLowerCase();
294
- if (choice === '' || choice === 'q' || choice === 'quit') return 0;
295
- if (choice === '1') {
296
- const specPath = (await rl.question('job spec path: ')).trim();
297
- const registry = (await rl.question('registry dir: ')).trim();
298
- const outDir = (await rl.question('out dir: ')).trim();
299
- return doExport([specPath, '--registry', registry, '--out', outDir], ctx);
300
- }
301
- if (choice === '2') {
302
- const bundleDir = (await rl.question('bundle dir: ')).trim();
303
- const repo = (await rl.question('repo path: ')).trim();
304
- const budget = (await rl.question('budget (blank to skip): ')).trim();
305
- const wall = (await rl.question('wall minutes (blank to skip): ')).trim();
306
- const approve = (await rl.question('approve hash (blank to skip): ')).trim();
307
- const args = [bundleDir, '--repo', repo];
308
- if (budget) args.push('--budget', budget);
309
- if (wall) args.push('--wall', wall);
310
- if (approve) args.push('--approve', approve);
311
- return await bundleMain(args, { ...ctx, deps, keysHome, parseFlags, printReds });
312
- }
313
- if (choice === '3') {
314
- const bundleDir = (await rl.question('bundle dir: ')).trim();
315
- return doHistory([bundleDir], ctx);
316
- }
317
- ctx.err(`unknown choice ${JSON.stringify(choice)} — one of: 1, 2, 3, q`);
318
- return 1;
296
+ if (cmd === 'start') return await startCmd(io, d);
297
+ if (cmd === 'stop') return await stopCmd(rest, io, d);
298
+ if (cmd === 'status') return await statusCmd(io, d);
299
+ if (cmd === 'install') return await installCmd(io, d);
300
+ return await menuCmd(io, d);
319
301
  } finally {
320
302
  rl.close();
321
303
  }
@@ -326,7 +308,7 @@ async function runMenu(deps, ctx, keysHome) {
326
308
  * code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
327
309
  * queued stdout on its own (F-something: `process.exit()` can discard it).
328
310
  * @param {string[]} argv
329
- * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any, runlistHome?: string, keysHome?: string }} deps
311
+ * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any, runlistHome?: string, keysHome?: string, processEnv?: Record<string,string|undefined> }} deps
330
312
  * @returns {Promise<number>}
331
313
  */
332
314
  export async function main(argv, deps = {}) {
@@ -345,7 +327,9 @@ export async function main(argv, deps = {}) {
345
327
 
346
328
  const [cmd, ...rest] = argv;
347
329
  if (keys.warning && cmd && ['run', 'run-u', 'interview', 'author', 'panel'].includes(cmd)) err(`WARNING: ${keys.warning}`);
348
- if (!cmd) return runMenu({ ...deps, stdin: deps.stdin ?? process.stdin, stdout }, ctx, keys.home);
330
+ const launcherHome = keys.home ?? deps.runlistHome;
331
+ if (!cmd) return runLauncher('menu', [], { ...deps, stdout }, ctx, launcherHome);
332
+ if (cmd === 'start' || cmd === 'stop' || cmd === 'status' || cmd === 'install') return runLauncher(cmd, rest, { ...deps, stdout }, ctx, launcherHome);
349
333
  if (cmd === 'export') return doExport(rest, ctx);
350
334
  if (cmd === 'run') return bundleMain(rest, { ...ctx, keysHome: keys.home, parseFlags, printReds });
351
335
  if (cmd === 'history') return doHistory(rest, ctx);
@@ -388,6 +372,6 @@ export async function main(argv, deps = {}) {
388
372
  // and spine/gate-audit reads over `127.0.0.1` only. `deps.runlistHome`
389
373
  // rides through the same injectable seam `run`/`run-u` already use.
390
374
  if (cmd === 'panel') return panelMain(rest, { out, err, runlistHome: deps.runlistHome, env: deps.env });
391
- err(`unknown command ${JSON.stringify(cmd)} — one of: export, run, history, run-u, interview, author, replay, runs, panel`);
375
+ err(`unknown command ${JSON.stringify(cmd)} — one of: export, run, history, run-u, interview, author, replay, runs, panel, start, stop, status, install`);
392
376
  return 1;
393
377
  }