bareloop 0.42.0 → 0.43.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bareloop.context.md +53 -30
  3. package/package.json +1 -1
  4. package/src/authorjob.js +6 -0
  5. package/src/declaredclose.js +2 -2
  6. package/src/interviewrun.js +0 -1
  7. package/src/job.js +38 -11
  8. package/src/panel/authorroutes.js +107 -33
  9. package/src/panel/authorsession.js +59 -27
  10. package/src/panel/index.html +813 -484
  11. package/src/panel/runroutes.js +163 -5
  12. package/src/panel/scheduler.js +156 -0
  13. package/src/panel/schedules.js +667 -0
  14. package/src/panel/schedulestart.js +243 -0
  15. package/src/panel/server.js +451 -48
  16. package/src/planrun.js +39 -7
  17. package/src/providerrows.js +3 -3
  18. package/src/reviewdoor.js +37 -0
  19. package/src/runlist.js +35 -0
  20. package/src/schedulewords.js +296 -0
  21. package/src/trigger.js +48 -0
  22. package/src/triggerclock.js +127 -0
  23. package/src/triggerfiles.js +215 -0
  24. package/src/triggerledger.js +79 -0
  25. package/src/userrun.js +6 -7
  26. package/types/declaredclose.d.ts +1 -1
  27. package/types/job.d.ts +8 -1
  28. package/types/panel/authorroutes.d.ts +34 -1
  29. package/types/panel/authorsession.d.ts +37 -12
  30. package/types/panel/runroutes.d.ts +32 -1
  31. package/types/panel/scheduler.d.ts +17 -0
  32. package/types/panel/schedules.d.ts +412 -0
  33. package/types/panel/schedulestart.d.ts +106 -0
  34. package/types/panel/server.d.ts +143 -14
  35. package/types/planrun.d.ts +2 -0
  36. package/types/providerrows.d.ts +2 -2
  37. package/types/reviewdoor.d.ts +27 -0
  38. package/types/runlist.d.ts +20 -0
  39. package/types/schedulewords.d.ts +135 -0
  40. package/types/trigger.d.ts +37 -0
  41. package/types/triggerclock.d.ts +52 -0
  42. package/types/triggerfiles.d.ts +93 -0
  43. package/types/triggerledger.d.ts +35 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,52 @@ 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.43.0] - 2026-10-16
