bareloop 0.33.0 → 0.35.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 (56) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/bareloop.context.md +263 -27
  3. package/package.json +1 -1
  4. package/src/bundlerun.js +13 -4
  5. package/src/draftspend.js +82 -0
  6. package/src/exits.js +10 -1
  7. package/src/interviewrun.js +10 -9
  8. package/src/job.js +39 -2
  9. package/src/judged.js +25 -5
  10. package/src/kinds.js +2 -0
  11. package/src/ledger.js +104 -9
  12. package/src/legs.js +171 -0
  13. package/src/monthly.js +133 -87
  14. package/src/panel/authorroutes.js +95 -15
  15. package/src/panel/authorsession.js +408 -113
  16. package/src/panel/importroutes.js +359 -0
  17. package/src/panel/importrun.js +203 -0
  18. package/src/panel/index.html +1071 -184
  19. package/src/panel/runroutes.js +219 -0
  20. package/src/panel/server.js +855 -81
  21. package/src/panel/status.js +51 -0
  22. package/src/planrun.js +63 -31
  23. package/src/providerrows.js +21 -2
  24. package/src/providers.js +4 -4
  25. package/src/ralph.js +3 -0
  26. package/src/replay.js +139 -19
  27. package/src/replayio.js +11 -4
  28. package/src/reuse.js +20 -3
  29. package/src/run.js +6 -3
  30. package/src/runlist.js +90 -7
  31. package/src/text.js +20 -0
  32. package/src/u-readout.js +9 -1
  33. package/src/u-watchdog.mjs +4 -2
  34. package/src/userrun.js +166 -65
  35. package/types/draftspend.d.ts +53 -0
  36. package/types/job.d.ts +25 -0
  37. package/types/judged.d.ts +5 -2
  38. package/types/ledger.d.ts +44 -0
  39. package/types/legs.d.ts +103 -0
  40. package/types/monthly.d.ts +39 -22
  41. package/types/panel/authorroutes.d.ts +38 -1
  42. package/types/panel/authorsession.d.ts +109 -2
  43. package/types/panel/importroutes.d.ts +129 -0
  44. package/types/panel/importrun.d.ts +77 -0
  45. package/types/panel/runroutes.d.ts +32 -0
  46. package/types/panel/server.d.ts +255 -2
  47. package/types/panel/status.d.ts +51 -0
  48. package/types/planrun.d.ts +13 -21
  49. package/types/providerrows.d.ts +14 -1
  50. package/types/providers.d.ts +1 -2
  51. package/types/replay.d.ts +25 -1
  52. package/types/replayio.d.ts +2 -2
  53. package/types/reuse.d.ts +5 -0
  54. package/types/run.d.ts +6 -2
  55. package/types/runlist.d.ts +66 -2
  56. package/types/text.d.ts +19 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Split one spine's parsed records into its legs. Pure.
