bareloop 0.46.0 → 0.46.1

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,32 @@ 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.46.1] - 2026-10-24
9
+
10
+ ### Fixed
11
+
12
+ - **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`).
13
+
14
+ - **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).
15
+
16
+ - **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.
17
+
18
+ - **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.
19
+
20
+ - **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).
21
+
22
+ - **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.
23
+
24
+ - **`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.
25
+
26
+ ### Changed
27
+
28
+ - **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.
29
+
30
+ - **`stageQuestionText`'s JSDoc now sits on the function, not on `GENRE_WHAT`.** Comments and declaration order only.
31
+
32
+ - **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.
33
+
8
34
  ## [0.46.0] - 2026-10-23
9
35
 
10
36
  ### Added
@@ -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):
@@ -3889,7 +3891,7 @@ No model is called anywhere in learnings (a test walks the import closure). Pure
3889
3891
  - **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
3892
  - **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
3893
  - **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).
3894
+ - **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
3895
 
3894
3896
  ### 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
3897
 
@@ -3976,7 +3978,7 @@ above 0, or `null`/blank to clear) — an empty or key-less body is a `400`, nev
3976
3978
  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
3979
  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
3980
  `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**
3981
+ 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
3982
  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
3983
  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
3984
  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 +4441,12 @@ Check what the person asked for, not what the worker chose to write. bareloop ne
4439
4441
  - **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
4442
  - **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
4443
  - **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.
4444
+ - **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
4445
  - **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
4446
  - **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
4447
  - **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
4448
  - **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.
4449
+ - **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
4450
 
4449
4451
 
4450
4452
  ## 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.46.1",
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
  }
@@ -25,6 +25,7 @@ import { readFileSync } from 'node:fs';
25
25
  import { planMatchesClose } from '../rubricstep.js';
26
26
  import { createSession, validateJobCard, validateReuseCard, jobBoxesChanged, buildReuseSpec, cardFields, nextFreeJobName, startDestinationProblem, STEP_LABELS } from './authorsession.js';
27
27
  import { parseInputs } from '../jobblock.js';
28
+ import { isReusableGreen } from './status.js';
28
29
  import { proveInputFiles, looksLikeRubricInput, webFetchState } from '../source.js';
29
30
  import { validateJob } from '../job.js';
30
31
 
@@ -282,7 +283,7 @@ export function createAuthorRoutes(opts) {
282
283
  ok: true, origin: pre.origin, card, from: pre.from, note: pre.note,
283
284
  // ONE door (v1.101): `green` = the origin earned a real green (an imported job counts), so an unchanged job reuses the signed plan;
284
285
  // `nextName` = the Job name box once a job box changes (the box shows the origin's name until then). Nothing is locked.
285
- green: pre.origin.importId !== undefined || pre.outcome === 'green' || pre.outcome === 'already-green',
286
+ green: pre.origin.importId !== undefined || isReusableGreen(pre.outcome),
286
287
  nextName: nextFreeJobName(pre.origin.job, opts.runJobNames ? opts.runJobNames() : [], { jobsDir: opts.jobsDir }),
287
288
  specHash: pre.specHash, workflowKey: pre.workflowKey, trackRecord: pre.trackRecord,
288
289
  line: pre.line, specNamesModel: pre.specNamesModel, specWorker: pre.specWorker,
@@ -357,7 +358,7 @@ export function createAuthorRoutes(opts) {
357
358
  // a scheduled run's green waits for the person's Accept (quarantine): its plan is neither reused nor re-drafted until then
358
359
  if (pre.outcome === 'green-held') { send(409, { ok: false, error: 'This run was started by a schedule and is waiting for your Accept. Accept it first, then it can be reused.' }); return true; }
359
360
  card.jobName = pre.origin.job; // an unchanged job keeps its name; a typed one is ignored
360
- if (fromImport !== null || pre.outcome === 'green' || pre.outcome === 'already-green') {
361
+ if (fromImport !== null || isReusableGreen(pre.outcome)) {
361
362
  const rv = validateReuseCard(card, { rows });
362
363
  if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
363
364
  const spec = buildReuseSpec(pre.spec, card, rows);
@@ -1146,13 +1146,11 @@ export function createSession(card, deps = {}) {
1146
1146
  const no = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
1147
1147
  appendDraftLog(outDir, { kind: 'step-reds', no: no === -1 ? state.steps.length - 1 : no, stop: authored.stop ?? 'authoring-failed', reds: redsRecord(stopReds), at: new Date().toISOString() });
1148
1148
  }
1149
- refuse(authored.stop === 'confirm-abandoned' || authored.stop === 'confirm-restart'
1150
- ? `Stopped: ${authored.stop}${authored.refusal ? ` — ${authored.refusal.detail}` : ''}`
1151
- : draftStopText({
1152
- stop: String(authored.stop ?? 'authoring-failed'), reds: stopReds, refusal: authored.refusal,
1153
- spend: { knownUsd: state.draftSpentUsd, spendComplete: state.draftSpendComplete },
1154
- wholeJob: RUBRIC_FILES,
1155
- }), authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused');
1149
+ refuse(draftStopText({
1150
+ stop: String(authored.stop ?? 'authoring-failed'), reds: stopReds, refusal: authored.refusal,
1151
+ spend: { knownUsd: state.draftSpentUsd, spendComplete: state.draftSpendComplete },
1152
+ wholeJob: RUBRIC_FILES,
1153
+ }), authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused');
1156
1154
  return;
1157
1155
  }
1158
1156
 
@@ -2910,7 +2910,17 @@
2910
2910
  });
2911
2911
  });
2912
2912
  if(sum){
2913
- var first = items.length ? (Array.isArray(items[0].hints) ? items[0].hints.length : 0) : 0;
2913
+ // count DISTINCT hints across the draft and replan blocks: the same ledger row (ids[i], parallel to hints[i]) handed twice is one hint
2914
+ var seen = {};
2915
+ var first = 0;
2916
+ items.forEach(function(it){
2917
+ var hs = Array.isArray(it.hints) ? it.hints : [];
2918
+ var byId = Array.isArray(it.ids) && it.ids.length === hs.length;
2919
+ hs.forEach(function(h, i){
2920
+ var key = byId ? "id:" + it.ids[i] : "n:" + it.phase + ":" + h.n;
2921
+ if(!seen[key]){ seen[key] = true; first += 1; }
2922
+ });
2923
+ });
2914
2924
  var label = items.length ? head + " \u00B7 " + first + (first === 1 ? " hint" : " hints") : head;
2915
2925
  // no arrow (hamr 2026-10-23): the state chip leads; aria-expanded carries open/closed. The same `[✓] word` state markup the schedule/step headers wear (stateDotHTML), then the plain-words title
2916
2926
  var paint = function(){ sum.innerHTML = stateDotHTML(glyphClass("\u2713"), "learnings", "audit-learnings-state") + " " + escapeXml(label); };
@@ -4939,6 +4949,8 @@
4939
4949
  var suffix = reach.modelListed === false ? " (model not listed)" : "";
4940
4950
  return { keyOk: true, ok: true, text: "[✓] " + r.envKey + " found · " + modelId + " reachable" + suffix };
4941
4951
  }
4952
+ // a blank Base URL is nothing to probe, not flakiness: say the server's own plain sentence (src/modellist.js owns it)
4953
+ if(reach.status === "no-base-url" && reach.note) return { keyOk: true, ok: false, text: "[✗] " + reach.note };
4942
4954
  return { keyOk: true, ok: false, text: reachStatusText(reach.status) };
4943
4955
  }
4944
4956
  function refreshModelStatus(){
@@ -46,6 +46,11 @@ export function perFireSpec(schedule, cause) {
46
46
  if (jobSpecHash(schedule.spec) !== schedule.specHash) return { ok: false, why: 'The saved schedule no longer matches what you signed. Nothing started.' };
47
47
  const spec = JSON.parse(JSON.stringify(schedule.spec));
48
48
  if (cause.kind === 'file') {
49
+ // the card text is `label: value` lines: a name holding a line break would read back as ANOTHER Inputs line, and edge whitespace
50
+ // is trimmed to a path that does not exist (a leading space in the file name sits mid-path and is harmless). Refused with its reason, never rewritten.
51
+ if (/[\r\n]/.test(cause.path) || cause.path !== cause.path.trim()) {
52
+ return { ok: false, why: `The file name ${JSON.stringify(cause.path)} has a line break or a space at its end, which the job form cannot carry. Rename the file. Nothing started.` };
53
+ }
49
54
  const first = Array.isArray(spec.inputs) ? spec.inputs[0] : null;
50
55
  if (!first) return { ok: false, why: 'The saved schedule has no input for the file to replace. Nothing started.' };
51
56
  spec.inputs = [{ ...first, value: cause.path }, ...spec.inputs.slice(1)];
@@ -68,7 +68,7 @@ import { SHAPES } from '../providershapes.js';
68
68
  import { resolveProvider } from '../providers.js';
69
69
  import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
70
70
  import {
71
- statusFor, partStatusFor, GOAL_MET_LINE, doorViewOf, ACTOR_MARKER, DOOR_WORDS,
71
+ statusFor, partStatusFor, GOAL_MET_LINE, doorViewOf, ACTOR_MARKER, DOOR_WORDS, isReusableGreen,
72
72
  } from './status.js';
73
73
  import { thinkingJobText } from '../thinking.js';
74
74
  import { judgedFilesOf, lineRules, stepFileRel, proseNotCheckedItems } from '../rubricstep.js';
@@ -542,7 +542,7 @@ function endedBase(summary, death, o) {
542
542
  if (o.destinationRefused) {
543
543
  // Reuse workflow only on a run that earned its green (hamr 2026-10-08, as below); every ending offers Reuse workflow
544
544
  // (withReuseAction adds it) and is told to change the job
545
- const earned = outcome === 'green';
545
+ const earned = isReusableGreen(outcome);
546
546
  return {
547
547
  reason: 'Goal met, but the output could not be delivered.',
548
548
  next: earned ? 'Fix the destination, then Reuse workflow.' : CHANGE,
@@ -550,10 +550,9 @@ function endedBase(summary, death, o) {
550
550
  actions: earned ? REUSE : [],
551
551
  };
552
552
  }
553
- // hamr 2026-10-08: already-green (the check passed before any work) is not a reusable job — only a run that
554
- // earned its green offers Reuse workflow; every ending offers Reuse workflow (withReuseAction adds it). `satisfied` is a
555
- // close VERDICT, never a run outcome (ralph and the job-end emitters write `green`), so it rides with already-green.
556
- if (outcome !== 'green') return { reason: 'Goal met.', next: CHANGE, line: GOAL_MET_LINE, actions: [] };
553
+ // a reusable green (isReusableGreen: green or already-green, hamr 2026-10-15) says nothing is left to do; every ending offers
554
+ // Reuse workflow (withReuseAction adds it). `satisfied` is a close VERDICT, never a run outcome, so it is not one.
555
+ if (!isReusableGreen(outcome)) return { reason: 'Goal met.', next: CHANGE, line: GOAL_MET_LINE, actions: [] };
557
556
  return { reason: 'Goal met.', next: 'Nothing to do.', line: GOAL_MET_LINE, actions: REUSE };
558
557
  }
559
558
  if (outcome === 'cap-halt') {
@@ -1530,7 +1529,8 @@ function scheduleExtrasFor(runid, job, rows, nos, opts, view) {
1530
1529
  }
1531
1530
  const got = hit ?? fallback;
1532
1531
  const no = got ? nos.get(got.r.runid) : undefined;
1533
- const word = got ? summarizeRow(got.r).status?.word : undefined;
1532
+ // events: a fire refused at the run-start fetch has no spine, only its settled event; without them it has no status word
1533
+ const word = got ? summarizeRow(got.r, Date.now(), undefined, readRunList(opts).events).status?.word : undefined;
1534
1534
  lastWords = `last ${dateTimeWords(Number(lastRow.at))}${no ? ` · run-${no}` : ''}${word ? ` · ${word}` : ''}${got?.rec.catchUp === true ? ' · catch-up' : ''}`;
1535
1535
  } else {
1536
1536
  lastWords = `last ${dateTimeWords(Number(lastRow.at))} · ${skippedWords(lastRow, true)}`;
@@ -3514,7 +3514,7 @@ export function getStartFrom(runid, opts = {}) {
3514
3514
  // T D.2: a SCHEDULED run's green reads `green-held` until the person accepts it, so that same refusal holds it unchanged
3515
3515
  outcome: (() => {
3516
3516
  const o = legsOf(records).at(-1)?.outcome ?? null;
3517
- return o === 'green' && heldScheduled(records, triggerRecordOf(row)) ? 'green-held' : o;
3517
+ return isReusableGreen(o) && heldScheduled(records, triggerRecordOf(row)) ? 'green-held' : o;
3518
3518
  })(),
3519
3519
  card,
3520
3520
  from: 'signed job',
@@ -3579,7 +3579,7 @@ export function listReuseJobs(opts = {}) {
3579
3579
  try { records = parseJsonl(row.spine).records; } catch { continue; }
3580
3580
  const outcome = legsOf(records).at(-1)?.outcome ?? null;
3581
3581
  // a real green or an already-green (hamr 2026-10-15: if it is already fixed and nothing is to be done, it is green and can be reused)
3582
- if (outcome !== 'green' && outcome !== 'already-green') continue;
3582
+ if (!isReusableGreen(outcome)) continue;
3583
3583
  if (heldScheduled(records, triggerRecordOf(row))) continue; // T D.2: a scheduled green waits for the person's Accept
3584
3584
  const signed = signedSpecForRun(row, records);
3585
3585
  if (!signed) continue;
@@ -20,6 +20,17 @@ import { capKindOf } from '../legs.js';
20
20
  /** the sentence a passed run carries as its reason (the Ended block's `line`) */
21
21
  export const GOAL_MET_LINE = 'goal met';
22
22
 
23
+ /**
24
+ * ONE spelling of "this run's outcome is a green that can be reused" (the Reuse picker, the Reuse start/prefill, the Ended block's
25
+ * next line). hamr 2026-10-15: an `already-green` (fixed before any work) counts. `satisfied` is a close verdict, never a run outcome.
26
+ * The SCHEDULE gate (schedules.js greenFor) deliberately stays on a real `green` only (T3).
27
+ * @param {string|null|undefined} outcome
28
+ * @returns {boolean}
29
+ */
30
+ export function isReusableGreen(outcome) {
31
+ return outcome === 'green' || outcome === 'already-green';
32
+ }
33
+
23
34
  /** @typedef {'running'|'waiting'|'passed'|'failed'|'capped'|'stopped'|'died'} StatusKey */
24
35
 
25
36
  /** the table: key -> sign + word */
package/src/stagewords.js CHANGED
@@ -2,6 +2,24 @@
2
2
  // src/panel/server.js (verbatim) so the Audit/step map AND the learnings cards read the same sentence from the same
3
3
  // code — a second spelling is how two screens come to disagree about one check. Pure; no file IO, no model.
4
4
 
5
+ /** the genre-owned noun for the count a TYPES-genre typecheck stage measures (see {@link stageQuestionText}) */
6
+ export const GENRE_WHAT = Object.freeze({ TYPES: 'type errors' });
7
+
8
+ /** `cmd`+`args`, as a comparable key — the one signal (besides `kind`
9
+ * itself) that two stages measure the SAME command's output, used to tell a
10
+ * genuine typecheck stage apart from the TYPES genre's other `lower-is-
11
+ * better`/`baseline: 0` stage (the failing-test-count half of `suite-green`'s
12
+ * "two assertions"), which shares that stage's own shape but not its
13
+ * population. `null` for a stage whose `cmd` isn't a non-empty string —
14
+ * never matches anything, so it never falsely suppresses a real typecheck
15
+ * question.
16
+ * @param {any} params @returns {string|null} */
17
+ export function cmdKey(params) {
18
+ if (!params || typeof params.cmd !== 'string' || params.cmd.length === 0) return null;
19
+ const args = Array.isArray(params.args) ? params.args : [];
20
+ return JSON.stringify([params.cmd, args]);
21
+ }
22
+
5
23
  /**
6
24
  * THE PLAIN-QUESTION OWNER (plain-checks build, 2026-09-28, hamr's ruling:
7
25
  * the "checks N/M" headline is WITHDRAWN — "confusing, reads like 6 failed
@@ -64,23 +82,6 @@
64
82
  * @param {{kind: string|null, params: any, genre: string|null, suiteCmdKeys: Set<string>}} o
65
83
  * @returns {string|null}
66
84
  */
67
- export const GENRE_WHAT = Object.freeze({ TYPES: 'type errors' });
68
-
69
- /** `cmd`+`args`, as a comparable key — the one signal (besides `kind`
70
- * itself) that two stages measure the SAME command's output, used to tell a
71
- * genuine typecheck stage apart from the TYPES genre's other `lower-is-
72
- * better`/`baseline: 0` stage (the failing-test-count half of `suite-green`'s
73
- * "two assertions"), which shares that stage's own shape but not its
74
- * population. `null` for a stage whose `cmd` isn't a non-empty string —
75
- * never matches anything, so it never falsely suppresses a real typecheck
76
- * question.
77
- * @param {any} params @returns {string|null} */
78
- export function cmdKey(params) {
79
- if (!params || typeof params.cmd !== 'string' || params.cmd.length === 0) return null;
80
- const args = Array.isArray(params.args) ? params.args : [];
81
- return JSON.stringify([params.cmd, args]);
82
- }
83
-
84
85
  export function stageQuestionText({
85
86
  kind, params, genre, suiteCmdKeys,
86
87
  }) {
@@ -303,6 +303,13 @@ export function redsRecord(reds: PlainRed[], { cases, signing }?: {
303
303
  }): Record<string, any>[];
304
304
  /** the stops this module explains in plain words */
305
305
  export const PLAIN_PROPOSAL_STOPS: readonly string[];
306
+ /**
307
+ * ONE owner of the plain sentence for every drafting stop code (hamr 2026-10-09: "a user should get a reason they can
308
+ * understand and do something about"). What happened; `STOP_TODO` below overrides the default "draft again" where that is wrong.
309
+ * Used by `draftStopText` for every stop the panel and the CLI show; a code not listed reads `STOP_UNKNOWN`, never the raw code.
310
+ * @type {Record<string, string>}
311
+ */
312
+ export const STOP_HEADS: Record<string, string>;
306
313
  /** codes the declaration gates (validateJob, validateCloseDecl) raise: one plain sentence covers the family */
307
314
  export const DECLARATION_CODES: readonly string[];
308
315
  export type PlainRed = {
@@ -1,3 +1,11 @@
1
+ /**
2
+ * ONE spelling of "this run's outcome is a green that can be reused" (the Reuse picker, the Reuse start/prefill, the Ended block's
3
+ * next line). hamr 2026-10-15: an `already-green` (fixed before any work) counts. `satisfied` is a close verdict, never a run outcome.
4
+ * The SCHEDULE gate (schedules.js greenFor) deliberately stays on a real `green` only (T3).
5
+ * @param {string|null|undefined} outcome
6
+ * @returns {boolean}
7
+ */
8
+ export function isReusableGreen(outcome: string | null | undefined): boolean;
1
9
  /**
2
10
  * The status of one run.
3
11
  * @param {{outcome: string|null|undefined, died?: boolean, category?: string|null, moneyHalt?: boolean}} o
@@ -8,21 +8,6 @@
8
8
  * question.
9
9
  * @param {any} params @returns {string|null} */
10
10
  export function cmdKey(params: any): string | null;
11
- export function stageQuestionText({ kind, params, genre, suiteCmdKeys, }: {
12
- kind: any;
13
- params: any;
14
- genre: any;
15
- suiteCmdKeys: any;
16
- }): string | null;
17
- /**
18
- * Stage name to its plain question, off a signed spec's `closeDecl.stages` — the one pass that gathers every
19
- * `command-exit` stage's cmd+args FIRST (so the failing-test-count half of `suite-green`'s pair can be told from a
20
- * genuine typecheck stage) and then asks {@link stageQuestionText} per stage. `null` for a stage with no honest
21
- * question; an empty map for a spec with no declared stages.
22
- * @param {any} spec
23
- * @returns {Map<string, string|null>}
24
- */
25
- export function stageQuestionsByName(spec: any): Map<string, string | null>;
26
11
  /**
27
12
  * THE PLAIN-QUESTION OWNER (plain-checks build, 2026-09-28, hamr's ruling:
28
13
  * the "checks N/M" headline is WITHDRAWN — "confusing, reads like 6 failed
@@ -85,6 +70,22 @@ export function stageQuestionsByName(spec: any): Map<string, string | null>;
85
70
  * @param {{kind: string|null, params: any, genre: string|null, suiteCmdKeys: Set<string>}} o
86
71
  * @returns {string|null}
87
72
  */
73
+ export function stageQuestionText({ kind, params, genre, suiteCmdKeys, }: {
74
+ kind: string | null;
75
+ params: any;
76
+ genre: string | null;
77
+ suiteCmdKeys: Set<string>;
78
+ }): string | null;
79
+ /**
80
+ * Stage name to its plain question, off a signed spec's `closeDecl.stages` — the one pass that gathers every
81
+ * `command-exit` stage's cmd+args FIRST (so the failing-test-count half of `suite-green`'s pair can be told from a
82
+ * genuine typecheck stage) and then asks {@link stageQuestionText} per stage. `null` for a stage with no honest
83
+ * question; an empty map for a spec with no declared stages.
84
+ * @param {any} spec
85
+ * @returns {Map<string, string|null>}
86
+ */
87
+ export function stageQuestionsByName(spec: any): Map<string, string | null>;
88
+ /** the genre-owned noun for the count a TYPES-genre typecheck stage measures (see {@link stageQuestionText}) */
88
89
  export const GENRE_WHAT: Readonly<{
89
90
  TYPES: "type errors";
90
91
  }>;