9
+
10
+ ### Added
11
+
12
+ - **A job can be scheduled from the panel (T, Part 3 of 8; panel-only, nothing fires yet).** A signed spec may carry a `trigger` (`daily`, `weekdays`, `weekly`, `new-file` folder, `file-updated` file), set only by a two-click, $0 door (`POST /api/author/schedule-prepare`, then `POST /api/author/schedule` with the hash it showed; `when: null` cancels and is never refused). It needs a real green run of the job's current workflow (a rubric job also needs an honoured Accept). Files live under `~/.config/bareloop/triggers/<job>/` (`schedule.json`, one `ledger.jsonl` whose rows all carry the signature). The agent never writes a trigger: a draft that carries one is refused by name. `workflowKey` ignores `trigger`; `cadence` is no longer required (an old spec keeps it, still valid, same hash) and new specs no longer write it.
13
+ - **One starter for a scheduled run (T, Part 4 of 8; still no timer).** A scheduled fire goes through the same session start and the same `signRun` as a Reuse the person clicked (headless sessions kept apart from the person's chat session; `--approve` is the hash derived by code; a scheduled spawn passes `--review-door`). Fire order: busy, monthly, `wouldAccept`, started; each outcome is one row, told once per slot-or-file, outcome and reason. A busy, monthly or refused fire consumes nothing, so it retries when things are back up; the `fire`/`ran` row is written only after every check said yes, and a launch error after it writes a `released` row (at most 3 times).
14
+ - **The panel's scheduler (T, Part 5 of 8).** While the panel is up a 500 ms timer (unref'd, stopped when the server closes) arms each signed schedule, fires a due clock slot or a queued file through the one starter, and writes the `arrived` rows; poll only, never `fs.watch`. A run of the job is busy only while its pid is alive AND the process started no later than 5 s after its run row was written (a recycled pid is not that run). One job's failure is written once and never stops the others. Missed slots still collapse to one catch-up.
15
+ - **The words of a schedule (T, Part 6 of 8).** `src/schedulewords.js` is the one owner of every schedule sentence: the next time (`today 15:00`, `tomorrow 09:00`), `next in 20m`, the NEXT line (a run waiting for your Accept does not hold the next one; the month used up reads as a red `Paused: ...` with two-decimal money and a typed red flag), the catch-up note and the edit-clears sentence. The page paints strings and works out nothing.
16
+ - **Schedule run, from the panel (T, Part 7 of 8).** The Run tab's last button is `Schedule run` (a job with a good run) or `Cancel / modify schedule` (a scheduled job, on every run of it, also while one is live); a job with no good run has neither. It starts and signs nothing: it opens the Chat card in schedule mode with a `When` field (By hand / Every day / Weekdays / Every <day> / When <file> is updated / When a new file arrives in <folder>). One main button: `Sign & run` while When is unchanged, `Sign schedule` / `Cancel schedule` when changed, `Confirm: <words>` after click 1 ($0, signs nothing), and after click 2 the card clears to the empty New job card with the button dead for 1.5 s (no Run invitation after a sign). Runs list gets a `Scheduled` section after Needs you (soonest first, a click opens the job's newest run). ENDED / NEXT moved from the Audit tab to the Run tab, with a SCHEDULED / NEXT twin directly below it (NEXT red when the month is used up) and a note on why a run started and when it was a catch-up. Editing a job never clears its schedule (see Changed: one-door Reuse). Every schedule word is one table (`SCHEDULE_WORDS`) the server sends.
17
+ - **A run waiting at its review door shows `Accept` in Needs you** (never ages out).
18
+ - **Accept, from the panel (T, Part 8 of 8).** A run waiting at its review door gets an `Accept` button on the Run tab (right after Resume, before Edit in chat), shown only when the server says a door is open. It runs the engine's own door path (`run-u --spec --door --decide accept --approve`): the mechanical stages re-run, a red is `door-accept-red`, the verdict is untouched; the engine's own words are shown on a refusal. A scheduled run's green is held (not offered by Reuse, `green-held`) until it is accepted.
19
+
20
+ ### Changed
21
+
22
+ - **The Runs list search and run cards follow fwdloop M11 am5 and am6 (PANEL-BUILD.md P9 v2 section O; replaces the "one newest older match past the 7" rule).** The search splits at spaces and every word must match; `run-2` matches only run-2 (never run-20); `MM/DD` or `YYYY-MM-DD` matches the runs that started that local day (a part date such as `10/1` matches nothing); while a search or chip is on, a job's older-runs fold shows only matching runs, newest first, at most 7, plus the selected run. The line after the 7 reads `(and k older · <oldest> to <newest> of those k · search above)` or, while searching, `(and k more matches · add a day or a run)`; the search box reads `job, run-23, or a day 10/12`. Line 1 of every run card, and of the Running now and Scheduled cards, now sets the date, model, time, cost and next-run text in the same size and baseline as the job and run name (`run-2 · 10/12 13:46 · deepseek-flash`), not in the small raised faint span.
23
+ - **The Runs search takes any day form, ignores brackets and says it plainer (fwdloop M11 am7; PANEL-BUILD.md section O am7).** `10/13`, `10-13` and `2026-10-13` mean the same local day; `aging-live-4 (run-2)` finds what `aging-live-4 run-2` finds; the line after the 7 gives the hidden runs' real oldest-to-newest days (`4 more older runs, 10/11 to 10/13. Search to find one.`, or `6 more runs on 10/11. ...`, or `3 more match. Add a date or run number to narrow it.` while searching); the search box reads `search: job name, run-23, or a date 10/13`. Replaces am5 item 5's words.
24
+ - **A scheduled fire refused with `install-needed` backs off 1, 2, 3, 5, 8, 13, 21 minutes, then stops and waits for you (PANEL-BUILD.md T addendum item 11, RULED 2026-10-15).** It used to redo the copy and `npm ci` on every 500 ms tick; the first fix was a flat 1 minute. Now each consecutive refusal of a job waits the next Fibonacci step (the count is in memory and starts over after a start, a cancel or a re-sign); the next step that would pass 60 minutes in total is not taken: ONE `gave-up` row is written and the schedule pauses (read from the ledger, so a restart does not resume it). No clock slot fires and no queued file runs while paused; new files still queue. The job shows in Runs list Needs you with the pill `Check` (reword is one edit in `SCHEDULE_WORDS`), and the run page's NEXT is red: `Stopped trying after 60 minutes: <words>. Fix it, then Cancel / modify schedule to start again.` Re-signing through Cancel / modify schedule, or cancelling, clears it. During a wait NEXT reads `Trying again in N minutes.`
25
+ - **The Runs search box reads `job, run-23, or date 10/13` (fwdloop M11 am8; PANEL-BUILD.md section O am8).** am7's longer words clipped at 320 px; the new ones fit, and a test pins the words and a 32-character limit.
26
+ - **The Runs search line after the 7 shows the hidden matches' real days (fwdloop M11 am9; PANEL-BUILD.md section O am9).** While searching: `5 more matches, 10/11 to 10/12. Add a date or run number to narrow it.`, or `50 more matches on 10/09. Add a run number to narrow it.`; a hidden match with no date drops the date part. Replaces am7 item 4's `k more match.` words.
27
+ - **Runs-list card money names the drafting share.** The short form reads `$0.22 (drafting <$0.01)` (was `$0.22 (<$0.01)`, the bracket's meaning unclear); a floor reads `(drafting at least $X)`. The long form `($X drafting)` is unchanged.
28
+ - **The panel's Runs list is rebuilt in fwdloop's shape (P9 v2).** One list, top to bottom: Running now (live runs, the wall counting up in place), Needs you (a stopped run the engine would resume, with a `Resume` label, for 7 days), the filters, Jobs (run in the last 30 days, newest activity first) and a folded `quiet jobs`. A job card shows `job · k runs · check type` and its newest run; its runs sit under it, with the older ones behind `older runs (k)` (7 shown, then "use search"). The Workflows / History switch and the flat History list are gone.
29
+ - **Search reads the whole visible row** (job, run-N, run id, model, status word, reason, date, cost, wall, check type); a matching run is marked with a blue bar, never selected, and the fold opens on it. The search box placeholder is shorter.
30
+ - **`GET /api/runs` now returns `{runs, jobs, limits}`.** The server decides live, needs-you, each job's newest run, the quiet split and the limits; the page works out nothing.
31
+
32
+ - **Reuse workflow is ONE door on every run (RULED 2026-10-15).** Every box opens; the card's job boxes pick the road. A green or already-green run with no job box changed signs its plan verbatim under its own name with no drafting; a changed job drafts a NEW workflow under `<base>-N` (a name already used by another job is refused in plain words). Edit in chat and the locked-box refusal are gone, and an already-green run now counts as green for Reuse and the picker.
33
+ - **Remove from Needs you (`POST /api/runs/:runid/needs-remove`).** A stopped run's card leaves Needs you with one `needs-removed` row in `runs.jsonl`; its status, list row, Resume and money stay, and a later stop brings it back.
34
+ - **The SCHEDULE line says why, and only real problems are red.** Line 2 says a run is waiting for your Accept, and `busy` reads "still working"; before the first fire it says no run yet, and an unarmed schedule ends `next: not armed yet`. Only a refused try, the give-up and a used-up month are red, and the month-used-up red shows only in the month it happened; busy and the install back-off are not red.
35
+ - **A run's name reads `job · run-N` in Running now, Needs you and Scheduled,** and the Scheduled and gave-up cards carry the run's number from the server.
36
+ - **A stopped run is grey, not red.** The `[■]` sign, the step-map chip and the Run tab frame all use the same grey as `[·]` (you stopped it, it did not fail); a schedule problem on the run still wins red.
37
+ - **A sign blinks only while the run is running or waiting for your Accept;** every other sign rests (reduced motion keeps the sign).
38
+ - **The Audit tab of a scheduled job has its own folded Schedule section at the top:** this run's start, and the tries that started nothing (all signings, newest 7), in the same rounds-table shape as the drafting and step sections.
39
+ - **The Run tab has one summary block (ENDED dated, NEXT, SCHEDULE with last and next fire; red on a problem), and the Job tab has Started and Schedule rows.** Editing a job never clears a schedule. Runs-list rows say `step N of M` while running.
40
+ - **Resume and Accept answer in plain words, never raw engine output (RULED 2026-10-15/16).** A known engine sentence or a fixed plain sentence with the log path replaces raw output (`engineRefusalWords`); Resume and Accept on a run whose saved job fails today's checks say so in plain words and refuse at $0 before spawning anything. An Accept refused by its final checks names each failed check by its plain question, never the stage id.
41
+
42
+ ### Fixed
43
+
44
+ - **Schedule run and Cancel / modify schedule never start a run (live defect, run mv9euoam).** When only Destination, Inputs or a cap changed (or nothing changed), the card's main button stayed the Reuse card's `Sign & run` and started a hand run without touching the schedule. The schedule card now has no run path: a change in When, Inputs, Destination or a cap makes the button `Sign schedule` (then `Confirm`, the $0 two-click door); unchanged is a disabled `Sign schedule` with the "nothing changed" words.
45
+ - **A refused scheduled fire now shows on the open run page.** The page stopped refetching a run once it was no longer live, so the SCHEDULED / NEXT block of a finished run stayed as first painted and the red NEXT ("The last try did not start: ...") the server already sent never appeared. A finished run of a scheduled job is now refetched on the runs-list cadence (10 s).
46
+ - **A scheduled start that is refused leaves no session folder behind, and a refusing child cannot loop.** `wouldAccept` now runs the session's own Destination check and the good-run check before any session exists; any session the starter made for a start that is then refused (the copy refused a symlink, packages missing, the saved copy no longer hashing to what was signed, a failed spawn) is removed by the starter, and a child that exits non-zero within 1.5 s with no row in the run list counts as a release toward `MAX_RELEASES`, so the retries and the rows stay bounded. The live failure wrote 237 folders at 500 ms.
47
+ - **A plain-files job can be scheduled and reused with a real Destination (rubric-2 schedule refused at its first fire).** The Destination of a plain-files rubric job is an absolute folder that the signed spec never holds (its fence is `output/` inside the run's tree), but the Reuse and Schedule cards opened with that fence and `buildReuseSpec` wrote whatever was typed into it, so a schedule signed fine and its fire was refused ("Destination must be the full path of a folder"). The card now opens with the folder the origin run delivered to, an absolute Destination stays out of the spec (it rides on the schedule and in the hash both clicks bind), and ONE check (`startDestinationProblem`) runs at both schedule clicks, at the HTTP start (before any session folder exists) and in a scheduled fire's `wouldAccept`.
48
+ - **A rubric job signed without an explicit `judge` can be scheduled and reused.** `workflowKey` now hashes the RESOLVED judge of a soft-green spec (the runner's `resolveJobJudge`), so a missing judge and its pinned default (what Schedule run and Reuse sign) are one workflow; an explicitly different judge is another, and deterministic keys are unchanged. Before, Schedule run (and Reuse) of such a job was refused with "not the one that had the good run" after an honoured Accept (mv76m82c). Soft-green keys differ from before, so a schedule saved by an earlier build for a judge-less rubric job needs re-signing.
49
+ - **A replan answer cut off at its length limit is no longer reported as "The model provider failed" (RULED 2026-10-16, F229, run mv9gm796).** It is a bounded failed attempt: the cut round is booked, the half-answer is never validated and is handed to the one redraft as a plain-words red (a cut redraft, even after a bad first answer, ends the run), and the run ends with the plain sentence "The replan answer was cut off at its length limit, so no new plan came back." It is no longer in the resumable provider-red set. Scout and the first draft are unchanged. Test-proven only.
50
+ - **Reuse workflow on a saved job that makes the validator throw answers the plain "made by an older bareloop" words** instead of a raw `internal error` 500.
51
+ - **A scheduled start's `lateMs` and `startedAt` use the same clock the scheduler uses** (they read the wall clock beside an injected scheduler clock).
52
+ - **The `signed` schedule-ledger row no longer carries the unread `originRunid` field** (the Audit reads `schedule.json` and `trigger.json`).
53
+
8
54
  ## [0.42.0] - 2026-10-14
9
55
 
10
56
  ### Added
@@ -153,7 +153,7 @@ minting claim, or the shell-owned retry cap — all unknown-field reds.
153
153
  | `description` | non-empty string | |
154
154
  | `provider` | `anthropic-api` \| `openai-api` \| `gemini-api` \| `clipipe-subscription` | menu (`PROVIDERS`): an API SHAPE (a wire protocol), part of the lineage key by definition. Only `anthropic-api` is guaranteed (F48); the shapes go through the provider factory (`src/providers.js`) — see "Worker surface" above for the `baseUrl` field and the row's old-max_tokens tick; `gemini-api` (PRD item 31.3) is ADMITTED-PENDING-PROBE — zero runs, and every launch says so (`PROBE_STATUS`/`probeWarningLines`); `clipipe-subscription` drives the worker natively and needs `opts.nativeProvider` |
155
155
  | `conditions` | `{ providerPath?, closeVerbosity?, taskFraming?, scaffold? }` | declared keys only, string values — the environment label (consumed by the N3 lineage key; recorded on spines from run one) |
156
- | `cadence` | `{ unit: hour\|day\|week, every: 1..30 }` | validated now, consumed at N5 (Scheduler) |
156
+ | `cadence` | `{ unit: hour\|day\|week, every: 1..30 }` | still validated if present but IGNORED: it starts nothing (an old signed spec keeps it, never rewritten). Scheduling is the `trigger` field, panel-only (see "Triggers" below) |
157
157
  | `budgetUsd` | finite `0 < n <= shell cap` | ceiling chain: workflow ≤ job ≤ shell — each layer may tighten, never exceed |
158
158
  | `maxWallMs` | optional integer ms `>= MIN_WALL_MS` (one close timeout) | the run's wall clock. **NO DEFAULT, by ruling** — absent means time-unbounded *by explicit operator choice*, never by fallback (F45: a defaulted cap is a silent second ceiling). Enforcement is a BETWEEN-ROUND deadline, so the honest worst case is `maxWallMs + closeStages × closeTimeoutMs` — every stage of a staged close gets the FULL timeout — and all three numbers are reported (`loop.stop()` cannot cut an in-flight call — F61 measured 500ms→4,018ms). The `MIN_WALL_MS` floor is a ONE-stage, un-autoset-default number, so a spec with many stages OR a raised/autoset `closeTimeoutMs` can validate while its overshoot dwarfs its cap: `runPlan` adds a RUN-START check (PRD item 27/M3) that refuses `wall-under-close-timeout` at $0 once the EFFECTIVE per-stage ceiling is known (autoset or signed), rather than a silent clamp. Operator-only, tighten-only; adding or changing it changes the spec hash |
159
159
  | `closeTimeoutMs` | optional integer ms `>= CLOSE_TIMEOUT_FLOOR_MS` (120,000 — hamr's arbiter floor, 2026-09-07) | PRD item 27/M3's SIGNED override for the close's own per-stage wall-clock cap. Absent (the normal case): the run autosets it from a $0 seed timing pass — `max(FLOOR, K × slowest measured stage)`, `K = 5` (hamr, 2026-09-07: "2 and 5 are fine"). Present: the operator's own number wins outright and may legally sit ABOVE *or below* the autoset estimate (the rates-passthrough shape, F113 — never tighten-only). Arbiter territory exactly like `budgetUsd`/`maxWallMs`/`close[].sha256`: the authoring pipeline's `assembleSpec` refuses a draft carrying it |
@@ -1437,9 +1437,13 @@ ambiguity, not a merge. `resolveHumanRuling(fresh, held)` is the exported seam t
1437
1437
  `branch-red` is the WORK BRANCH refusing (below): the patient is not a git checkout, its
1438
1438
  branch namespace has no free name, or a resume's recorded branch is gone. Zero tokens, and
1439
1439
  never a fallback to working on the branch the run was handed. `provider-red` is a
1440
- transport throw, or a scout/drafter/replan round the API cut off mid-generation
1440
+ transport throw, or a scout or first-draft round the API cut off mid-generation
1441
1441
  (`truncated:max_tokens`, BA-6 — before which it laundered into a clean finish, F25): no
1442
- verdict exists and the failed round's spend is only partly known (F6). A STEP or FIX-loop
1442
+ verdict exists and the failed round's spend is only partly known (F6). A REPLAN answer cut
1443
+ off at `max_tokens` is NOT provider-red (hamr 2026-10-16, F229): the half answer is never
1444
+ validated, the redraft is told in plain words to answer shorter, and a second cut ends as
1445
+ `plan-red` with an escalation `cause: 'truncated'` and the code-owned sentence "The replan
1446
+ answer was cut off at its length limit, so no new plan came back." A STEP or FIX-loop
1443
1447
  worker round cut at `max_tokens` is NOT provider-red (F122 "2A" half b, hamr 2026-10-05, fix
1444
1448
  phase added by ruling A): it is a failed attempt — the round is booked, its output discarded,
1445
1449
  an `attempt-bounded` record carries the reason `output cut at the token limit (max_tokens)`,
@@ -3411,11 +3415,12 @@ key you want first (the panel picks the row by the Model menu).
3411
3415
  - **`bareloop panel [--port N]`** (PANEL-BUILD.md P1) → a READ-ONLY `node:http` server
3412
3416
  (no new dependency), bound to `127.0.0.1` ONLY, default port `4700`. Serves the panel's
3413
3417
  look (Tokyo Night box-drawing TUI style) over the run list's real data: `GET /` the page;
3414
- `GET /api/runs` (newest first, each row enriched with a glyph/check-type/spend/wall
3415
- summary, `fileMissing:true` when the listed spine is gone — the client groups this SAME
3416
- list by job for the left pane's "Workflows" view and renders it flat for "History", both
3417
- behind one `Runs` tab toggle; there is no separate `/api/workflows` endpoint, deleted in
3418
- the P1 Runs-tab merge); `GET /api/runs/:runid` (the full replay — parts/steps, counters,
3418
+ `GET /api/runs` (P9 v2: `{runs, jobs, scheduled, gaveUp, limits}`; `runs` newest first, each row enriched with a glyph/check-type/spend/wall
3419
+ summary, `fileMissing:true` when the listed spine is gone, and the states the left list draws, all decided by the server:
3420
+ `live`, `liveWhy` (`step N of M`), `liveSince`/`priorWallMs`, `needs` (`'Resume'` while the engine would accept a resume and
3421
+ the run stopped under 7 days ago and was not removed; `'Accept'` while the run waits at its review door, `doorWaiting`), `stoppedAt`; `jobs` = `[{job, headRunid, quiet, activityMs}]`
3422
+ newest activity first; `scheduled` = the Scheduled section's cards and `gaveUp` = the Needs-you cards of schedules that gave up (both built by the schedule door, `listScheduled` / `listGaveUp`); `limits` = `{quietDays:30, olderRuns:7, needsYouDays:7}`. The page groups `runs` by `jobs` into Running now,
3423
+ Needs you, Jobs and a quiet fold; there is no Workflows / History switch and no separate `/api/workflows` endpoint); `GET /api/runs/:runid` (the full replay — parts/steps, counters,
3419
3424
  summary, model/provider/judge). Every `GET /api/runs` row and the detail carry `runNo` (P6 item 7): runs of one job are numbered
3420
3425
  oldest `at` first by ONE server function (`runNumbers`), and the page names a run `job (run-N)`; the run id stays in the Audit
3421
3426
  tab and every command line. A run that came out of a panel drafting session also carries the DRAFTING part as the FIRST element of
@@ -3590,6 +3595,7 @@ key you want first (the panel picks the row by the Model menu).
3590
3595
  Chat card in RESUME mode (title `RESUME <job> (run-N)`, filled from `GET /api/author/start-from`, every box locked
3591
3596
  except the two caps, "spent so far" shown, Clear/Start drafting hidden) and `Sign & resume` on that card is the human
3592
3597
  click, posting this same route; the run's Job tab is read-only on every run.
3598
+ **Remove from Needs you** → `POST /api/runs/:runid/needs-remove` (`src/panel/runroutes.js`, same `checkHumanGuard`, no body): appends ONE `{runid, type:'needs-removed', at}` row to `runs.jsonl` (`appendNeedsRemoved`; `readNeedsRemovals` reads it) and changes nothing else; 409 in plain words when the run is not in Needs you or waits for Accept. `readRunList` ignores the row type, so monthly accounting, the run counter and the scheduler never see it; a later stop (a newer `job-end`) puts the run back in Needs you. The run detail carries `canRemoveFromNeeds` and `needsAudit`.
3593
3599
  **Stop (item 5)** → `POST /api/runs/:runid/stop` (`src/panel/runroutes.js`, `checkHumanGuard`, no body).
3594
3600
  It writes the run's STOP REQUEST, a file `<spine>.stop` (`stopFilePath(spine)`, `src/legs.js` — the one
3595
3601
  spelling), and signals nothing. `404` unknown run; `409` when the run's LATEST leg is not live
@@ -3613,34 +3619,38 @@ key you want first (the panel picks the row by the Model menu).
3613
3619
  scout, plan or close-fix phase is honoured at that phase's next round boundary, the same as in a step (it used to be read in
3614
3620
  step workers only). A stop in the close-fix loop reads "You stopped the run during the fix phase … a resume continues the
3615
3621
  run" (`ralph`'s `phase` option; the step loop keeps "a resume re-enters this step").
3616
- **Edit in chat (2026-10-08)** — `ended.actions` carries `{id:'edit', label:'Edit in chat'}` on EVERY ending that is not a
3617
- real green (failed, capped, stopped, died, every other red, and an already-green or delivery-refused already-green),
3618
- after `resume` when both show. The button reads `GET /api/author/start-from` and opens the Chat card filled from the
3619
- signed job with EVERY box open and the normal Start drafting path (no reuse state, no borrowed track record), under a
3620
- code-owned origin line ("Copied from <job> (run-N) — <status word>. …").
3621
- **Reuse workflow (replaces P5 item 3's Start from this, 2026-10-03)** — a button on runs that EARNED their green only
3622
- (the Run tab's action row and the Ended block; `ended.actions` carries `{id:'reuse', label:'Reuse workflow'}` on `green`
3623
- and green-with-destination-refused `green` rows, and on no other: an `already-green` run (the check passed before any
3624
- work) offers Edit in chat instead, and a red row's next line is "Change the job: press Edit in chat."; stopped/capped/died
3625
- rows offer `resume` and `edit`) and on every imported job; the Chat card's Check type is one radio group of three
3622
+ **Reuse workflow — ONE door on every run (2026-10-15, PRD v1.101; it replaced the green-only button and the old edit door)** —
3623
+ `ended.actions` carries `{id:'reuse', label:'Reuse workflow'}` on EVERY ending (green, failed, capped, stopped, died, every
3624
+ other red, already-green), after `resume` when both show, and on every imported job; a red row's next line is "Change the
3625
+ job: press Reuse workflow." The Chat card's Check type is one radio group of three
3626
3626
  (Deterministic · Rubric · Reuse workflow), and the third opens a search picker over `GET /api/author/reuse-jobs` (below) that
3627
- fills the card through this same path (a pick, then `change` returns to the empty search; Deterministic or Rubric leaves reuse).
3628
- It opens the Chat tab's New job card
3629
- filled from the SIGNED job: a NEW run (new runid), never a rerun, nothing drafted ($0). **Only five boxes are
3630
- open — Inputs, Destination, Model, $ cap, Time cap**; every other box (check type, job name, The job) is the signed workflow, greyed, and the SERVER refuses a start that changed one
3631
- (`400 "<Field> is locked on a reused workflow — Clear the card to change it"`; the page is never trusted).
3632
- Changing a locked box is Clear (the Chat card's top-right button), which drafts. Routes, behind `checkHumanGuard`:
3627
+ fills the card through this same path (a pick puts the radio on the job's own check type). The button opens the Chat tab's New
3628
+ job card filled from the SIGNED job with EVERY box open (job lines and `~` rules, check type, judge examples, thinking, Inputs,
3629
+ Destination, Model, $ cap, Time cap) under a code-owned origin line "Reuse: <job> (run-N)"; nothing is locked and the server
3630
+ no longer refuses a changed job box. **The path is decided by the CARD, not the drafted spec** (`jobBoxesChanged(card, originCard)`
3631
+ in `src/panel/authorsession.js`): the JOB boxes are the job text, the check type and the thinking tick; the RUN boxes (Inputs,
3632
+ Destination, Model, $ cap, Time cap) never decide it. A GREEN origin (an imported job counts as green) with no job box changed is
3633
+ today's reuse — the signed plan verbatim, nothing drafted ($0), a new run of the same workflow signed under the ORIGIN job name.
3634
+ Any job box changed (green or red origin) drafts a NEW workflow named by the Job name box. A non-green origin with no job box
3635
+ changed also drafts (an unproven plan is never reused), signed under the ORIGIN name (another try of the same job). The name is
3636
+ fixed before drafting, so the hash the person signs already covers it. The Job name box shows the origin name until a job box
3637
+ changes, then the next free `<base>-N` (`GET start-from` returns it as `nextName`; renamable; base = the part before a trailing
3638
+ `-<digits>` when that part is itself a run-list or jobs/ job, so `x-2` suggests `x-3`); changing the boxes back restores the origin name. A name owned by a
3639
+ different job, or the origin's own name on a changed job, is refused at $0 ("A job named "x" already exists and is a different job.
3640
+ Pick another name."); a name typed while nothing is changed is ignored (the origin's is forced). The main button reads Sign & run on
3641
+ the reuse path and Start drafting otherwise, flipping as the person types. A new workflow has no schedule until its own first green;
3642
+ nothing here ever clears a schedule. Only the schedule card keeps its job boxes locked (it signs the green run's plan as it is).
3643
+ Routes, behind `checkHumanGuard`:
3633
3644
  `GET /api/author/start-from?runid=<id>` or `?import=<id>` → `{ok, origin, card, from:'signed job'|'imported job',
3634
- note, locked:[…], open:['source','destination','model','capUsd','maxWallMs'], specHash, workflowKey, trackRecord:{runs,
3645
+ note, green, nextName, specHash, workflowKey, trackRecord:{runs,
3635
3646
  green, notGreen, live, finished, avgSpendUsd, avgWallMs}, line}` (`404` unknown, `409` with an `error` when
3636
3647
  there is nothing to copy: no log, the run's signed job is not on disk, or an imported job that changed, lost its
3637
3648
  dependency or no longer matches its signed bytes); `POST /api/author/start` accepts `startFrom: <runid>` or
3638
- `startFrom: {importId}` beside the card and answers `{ok, sessionId, reuse, state}`; a local `startFrom: <runid>` whose
3639
- latest outcome is not `green` (`start-from` carries that `outcome`) is refused `409 "only a run that finished green can
3640
- be reused — this one did not"` — the server owns the rule, not just the pickers. The old
3649
+ `startFrom: {importId}` beside the card and answers `{ok, sessionId, reuse, state}` (`reuse` false = drafting); the server
3650
+ makes the card-compare decision above, so a hand-made POST cannot reuse a plan that did not earn its green. The old
3641
3651
  `POST /api/author/start-from-check` and the "Changed — new job" mode are gone. `GET /api/author/reuse-jobs[?model=<Name>]` →
3642
3652
  `{ok, jobs:[{job, runid, checkType:'deterministic'|'rubric', line}]}` is the Chat card's Reuse workflow picker list: one entry per
3643
- job (same `workflowKey`) with at least one real `green` local run (`already-green` is not a reuse source), `runid` = its newest green run (hand it to `start-from`), `line` =
3653
+ job (same `workflowKey`) with at least one `green` or `already-green` local run (RULED 2026-10-15: an already-green run is a reuse source), `runid` = its newest such run (hand it to `start-from`), `line` =
3644
3654
  start-from's own estimate line, newest green first; imported jobs are not listed. **Identity, two keys:** the
3645
3655
  signature hash `jobSpecHash` is unchanged (it covers the caps and the write fence — what the person signs);
3646
3656
  `workflowKey(spec)` (`src/job.js`, not exported from the package root) is a sha256 over the signed spec WITHOUT
@@ -3651,7 +3661,7 @@ key you want first (the panel picks the row by the Model menu).
3651
3661
  when the Name is not the provider's default tier — spelled as authoring spells it; a RUBRIC origin with no `judge`
3652
3662
  first gets `judge` = its own resolved judge identity so the judge never follows the worker; an explicit judge is
3653
3663
  kept; deterministic jobs get none), and signs under a
3654
- NEW `jobSpecHash` with the SAME `workflowKey` (the session refuses a spec whose key moved). Locked boxes carry the
3664
+ NEW `jobSpecHash` with the SAME `workflowKey` (the session refuses a spec whose key moved). The job boxes carry the
3655
3665
  signed values read by the Job tab's own readers. **Estimate line** above the card: "Same job — G green · N not
3656
3666
  green · about $X and M min a run", from every LISTED run whose signed spec has the same `workflowKey` AND ran the same worker (provider + baseUrl + model) as the Model box now names — `GET /api/author/start-from?…&model=<Name>` recomputes it when the box changes; none = "Same job — no runs yet on this model" (one run =
3657
3667
  one entry via `legsOf`; the averages read FINISHED runs with exact figures — spend from `chainSpend`, working time
@@ -4374,3 +4384,16 @@ Check what the person asked for, not what the worker chose to write. bareloop ne
4374
4384
  - **Web addresses as inputs (Part 1c, `src/pagefetch.js`, `src/source.js`).** An Inputs line may be `label: https://...` (also a rubric card's FIRST line; `looksLikeRubricInput`). `fetchPageText(url)` calls barebrowse `connect({ incognito: true, blockPrivateNetwork: true })` (`CONNECT_OPTIONS`) → `goto` → `waitForNetworkIdle({ idle: 1500, timeout: 20000 })` (bounded; a page that never settles is read as it stands) → `snapshot({ mode: 'act' })`, always closing the browser, with our own 90 s deadline (L1: a load-time `browse()` snapshot gave 77,951, 8,801 or 719 characters of the same results page; L2: a page whose text outside the banner / navigation / contentinfo landmarks is under `MIN_CONTENT_CHARS` = 100 (`pageContentChars`) is a stop too, "only the site's own header and menus", and an input with no content never reaches the look or an early ask: the survey is ABSENT, "holds nothing to read", nothing spent); an empty or under-200-character page (`MIN_PAGE_CHARS`), a bot challenge, an unreachable address, a credentialed or secret-shaped address, or a page carrying a secret shape is a plain-words $0 stop on its line. `proveRubricInputs({ ..., fetchUrl })` freezes the page text under `input/<label>.txt` and records `url` on the manifest input row. A FRESH `run-u --spec` start (not `--resume` / `--door` / `--decide`) calls `refreezeUrlInputs`: it fetches every recorded address again, writes that text into the prepared tree, commits it as the new seed and rewrites `source.json` (new `sha256` and `seed`, `draftSha256` keeps what the look saw) BEFORE the worker starts, so quotes are checked against the run's copy and the worker reads it, never the live page. A failed or empty fetch stops the run, "The run did not start. Nothing spent." Plan card (L3): `settlePersonsWords` (called in `authorClose` before validation) unmarks AI's proposal on a limit made of the person's own numbers, swaps in the one explicit positive time range the person wrote, and drops a `refused` entry for a `~` rule a part of its line carries; the look's fact may carry `forRule` (a rule it numbered from its own knowledge, marked AI's proposal), which removes that rule from the look's not-found list so it is not asked. Test seams: `fetchPageText(url, { connect })`, `deps.fetchUrl` (`run-u`), `deps.fetchUrl` / `deps.lookFn` (`createSession`).
4375
4385
  - **Ask at once, only about intent (I1).** A look not-found entry that names a `~` rule becomes a question worded by code (`ruleQuestions`: line and rule named), asked before the confirm turn through the existing question gate (`answerQuestions`; `normalizeQuestions` caps it at `MAX_QUESTIONS`); the answer is a new `~` rule on that line. A rubric job on files never offers the piecemeal try-3 question; its failed-draft stop (`draftStopText({ wholeJob: true })`) says to review the whole job and submit it again.
4376
4386
  - **Known limits (documented, not built around).** Airport codes like SFO have no stable label on a flight page: direction is checkable only through the full airport names the page uses. An unanchored `text` fact cannot tell which field a value sits in. `am` / `pm` in lower case and `9:27AM` are malformed times; `v0.41.0` is one token. A fact step's quote proves the value is in a real record, not that it is the RIGHT record: "the cheapest" and "the last 3" are Not checked. Verified on real files only; the live proof (DeepSeek, both jobs through the panel) has not been run.
4387
+
4388
+ ## Triggers — a job that starts by itself (panel-only; `src/trigger*.js`, `src/panel/schedules.js`, `schedulestart.js`, `scheduler.js`, `src/schedulewords.js`)
4389
+
4390
+ - **The `trigger` field of a signed spec** is a typed object, never cron text: `{kind:'daily'|'weekdays', hour, minute}`, `{kind:'weekly', day:'mon'..'sun', hour, minute}`, `{kind:'new-file', folder}` or `{kind:'file-updated', path}` (absolute paths). By hand = the key is ABSENT, so a by-hand spec hashes as before. `workflowKey` ignores it. The agent never sets it: `assembleSpec` refuses a draft that carries it.
4391
+ - **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`.
4392
+ - **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`.
4393
+ - **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.
4394
+ - **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.
4395
+ - **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`).
4396
+ - **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.
4397
+ - **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`.
4398
+ - **Every word the page shows** comes from `src/schedulewords.js` (`SCHEDULE_WORDS` is the one button table the server sends).
4399
+ - **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, head, rows}`; 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.42.0",
3
+ "version": "0.43.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",
package/src/authorjob.js CHANGED
@@ -1026,6 +1026,12 @@ export function assembleSpec(specDraft, { closeDecl, verdictType }) {
1026
1026
  + 'this fold writes it. Merging would drop or overwrite what the draft says, and a signed spec that does not '
1027
1027
  + 'contain what its author typed is the one failure nobody can see. Remove the field(s) from the draft');
1028
1028
  }
1029
+ // T (PRD v1.99, the agent never sets a trigger): a schedule is the person's own two-click act in the panel, never a
1030
+ // draft field. Refused by name, like the authored half.
1031
+ if (draft.trigger !== undefined) {
1032
+ throw new Error('the spec draft carries trigger — a schedule is set only by the person, in the panel\'s two-click schedule '
1033
+ + 'door, never by a draft. Remove the field from the draft');
1034
+ }
1029
1035
  return { ...draft, verdictType, closeDecl };
1030
1036
  }
1031
1037
 
@@ -107,7 +107,7 @@ export const CLOSE_DECL_FIELDS = Object.freeze(['genre', 'lang', 'stages', 'note
107
107
  /** what a stored calibration set carries, ENUMERATED: the frozen cases and the judge
108
108
  * tier that graded them. Both are inside the spec, so `jobSpecHash` covers both — which
109
109
  * is the whole mechanism behind "a judge-model bump forces a full recalibration"
110
- * (src/judged.js, `JUDGE_MODEL`): the field moves, the hash moves, the signature dies,
110
+ * (src/judged.js, `resolveJudge`): the field moves, the hash moves, the signature dies,
111
111
  * and the re-sign re-runs the gate. Anything else in there is a field nobody reads. */
112
112
  export const CALIBRATION_FIELDS = Object.freeze(['cases', 'judgeModel']);
113
113
 
@@ -447,7 +447,7 @@ export function validateCloseDecl(closeDecl, opts = {}) {
447
447
  // WHICH TIER GRADED THIS SET, and it is REQUIRED rather than optional. A
448
448
  // stored set is only worth the judge that graded it (§4.2's safety argument
449
449
  // is BA-20's haiku-4.5 injection evidence and nothing else), so a set with
450
- // no model beside it is a set nobody can attribute — and the `JUDGE_MODEL`
450
+ // no model beside it is a set nobody can attribute — and the `CALIBRATION_FIELDS`
451
451
  // docstring's promise that a bump forces recalibration has nothing to fire
452
452
  // on. Required HERE and not at the stage, for the same reason the SET's own
453
453
  // presence is the signing gate's call and not this validator's: this gate
@@ -690,7 +690,6 @@ export async function main(argv, deps = {}) {
690
690
  // endpoint on its own in that case (PRD item 28 ruling (d)).
691
691
  ...(baseUrlArg === null ? {} : { baseUrl: baseUrlArg }),
692
692
  ...(modelArg === null ? {} : { model: modelArg }),
693
- cadence: { unit: 'day', every: 1 },
694
693
  budgetUsd,
695
694
  ...(wallMin === null ? {} : { maxWallMs: Math.round(wallMin * 60_000) }),
696
695
  // ABSENT for a plain folder (`writeScope === null`, D5/step S6) — never a
package/src/job.js CHANGED
@@ -18,6 +18,8 @@ import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiter
18
18
  import { validateCloseDecl, DECLARED_CLOSE_CLASSES } from './declaredclose.js';
19
19
  import { inputLabelsOf } from './rubricstep.js';
20
20
  import { CLOSE_TIMEOUT_FLOOR_MS } from './closetimeout.js';
21
+ import { validateTrigger } from './trigger.js';
22
+ import { resolveJobJudge } from './judged.js';
21
23
 
22
24
  // The menus below ARE the close-authoring hierarchy (PRD §7) and ship frozen:
23
25
  // they are read at call time, so a mutable export would let adopter code
@@ -168,7 +170,7 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
168
170
  // `steps` stays in the field list ONLY so a retired spec reds by name
169
171
  // (`shape-retired`) instead of falling through to a generic unknown-field —
170
172
  // the operator gets told what happened, not just that something is wrong.
171
- const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'thinking', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools', 'jobLines', 'inputs'];
173
+ const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'trigger', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'thinking', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools', 'jobLines', 'inputs'];
172
174
  /** the exact fields of a signed `judge` override — nested objects red unknown
173
175
  * keys too, because no smuggling level exists in a signed spec */
174
176
  const JUDGE_FIELDS = ['provider', 'model'];
@@ -278,15 +280,25 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
278
280
  }
279
281
  }
280
282
 
281
- if (spec.cadence === undefined) red('missing-required', 'cadence');
282
- else if (!isObj(spec.cadence)) red('invalid-value', 'cadence', 'object of {unit, every}');
283
- else {
284
- const cad = spec.cadence;
285
- for (const key of Object.keys(cad)) {
286
- if (key !== 'unit' && key !== 'every') red('unknown-field', `cadence.${key}`, 'nested objects red unknown keys too — no smuggling level exists in a signed spec');
283
+ // T (PRD v1.99): `cadence` is no longer required. A spec signed before triggers keeps its `cadence` (still validated
284
+ // when present, read as by hand, never rewritten, never re-hashed); a new spec carries none.
285
+ if (spec.cadence !== undefined) {
286
+ if (!isObj(spec.cadence)) red('invalid-value', 'cadence', 'object of {unit, every}');
287
+ else {
288
+ const cad = spec.cadence;
289
+ for (const key of Object.keys(cad)) {
290
+ if (key !== 'unit' && key !== 'every') red('unknown-field', `cadence.${key}`, 'nested objects red unknown keys too — no smuggling level exists in a signed spec');
291
+ }
292
+ if (!CADENCE_UNITS.includes(cad.unit)) red('invalid-value', 'cadence.unit', CADENCE_UNITS.join('|'));
293
+ if (!(Number.isInteger(cad.every) && cad.every >= 1 && cad.every <= 30)) red('bounds', 'cadence.every', '1..30');
287
294
  }
288
- if (!CADENCE_UNITS.includes(cad.unit)) red('invalid-value', 'cadence.unit', CADENCE_UNITS.join('|'));
289
- if (!(Number.isInteger(cad.every) && cad.every >= 1 && cad.every <= 30)) red('bounds', 'cadence.every', '1..30');
295
+ }
296
+
297
+ // T: the trigger the person signed (a typed object, never cron text). ABSENT = by hand. Set only by the panel's
298
+ // two-click schedule door; the agent never writes it (assembleSpec refuses a draft that carries it).
299
+ if (spec.trigger !== undefined) {
300
+ const tv = validateTrigger(spec.trigger);
301
+ if (!tv.ok) red(tv.code, tv.path, tv.say);
290
302
  }
291
303
 
292
304
  if (spec.budgetUsd === undefined) red('missing-required', 'budgetUsd');
@@ -931,6 +943,13 @@ export function jobSpecHash(job) {
931
943
  */
932
944
  export const REUSE_OPEN_SPEC_FIELDS = Object.freeze(['source', 'destination', 'writeScope', 'budgetUsd', 'maxWallMs', 'inputs']);
933
945
 
946
+ /**
947
+ * The signed spec's schedule fields: {@link workflowKey} ignores them too, so a scheduled spec keeps the workflow
948
+ * key of the green run it was scheduled from (else Reuse would refuse every scheduled spec). They stay inside
949
+ * {@link jobSpecHash}. Kept apart from {@link REUSE_OPEN_SPEC_FIELDS}, which names the Reuse boxes.
950
+ */
951
+ export const SCHEDULE_SPEC_FIELDS = Object.freeze(['trigger']);
952
+
934
953
  /**
935
954
  * The worker-identity fields {@link workflowKey} ALSO ignores (hamr 2026-10-04: Model is open on a reuse; the Name
936
955
  * chosen in Settings carries its provider and baseUrl with it). They stay inside {@link jobSpecHash}, so what the person
@@ -945,7 +964,8 @@ export const WORKFLOW_KEY_IDENTITY_FIELDS = Object.freeze(['provider', 'baseUrl'
945
964
  * share a workflow key iff they ran the same goal, checks, guardrails and close — whatever source they ran on, wherever they wrote, whatever caps they had.
946
965
  * It is NOT a signature: {@link jobSpecHash} (which covers the caps and the fence) stays what the person signs, and
947
966
  * a reuse lands on a NEW jobSpecHash with the SAME workflowKey. Same never-throws contract as `jobSpecHash`, and
948
- * the same resolved-`tools` form, so an omitted-`tools` spec and its spelled-out menu share a key.
967
+ * the same resolved-`tools` form, so an omitted-`tools` spec and its spelled-out menu share a key. A soft-green spec keys on its
968
+ * RESOLVED judge identity (an absent `judge` is its worker's), so pinning the default judge never changes the key.
949
969
  * @param {object} job
950
970
  * @returns {string} sha256 hex
951
971
  */
@@ -954,7 +974,14 @@ export function workflowKey(job) {
954
974
  try {
955
975
  /** @type {any} */
956
976
  const rest = isObj(job) ? { ...job } : job;
957
- if (isObj(rest)) for (const f of [...REUSE_OPEN_SPEC_FIELDS, ...WORKFLOW_KEY_IDENTITY_FIELDS]) delete rest[f];
977
+ // A RUBRIC job's judge defaults to its own worker. The worker identity is dropped below, so resolve the judge FIRST
978
+ // (the runner's one spelling) and key on that: a spec with no `judge` and the same spec with the judge pinned to
979
+ // its default (what a reuse/schedule signs) are one workflow; an explicitly different judge is another.
980
+ // Deterministic specs have no judge, so their key is untouched. An unresolvable judge leaves the spec as signed.
981
+ if (isObj(rest) && rest.verdictType === 'soft-green') {
982
+ try { rest.judge = resolveJobJudge(/** @type {any} */ (rest), /** @type {any} */ (rest.provider), resolveWorkerModel); } catch { /* validateJob reds it */ }
983
+ }
984
+ if (isObj(rest)) for (const f of [...REUSE_OPEN_SPEC_FIELDS, ...WORKFLOW_KEY_IDENTITY_FIELDS, ...SCHEDULE_SPEC_FIELDS]) delete rest[f];
958
985
  c = canon(resolveSpec(rest));
959
986
  } catch { c = '\u0000unhashable'; }
960
987
  return createHash('sha256').update(`workflow-key-v1\n${c}`).digest('hex');