bareloop 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/bareloop.context.md +103 -41
- package/package.json +2 -2
- package/src/authorflow.js +26 -11
- package/src/authoring.js +1 -1
- package/src/authorjob.js +33 -15
- package/src/authorreadout.js +7 -6
- package/src/declaredclose.js +6 -3
- package/src/draftspend.js +20 -2
- package/src/facts.js +54 -9
- package/src/factwords.js +58 -6
- package/src/inputlook.js +38 -3
- package/src/interviewrun.js +0 -1
- package/src/job.js +39 -12
- package/src/kinds.js +5 -0
- package/src/pagedropwords.js +64 -0
- package/src/pagemap.js +264 -0
- package/src/pagerules.js +319 -0
- package/src/panel/authorroutes.js +138 -60
- package/src/panel/authorsession.js +88 -32
- package/src/panel/index.html +1251 -522
- package/src/panel/jobplan.js +4 -2
- package/src/panel/runroutes.js +188 -6
- package/src/panel/scheduler.js +156 -0
- package/src/panel/schedules.js +667 -0
- package/src/panel/schedulestart.js +243 -0
- package/src/panel/server.js +1234 -68
- package/src/panel/status.js +33 -0
- package/src/panelmoney.js +29 -0
- package/src/planrun.js +92 -11
- package/src/promptregisters.js +4 -0
- package/src/providerrows.js +3 -3
- package/src/replay.js +118 -7
- package/src/reviewdoor.js +37 -0
- package/src/rubricstep.js +123 -11
- package/src/runlist.js +37 -1
- package/src/schedulewords.js +299 -0
- package/src/source.js +18 -0
- package/src/thinking.js +63 -2
- package/src/trigger.js +48 -0
- package/src/triggerclock.js +127 -0
- package/src/triggerfiles.js +215 -0
- package/src/triggerledger.js +79 -0
- package/src/userrun.js +34 -21
- package/types/authorflow.d.ts +42 -6
- package/types/authorjob.d.ts +10 -2
- package/types/declaredclose.d.ts +3 -1
- package/types/draftspend.d.ts +27 -1
- package/types/facts.d.ts +15 -0
- package/types/factwords.d.ts +26 -3
- package/types/inputlook.d.ts +15 -0
- package/types/job.d.ts +8 -1
- package/types/kinds.d.ts +5 -0
- package/types/pagedropwords.d.ts +26 -0
- package/types/pagemap.d.ts +154 -0
- package/types/pagerules.d.ts +86 -0
- package/types/panel/authorroutes.d.ts +50 -14
- package/types/panel/authorsession.d.ts +37 -12
- package/types/panel/runroutes.d.ts +32 -1
- package/types/panel/scheduler.d.ts +17 -0
- package/types/panel/schedules.d.ts +412 -0
- package/types/panel/schedulestart.d.ts +106 -0
- package/types/panel/server.d.ts +297 -15
- package/types/panel/status.d.ts +51 -0
- package/types/panelmoney.d.ts +5 -0
- package/types/planrun.d.ts +2 -0
- package/types/providerrows.d.ts +2 -2
- package/types/replay.d.ts +21 -1
- package/types/reviewdoor.d.ts +27 -0
- package/types/rubricstep.d.ts +57 -1
- package/types/runlist.d.ts +24 -1
- package/types/schedulewords.d.ts +137 -0
- package/types/source.d.ts +11 -0
- package/types/thinking.d.ts +39 -0
- package/types/trigger.d.ts +37 -0
- package/types/triggerclock.d.ts +52 -0
- package/types/triggerfiles.d.ts +93 -0
- package/types/triggerledger.d.ts +35 -0
- package/types/userrun.d.ts +4 -0
|
@@ -19,19 +19,27 @@
|
|
|
19
19
|
|
|
20
20
|
import { randomBytes } from 'node:crypto';
|
|
21
21
|
import { spawn as realSpawn } from 'node:child_process';
|
|
22
|
-
import { openSync } from 'node:fs';
|
|
22
|
+
import { openSync, existsSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { readFileSync } from 'node:fs';
|
|
25
25
|
import { planMatchesClose } from '../rubricstep.js';
|
|
26
|
-
import { createSession, validateJobCard, validateReuseCard,
|
|
26
|
+
import { createSession, validateJobCard, validateReuseCard, jobBoxesChanged, buildReuseSpec, cardFields, nextFreeJobName, startDestinationProblem, STEP_LABELS } from './authorsession.js';
|
|
27
27
|
import { parseInputs } from '../jobblock.js';
|
|
28
|
-
import { proveInputFiles, looksLikeRubricInput } from '../source.js';
|
|
28
|
+
import { proveInputFiles, looksLikeRubricInput, webFetchState } from '../source.js';
|
|
29
29
|
import { validateJob } from '../job.js';
|
|
30
|
+
|
|
31
|
+
/** plain words for a run whose saved job no longer passes today's `validateJob` (RULED 2026-10-15): never the raw red codes */
|
|
32
|
+
export const OLD_JOB_REUSE_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be reused. Start a new job instead.";
|
|
33
|
+
export const OLD_JOB_RESUME_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be resumed. Use Reuse workflow on a newer run, or start a new job.";
|
|
34
|
+
export const OLD_JOB_ACCEPT_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be accepted. Use Reuse workflow on a newer run, or start a new job.";
|
|
35
|
+
export const OLD_JOB_RERUN_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be rerun. Use Reuse workflow on a newer run, or start a new job.";
|
|
36
|
+
export const OLD_JOB_PAUSE_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be paused from here. Use Reuse workflow on a newer run, or start a new job.";
|
|
30
37
|
import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
|
|
31
38
|
import { ConfigError } from '../config.js';
|
|
32
39
|
import { keysForDoor, keysHome } from '../keysfile.js';
|
|
33
40
|
import { endpointOf, judgePick, keyValueOf, rowsForHome, tickedLines } from '../providerrows.js';
|
|
34
41
|
import { thinkingForLine } from '../thinking.js';
|
|
42
|
+
import { panelMoney2 } from '../panelmoney.js';
|
|
35
43
|
import { apiKeyProblem, checkProviderReachable } from '../providers.js';
|
|
36
44
|
|
|
37
45
|
/** @returns {string} a fresh per-process token — never persisted, never logged */
|
|
@@ -85,6 +93,18 @@ export function checkHumanGuard(req, { token, port }) {
|
|
|
85
93
|
* one; a terminal session is kept (so its chat/hash stay readable) but no
|
|
86
94
|
* longer counts against the one-at-a-time rule.
|
|
87
95
|
*/
|
|
96
|
+
/**
|
|
97
|
+
* PRD v1.106 Part 5 Q2: the "fetching the page..." step is done once the run's own refetch has written `refrozenAt` (source.json, written last).
|
|
98
|
+
* Settled when the state is read, so nothing polls and the step never stays running after the fetch.
|
|
99
|
+
* @param {any} state
|
|
100
|
+
*/
|
|
101
|
+
export function settleFetchingStep(state) {
|
|
102
|
+
const st = Array.isArray(state?.steps) ? state.steps.find((/** @type {any} */ x) => x.id === 'fetching') : null;
|
|
103
|
+
if (!st || st.status !== 'running') return;
|
|
104
|
+
const file = join(String(state.outDir), 'source-seed', 'source.json');
|
|
105
|
+
if (existsSync(file) && !webFetchState(file, st.since).fetching) st.status = 'done';
|
|
106
|
+
}
|
|
107
|
+
|
|
88
108
|
const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'signing-failed']);
|
|
89
109
|
|
|
90
110
|
/**
|
|
@@ -95,6 +115,9 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
|
|
|
95
115
|
* @param {{ port: number, token: string, env?: Record<string,string|undefined>,
|
|
96
116
|
* sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
|
|
97
117
|
* jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
|
|
118
|
+
* schedules?: ReturnType<typeof import('./schedules.js').createScheduleDoor>,
|
|
119
|
+
* sessionDeps?: Record<string, any>,
|
|
120
|
+
* runJobNames?: () => string[],
|
|
98
121
|
* startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any, listJobs?: (o?: {model?: string}) => any[]} }} opts
|
|
99
122
|
*/
|
|
100
123
|
export function createAuthorRoutes(opts) {
|
|
@@ -110,8 +133,43 @@ export function createAuthorRoutes(opts) {
|
|
|
110
133
|
/** @type {Map<string, ReturnType<typeof createSession>>} */
|
|
111
134
|
const sessions = new Map();
|
|
112
135
|
|
|
136
|
+
/** Headless (scheduled) sessions live apart from the person's: neither blocks nor is blocked by the other (T I.5). Not addressable over HTTP. */
|
|
137
|
+
/** @type {Map<string, ReturnType<typeof createSession>>} */
|
|
138
|
+
const headless = new Map();
|
|
139
|
+
|
|
113
140
|
const hasLiveSession = () => [...sessions.values()].some((s) => !TERMINAL_PHASES.has(s.state.phase));
|
|
114
141
|
|
|
142
|
+
/**
|
|
143
|
+
* THE one session start (T Part 4, C.4.2): prove the Inputs at $0, then create the session — the route's tail, factored so the
|
|
144
|
+
* HTTP route and the schedule starter share ONE function. A scheduled fire is exactly a Reuse the person did not click.
|
|
145
|
+
* `headless` puts the session in its own map and skips the one-live-session rule.
|
|
146
|
+
* @param {any} card the form (fields only)
|
|
147
|
+
* @param {{spec: any, workflowKey: string}|null} reuse
|
|
148
|
+
* @param {import('../jobblock.js').JobInput[]} inputs the parsed Inputs
|
|
149
|
+
* @param {{headless?: boolean}} [o]
|
|
150
|
+
* @returns {Promise<{ok: true, session: ReturnType<typeof createSession>}|{ok: false, status: number, error: string}>}
|
|
151
|
+
*/
|
|
152
|
+
function startSession(card, reuse, inputs, o = {}) {
|
|
153
|
+
// ONE owner of "is this a rubric job on files": a reuse (and so a scheduled fire) reads it from the signed spec's verdict type,
|
|
154
|
+
// never from the card; a fresh job reads the card. A rubric job whose line 1 is a file or URL has no repo to prove inputs against;
|
|
155
|
+
// the session proves every line itself (`proveRubricInputs`), at $0, before any model call.
|
|
156
|
+
const rubric = reuse ? reuse.spec?.verdictType === 'soft-green' : card?.checkType === 'rubric';
|
|
157
|
+
if (reuse) card = { ...card, checkType: rubric ? 'rubric' : 'deterministic' };
|
|
158
|
+
const filesJob = rubric && inputs.length > 0 && looksLikeRubricInput(inputs[0].value);
|
|
159
|
+
return (filesJob ? Promise.resolve(/** @type {{ok: true}} */ ({ ok: true })) : proveInputFiles(inputs)).then((proof) => {
|
|
160
|
+
if (!proof.ok) return { ok: /** @type {false} */ (false), status: 400, error: proof.error };
|
|
161
|
+
if (!o.headless && hasLiveSession()) return { ok: /** @type {false} */ (false), status: 409, error: 'an authoring session is already live — one at a time' };
|
|
162
|
+
// the SAME Destination check the session runs, at $0, BEFORE the session (and its folder) exists
|
|
163
|
+
const destProblem = startDestinationProblem(card);
|
|
164
|
+
if (destProblem !== null) return { ok: /** @type {false} */ (false), status: 400, error: destProblem };
|
|
165
|
+
const session = createSession(cardFields(card), {
|
|
166
|
+
env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}), ...(reuse ? { reuse } : {}), ...(opts.sessionDeps ?? {}),
|
|
167
|
+
});
|
|
168
|
+
(o.headless ? headless : sessions).set(session.id, session);
|
|
169
|
+
return { ok: /** @type {true} */ (true), session };
|
|
170
|
+
}).catch((e) => ({ ok: /** @type {false} */ (false), status: 500, error: `could not check the inputs: ${/** @type {Error} */ (e)?.message ?? e}` }));
|
|
171
|
+
}
|
|
172
|
+
|
|
115
173
|
/**
|
|
116
174
|
* @param {import('node:http').IncomingMessage} req
|
|
117
175
|
* @param {import('node:http').ServerResponse} res
|
|
@@ -217,10 +275,20 @@ export function createAuthorRoutes(opts) {
|
|
|
217
275
|
const pre = sp.has('import') ? opts.startFrom.getImport(sp.get('import') ?? '', asModel) : opts.startFrom.get(runid, asModel);
|
|
218
276
|
if (pre === null) { send(404, { ok: false, error: 'no such run' }); return true; }
|
|
219
277
|
if (!pre.ok) { send(409, pre); return true; }
|
|
278
|
+
// the card of a SCHEDULED job opens with the folder that schedule delivers to (a plain-files job keeps it on the schedule, not in the spec)
|
|
279
|
+
const sched = sp.get('mode') === 'schedule' && opts.schedules ? opts.schedules.readSchedule(pre.origin.job) : null;
|
|
280
|
+
const card = typeof sched?.destination === 'string' ? { ...pre.card, destination: sched.destination } : pre.card;
|
|
220
281
|
send(200, {
|
|
221
|
-
ok: true, origin: pre.origin, card
|
|
222
|
-
|
|
282
|
+
ok: true, origin: pre.origin, card, from: pre.from, note: pre.note,
|
|
283
|
+
// ONE door (v1.101): `green` = the origin earned a real green (an imported job counts), so an unchanged job reuses the signed plan;
|
|
284
|
+
// `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
|
+
nextName: nextFreeJobName(pre.origin.job, opts.runJobNames ? opts.runJobNames() : [], { jobsDir: opts.jobsDir }),
|
|
287
|
+
specHash: pre.specHash, workflowKey: pre.workflowKey, trackRecord: pre.trackRecord,
|
|
223
288
|
line: pre.line, specNamesModel: pre.specNamesModel, specWorker: pre.specWorker,
|
|
289
|
+
// T Part 3: `mode=schedule` also returns the job's current schedule (data only) for the When field's prefill; Part 7 adds the
|
|
290
|
+
// card's own words (`scheduleCard`: now / refusal / the button table). A reuse never touches a schedule (v1.100).
|
|
291
|
+
...(sp.get('mode') === 'schedule' && opts.schedules ? { schedule: opts.schedules.read(pre.origin.job), scheduleCard: opts.schedules.cardFor(pre.origin.job) } : {}),
|
|
224
292
|
});
|
|
225
293
|
return true;
|
|
226
294
|
}
|
|
@@ -236,11 +304,23 @@ export function createAuthorRoutes(opts) {
|
|
|
236
304
|
return true;
|
|
237
305
|
}
|
|
238
306
|
|
|
307
|
+
// ── T Part 3: the schedule door, two clicks, both $0 (no drafter, no run, no provider call). Click 1 signs and
|
|
308
|
+
// writes nothing; click 2 carries the hash it was shown. `when: null` cancels (never refused). Placed with the
|
|
309
|
+
// other exact matches, before the session regex below.
|
|
310
|
+
if (pathname === '/api/author/schedule-prepare' || pathname === '/api/author/schedule') {
|
|
311
|
+
if (!opts.schedules) { send(404, { ok: false, error: 'not found' }); return true; }
|
|
312
|
+
if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
|
|
313
|
+
const r = pathname.endsWith('-prepare') ? opts.schedules.prepare(body ?? {}) : opts.schedules.set(body ?? {});
|
|
314
|
+
send(r.status, r.body);
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
239
318
|
// A refreshed page re-attaches to the one live (non-terminal) session: its id and state (card, messages, steps),
|
|
240
319
|
// or `null`. The server's session map is the only truth; nothing is stored page-side.
|
|
241
320
|
if (pathname === '/api/author/live') {
|
|
242
321
|
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
243
322
|
const live = [...sessions.values()].find((s) => !TERMINAL_PHASES.has(s.state.phase));
|
|
323
|
+
if (live) settleFetchingStep(live.state);
|
|
244
324
|
send(200, { ok: true, sessionId: live ? live.state.id : null, state: live ? live.state : null });
|
|
245
325
|
return true;
|
|
246
326
|
}
|
|
@@ -250,11 +330,15 @@ export function createAuthorRoutes(opts) {
|
|
|
250
330
|
if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
|
|
251
331
|
const card = body ?? {};
|
|
252
332
|
const rows = rowsForHome(keysHome(opts.home), envNow());
|
|
253
|
-
// Reuse workflow
|
|
254
|
-
//
|
|
255
|
-
//
|
|
333
|
+
// Reuse workflow, ONE DOOR (v1.101): every box is open; the CARD's job boxes (job text, check type, thinking) are compared with
|
|
334
|
+
// the origin run's. A GREEN origin (or an imported job) with no job box changed signs the origin's plan verbatim, under the
|
|
335
|
+
// origin's name, with no drafting. Any other start drafts: a changed job is a NEW workflow under the Job name box; an unchanged
|
|
336
|
+
// job from a run that is neither green nor already-green drafts again under the ORIGIN's name (another try of the same job). The name is decided
|
|
337
|
+
// here, before drafting, so the hash the person signs already covers it. The server never trusts the page.
|
|
256
338
|
/** @type {{spec: any, workflowKey: string}|null} */
|
|
257
339
|
let reuse = null;
|
|
340
|
+
/** @type {string|undefined} */
|
|
341
|
+
let allowName;
|
|
258
342
|
const from = card.startFrom;
|
|
259
343
|
const fromRunid = typeof from === 'string' ? from : (typeof from?.runid === 'string' ? from.runid : null);
|
|
260
344
|
const fromImport = typeof from?.importId === 'string' ? from.importId : null;
|
|
@@ -262,42 +346,43 @@ export function createAuthorRoutes(opts) {
|
|
|
262
346
|
const pre = opts.startFrom ? (fromImport !== null ? opts.startFrom.getImport(fromImport) : opts.startFrom.get(fromRunid)) : null;
|
|
263
347
|
if (pre === null || pre === undefined) { send(404, { ok: false, error: 'no such run to reuse' }); return true; }
|
|
264
348
|
if (!pre.ok) { send(409, pre); return true; }
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
349
|
+
const changed = jobBoxesChanged(card, pre.card);
|
|
350
|
+
if (changed) {
|
|
351
|
+
// a NEW workflow: its name may not be any existing job's, the origin's included (a different job cannot pass for the old one)
|
|
352
|
+
if (typeof card.jobName === 'string' && (card.jobName === pre.origin.job || (opts.runJobNames && opts.runJobNames().includes(card.jobName)))) {
|
|
353
|
+
send(400, { ok: false, error: `A job named "${card.jobName}" already exists and is a different job. Pick another name.` });
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
// a scheduled run's green waits for the person's Accept (quarantine): its plan is neither reused nor re-drafted until then
|
|
358
|
+
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
|
+
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
|
+
const rv = validateReuseCard(card, { rows });
|
|
362
|
+
if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
|
|
363
|
+
const spec = buildReuseSpec(pre.spec, card, rows);
|
|
364
|
+
// validateJob can THROW (not only red) on a saved job the guard-battery lookup no longer admits: same plain words, never a raw 500
|
|
365
|
+
/** @type {ReturnType<typeof validateJob>|null} */
|
|
366
|
+
let jv = null;
|
|
367
|
+
try { jv = validateJob(spec, { shellCapUsd: spec.budgetUsd }); } catch { jv = null; }
|
|
368
|
+
// RULED 2026-10-15: an old run's saved job that fails today's checks gets plain words; the raw red codes ride in `reds`, which the page never shows
|
|
369
|
+
if (jv === null || !jv.ok) { send(400, { ok: false, error: OLD_JOB_REUSE_WORDS, reds: jv === null ? [] : jv.reds.map((r) => `${r.code} at ${r.path}`) }); return true; }
|
|
370
|
+
reuse = { spec, workflowKey: pre.workflowKey };
|
|
371
|
+
} else allowName = pre.origin.job;
|
|
271
372
|
}
|
|
272
|
-
const locked = lockedFieldChanged(card, pre.card);
|
|
273
|
-
if (locked !== null) { send(400, { ok: false, error: `${locked} is locked on a reused workflow — Clear the card to change it` }); return true; }
|
|
274
|
-
const rv = validateReuseCard(card, { rows });
|
|
275
|
-
if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
|
|
276
|
-
const spec = buildReuseSpec(pre.spec, card, rows);
|
|
277
|
-
const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
|
|
278
|
-
if (!jv.ok) { send(400, { ok: false, error: `the spec this reuse would sign is not valid (${jv.reds.map((r) => `${r.code} at ${r.path}`).join('; ')})` }); return true; }
|
|
279
|
-
reuse = { spec, workflowKey: pre.workflowKey };
|
|
280
373
|
}
|
|
281
374
|
if (reuse === null) {
|
|
282
|
-
const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows });
|
|
375
|
+
const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows, ...(allowName !== undefined ? { allowName } : {}) });
|
|
283
376
|
if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
|
|
284
377
|
}
|
|
285
378
|
// P7: the Inputs are proven at $0 before a session (so before any token): line 1 the repo, lines 2+ tracked files inside it.
|
|
286
379
|
// A miss is a plain 400 naming the line, shown under the card exactly like every other refused start.
|
|
287
380
|
const ip = parseInputs(card.inputs);
|
|
288
381
|
if (!ip.ok) { send(400, { ok: false, error: ip.error }); return true; }
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (!proof.ok) { send(400, { ok: false, error: proof.error }); return; }
|
|
294
|
-
if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return; }
|
|
295
|
-
const session = createSession(cardFields(card), {
|
|
296
|
-
env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}), ...(reuse ? { reuse } : {}),
|
|
297
|
-
});
|
|
298
|
-
sessions.set(session.id, session);
|
|
299
|
-
send(200, { ok: true, sessionId: session.id, reuse: reuse !== null, state: session.state });
|
|
300
|
-
}).catch((e) => { send(500, { ok: false, error: `could not check the inputs: ${/** @type {Error} */ (e)?.message ?? e}` }); });
|
|
382
|
+
startSession(card, reuse, ip.inputs).then((r) => {
|
|
383
|
+
if (!r.ok) { send(r.status, { ok: false, error: r.error }); return; }
|
|
384
|
+
send(200, { ok: true, sessionId: r.session.id, reuse: reuse !== null, state: r.session.state });
|
|
385
|
+
});
|
|
301
386
|
return true;
|
|
302
387
|
}
|
|
303
388
|
|
|
@@ -309,6 +394,7 @@ export function createAuthorRoutes(opts) {
|
|
|
309
394
|
|
|
310
395
|
if (sub === null) {
|
|
311
396
|
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
397
|
+
settleFetchingStep(session.state);
|
|
312
398
|
send(200, { ok: true, state: session.state });
|
|
313
399
|
return true;
|
|
314
400
|
}
|
|
@@ -336,7 +422,7 @@ export function createAuthorRoutes(opts) {
|
|
|
336
422
|
}
|
|
337
423
|
if (sub === 'sign') {
|
|
338
424
|
const claimedHash = String(body?.specHash ?? '');
|
|
339
|
-
const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home });
|
|
425
|
+
const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home, ...(opts.schedules ? { schedules: opts.schedules } : {}) });
|
|
340
426
|
send(r.ok ? 200 : 400, r);
|
|
341
427
|
return true;
|
|
342
428
|
}
|
|
@@ -344,31 +430,14 @@ export function createAuthorRoutes(opts) {
|
|
|
344
430
|
return true;
|
|
345
431
|
}
|
|
346
432
|
|
|
347
|
-
return {
|
|
433
|
+
return {
|
|
434
|
+
handle, sessions, headless, startSession, envNow, spawnFn, bareloopBin,
|
|
435
|
+
/** a headless session the starter has removed from disk leaves its map too @param {string} id */
|
|
436
|
+
forgetHeadless: (id) => { headless.delete(id); },
|
|
437
|
+
};
|
|
348
438
|
}
|
|
349
439
|
|
|
350
|
-
|
|
351
|
-
* The panel's OWN 2-decimal money render for a chat line — hamr's ruling
|
|
352
|
-
* 2026-09-28 (2nd addendum, panel money 2-decimals): a positive amount below
|
|
353
|
-
* a cent reads `<$0.01`, never `$0.00` (a real cost must never read as
|
|
354
|
-
* free). This is the server-side (Node, chat-text) sibling of
|
|
355
|
-
* `src/panel/index.html`'s own inline `panelMoney` — the two can't share
|
|
356
|
-
* code (one runs in the browser as inlined script, the other in Node), the
|
|
357
|
-
* same duplication `src/replay.js`'s `money()`/`index.html`'s `money()`
|
|
358
|
-
* already carry, so both apply the identical rule rather than drift.
|
|
359
|
-
* @param {number} n a non-negative dollar amount
|
|
360
|
-
* @returns {string}
|
|
361
|
-
*/
|
|
362
|
-
export function panelMoney2(n) {
|
|
363
|
-
if (n > 0 && n < 0.01) return '<$0.01';
|
|
364
|
-
// two-step rounding (matching index.html's own panelMoney): clean to
|
|
365
|
-
// 6-decimal precision first (the same precision src/text.js's tallyCalls
|
|
366
|
-
// sums to) before rounding to cents, so a summed float landing just under
|
|
367
|
-
// an exact cent boundary is never mis-rounded.
|
|
368
|
-
const clean = Math.round(n * 1e6) / 1e6;
|
|
369
|
-
const cents = Math.round(clean * 100 + 1e-6);
|
|
370
|
-
return `$${(cents / 100).toFixed(2)}`;
|
|
371
|
-
}
|
|
440
|
+
export { panelMoney2 };
|
|
372
441
|
|
|
373
442
|
/**
|
|
374
443
|
* THE ONLY function in this file (or anywhere in the panel) that spawns a
|
|
@@ -378,7 +447,7 @@ export function panelMoney2(n) {
|
|
|
378
447
|
* it to sign"), and this is where it is checked, not trusted.
|
|
379
448
|
* @param {ReturnType<typeof createSession>} session
|
|
380
449
|
* @param {string} claimedHash
|
|
381
|
-
* @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string, home?: string}} o
|
|
450
|
+
* @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string, home?: string, reviewDoor?: boolean, onSpawnError?: (e: Error) => void, onExit?: (code: number|null) => void}} o
|
|
382
451
|
* @returns {{ok: boolean, error?: string, job?: string}}
|
|
383
452
|
*/
|
|
384
453
|
export function signRun(session, claimedHash, o) {
|
|
@@ -430,6 +499,8 @@ export function signRun(session, claimedHash, o) {
|
|
|
430
499
|
'systemd-inhibit', '--why=bareloop panel run',
|
|
431
500
|
process.execPath, o.bareloopBin,
|
|
432
501
|
'run-u', '--spec', session.state.resolvedSpecPath, '--approve', session.state.specHash,
|
|
502
|
+
// T (F.1): a scheduled run opens the review door even when deterministic, so its green waits for the person's Accept
|
|
503
|
+
...(o.reviewDoor === true ? ['--review-door'] : []),
|
|
433
504
|
...(draftSpentUsd !== null ? ['--draft-spent-usd', String(draftSpentUsd), ...(draftIncomplete ? ['--draft-spend-incomplete'] : [])] : []),
|
|
434
505
|
];
|
|
435
506
|
/** @type {number|null} */
|
|
@@ -439,6 +510,8 @@ export function signRun(session, claimedHash, o) {
|
|
|
439
510
|
detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: o.env,
|
|
440
511
|
});
|
|
441
512
|
if (typeof child?.unref === 'function') child.unref();
|
|
513
|
+
if (o.onSpawnError && typeof child?.once === 'function') child.once('error', o.onSpawnError);
|
|
514
|
+
if (o.onExit && typeof child?.once === 'function') child.once('exit', o.onExit);
|
|
442
515
|
session.state.phase = 'signed';
|
|
443
516
|
// hamr 2026-10-05: the sign shows as the last step of the one progress list, never as a thread bubble
|
|
444
517
|
// hamr 2026-10-05 (option A): the drafting-spend line is that step's detail — nothing when there was no drafting spend (a Reuse)
|
|
@@ -446,5 +519,10 @@ export function signRun(session, claimedHash, o) {
|
|
|
446
519
|
? `drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
|
|
447
520
|
: '';
|
|
448
521
|
if (Array.isArray(session.state.steps)) session.state.steps.push({ id: 'signed', label: STEP_LABELS.signed, status: 'done', detail: draftLine });
|
|
522
|
+
// PRD v1.106 Part 5 Q2: a run with a web input fetches the page again before its log exists (3-5 s of silence): say so, next to the sign step
|
|
523
|
+
const sinceIso = new Date().toISOString();
|
|
524
|
+
if (Array.isArray(session.state.steps) && webFetchState(join(session.state.outDir, 'source-seed', 'source.json'), sinceIso).hasWeb) {
|
|
525
|
+
session.state.steps.push({ id: 'fetching', label: STEP_LABELS.fetching, status: 'running', since: sinceIso });
|
|
526
|
+
}
|
|
449
527
|
return { ok: true, job: session.state.resolvedSpecPath };
|
|
450
528
|
}
|
|
@@ -53,6 +53,7 @@ import { closeJudges } from '../kinds.js';
|
|
|
53
53
|
import { RUBRIC_LANG } from '../authoring.js';
|
|
54
54
|
import { rubricGoal, reconcileRubricPlan } from '../rubricstep.js';
|
|
55
55
|
import { redactSecrets } from '../validate.js';
|
|
56
|
+
import { isDropIt, isAgree } from '../pagemap.js';
|
|
56
57
|
import { tallyCalls } from '../text.js';
|
|
57
58
|
import { writeDraftSpend, appendDraftLog } from '../draftspend.js';
|
|
58
59
|
import { PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText, draftStopText, redsRecord, declarationRecord, calibrationSummary, tryRefusedText } from '../authorreadout.js';
|
|
@@ -85,6 +86,7 @@ export const STEP_LABELS = Object.freeze({
|
|
|
85
86
|
gates: 'checking signing gates',
|
|
86
87
|
hash: 'generating hash',
|
|
87
88
|
signed: 'signed hash',
|
|
89
|
+
fetching: 'fetching the page…',
|
|
88
90
|
});
|
|
89
91
|
|
|
90
92
|
/**
|
|
@@ -198,6 +200,25 @@ export function jobNameTaken(name, opts = {}) {
|
|
|
198
200
|
} catch { return false; }
|
|
199
201
|
}
|
|
200
202
|
|
|
203
|
+
/**
|
|
204
|
+
* The next free name for a NEW workflow made from `origin` (RULED 2026-10-15, v1.101): `<base>-N`, N the next free number from 2.
|
|
205
|
+
* Base: when the origin ends in `-<digits>` AND the part before it is itself a job (in the run list or jobs/), that part (so `x-2`
|
|
206
|
+
* suggests `x-3`, never `x-2-2`); else the origin's own name. Free = not a run-list job and not {@link jobNameTaken}.
|
|
207
|
+
* @param {string} origin
|
|
208
|
+
* @param {Iterable<string>} runJobs job names in the panel's run list
|
|
209
|
+
* @param {{jobsDir?: string}} [opts] test seam for {@link jobNameTaken}
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
export function nextFreeJobName(origin, runJobs, opts = {}) {
|
|
213
|
+
const used = new Set(runJobs);
|
|
214
|
+
const m = /^(.+)-\d+$/.exec(origin);
|
|
215
|
+
const base = m && (used.has(m[1]) || jobNameTaken(m[1], opts)) ? m[1] : origin;
|
|
216
|
+
for (let n = 2; ; n += 1) {
|
|
217
|
+
const name = `${base}-${n}`;
|
|
218
|
+
if (!used.has(name) && !jobNameTaken(name, opts)) return name;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
201
222
|
/** the form's fields, in card order — what `card.json` stores, verbatim (P5 item 3) */
|
|
202
223
|
export const CARD_FIELDS = Object.freeze(['jobName', 'checkType', 'provider', 'model', 'thinking', 'jobText', 'inputs', 'destination', 'capUsd', 'maxWallMs']);
|
|
203
224
|
|
|
@@ -225,30 +246,25 @@ export function cardFields(card) {
|
|
|
225
246
|
}
|
|
226
247
|
|
|
227
248
|
/**
|
|
228
|
-
* REUSE WORKFLOW (
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
249
|
+
* REUSE WORKFLOW, ONE DOOR (RULED 2026-10-15, v1.101; replaces v1.88's five open boxes). Every box on the card is open. What decides
|
|
250
|
+
* the path is the CARD compared with the origin run's card, never the drafted spec: the JOB boxes are the job text (numbered lines,
|
|
251
|
+
* `~` rules, `~ PASS`/`~ FAIL`), the check type and the thinking tick. The RUN boxes (Inputs, Destination, Model, $ cap, Time cap)
|
|
252
|
+
* never decide it. A green origin with no job box changed reuses the signed plan verbatim (no drafting); anything else drafts.
|
|
232
253
|
*/
|
|
233
|
-
export const
|
|
234
|
-
/** every card field that is NOT open on a reuse card, in card order */
|
|
235
|
-
export const REUSE_LOCKED_FIELDS = Object.freeze(CARD_FIELDS.filter((f) => !REUSE_OPEN_FIELDS.includes(f)));
|
|
236
|
-
const REUSE_LABELS = Object.freeze(/** @type {Record<string, string>} */ ({
|
|
237
|
-
jobName: 'Job name', checkType: 'Check type', jobText: 'The job',
|
|
238
|
-
}));
|
|
254
|
+
export const JOB_BOXES = Object.freeze(['jobText', 'checkType', 'thinking']);
|
|
239
255
|
|
|
240
256
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
257
|
+
* Did any JOB box on `card` change from what the origin run prefilled (`origin`)? Whitespace is not compared (a one-line input drops
|
|
258
|
+
* a text's newlines); an absent thinking tick reads as off.
|
|
243
259
|
* @param {Record<string, any>} card the form as it was submitted
|
|
244
260
|
* @param {Record<string, any>} origin the form as the server prefilled it
|
|
245
|
-
* @returns {
|
|
261
|
+
* @returns {boolean}
|
|
246
262
|
*/
|
|
247
|
-
export function
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
263
|
+
export function jobBoxesChanged(card, origin) {
|
|
264
|
+
const text = (/** @type {any} */ v) => (typeof v === 'string' ? v.replace(/\s+/g, '') : '');
|
|
265
|
+
return text(card?.jobText) !== text(origin?.jobText)
|
|
266
|
+
|| (card?.checkType === 'rubric') !== (origin?.checkType === 'rubric')
|
|
267
|
+
|| (card?.thinking === true) !== (origin?.thinking === true);
|
|
252
268
|
}
|
|
253
269
|
|
|
254
270
|
/**
|
|
@@ -267,7 +283,9 @@ export function lockedFieldChanged(card, origin) {
|
|
|
267
283
|
*/
|
|
268
284
|
export function buildReuseSpec(originSpec, card, rows) {
|
|
269
285
|
const spec = JSON.parse(JSON.stringify(originSpec));
|
|
270
|
-
|
|
286
|
+
// an ABSOLUTE Destination is a per-run output folder (a plain-files job): it is never part of the signed spec, and the spec's own
|
|
287
|
+
// fence (inside the run's tree) stays as signed. A repo job's Destination is the fence and is relative (an absolute one is refused).
|
|
288
|
+
if (!isAbsoluteDestination(String(card.destination ?? '').trim())) spec.writeScope = String(card.destination ?? '').split(/[,\n]/).map((x) => x.trim()).filter(Boolean);
|
|
271
289
|
const ip = parseInputs(card.inputs);
|
|
272
290
|
if (ip.ok) spec.inputs = ip.inputs; else delete spec.inputs;
|
|
273
291
|
spec.budgetUsd = card.capUsd;
|
|
@@ -310,6 +328,27 @@ const isAbsoluteDestination = (destination) => /^(\/|[a-zA-Z]:[\\/])/.test(desti
|
|
|
310
328
|
|
|
311
329
|
export { nonRepoSourceMessage };
|
|
312
330
|
|
|
331
|
+
/** the refusal for a plain-files rubric job whose Destination is not an absolute folder (ends with the words the session start shows) */
|
|
332
|
+
export const FILES_DESTINATION_WORDS = 'Destination must be the full path of a folder, like /home/me/out — the finished files are delivered there. Nothing spent.';
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* THE ONE Destination check of a session start, at $0, before any session, copy or model call. The session's own pipeline, the HTTP
|
|
336
|
+
* start (`startSession`), the schedule's two clicks and a scheduled fire's `wouldAccept` all call THIS (never a copy of it), so a start
|
|
337
|
+
* that would be refused for its Destination is refused before anything is made.
|
|
338
|
+
* @param {{inputs?: unknown, destination?: unknown, checkType?: unknown}} card the Inputs text (line 1 = the source), the Destination and the check type
|
|
339
|
+
* @returns {string|null} the plain refusal, or null when the Destination is fine
|
|
340
|
+
*/
|
|
341
|
+
export function startDestinationProblem(card) {
|
|
342
|
+
const ip = parseInputs(card?.inputs);
|
|
343
|
+
const source = ip.ok ? (ip.inputs[0]?.value ?? '') : '';
|
|
344
|
+
const destination = typeof card?.destination === 'string' ? card.destination : '';
|
|
345
|
+
const isRepoLike = looksLikeRepoSource(source);
|
|
346
|
+
const repo = repoDestinationProblem(destination, isRepoLike);
|
|
347
|
+
if (repo !== null) return repo;
|
|
348
|
+
if (card?.checkType === 'rubric' && !isRepoLike && !isAbsoluteDestination(destination)) return FILES_DESTINATION_WORDS;
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
313
352
|
/**
|
|
314
353
|
* The ONE Destination rule for a REPO source (fresh card and Reuse card alike): there Destination is the write fence,
|
|
315
354
|
* relative to the repo, so an absolute path is refused — at $0, before any copy or model call. A FOLDER source (or a
|
|
@@ -327,14 +366,14 @@ export function repoDestinationProblem(destination, isRepoLike) {
|
|
|
327
366
|
* creates a session or spends anything — never inside the async pipeline,
|
|
328
367
|
* so a bad card refuses synchronously, in the same HTTP response.
|
|
329
368
|
* @param {any} card
|
|
330
|
-
* @param {{jobsDir?: string, rows?: readonly import('../providerrows.js').KeyRow[]}} [opts] `rows` = the Settings rows (the Model menu); jobsDir is a server-side test seam — NEVER read off the client card
|
|
369
|
+
* @param {{jobsDir?: string, allowName?: string, rows?: readonly import('../providerrows.js').KeyRow[]}} [opts] `rows` = the Settings rows (the Model menu); jobsDir is a server-side test seam — NEVER read off the client card; `allowName` = the origin job's own name on an unchanged red Reuse (it is the same job, not a collision)
|
|
331
370
|
* @returns {{ok: true}|{ok: false, error: string}}
|
|
332
371
|
*/
|
|
333
372
|
export function validateJobCard(card, opts = {}) {
|
|
334
373
|
if (!card || typeof card !== 'object') return { ok: false, error: 'missing job card' };
|
|
335
374
|
const { jobName, checkType, model, destination, capUsd } = card;
|
|
336
375
|
if (!isSlug(jobName)) return { ok: false, error: 'Job name must be a kebab-case slug (letters, digits, dashes)' };
|
|
337
|
-
if (jobNameTaken(jobName, { jobsDir: opts.jobsDir })) {
|
|
376
|
+
if (jobName !== opts.allowName && jobNameTaken(jobName, { jobsDir: opts.jobsDir })) {
|
|
338
377
|
return { ok: false, error: `a job named "${jobName}" already exists — P3 is new jobs only (Q4=A)` };
|
|
339
378
|
}
|
|
340
379
|
if (checkType !== 'deterministic' && checkType !== 'rubric') return { ok: false, error: 'Check type must be deterministic or rubric' };
|
|
@@ -502,7 +541,20 @@ export function createSession(card, deps = {}) {
|
|
|
502
541
|
* @param {{kind: string, [k: string]: any}} step
|
|
503
542
|
* @returns {Promise<string|null>}
|
|
504
543
|
*/
|
|
505
|
-
const ask = (step) => new Promise((
|
|
544
|
+
const ask = (step) => new Promise((resolveFn0) => {
|
|
545
|
+
// PRD v1.106 Part 5 Q1: every question the person answers (the confirm card's, the early look ask, the last-try ask) is one `human` row in the
|
|
546
|
+
// drafting log, with the answer secret-masked BEFORE it is written. Written only when the answer exists (a blank one re-asks and leaves nothing).
|
|
547
|
+
const resolveFn = step.kind !== 'answer' ? resolveFn0 : (/** @type {string|null} */ value) => {
|
|
548
|
+
const said = value === null ? '' : redactSecrets(String(value).trim());
|
|
549
|
+
if (said !== '' && typeof step.question === 'string') {
|
|
550
|
+
const running = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
|
|
551
|
+
appendDraftLog(outDir, {
|
|
552
|
+
kind: 'human', no: running === -1 ? Math.max(0, state.steps.length - 1) : running, source: typeof step.source === 'string' ? step.source : 'confirm',
|
|
553
|
+
question: redactSecrets(step.question), answer: said, ...(Number.isInteger(step.line) ? { line: step.line } : {}), at: new Date().toISOString(),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
resolveFn0(value);
|
|
557
|
+
};
|
|
506
558
|
state.pendingAsk = step;
|
|
507
559
|
resolvePending = resolveFn;
|
|
508
560
|
if (step.kind === 'language') say('bot', `${step.field?.prompt ?? 'Which language is this job about?'} (${(step.candidates ?? []).join(', ')})`);
|
|
@@ -602,6 +654,8 @@ export function createSession(card, deps = {}) {
|
|
|
602
654
|
kind: 'call', no: callNo, step: state.steps[callNo]?.id ?? null, label: call.label, model: draftIdentity?.model ?? null,
|
|
603
655
|
thinking: draftThinkingPlan === null ? null : draftThinkingPlan.mode, thinkingSupport: draftThinkingPlan === null ? null : draftThinkingPlan.support,
|
|
604
656
|
...(call.thinkingStatus === undefined ? {} : { thinkingStatus: call.thinkingStatus }),
|
|
657
|
+
// idea A core: the page-word map's scrubbed answer (src/pagemap.js) - unsigned, kept so a run can be replayed at $0
|
|
658
|
+
...(typeof call.raw === 'string' ? { raw: call.raw } : {}),
|
|
605
659
|
costUsd: call.costUsd ?? null, unpricedRounds: call.unpricedRounds ?? 0, at: nowIso,
|
|
606
660
|
});
|
|
607
661
|
try {
|
|
@@ -685,7 +739,7 @@ export function createSession(card, deps = {}) {
|
|
|
685
739
|
// line is one file, frozen by `proveRubricInputs`; there is no repo, no language and no scout (docs/product/ITEM33-BUILD.md)
|
|
686
740
|
const RUBRIC_FILES = card.checkType === 'rubric' && !isRepoLike;
|
|
687
741
|
// hamr's ruling B (2026-10-05): ONE rule for both a fresh card and a reuse card, at $0, before the copy and any model call
|
|
688
|
-
const destProblem =
|
|
742
|
+
const destProblem = startDestinationProblem(card);
|
|
689
743
|
if (destProblem !== null) { refuse(destProblem); return; }
|
|
690
744
|
|
|
691
745
|
state.phase = 'preparing-source';
|
|
@@ -707,10 +761,6 @@ export function createSession(card, deps = {}) {
|
|
|
707
761
|
// fence read as a directory and refused as "not absolute" — hiding the real problem with the Source. A scope-shaped
|
|
708
762
|
// (relative) Destination is therefore handed to the door only when the source is repo-like.
|
|
709
763
|
const destIsDir = isAbsoluteDestination(card.destination);
|
|
710
|
-
if (RUBRIC_FILES && !destIsDir) {
|
|
711
|
-
refuse('Destination must be the full path of a folder, like /home/me/out — the finished files are delivered there. Nothing spent.');
|
|
712
|
-
return;
|
|
713
|
-
}
|
|
714
764
|
/** @type {any} */
|
|
715
765
|
let prep;
|
|
716
766
|
if (RUBRIC_FILES) {
|
|
@@ -945,7 +995,6 @@ export function createSession(card, deps = {}) {
|
|
|
945
995
|
model: modelChoice.name,
|
|
946
996
|
// the "Thinking on" tick is signed ONLY when ticked (unticked writes no field: the hash stays what it would have been)
|
|
947
997
|
...(card.thinking === true && draftThinkingPlan?.support !== false ? { thinking: true } : {}),
|
|
948
|
-
cadence: { unit: 'day', every: 1 },
|
|
949
998
|
budgetUsd: card.capUsd,
|
|
950
999
|
...(typeof card.maxWallMs === 'number' ? { maxWallMs: card.maxWallMs } : {}),
|
|
951
1000
|
writeScope,
|
|
@@ -989,27 +1038,34 @@ export function createSession(card, deps = {}) {
|
|
|
989
1038
|
// ask path as every other (the chat box + Send); the answer is REQUIRED (a blank one asks again, there is no skip) and is
|
|
990
1039
|
// added WORD FOR WORD as a `~` rule on the job line it names — never rewritten. The lines are mutated in place, so the
|
|
991
1040
|
// signed `jobLines`, the answers text and the plan card all read the same words.
|
|
992
|
-
const answerQuestions = async (/** @type {{questions: {line: number, question: string}[], dropped: {reason: string, raw: any}[], confirmed: any}} */ o) => {
|
|
1041
|
+
const answerQuestions = async (/** @type {{questions: {line: number, question: string, rule?: number, source?: string, ask?: string, reading?: string}[], dropped: {reason: string, raw: any}[], confirmed: any}} */ o) => {
|
|
993
1042
|
if (o.dropped.length) appendDraftLog(outDir, { kind: 'questions-dropped', no: Math.max(0, state.steps.length - 1), dropped: o.dropped, at: new Date().toISOString() });
|
|
994
1043
|
const total = o.questions.length;
|
|
1044
|
+
/** @type {{line: number, rule: number}[]} */
|
|
1045
|
+
const left = [];
|
|
995
1046
|
for (let k = 0; k < total; k += 1) {
|
|
996
1047
|
const q = o.questions[k];
|
|
997
1048
|
let text = '';
|
|
998
1049
|
for (;;) {
|
|
999
|
-
const a = await ask({ kind: 'answer', index: k + 1, total, question: q.question, line: q.line });
|
|
1050
|
+
const a = await ask({ kind: 'answer', source: typeof q.source === 'string' ? q.source : 'try-3', index: k + 1, total, question: q.question, line: q.line });
|
|
1000
1051
|
if (a === null) return null;
|
|
1001
1052
|
// one `~` line is one line: the person's own words, newlines folded to spaces, nothing else touched
|
|
1002
1053
|
text = String(a).replace(/\s*\n\s*/g, ' ').trim();
|
|
1003
1054
|
if (text) break;
|
|
1004
1055
|
say('bot', 'An answer is needed here — there is no skip. Type it in the box below and press Send.');
|
|
1005
1056
|
}
|
|
1057
|
+
// PRD v1.107 Part 5b: a code-forced look question about a `~` rule. A can't-check ask answered "drop it" adds NO rule; the caller puts that
|
|
1058
|
+
// rule on the look's Not checked list. A reading ask answered "agree" adds the code's reading, in plain words, as the `~` rule (so the
|
|
1059
|
+
// bound is now the person's own words); anything else typed is the person's own rule, word for word.
|
|
1060
|
+
if (q.source === 'look' && q.ask === 'reading' && typeof q.reading === 'string' && isAgree(text)) text = q.reading;
|
|
1061
|
+
if (q.source === 'look' && q.ask === 'cant-check' && Number.isInteger(q.rule) && isDropIt(text)) { left.push({ line: q.line, rule: /** @type {number} */ (q.rule) }); continue; }
|
|
1006
1062
|
const line = jobLines.find((l) => l.n === q.line);
|
|
1007
1063
|
if (line) { line.rule = line.rule ? `${line.rule}; ${text}` : text; line.rules.push(text); }
|
|
1008
1064
|
}
|
|
1009
1065
|
draft.jobLines = signedJobLines(jobLines);
|
|
1010
1066
|
answers[1] = renderJobLines(jobLines);
|
|
1011
|
-
state.messages.push({ ...planMessage(o.confirmed ?? {}, jobLines), text: 'Your answers are in as ~ rules on those job lines. Drafting again.' });
|
|
1012
|
-
return { jobLines, answers };
|
|
1067
|
+
state.messages.push({ ...planMessage(o.confirmed ?? {}, jobLines), text: left.length === total ? 'Noted: those stay Not checked. Drafting.' : 'Your answers are in as ~ rules on those job lines. Drafting again.' });
|
|
1068
|
+
return { jobLines, answers, left };
|
|
1013
1069
|
};
|
|
1014
1070
|
|
|
1015
1071
|
state.phase = 'drafting';
|