3
+ * @param {any[]} events one spine's parsed records, in file order
4
+ * @returns {Leg[]} always at least one leg for a non-empty list; `[]` for an empty one
5
+ */
6
+ export function legsOf(events: any[]): Leg[];
7
+ /**
8
+ * The legs' WORKING time, gap excluded (hamr 2026-10-02, 2A: only working time charges the wall). Each leg
9
+ * counts first record → last record; the quiet time between one leg's end and the next leg's marker is
10
+ * nobody's. `null` = unknown (no leg carries two parseable stamps) — never 0. A leg whose stamps are
11
+ * unreadable adds nothing, so the figure is a FLOOR, `complete: false`.
12
+ * @param {Leg[]} legs
13
+ * @returns {{ ms: number|null, complete: boolean }}
14
+ */
15
+ export function legsWallMs(legs: Leg[]): {
16
+ ms: number | null;
17
+ complete: boolean;
18
+ };
19
+ /**
20
+ * Parse a spine's TEXT, tolerant exactly where the writer leaves damage. A kill mid-append leaves a torn
21
+ * line; a resume never edits it (the spine is append-only forever) — it writes one `\n` and starts a fresh
22
+ * line, so the torn bytes end up alone on their own line, immediately BEFORE a `leg-resume` marker. That
23
+ * line, and the file's own last two lines (the older tail rule: a kill mid-append of the file's final
24
+ * record), are tolerated and named. An unparseable line anywhere else is `corrupt`: a reconstruction from
25
+ * it would invent a history.
26
+ * @param {string} raw
27
+ * @returns {{ records: any[], tolerated: {line: number, why: 'tail'|'before-leg-resume'}[], corrupt: {line: number, message: string}|null }}
28
+ * `line` is 1-based. Only the FIRST corrupt line is reported.
29
+ */
30
+ export function parseSpineText(raw: string): {
31
+ records: any[];
32
+ tolerated: {
33
+ line: number;
34
+ why: "tail" | "before-leg-resume";
35
+ }[];
36
+ corrupt: {
37
+ line: number;
38
+ message: string;
39
+ } | null;
40
+ };
41
+ /**
42
+ * Where leg `leg`'s watchdog kill note lives. Leg 1 keeps the name every run has always had
43
+ * (`<spine>.watchdog.json`); a resumed leg's note sits beside it (`<spine>.leg<N>.watchdog.json`) so one
44
+ * leg never overwrites another's evidence of how it died.
45
+ * @param {string} spineFile
46
+ * @param {number} leg 1-based
47
+ * @returns {string}
48
+ */
49
+ export function watchdogNotePath(spineFile: string, leg: number): string;
50
+ /**
51
+ * P5 item 5 (Stop) — where a run's STOP REQUEST lives: a file beside its spine. The panel's stop route writes it;
52
+ * the engine reads it at the between-steps seam (src/planrun.js) and consumes it. One spelling, here, so the route
53
+ * and the engine can never name two files. A request belongs to ONE leg: the engine deletes it when it honours it,
54
+ * when the leg ends, and again at the start of the next leg (src/userrun.js).
55
+ * @param {string} spineFile
56
+ * @returns {string}
57
+ */
58
+ export function stopFilePath(spineFile: string): string;
59
+ /** The marker record's `type`. */
60
+ export const LEG_RESUME: "leg-resume";
61
+ export type Leg = {
62
+ /**
63
+ * 1-based position in file order (the marker's own `leg` field is never trusted over the order)
64
+ */
65
+ leg: number;
66
+ /**
67
+ * the leg's first record (the `leg-resume` marker for leg 2+; the file's first record for leg 1)
68
+ */
69
+ start: any | null;
70
+ /**
71
+ * the leg's last record
72
+ */
73
+ end: any | null;
74
+ /**
75
+ * every record of the leg, in file order, marker included
76
+ */
77
+ records: any[];
78
+ /**
79
+ * the leg's own `job-start` (null: it ended before runJob wrote one)
80
+ */
81
+ jobStart: any | null;
82
+ /**
83
+ * the leg's own LAST `job-end` (null: it died, or it is still running)
84
+ */
85
+ jobEnd: any | null;
86
+ /**
87
+ * the leg's terminal: its `job-end` outcome, else a `run-end` outcome (a $0 refusal), else null
88
+ */
89
+ outcome: string | null;
90
+ /**
91
+ * leg 1: null. Leg 2+: how the PREVIOUS leg ended, as the marker declares it
92
+ * (else derived from that leg's own terminal), `'died'` when it recorded none
93
+ */
94
+ after: string | null;
95
+ /**
96
+ * `Date.parse` of the start record's `ts`
97
+ */
98
+ startMs: number | null;
99
+ /**
100
+ * `Date.parse` of the end record's `ts`
101
+ */
102
+ endMs: number | null;
103
+ };
@@ -1,18 +1,8 @@
1
- /**
2
- * One run's own spend, off its spine records: this LEG's figure (never the chain fold),
3
- * plus the drafting spend the run was signed with (counted once — on the first leg only).
4
- * @param {any[]} records
5
- * @returns {{ usd: number, complete: boolean }}
6
- */
7
- export function legSpend(records: any[]): {
8
- usd: number;
9
- complete: boolean;
10
- };
11
1
  /**
12
2
  * One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
13
3
  * first -> last record floor (the SAME floor a died / still-running run's "at least" wall uses,
14
4
  * `floorsFromRecords`). `ms` null = unknown — never 0.
15
- * @param {any[]} records
5
+ * @param {any[]} records one spine's records (one leg or several)
16
6
  * @returns {{ ms: number|null, complete: boolean }}
17
7
  */
18
8
  export function legWall(records: any[]): {
@@ -29,20 +19,23 @@ export function legTokens(records: any[]): number;
29
19
  /**
30
20
  * Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
31
21
  * and the per-provider figures share.
32
- * @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below)
22
+ * @param {{ home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string, thisMonthOnly?: boolean }} [opts] `sessionsRoot` = where the panel's sessions live (default `<home>/panel-sessions`); `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below); `thisMonthOnly` = do not parse the spine of a run listed in another local month (the refusal check drops it anyway) unless its claim is still held
33
23
  * @returns {Leg[]}
34
24
  */
35
25
  export function readLegs(opts?: {
36
26
  home?: string;
27
+ sessionsRoot?: string;
37
28
  now?: () => number;
38
29
  aboveRunid?: string;
30
+ thisMonthOnly?: boolean;
39
31
  }): Leg[];
40
32
  /**
41
- * @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`: see {@link readLegs}
42
- * @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's)
33
+ * @param {{ home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`, `sessionsRoot`: see {@link readLegs}
34
+ * @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number, heldUsd: number, heldRuns: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's); `heldUsd` = what in-progress runs set aside beyond what they have spent (`reservedUsd - usd`), over `heldRuns` runs
43
35
  */
44
36
  export function monthSpend(opts?: {
45
37
  home?: string;
38
+ sessionsRoot?: string;
46
39
  now?: () => number;
47
40
  aboveRunid?: string;
48
41
  }): {
@@ -50,18 +43,21 @@ export function monthSpend(opts?: {
50
43
  atLeast: boolean;
51
44
  runs: number;
52
45
  reservedUsd: number;
46
+ heldUsd: number;
47
+ heldRuns: number;
53
48
  };
54
49
  /**
55
50
  * The Money tab's figures: all-time and this-month spend, and per-provider breakdown
56
51
  * (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
57
52
  * pooled into another row). Any leg with unknown spend makes the figure it belongs to
58
53
  * an "at least".
59
- * @param {{ home?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
54
+ * @param {{ home?: string, sessionsRoot?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
60
55
  * @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
61
56
  * byProvider: Record<string, {label: string, monthUsd: number, monthAtLeast: boolean, totalUsd: number, totalAtLeast: boolean, tokens: number, vouchedRounds: number, otherRounds: number, monthWallMs: number|null, monthWallAtLeast: boolean, totalWallMs: number|null, totalWallAtLeast: boolean}> }}
62
57
  */
63
58
  export function spendSummary(opts?: {
64
59
  home?: string;
60
+ sessionsRoot?: string;
65
61
  now?: () => number;
66
62
  rows?: readonly import("./providerrows.js").KeyRow[];
67
63
  }): {
@@ -97,6 +93,8 @@ export function spendSummary(opts?: {
97
93
  * @property {number|null} leftUsd what is left this month (null = no limit set)
98
94
  * @property {number|null} limitUsd the person's monthly limit (null = none)
99
95
  * @property {boolean} atLeast the month's spend is a floor, so `leftUsd` is a ceiling
96
+ * @property {number} [heldUsd] what run(s) in progress have set aside beyond their spend so far (present when a limit is set)
97
+ * @property {number} [heldRuns] how many runs hold it
100
98
  */
101
99
  /**
102
100
  * The person's monthly limit off a parsed config.json: key absent (or null) = no limit (null);
@@ -112,17 +110,19 @@ export function monthlyLimitOf(config: Record<string, any>, home?: string): numb
112
110
  * number above 0 throws `ConfigError`.
113
111
  * Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
114
112
  * whose own instrument is broken refuses; it never silently runs with no limit.
115
- * @param {{ capUsd: number, home?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
113
+ * @param {{ capUsd: number, home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
116
114
  * @returns {MonthlyRoom}
117
115
  */
118
- export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }: {
116
+ export function checkMonthlyRoom({ capUsd, home, now, aboveRunid, sessionsRoot }: {
119
117
  capUsd: number;
120
118
  home?: string;
119
+ sessionsRoot?: string;
121
120
  now?: () => number;
122
121
  aboveRunid?: string;
123
122
  }): MonthlyRoom;
124
123
  /**
125
- * The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal).
124
+ * The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal). When run(s) in progress hold
125
+ * part of the limit, the same line says how much.
126
126
  * @param {MonthlyRoom} room
127
127
  * @returns {string|null} null when the run fits
128
128
  */
@@ -148,19 +148,21 @@ export function settleDeadClaims({ home, by, aboveRunid }: {
148
148
  * it appends `{type:'released', reason:'refused'}` and reports the refusal, nothing spent. No limit set =
149
149
  * no claim (`claimed: false`; the caller lists the run as it always did). An unreadable config, or a row
150
150
  * that cannot be written, throws `ConfigError` — the gate refuses, never reads "no limit".
151
- * @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number }} args
151
+ * A RESUMED leg (`leg` 2+, P5-R) claims the same way but appends a `leg-start` beside the run's one row
152
+ * (the row itself is added only when the list has none), and a refusal gives back THAT leg's claim.
153
+ * @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number, leg?: number }} args
152
154
  * @returns {{ room: MonthlyRoom, claimed: boolean }}
153
155
  */
154
- export function claimRun({ row, capUsd, home, now }: {
156
+ export function claimRun({ row, capUsd, home, now, leg }: {
155
157
  row: import("./runlist.js").RunRow;
156
158
  capUsd: number;
157
159
  home?: string;
158
160
  now?: () => number;
161
+ leg?: number;
159
162
  }): {
160
163
  room: MonthlyRoom;
161
164
  claimed: boolean;
162
165
  };
163
- export { isLiveRunner };
164
166
  export type Leg = {
165
167
  /**
166
168
  * when the run was listed
@@ -208,6 +210,10 @@ export type Leg = {
208
210
  * the spine was missing/unreadable or the listed date was bad (spend unknown)
209
211
  */
210
212
  unreadable: boolean;
213
+ /**
214
+ * drafting spend of a panel session that never became a run (src/draftspend.js) — not a run: no wall, not counted in `runs`
215
+ */
216
+ draft?: boolean | undefined;
211
217
  };
212
218
  export type MonthlyRoom = {
213
219
  ok: boolean;
@@ -223,5 +229,16 @@ export type MonthlyRoom = {
223
229
  * the month's spend is a floor, so `leftUsd` is a ceiling
224
230
  */
225
231
  atLeast: boolean;
232
+ /**
233
+ * what run(s) in progress have set aside beyond their spend so far (present when a limit is set)
234
+ */
235
+ heldUsd?: number | undefined;
236
+ /**
237
+ * how many runs hold it
238
+ */
239
+ heldRuns?: number | undefined;
226
240
  };
227
- import { isLiveRunner } from './runlist.js';
241
+ import { legSpend } from './ledger.js';
242
+ import { runSpend } from './ledger.js';
243
+ import { chainSpend } from './ledger.js';
244
+ export { legSpend, runSpend, chainSpend };
@@ -1,5 +1,20 @@
1
1
  /** @returns {string} a fresh per-process token — never persisted, never logged */
2
2
  export function mintToken(): string;
3
+ /**
4
+ * The ONE Host check: the request names this panel's own bind address. A page on another
5
+ * origin reaching the panel through a rebound DNS name sends that name as Host, so it fails here.
6
+ * @param {import('node:http').IncomingMessage} req
7
+ * @param {{port: number}} o
8
+ * @returns {{ok: true}|{ok: false, reason: string}}
9
+ */
10
+ export function checkHostGuard(req: import("node:http").IncomingMessage, { port }: {
11
+ port: number;
12
+ }): {
13
+ ok: true;
14
+ } | {
15
+ ok: false;
16
+ reason: string;
17
+ };
3
18
  /**
4
19
  * `true` when a request may reach ANY route in this file — the token header
5
20
  * plus an Origin/Host that names this exact bind address. `req.headers`
@@ -25,7 +40,8 @@ export function checkHumanGuard(req: import("node:http").IncomingMessage, { toke
25
40
  * deliberately has none of).
26
41
  * @param {{ port: number, token: string, env?: Record<string,string|undefined>,
27
42
  * sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
28
- * jobsDir?: string, fetchImpl?: typeof fetch, home?: string }} opts
43
+ * jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
44
+ * startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any} }} opts
29
45
  */
30
46
  export function createAuthorRoutes(opts: {
31
47
  port: number;
@@ -37,10 +53,31 @@ export function createAuthorRoutes(opts: {
37
53
  jobsDir?: string;
38
54
  fetchImpl?: typeof fetch;
39
55
  home?: string;
56
+ startFrom?: {
57
+ get: (runid: string, o?: {
58
+ model?: string;
59
+ }) => any;
60
+ getImport: (id: string, o?: {
61
+ model?: string;
62
+ }) => any;
63
+ };
40
64
  }): {
41
65
  handle: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse, pathname: string, body: any) => boolean;
42
66
  sessions: Map<string, any>;
43
67
  };
68
+ /**
69
+ * The panel's OWN 2-decimal money render for a chat line — hamr's ruling
70
+ * 2026-09-28 (2nd addendum, panel money 2-decimals): a positive amount below
71
+ * a cent reads `<$0.01`, never `$0.00` (a real cost must never read as
72
+ * free). This is the server-side (Node, chat-text) sibling of
73
+ * `src/panel/index.html`'s own inline `panelMoney` — the two can't share
74
+ * code (one runs in the browser as inlined script, the other in Node), the
75
+ * same duplication `src/replay.js`'s `money()`/`index.html`'s `money()`
76
+ * already carry, so both apply the identical rule rather than drift.
77
+ * @param {number} n a non-negative dollar amount
78
+ * @returns {string}
79
+ */
80
+ export function panelMoney2(n: number): string;
44
81
  /**
45
82
  * THE ONLY function in this file (or anywhere in the panel) that spawns a
46
83
  * run. Refuses unless the session is `prepared` AND the hash the request
@@ -1,3 +1,14 @@
1
+ /** @typedef {{id: string, label: string, status: 'running'|'done'|'failed', detail: string}} StepLine */
2
+ /** @param {StepLine[]} steps @param {string} id the LATEST line carrying this id (the list is a time-ordered log; an id can repeat) */
3
+ export function latestStep(steps: StepLine[], id: string): StepLine | undefined;
4
+ /**
5
+ * The progress list is a chronological log of segments (hamr 2026-10-05): starting a different step closes the running
6
+ * line; a step that already has a line and starts again AFTER other steps gets a NEW line appended at the end (the
7
+ * earlier line keeps its status and detail). Existing lines are never moved. Re-starting the step that is still the
8
+ * LAST line just resumes that line.
9
+ * @param {StepLine[]} steps @param {string} id @param {string} [detail] (absent = keep what the line has)
10
+ */
11
+ export function advanceSteps(steps: StepLine[], id: string, detail?: string): void;
1
12
  /**
2
13
  * `true` when `jobs/<name>.json` already exists in the repo's own jobs/ dir
3
14
  * (resolved from THIS module's own on-disk location, never `process.cwd()`,
@@ -11,6 +22,67 @@
11
22
  export function jobNameTaken(name: string, opts?: {
12
23
  jobsDir?: string;
13
24
  }): boolean;
25
+ /**
26
+ * Only the form's own fields, nothing else a request body might carry (`startFrom`, stray keys).
27
+ * @param {any} card
28
+ * @returns {Record<string, any>}
29
+ */
30
+ export function cardFields(card: any): Record<string, any>;
31
+ /**
32
+ * The first LOCKED box on `card` that differs from what the signed workflow prefilled (`origin`), or `null` when
33
+ * every locked box is untouched. Blank and absent read alike; whitespace is not compared.
34
+ * @param {Record<string, any>} card the form as it was submitted
35
+ * @param {Record<string, any>} origin the form as the server prefilled it
36
+ * @returns {string|null} the offending field's label
37
+ */
38
+ export function lockedFieldChanged(card: Record<string, any>, origin: Record<string, any>): string | null;
39
+ /**
40
+ * The spec a reuse signs: a COPY of the origin's signed spec with ONLY the open fields set from the card —
41
+ * `writeScope` from Destination, `budgetUsd` from the $ cap, `maxWallMs` from the Time cap (absent when blank), and the
42
+ * worker (`provider`/`baseUrl`/`model`) from the Model Name chosen in Settings > Providers, spelled exactly as normal
43
+ * authoring spells it (`baseUrl` only when the row has one; `model` only when the Name is not the provider's default
44
+ * tier). A RUBRIC (soft-green) origin with no explicit `judge` first has its judge pinned to the ORIGIN's resolved judge
45
+ * identity — the judge must not change because the worker did (`resolveJobJudge`, the one spelling); an explicit
46
+ * `judge` is kept; a deterministic job has no judge to move. Source is not in a spec (it lives beside the run). The copy
47
+ * hashes to a NEW `jobSpecHash` and the SAME `workflowKey` — the caller checks the second after building.
48
+ * @param {any} originSpec
49
+ * @param {Record<string, any>} card
50
+ * @param {readonly import('../providerrows.js').KeyRow[]} [rows] the Settings rows; without them (or without `card.model`) the worker is left as signed
51
+ * @returns {any}
52
+ */
53
+ export function buildReuseSpec(originSpec: any, card: Record<string, any>, rows?: readonly import("../providerrows.js").KeyRow[]): any;
54
+ /**
55
+ * `$0` validation for a REUSE start: the signed job carries the goal, checks and guardrails, so those boxes are not
56
+ * required here — only what the new session itself needs: a Source, a Destination, a Model that is a Settings Name,
57
+ * and the cap. (The spec built from the open boxes is validated by the route with `validateJob`.)
58
+ * @param {any} card
59
+ * @param {{rows?: readonly import('../providerrows.js').KeyRow[]}} [opts]
60
+ * @returns {{ok: true}|{ok: false, error: string}}
61
+ */
62
+ export function validateReuseCard(card: any, opts?: {
63
+ rows?: readonly import("../providerrows.js").KeyRow[];
64
+ }): {
65
+ ok: true;
66
+ } | {
67
+ ok: false;
68
+ error: string;
69
+ };
70
+ /**
71
+ * The $0 refusal for a non-repo source, worded by the kind the source door recorded (`prep.manifest.kind`) — a single
72
+ * file is not a "folder", and Source must be the repo folder with the file named in Destination (hamr 2026-10-05).
73
+ * @param {string} kind `prep.manifest.kind`: 'file' | 'folder' | 'url' (a 'repo' never reaches this)
74
+ * @returns {string}
75
+ */
76
+ export function nonRepoSourceMessage(kind: string): string;
77
+ /**
78
+ * The ONE Destination rule for a REPO source (fresh card and Reuse card alike): there Destination is the write fence,
79
+ * relative to the repo, so an absolute path is refused — at $0, before any copy or model call. A FOLDER source (or a
80
+ * source that does not peek as a repo) keeps today's behaviour: its Destination is an absolute output directory.
81
+ * @param {string} destination
82
+ * @param {boolean} isRepoLike `looksLikeRepoSource(card.source)`
83
+ * @returns {string|null} the plain refusal, or null when the Destination is fine
84
+ */
85
+ export function repoDestinationProblem(destination: string, isRepoLike: boolean): string | null;
14
86
  /**
15
87
  * `$0` validation of the job card fields the server must check BEFORE it
16
88
  * creates a session or spends anything — never inside the async pipeline,
@@ -36,8 +108,9 @@ export function validateJobCard(card: any, opts?: {
36
108
  * @param {any} card the validated job card (see {@link validateJobCard})
37
109
  * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
38
110
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
39
- * prepareSigningFn?: Function}} [deps] the last five are TEST SEAMS ONLY — see the note
40
- * just above where each is read, below.
111
+ * prepareSigningFn?: Function, reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
112
+ * TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
113
+ * caller (`/api/author/start`) sets it only when the server's own same-job rule held.
41
114
  * @returns {any} the session object
42
115
  */
43
116
  export function createSession(card: any, deps?: {
@@ -50,4 +123,38 @@ export function createSession(card: any, deps?: {
50
123
  confirmGenerate?: Function;
51
124
  authorFn?: Function;
52
125
  prepareSigningFn?: Function;
126
+ reuse?: {
127
+ spec: any;
128
+ workflowKey: string;
129
+ };
53
130
  }): any;
131
+ /**
132
+ * THE PROGRESS LIST's one table (hamr 2026-10-04, "one place showing all"): every pipeline step the left Chat panel
133
+ * lists, in the order they first appear, each with a stable id and a plain code-owned label. A step is one line that
134
+ * updates in place (running -> done or failed with its reason); the thread carries only chat turns.
135
+ * @type {Readonly<Record<string, string>>}
136
+ */
137
+ export const STEP_LABELS: Readonly<Record<string, string>>;
138
+ /**
139
+ * The library's own `onPhase(name, ...)` calls (`src/authorjob.js`/`src/authorflow.js`) -> the step each one advances.
140
+ * A phase with no entry leaves the current step as it was.
141
+ * @type {Readonly<Record<string, keyof typeof STEP_LABELS>>}
142
+ */
143
+ export const PHASE_STEP: Readonly<Record<string, keyof typeof STEP_LABELS>>;
144
+ /** the form's fields, in card order — what `card.json` stores, verbatim (P5 item 3) */
145
+ export const CARD_FIELDS: readonly string[];
146
+ /**
147
+ * REUSE WORKFLOW (hamr 2026-10-03, replaces P5 item 3's same-job rule; Model opened 2026-10-04): a reuse card has exactly
148
+ * FIVE open boxes — Source, Destination, Model, $ cap, Time cap — and every other box is the signed workflow, shown greyed and never
149
+ * editable. The page greys them; THIS is what refuses (the server never trusts the page). Changing any locked box
150
+ * is a different job: Clear the card, which drafts.
151
+ */
152
+ export const REUSE_OPEN_FIELDS: readonly string[];
153
+ /** every card field that is NOT open on a reuse card, in card order */
154
+ export const REUSE_LOCKED_FIELDS: readonly string[];
155
+ export type StepLine = {
156
+ id: string;
157
+ label: string;
158
+ status: "running" | "done" | "failed";
159
+ detail: string;
160
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Expand, normalise and `realpath` a requested folder. Never throws.
3
+ * @param {unknown} input the raw `path` (absent/blank = the person's home folder)
4
+ * @param {string} userHome the home folder (`~`)
5
+ * @returns {{ok: true, requested: string, path: string}|{ok: false, error: string}}
6
+ */
7
+ export function resolveFsPath(input: unknown, userHome: string): {
8
+ ok: true;
9
+ requested: string;
10
+ path: string;
11
+ } | {
12
+ ok: false;
13
+ error: string;
14
+ };
15
+ /**
16
+ * The exported job bundles below `dir` (a `realpath` already), searched DOWN at most SEARCH_DEPTH levels. Symlinks are
17
+ * never followed, `node_modules`, `.git` and every dot-folder are skipped, a bundle is never descended into, an
18
+ * unreadable folder is skipped silently, and the search is capped (folders visited, results). Only names are read.
19
+ * @param {string} dir
20
+ * @returns {{entries: {name: string, path: string, bundle: true}[], visited: number, stopped: boolean, truncated: boolean}}
21
+ */
22
+ export function findBundles(dir: string): {
23
+ entries: {
24
+ name: string;
25
+ path: string;
26
+ bundle: true;
27
+ }[];
28
+ visited: number;
29
+ stopped: boolean;
30
+ truncated: boolean;
31
+ };
32
+ /** @param {string} [home] @returns {string} `<home>/imports.jsonl` */
33
+ export function importsPath(home?: string): string;
34
+ /** the stable id of an imported folder (its real path, hashed — never the path itself in a URL) */
35
+ export function importId(dir: string): string;
36
+ /**
37
+ * Record one import: ONE row per real folder. Any earlier row for the same folder is dropped and the new one is
38
+ * written, so re-importing updates the entry instead of adding a second. The rewrite goes through a temp file + rename.
39
+ * @param {string|undefined} home
40
+ * @param {{at: string, dir: string, job: string, bundleHash: string}} row
41
+ */
42
+ export function recordImport(home: string | undefined, row: {
43
+ at: string;
44
+ dir: string;
45
+ job: string;
46
+ bundleHash: string;
47
+ }): void;
48
+ /**
49
+ * The imported list: the LATEST row per folder (a re-import supersedes), newest first. A malformed line is skipped.
50
+ * @param {string} [home]
51
+ * @returns {{at: string, dir: string, job: string, bundleHash: string, id: string}[]}
52
+ */
53
+ export function readImports(home?: string): {
54
+ at: string;
55
+ dir: string;
56
+ job: string;
57
+ bundleHash: string;
58
+ id: string;
59
+ }[];
60
+ /**
61
+ * Re-read one imported folder NOW and say how it stands against the import: `ok`, `changed` (the bundle's own
62
+ * hash, a tampered file or an unreadable part differ from what was imported — shown red as "changed since
63
+ * import"), or `missing` (the folder is gone). Never throws.
64
+ * @param {{dir: string, bundleHash: string}} row
65
+ * @returns {{status: 'ok'|'changed'|'missing', statusText: string|null, reds: string[], bundle: ReturnType<typeof readBundle>}}
66
+ */
67
+ export function bundleStatus(row: {
68
+ dir: string;
69
+ bundleHash: string;
70
+ }): {
71
+ status: "ok" | "changed" | "missing";
72
+ statusText: string | null;
73
+ reds: string[];
74
+ bundle: ReturnType<typeof readBundle>;
75
+ };
76
+ /**
77
+ * The `bundle` half of an import view: the exported history (the registry rows the bundle shipped), the approval
78
+ * on THIS machine (`blessing.json`, written by the first green run here), nothing about the folder's other files.
79
+ * @param {ReturnType<typeof readBundle>} bundle
80
+ */
81
+ export function bundleHistoryAndApproval(bundle: ReturnType<typeof readBundle>): {
82
+ history: {
83
+ greens: number;
84
+ reds: number;
85
+ total: number;
86
+ recent: {
87
+ at: any;
88
+ runid: any;
89
+ outcome: any;
90
+ costUsd: any;
91
+ }[];
92
+ };
93
+ approved: boolean;
94
+ approvedText: string;
95
+ };
96
+ /**
97
+ * The import routes' handler. `describeSpec(spec)` is the server's own spec reader (the SAME fact-readers the Job
98
+ * tab uses, so an imported job and a run read alike): `{checkType, goal, success, guardrails, model, budgetUsd,
99
+ * maxWallMs}`.
100
+ * @param {{ port: number, token: string, home?: string, userHome?: string,
101
+ * describeSpec: (spec: any) => any }} opts
102
+ */
103
+ export function createImportRoutes(opts: {
104
+ port: number;
105
+ token: string;
106
+ home?: string;
107
+ userHome?: string;
108
+ describeSpec: (spec: any) => any;
109
+ }): {
110
+ handle: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse, pathname: string, body: any) => boolean;
111
+ viewOf: (row: {
112
+ id: string;
113
+ at: string;
114
+ dir: string;
115
+ job: string;
116
+ bundleHash: string;
117
+ }) => any;
118
+ };
119
+ /** the longest path the routes will look at (bytes) */
120
+ export const MAX_PATH_LEN: 4096;
121
+ /** how many levels below the current folder the bundle search looks */
122
+ export const SEARCH_DEPTH: 3;
123
+ /** the most folders one search visits; past it `stopped: true` */
124
+ export const MAX_VISITED: 5000;
125
+ /** the most bundles one search returns; past it `truncated: true` */
126
+ export const MAX_RESULTS: 200;
127
+ /** The ONE spelling of the plain-words line for a changed import (Run tab, reuse refusal). The list tag stays the short statusText. */
128
+ export const IMPORT_CHANGED_LINE: "files changed since you imported it \u2014 Reuse is off. Import it again if the change was yours.";
129
+ import { readBundle } from '../bundle.js';
@@ -0,0 +1,77 @@
1
+ /** the id an imported run has in `/api/runs/<id>` @param {string} importId @param {string} runid */
2
+ export function importedRunId(importId: string, runid: string): string;
3
+ /** @param {string} id @returns {{importId: string, runid: string}|null} */
4
+ export function parseImportedRunId(id: string): {
5
+ importId: string;
6
+ runid: string;
7
+ } | null;
8
+ /**
9
+ * The spine path of `<dir>/runs/<runid>`, or null when it is not a plain, bounded file in plain folders: `runs/` and
10
+ * the run folder must be real directories (a link is refused), the spine a regular file under the size bound, and a
11
+ * tool log beside it (either spelling the replay reader looks for) must not be a link or oversize either.
12
+ * @param {string} dir the bundle folder
13
+ * @param {string} runid
14
+ * @returns {string|null}
15
+ */
16
+ export function safeSpinePath(dir: string, runid: string): string | null;
17
+ /**
18
+ * The run folders of a bundle that hold a readable spine, newest spine first (bounded).
19
+ * @param {string} dir
20
+ * @returns {{runid: string, spine: string, mtimeMs: number}[]}
21
+ */
22
+ export function bundleRuns(dir: string): {
23
+ runid: string;
24
+ spine: string;
25
+ mtimeMs: number;
26
+ }[];
27
+ /**
28
+ * When the run started: its `job-start` record's `ts`, else the spine file's mtime (an ISO string). Never throws.
29
+ * @param {string} spine
30
+ * @returns {string}
31
+ */
32
+ export function spineStartedAt(spine: string): string;
33
+ /**
34
+ * The newest run in the bundle whose spine ended green (`green` / `already-green`, the same two the run page's Ended
35
+ * block calls green). A spine that cannot be read is skipped, never fatal.
36
+ * @param {string} dir
37
+ * @returns {{runid: string, spine: string, at: string}|null}
38
+ */
39
+ export function latestGreenRun(dir: string): {
40
+ runid: string;
41
+ spine: string;
42
+ at: string;
43
+ } | null;
44
+ /**
45
+ * The newest green version across a bundle's bridges (by `greenAt`), with the history row of the same run (it
46
+ * carries whether the spend figure is complete). A malformed bridge or version is skipped.
47
+ * @param {any[]} bridges
48
+ * @returns {{bridge: any, version: any, historyRow: any|null}|null}
49
+ */
50
+ export function latestBridgeGreen(bridges: any[]): {
51
+ bridge: any;
52
+ version: any;
53
+ historyRow: any | null;
54
+ } | null;
55
+ /**
56
+ * The run detail of the bridge's green version, in the SAME shape `getRunDetail` returns (so the page's one run
57
+ * renderer paints it), plus `fromBridge: true`. Only what the bridge carries is filled; every other field is null
58
+ * ("not recorded" on the page), never 0: no tool counts, no cache, no per-step figures, no close stages' numbers.
59
+ * @param {{bridge: any, version: any, historyRow: any|null}} g
60
+ * @param {{job: string, checkType: string, checkTypeTitle: string|null, model: string|null, budgetUsd: number|null}} facts
61
+ * @returns {any}
62
+ */
63
+ export function bridgeRunDetail(g: {
64
+ bridge: any;
65
+ version: any;
66
+ historyRow: any | null;
67
+ }, facts: {
68
+ job: string;
69
+ checkType: string;
70
+ checkTypeTitle: string | null;
71
+ model: string | null;
72
+ budgetUsd: number | null;
73
+ }): any;
74
+ /** the most run folders one bundle is searched through (newest first) */
75
+ export const MAX_RUN_DIRS: 40;
76
+ /** the biggest spine / tool-log file that is read (bytes) */
77
+ export const MAX_RUN_FILE_BYTES: number;