bareloop 0.27.0 → 0.29.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 +114 -0
- package/bareloop.context.md +245 -33
- package/package.json +1 -1
- package/src/authorreadout.js +323 -0
- package/src/authorrun.js +1345 -0
- package/src/behaviour.js +5 -2
- package/src/cli.js +190 -8
- package/src/index.js +13 -1
- package/src/interviewrun.js +842 -0
- package/src/panel/index.html +1910 -0
- package/src/panel/server.js +1527 -0
- package/src/planrun.js +1 -1
- package/src/replay.js +632 -13
- package/src/replayio.js +159 -0
- package/src/reuse.js +1 -1
- package/src/run.js +1 -1
- package/src/runlist.js +281 -0
- package/src/u-patient.js +73 -0
- package/src/u-readout.js +451 -0
- package/src/userrun.js +2299 -0
- package/types/authorreadout.d.ts +172 -0
- package/types/authorrun.d.ts +37 -0
- package/types/behaviour.d.ts +5 -3
- package/types/cli.d.ts +2 -1
- package/types/index.d.ts +2 -1
- package/types/interviewrun.d.ts +46 -0
- package/types/panel/server.d.ts +265 -0
- package/types/planrun.d.ts +2 -2
- package/types/replay.d.ts +104 -6
- package/types/replayio.d.ts +93 -0
- package/types/reuse.d.ts +2 -2
- package/types/run.d.ts +2 -2
- package/types/runlist.d.ts +112 -0
- package/types/u-patient.d.ts +39 -0
- package/types/u-readout.d.ts +247 -0
- package/types/userrun.d.ts +141 -0
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
// The close-authoring INTERVIEW — the terminal front door — lifted out of
|
|
2
|
+
// `scripts/run-interview.mjs` into a library module (PANEL-BUILD.md P0 task
|
|
3
|
+
// 3/4, the same shape task 1 (`src/userrun.js`) and task 2 (`bareloop run-u`)
|
|
4
|
+
// already used). ONE argv-parsing `main(argv, deps)`, exported; `scripts/
|
|
5
|
+
// run-interview.mjs` is now a thin adapter over it, the same ~10-line shape
|
|
6
|
+
// `bin/bareloop.mjs` already is over `src/cli.js`.
|
|
7
|
+
//
|
|
8
|
+
// This is a LIFT, not a rewrite: the orchestration below is `scripts/
|
|
9
|
+
// run-interview.mjs`'s own body, unchanged in its logic — every
|
|
10
|
+
// `console.log`/`console.error`/`process.stdout.write` became `out`/`err`/
|
|
11
|
+
// `stdout.write` against an injectable stream, every `process.exit(n)` became
|
|
12
|
+
// a thrown `ExitSignal(n)` (a library function may never call `process.exit`
|
|
13
|
+
// itself, caught at the bottom of `main`), `process.argv` became the `argv`
|
|
14
|
+
// parameter, and `process.env` became an injectable `env`. The library calls
|
|
15
|
+
// this script already made (`runInterview`, `prepareSource`, `proveDestination`,
|
|
16
|
+
// `detectLanguage`, `validateJob`, …) were already library and are untouched.
|
|
17
|
+
//
|
|
18
|
+
// RUN-INTERVIEW — the close-authoring interview, at the terminal, one question at
|
|
19
|
+
// a time.
|
|
20
|
+
//
|
|
21
|
+
// D10 says the interview runs INSIDE bareloop, and `runInterview` (src/authorjob.js)
|
|
22
|
+
// is that interview: a PURE FUNCTION over answers, with no prompt loop in it, so it
|
|
23
|
+
// can be tested without a terminal and reused by a panel later. What was missing was
|
|
24
|
+
// the other half — something that actually ASKS. Until now the only way in was to
|
|
25
|
+
// hand-write an answers.json and a spec draft, which is precisely the SWE tax the
|
|
26
|
+
// premise of this product refuses (*"the user might not and mostly isn't swe"*).
|
|
27
|
+
//
|
|
28
|
+
// THIS MODULE IS GLUE, and deliberately thin:
|
|
29
|
+
// - the QUESTIONS are the library's frozen sets (`questionsFor` /
|
|
30
|
+
// `requiredAnswersFor`, keyed by verdict class), now the RESHAPED unified
|
|
31
|
+
// form (PRD item 33 M3 piece 3): Goal / Success / Guardrails, soft-green
|
|
32
|
+
// adding Judge Examples. Source and Destination are MECHANICAL fields —
|
|
33
|
+
// proven against the machine rather than typed — and their wording is
|
|
34
|
+
// ALSO the library's (`SOURCE_FIELD`, `destinationFieldFor`,
|
|
35
|
+
// `src/authorflow.js`), so this module prints what the library hands it
|
|
36
|
+
// for all six fields, never re-worded here, never re-ordered, never stored;
|
|
37
|
+
// - the REFUSALS are the library's (`runInterview`): a locked class refuses at
|
|
38
|
+
// admission as counted demand, an unfinished interview reds by question number;
|
|
39
|
+
// - the SCRUB is the library's: what lands on disk is `runInterview`'s own
|
|
40
|
+
// redacted copy of the answers, never the raw keystrokes;
|
|
41
|
+
// - the SPEC DRAFT is checked by `validateJob`, the same validator that will judge
|
|
42
|
+
// it after the paid call, so a typo'd slug or an under-floor wall is found for
|
|
43
|
+
// $0 rather than after a scout and a model call.
|
|
44
|
+
//
|
|
45
|
+
// IT NEVER TALKS TO A PROVIDER. The paid step is `run-author.mjs`, under ITS OWN
|
|
46
|
+
// ceiling (`--budget`), and this module either offers to spawn it or prints the
|
|
47
|
+
// exact command. Two ceilings, two names, and they are kept apart on screen:
|
|
48
|
+
// --budget the AUTHORING ceiling — what the close-authoring call may spend;
|
|
49
|
+
// budgetUsd the JOB's budget — what the RUN may spend, signed into the spec.
|
|
50
|
+
//
|
|
51
|
+
// node scripts/run-interview.mjs \
|
|
52
|
+
// --verdict soft-green --provider anthropic-api \
|
|
53
|
+
// --out /path/to/outdir [--budget 2.50] [--base-url https://api.deepseek.com/v1]
|
|
54
|
+
//
|
|
55
|
+
// --provider REQUIRED, NO DEFAULT (PRD item 34 L17): bareloop is
|
|
56
|
+
// LLM-agnostic, and a default here would silently lock every
|
|
57
|
+
// interview back onto one vendor. It is asked, once, and
|
|
58
|
+
// written into the draft's `provider` field — run-author.mjs
|
|
59
|
+
// then needs no flag of its own; it just resolves what this
|
|
60
|
+
// wrote down.
|
|
61
|
+
//
|
|
62
|
+
// --base-url OPTIONAL, NO DEFAULT (PRD item 33 close-out, "authoring
|
|
63
|
+
// provider selectable" — L17 named the provider but never
|
|
64
|
+
// admitted the endpoint): the table entry a provider name
|
|
65
|
+
// resolves to (`src/providers.js`) can be reached through an
|
|
66
|
+
// OpenAI-compatible gateway other than its own default host —
|
|
67
|
+
// DeepSeek, today's one secondary, is reached as
|
|
68
|
+
// `--provider openai-api --base-url https://api.deepseek.com/v1`.
|
|
69
|
+
// Absent, the field is left OUT of the draft entirely (never
|
|
70
|
+
// written as `null`/`''`): every provider constructor already
|
|
71
|
+
// defaults its own endpoint when none is given. When given, it
|
|
72
|
+
// goes into the draft's `baseUrl` field beside `provider` and
|
|
73
|
+
// through the SAME `validateJob` pass every other field takes
|
|
74
|
+
// below — an `http://` URL to a public host, an embedded
|
|
75
|
+
// `user:pass@`, or an unparseable string reds at $0, before
|
|
76
|
+
// anything is written (`src/job.js`'s existing `baseUrl` rule,
|
|
77
|
+
// PRD item 28 ruling (d): https:// required, http:// admitted
|
|
78
|
+
// only to a loopback host, never a credential in the URL).
|
|
79
|
+
//
|
|
80
|
+
// SOURCE AND DESTINATION REPLACE --patient (PRD item 33 M3, ruling 2,
|
|
81
|
+
// `docs/product/ITEM33-BUILD.md` "M3 — the intake form and confirm turn"): they
|
|
82
|
+
// are now the interview's own FIRST TWO QUESTIONS, asked before the picked
|
|
83
|
+
// class's frozen set, each proven mechanically for $0 the moment it is
|
|
84
|
+
// answered (`prepareSource`/`proveDestination`, `src/source.js`). For a repo
|
|
85
|
+
// source, Destination fills the signed `writeScope` field — the fence
|
|
86
|
+
// question this used to ask separately is GONE, folded into Destination. A
|
|
87
|
+
// non-repo source gets the form up to here and then an honest named stop:
|
|
88
|
+
// bareloop has no checks for that kind of job yet (ruling 7 → M4).
|
|
89
|
+
import {
|
|
90
|
+
writeFileSync, mkdirSync, existsSync, readFileSync, statSync,
|
|
91
|
+
} from 'node:fs';
|
|
92
|
+
import { createInterface } from 'node:readline';
|
|
93
|
+
import { spawnSync as realSpawnSync } from 'node:child_process';
|
|
94
|
+
import { fileURLToPath } from 'node:url';
|
|
95
|
+
import { resolve, join } from 'node:path';
|
|
96
|
+
import {
|
|
97
|
+
runInterview, questionsFor, requiredAnswersFor,
|
|
98
|
+
VERDICT_CLASSES, LOCKED_CLASSES, UNLISTED_CLASSES, MENU_CLASSES, AUTHORED_SPEC_FIELDS, CONFIRM_AUTHORED_FIELDS,
|
|
99
|
+
PLAIN_FOLDER_DEFERRED_FIELDS,
|
|
100
|
+
} from './authorjob.js';
|
|
101
|
+
import {
|
|
102
|
+
SOURCE_FIELD, destinationFieldFor, labelsFor,
|
|
103
|
+
} from './authorflow.js';
|
|
104
|
+
import { validateJob, validateBaseUrl, PROVIDERS } from './job.js';
|
|
105
|
+
import { resolveProvider, probeWarningLines, apiKeyProblem } from './providers.js';
|
|
106
|
+
import { scanSecrets, redactSecrets } from './validate.js';
|
|
107
|
+
import { detectLanguage } from './detectlang.js';
|
|
108
|
+
import { prepareSource, proveDestination, looksLikeRepoSource, missingDependencies } from './source.js';
|
|
109
|
+
import { parseCeiling, ceilingLine } from './authorreadout.js';
|
|
110
|
+
|
|
111
|
+
/** Thrown to unwind `main` to an exit code without ever calling
|
|
112
|
+
* `process.exit()` (constraint: a library function returns a code / throws,
|
|
113
|
+
* it never calls `process.exit` itself). Caught at the bottom of `main` —
|
|
114
|
+
* never expected to escape it. */
|
|
115
|
+
class ExitSignal extends Error {
|
|
116
|
+
/** @param {number} code */
|
|
117
|
+
constructor(code) {
|
|
118
|
+
super(`exit ${code}`);
|
|
119
|
+
this.code = code;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @typedef {object} Deps
|
|
125
|
+
* @property {Record<string,string|undefined>} [env]
|
|
126
|
+
* @property {NodeJS.ReadStream} [stdin]
|
|
127
|
+
* @property {NodeJS.WritableStream} [stdout]
|
|
128
|
+
* @property {NodeJS.WritableStream} [stderr]
|
|
129
|
+
* @property {typeof realSpawnSync} [spawnSync] the TEST SEAM for the final
|
|
130
|
+
* paid-step offer — a caller that supplies this never actually spawns
|
|
131
|
+
* `scripts/run-author.mjs`.
|
|
132
|
+
* @property {string} [invokedAs] the command name this run was actually
|
|
133
|
+
* reached through, for the usage message ONLY — `scripts/run-interview.mjs`
|
|
134
|
+
* supplies none (defaults to its own real invocation), `src/cli.js` passes
|
|
135
|
+
* `'bareloop interview'` so a person who typed that is told to re-run THAT,
|
|
136
|
+
* never a script path they never invoked and may not have on disk.
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parses this script's own argv shape and runs the interview against the
|
|
141
|
+
* given (or real) stdin/stdout. `scripts/run-interview.mjs` is the thin
|
|
142
|
+
* adapter over this, the same shape `bin/bareloop.mjs` already is over
|
|
143
|
+
* `src/cli.js`'s own `main(argv, deps)`.
|
|
144
|
+
* @param {string[]} argv
|
|
145
|
+
* @param {Deps} [deps]
|
|
146
|
+
* @returns {Promise<number>} an exit code — never calls process.exit
|
|
147
|
+
*/
|
|
148
|
+
export async function main(argv, deps = {}) {
|
|
149
|
+
const env = deps.env ?? process.env;
|
|
150
|
+
const stdin = deps.stdin ?? process.stdin;
|
|
151
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
152
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
153
|
+
const spawnSyncFn = deps.spawnSync ?? realSpawnSync;
|
|
154
|
+
const invokedAs = deps.invokedAs ?? 'node scripts/run-interview.mjs';
|
|
155
|
+
const out = (/** @type {string} */ s = '') => { stdout.write(`${s}\n`); };
|
|
156
|
+
const err = (/** @type {string} */ s) => { stderr.write(`${s}\n`); };
|
|
157
|
+
const arg = (/** @type {string} */ n) => { const i = argv.indexOf(`--${n}`); return i === -1 ? null : (argv[i + 1] ?? ''); };
|
|
158
|
+
const die = (/** @type {string} */ m) => { err(m); throw new ExitSignal(2); };
|
|
159
|
+
let exitCode = 0;
|
|
160
|
+
try {
|
|
161
|
+
|
|
162
|
+
// `--patient` IS GONE (PRD item 33 M3, ruling 2): Source and Destination are
|
|
163
|
+
// asked as the interview's own first two questions now, and each is proven
|
|
164
|
+
// against the machine the moment it is typed — never a flag handed on the
|
|
165
|
+
// command line. Stopped loud rather than silently ignored, the same rule
|
|
166
|
+
// `--lang`'s removal already applies below.
|
|
167
|
+
if (arg('patient') !== null) {
|
|
168
|
+
die('--patient is no longer a flag — Source is now the interview\'s first question '
|
|
169
|
+
+ '(PRD item 33 M3, ruling 2). Drop --patient and rerun; you will be asked for the source there.');
|
|
170
|
+
}
|
|
171
|
+
const outArg = arg('out');
|
|
172
|
+
const verdictArg = arg('verdict');
|
|
173
|
+
const providerArg = arg('provider');
|
|
174
|
+
// OPTIONAL, NO DEFAULT (see the header comment above) — `null` means the flag
|
|
175
|
+
// was never given at all, and the draft carries no `baseUrl` field for that
|
|
176
|
+
// case (not `null`, not `''`: an absent field vs. an empty one mean different
|
|
177
|
+
// things to `validateJob`). `''` (the flag given with nothing after it) is a
|
|
178
|
+
// real, deliberate operator input — it is NOT special-cased here, and reds
|
|
179
|
+
// below through the same `validateJob` pass every other field takes, the same
|
|
180
|
+
// way an empty `--verdict`/`--provider` value already does elsewhere in this
|
|
181
|
+
// file.
|
|
182
|
+
const baseUrlArg = arg('base-url');
|
|
183
|
+
// `--lang` IS GONE (PRD item 33 M3, ruling 3): language is a FACT of the
|
|
184
|
+
// repository, read off its own manifest, never a flag a person sets. A
|
|
185
|
+
// `--lang` on the command line now is an operator error, stopped loud rather
|
|
186
|
+
// than silently ignored — the old default (`js`) would otherwise keep
|
|
187
|
+
// working by accident and hide that the flag no longer does anything.
|
|
188
|
+
if (arg('lang') !== null) {
|
|
189
|
+
die('--lang is no longer a flag — language is auto-detected from Source\'s own manifest '
|
|
190
|
+
+ '(package.json/pyproject.toml/setup.py), never asked or set (PRD item 33 M3, ruling 3). Drop --lang and rerun.');
|
|
191
|
+
}
|
|
192
|
+
/** the AUTHORING ceiling, parsed by the same rule `run-author.mjs` parses it with
|
|
193
|
+
* (`parseCeiling`): absent is UNBOUNDED and announced, a malformed value is an
|
|
194
|
+
* error rather than a silent fallback. It is not stored in anything — it is handed
|
|
195
|
+
* straight to the child, which is the process that spends it. */
|
|
196
|
+
const { ceilingUsd: CEILING_USD, error: budgetError } = parseCeiling(arg('budget'));
|
|
197
|
+
|
|
198
|
+
if (!outArg || verdictArg === null || !providerArg) {
|
|
199
|
+
die(`usage: ${invokedAs} `
|
|
200
|
+
+ `--verdict <${MENU_CLASSES.join('|')}> --provider <${PROVIDERS.join('|')}> --out <outdir> [--budget <usd>] [--base-url <url>]`);
|
|
201
|
+
}
|
|
202
|
+
if (budgetError) die(budgetError);
|
|
203
|
+
// VALIDATED HERE, before a single line of the interview prints (never left to
|
|
204
|
+
// wait for `validateJob`'s pass over the finished draft, far below) — a typo
|
|
205
|
+
// must not cost the person the whole interview. `validateBaseUrl` is the ONE
|
|
206
|
+
// spelling of the shape rule (`src/job.js`), reused rather than re-checked by
|
|
207
|
+
// hand. The message NEVER echoes the raw value: it may carry credentials
|
|
208
|
+
// (`user:pass@host`), and naming the rule that refused it is enough.
|
|
209
|
+
if (baseUrlArg !== null) {
|
|
210
|
+
const baseUrlErr = validateBaseUrl(baseUrlArg);
|
|
211
|
+
if (baseUrlErr) die(`--base-url invalid — ${baseUrlErr}`);
|
|
212
|
+
}
|
|
213
|
+
// the menu is handed over ENUMERATED — an unknown value is a typo, refused as one.
|
|
214
|
+
// A LOCKED or UNLISTED class is a different answer entirely: it is admissible
|
|
215
|
+
// input, and the LIBRARY refuses it below as counted demand. `VERDICT_CLASSES` is
|
|
216
|
+
// the right list for the typo check for exactly that reason — narrowing it to the
|
|
217
|
+
// menu here would turn counted demand into an unrecorded typo (PRD item 31.1).
|
|
218
|
+
const VERDICT = /** @type {string} */ (verdictArg);
|
|
219
|
+
// the CHECK stays against the full `VERDICT_CLASSES` (see above); the PRINTED text
|
|
220
|
+
// names only the menu (item 34 L19: nothing customer-facing names an off-menu class).
|
|
221
|
+
if (!VERDICT_CLASSES.includes(VERDICT)) die(`--verdict ${VERDICT} is not a verdict class — one of ${MENU_CLASSES.join(' | ')}`);
|
|
222
|
+
|
|
223
|
+
// NO DEFAULT (PRD item 34 L17) — a default here would silently re-lock every
|
|
224
|
+
// interview onto one vendor. Missing or empty already died above, at the same
|
|
225
|
+
// usage message every other required flag shares, listing the same menu
|
|
226
|
+
// `src/job.js`'s own validator (`PROVIDERS`) admits. Membership is NOT
|
|
227
|
+
// re-checked here beyond that: `validateJob`, a few lines below, already reds
|
|
228
|
+
// an off-menu `provider` the same way it reds every other field — spelling
|
|
229
|
+
// that check twice would be the second rule this file keeps refusing to write.
|
|
230
|
+
const PROVIDER = /** @type {string} */ (providerArg);
|
|
231
|
+
|
|
232
|
+
const OUT = resolve(/** @type {string} */ (outArg));
|
|
233
|
+
/** the source door's own scratch root, fresh and unique per interview run —
|
|
234
|
+
* created by `prepareSource` itself (never here: `into-exists` is its own
|
|
235
|
+
* refusal for a reason), so only the PATH is decided up front. Under `--out`
|
|
236
|
+
* per PRD item 33 M3 ruling 2. */
|
|
237
|
+
const runid = Date.now().toString(36);
|
|
238
|
+
const INTO = join(OUT, `source-${runid}`);
|
|
239
|
+
|
|
240
|
+
// ── the terminal ─────────────────────────────────────────────────────────────
|
|
241
|
+
// `terminal: false` on purpose: the TTY's own canonical mode gives backspace and
|
|
242
|
+
// echo, and a piped stdin (a scripted session, a test) behaves identically. The
|
|
243
|
+
// async iterator makes END OF INPUT a value — `null` — instead of a promise that
|
|
244
|
+
// never settles, which is what a half-answered interview must be able to say.
|
|
245
|
+
const rl = createInterface({ input: stdin, terminal: false });
|
|
246
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
247
|
+
const say = (/** @type {string} */ s = '') => { stdout.write(`${s}\n`); };
|
|
248
|
+
const prompt = (/** @type {string} */ s) => { stdout.write(s); };
|
|
249
|
+
/** @returns {Promise<string|null>} the next line, or null at end of input */
|
|
250
|
+
const nextLine = async () => {
|
|
251
|
+
const { value, done } = await lines.next();
|
|
252
|
+
if (done) { say(); return null; }
|
|
253
|
+
// A TTY echoes what a person types; a PIPE does not, and the transcript then reads
|
|
254
|
+
// as a wall of prompts with no answers under them. Since a scripted session IS how
|
|
255
|
+
// this gets signed off and logged, the non-TTY case echoes the line itself — never
|
|
256
|
+
// the TTY case, where it would print everything twice.
|
|
257
|
+
//
|
|
258
|
+
// SCRUBBED on the way out, through the one inventory: this echo is the only place
|
|
259
|
+
// the script writes a keystroke anywhere but back to the person's own terminal, and
|
|
260
|
+
// a piped session is being logged by definition.
|
|
261
|
+
if (!stdin.isTTY) say(redactSecrets(String(value)));
|
|
262
|
+
return String(value);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** input ended before the interview did. NOTHING is written: a half-collected
|
|
266
|
+
* answers.json that looks finished is worse than no file at all.
|
|
267
|
+
* @param {string} where */
|
|
268
|
+
const stdinEnded = (where) => {
|
|
269
|
+
say('');
|
|
270
|
+
say(`INPUT ENDED — stdin closed at ${where}, so this interview never finished.`);
|
|
271
|
+
say('Nothing was written: a half-collected set of answers that LOOKS finished is the one failure nobody sees.');
|
|
272
|
+
throw new ExitSignal(2);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* One free-text answer, possibly several lines. A blank line ends it; an answer
|
|
277
|
+
* that is blank is RE-ASKED with the rule named, never accepted and never filled
|
|
278
|
+
* in on the person's behalf.
|
|
279
|
+
* @param {string} where what to say if stdin ends here
|
|
280
|
+
* @param {string} rule why an empty answer cannot stand
|
|
281
|
+
*/
|
|
282
|
+
const readAnswer = async (where, rule) => {
|
|
283
|
+
for (;;) {
|
|
284
|
+
/** @type {string[]} */
|
|
285
|
+
const got = [];
|
|
286
|
+
for (;;) {
|
|
287
|
+
prompt(got.length ? ' … ' : ' > ');
|
|
288
|
+
const l = await nextLine();
|
|
289
|
+
if (l === null) { if (got.length) break; stdinEnded(where); }
|
|
290
|
+
if (String(l).trim() === '') break;
|
|
291
|
+
got.push(String(l));
|
|
292
|
+
}
|
|
293
|
+
// SCRUBBED AT CAPTURE, through the one inventory. `runInterview` scrubs at its
|
|
294
|
+
// own ingest too and that is the seam of record — this is the same function, one
|
|
295
|
+
// step earlier, so that no raw keystroke can reach stdout by a route that does
|
|
296
|
+
// not go through the library (the answer recap below the goal prompt was exactly
|
|
297
|
+
// such a route, and printed a typed key back verbatim).
|
|
298
|
+
const text = redactSecrets(got.join('\n').trim());
|
|
299
|
+
if (text) return text;
|
|
300
|
+
say(` (nothing typed) ${rule}`);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A number the spec needs. Parsing only — every BOUND (the wall floor, the budget's
|
|
306
|
+
* ceiling coupling, the slug's alphabet) belongs to `validateJob`, which runs over
|
|
307
|
+
* the whole draft below rather than being re-spelled a field at a time here.
|
|
308
|
+
* @param {string} where @param {string} field @param {(s: string) => number|null} parse
|
|
309
|
+
* @param {boolean} [allowNull] whether the field's own "not set" answer is legal
|
|
310
|
+
*/
|
|
311
|
+
const readNumber = async (where, field, parse, allowNull = false) => {
|
|
312
|
+
for (;;) {
|
|
313
|
+
const raw = await readAnswer(where, `${field} has no default — it is yours to set. Again:`);
|
|
314
|
+
const v = parse(raw);
|
|
315
|
+
if (v === null && allowNull) return null;
|
|
316
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
|
|
317
|
+
say(` "${raw}" is not a positive number${allowNull ? ' (or the word `none`)' : ''} — ${field} is a number this run is held to. Again:`);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// ── the header (the part known before the interview starts) ─────────────────
|
|
322
|
+
say('INTERVIEW — your job, in your own words. Nothing here spends a cent.');
|
|
323
|
+
say(` verdict ${VERDICT} (YOUR pick — the close this authors promises to stay at or below it)`);
|
|
324
|
+
say(` provider ${PROVIDER}${baseUrlArg === null ? '' : ` (endpoint ${baseUrlArg})`}`);
|
|
325
|
+
say(` out ${OUT}`);
|
|
326
|
+
say(` ${ceilingLine(CEILING_USD)}`);
|
|
327
|
+
say(' no model is called from here: this collects your answers and hands them to run-author.mjs, which does the paid part under the ceiling above');
|
|
328
|
+
say(' (answers can be several lines — press Enter on an empty line, i.e. Enter twice, to finish an answer)');
|
|
329
|
+
// PRD item 31.3's probe rule, same warning `scripts/run-u.mjs` already prints
|
|
330
|
+
// at launch — never a refusal, just visible before money is about to be spent.
|
|
331
|
+
for (const line of probeWarningLines(PROVIDER) ?? []) say(` ${line}`);
|
|
332
|
+
|
|
333
|
+
// ── the OFF-MENU classes refuse BEFORE a single question, Source included ────
|
|
334
|
+
// Not this script's rule and not this script's words: `runInterview` is the
|
|
335
|
+
// admission path, and a locked pick comes back as a `request-red` refusal that is
|
|
336
|
+
// COUNTED demand for the class. Asking Source (which proves against the machine
|
|
337
|
+
// and can itself cost real disk I/O) for a job nothing here can close is an
|
|
338
|
+
// interview at the wrong price.
|
|
339
|
+
if (LOCKED_CLASSES.includes(VERDICT) || UNLISTED_CLASSES.includes(VERDICT)) {
|
|
340
|
+
const iv = runInterview({ answers: {}, verdictType: VERDICT });
|
|
341
|
+
const r = iv.refusal;
|
|
342
|
+
say(`REFUSED (${r?.kind ?? 'request-red'}) verb=${r?.verb ?? VERDICT} path=${r?.path ?? 'verdictType'}`);
|
|
343
|
+
say(r?.detail ?? '');
|
|
344
|
+
for (const o of r?.options ?? []) say(` · ${o}`);
|
|
345
|
+
say('\nNothing was asked and nothing was written — the refusal IS the record.');
|
|
346
|
+
throw new ExitSignal(1);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── 1. SOURCE — the form's first field (PRD item 33 M3, ruling 2) ────────────
|
|
350
|
+
say('');
|
|
351
|
+
say('── SOURCE ' + '─'.repeat(58));
|
|
352
|
+
// LIBRARY WORDING (PRD item 33 M3 piece 3, ruling 1) — this script prints what
|
|
353
|
+
// `src/authorflow.js` hands it and writes none of its own.
|
|
354
|
+
say(SOURCE_FIELD.prompt);
|
|
355
|
+
const sourceRaw = await readAnswer('the source',
|
|
356
|
+
'Source is required — this is what the job reads. Again:');
|
|
357
|
+
const isUrl = /^https?:\/\//i.test(sourceRaw);
|
|
358
|
+
const SOURCE = isUrl ? sourceRaw : resolve(sourceRaw);
|
|
359
|
+
if (!isUrl && !existsSync(SOURCE)) {
|
|
360
|
+
die(`${SOURCE} does not exist — Source is a folder, subfolder, file, or URL bareloop can actually read.`);
|
|
361
|
+
}
|
|
362
|
+
// the SAME rule `prepareSource` uses to route its own destination check
|
|
363
|
+
// (`looksLikeRepoSource`, `src/source.js`) — never a second, hand-typed copy
|
|
364
|
+
// of it. A Source that is a SUBFOLDER inside a git repo IS a repo source
|
|
365
|
+
// (PRD item 33 M3 ruling 2 addendum, 2026-09-13, landed 2a020d8): the
|
|
366
|
+
// boundary walks up to the NEAREST ancestor's `.git`, not only a `.git`
|
|
367
|
+
// sitting directly inside Source itself. It freezes as a repo (kind
|
|
368
|
+
// 'repo'), the same authoritative outcome `prepareSource`'s own walk
|
|
369
|
+
// produces for it — reported, never fought, below.
|
|
370
|
+
const IS_REPO = looksLikeRepoSource(SOURCE);
|
|
371
|
+
|
|
372
|
+
// ── LANGUAGE, DETECTED — never asked (PRD item 33 M3, ruling 3) ──────────────
|
|
373
|
+
// $0, no provider: reads the repo's own manifest, nearest wins, walking up to
|
|
374
|
+
// the repo root. Only run for a local DIRECTORY source — a URL or a single
|
|
375
|
+
// file skips detection entirely (M3 ruling 2/3: "not a code job"). Two of the
|
|
376
|
+
// four outcomes stop the interview before a single class question, on the
|
|
377
|
+
// same "counted demand, never a silent fallback" rule the old `--lang`
|
|
378
|
+
// comment already named:
|
|
379
|
+
// - `ambiguous` — two different languages' manifests at the same
|
|
380
|
+
// level; this script stays PROVIDER-FREE (D1),
|
|
381
|
+
// so it does not ask which one here — it says
|
|
382
|
+
// so and continues the form. The confirm turn
|
|
383
|
+
// (`run-author.mjs`, step S4) asks the person,
|
|
384
|
+
// interactively, before any paid call (D7).
|
|
385
|
+
// - `language-unsupported` — a known manifest (go.mod, Cargo.toml, ...)
|
|
386
|
+
// this catalogue has no genre data for yet.
|
|
387
|
+
// Refusing HERE, before any question, saves the
|
|
388
|
+
// person answering a form for a job that would
|
|
389
|
+
// only be refused later anyway.
|
|
390
|
+
// The other two outcomes are not errors and do not stop anything:
|
|
391
|
+
// - `resolved` — 'js' or 'python', carried through exactly where the
|
|
392
|
+
// old `--lang` value flowed.
|
|
393
|
+
// - `no-code-job` — no manifest anywhere in the walk. NOT a refusal (M3
|
|
394
|
+
// ruling 7): a plain-folder job still gets the form; it
|
|
395
|
+
// only gets an honest "no checks yet" stop later (M4).
|
|
396
|
+
let LANG = 'none-detected';
|
|
397
|
+
if (!isUrl && statSync(SOURCE).isDirectory()) {
|
|
398
|
+
const langResult = detectLanguage(SOURCE);
|
|
399
|
+
if (langResult.kind === 'ambiguous') {
|
|
400
|
+
// NO LONGER DIES (PRD item 33 M3 piece 4, step S5): `LANG` takes the
|
|
401
|
+
// first candidate as a placeholder — this script never signs anything
|
|
402
|
+
// and never writes `lang` anywhere the placeholder could be mistaken for
|
|
403
|
+
// the real pick; `run-author.mjs`'s own confirm turn resolves it for
|
|
404
|
+
// real, interactively, before the scout (D7), and that pick — not this
|
|
405
|
+
// one — is what lands in `closeDecl.lang`.
|
|
406
|
+
say(`Source has more than one supported language's manifest at the same (nearest) level: `
|
|
407
|
+
+ `${langResult.candidates.join(', ')} (in ${langResult.dir}).`);
|
|
408
|
+
say('Which one this job is about will be asked in the confirm turn, before any paid call, when you run run-author.mjs.');
|
|
409
|
+
LANG = langResult.candidates[0];
|
|
410
|
+
} else if (langResult.kind === 'language-unsupported') {
|
|
411
|
+
const r = langResult.refusal;
|
|
412
|
+
say(`REFUSED (${r.kind}) verb=${r.verb} path=${r.path}`);
|
|
413
|
+
say(r.detail);
|
|
414
|
+
for (const o of r.options) say(` · ${o}`);
|
|
415
|
+
// Honest, not "the refusal IS the record": THIS script has no spine (D10 —
|
|
416
|
+
// a spine here would be a new record format) and writes nothing at all, so
|
|
417
|
+
// there is nothing anywhere that counts this stop as demand. Only
|
|
418
|
+
// run-author.mjs's OWN language check (item 34 M3 loose-end fix) — reached
|
|
419
|
+
// by running that script directly against a prepared --source — is what
|
|
420
|
+
// records a language-unsupported stop as counted demand.
|
|
421
|
+
say('\nNothing was asked and nothing was written, and nothing here recorded this stop: run-interview.mjs keeps no '
|
|
422
|
+
+ 'spine of its own. run-author.mjs, run directly against a prepared --source, is what records a language-unsupported '
|
|
423
|
+
+ 'stop as counted demand.');
|
|
424
|
+
throw new ExitSignal(1);
|
|
425
|
+
} else {
|
|
426
|
+
LANG = langResult.kind === 'resolved' ? langResult.lang : 'none-detected';
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
say(` source ${SOURCE}`);
|
|
430
|
+
say(` lang ${LANG}`);
|
|
431
|
+
|
|
432
|
+
// ── 2. DESTINATION — the form's second field (PRD item 33 M3, ruling 2) ─────
|
|
433
|
+
// For a REPO source: the answer IS the write fence (`writeScope`) — exactly
|
|
434
|
+
// the FENCE question this interview used to ask separately, later, in the
|
|
435
|
+
// operator's half. That later question is GONE; `draft.writeScope` comes
|
|
436
|
+
// from here now.
|
|
437
|
+
// For every other kind: an absolute DIRECTORY the run may write into (never
|
|
438
|
+
// a filename) — proven against the machine, in a loop, so a bad answer is
|
|
439
|
+
// RE-ASKED rather than a hard exit (the same idiom every other answer in this
|
|
440
|
+
// script already uses).
|
|
441
|
+
say('');
|
|
442
|
+
say('── DESTINATION ' + '─'.repeat(53));
|
|
443
|
+
/** @type {string} */
|
|
444
|
+
let destinationRaw;
|
|
445
|
+
// LIBRARY WORDING (PRD item 33 M3 piece 3, ruling 1) — printed from
|
|
446
|
+
// `src/authorflow.js`'s own field, not re-typed here.
|
|
447
|
+
const destinationField = destinationFieldFor(IS_REPO);
|
|
448
|
+
if (IS_REPO) {
|
|
449
|
+
say(destinationField.prompt);
|
|
450
|
+
destinationRaw = await readAnswer('the destination', 'the destination is not optional: a run with no fence is ungated spend. Again:');
|
|
451
|
+
} else {
|
|
452
|
+
say(destinationField.prompt);
|
|
453
|
+
for (;;) {
|
|
454
|
+
const raw = await readAnswer('the destination', 'the destination is not optional — a run that cannot land its result should never spend. Again:');
|
|
455
|
+
// NEVER resolved against cwd here: `proveDestination` itself refuses a
|
|
456
|
+
// relative answer by name (`destination-not-absolute`) — pre-resolving
|
|
457
|
+
// would silently turn a person's typo'd relative path into an absolute
|
|
458
|
+
// one on their behalf, the exact "never accepted, never filled in" rule
|
|
459
|
+
// every other answer in this script already keeps.
|
|
460
|
+
const dp = await proveDestination(raw, { into: INTO });
|
|
461
|
+
if (dp.stop === null) { destinationRaw = raw; break; }
|
|
462
|
+
say(` ${dp.code}: ${dp.stop}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ── prepareSource — $0, no provider, BEFORE any class question (M3 ruling 2) ─
|
|
467
|
+
// Freezes Source into a hidden, scratch copy this job actually works from —
|
|
468
|
+
// H1's fix, the person never sees the git repo — and (for a non-repo source)
|
|
469
|
+
// re-proves Destination one more time against the machine, right before it
|
|
470
|
+
// matters, closing the gap between the interactive check above and the
|
|
471
|
+
// authoritative one `prepareSource` itself makes.
|
|
472
|
+
say('');
|
|
473
|
+
say(`preparing the source — a hidden, frozen copy this job works from ($0, no provider)…`);
|
|
474
|
+
const prep = await prepareSource({ source: SOURCE, into: INTO, destination: destinationRaw });
|
|
475
|
+
if (prep.stop !== null) {
|
|
476
|
+
const field = prep.code.startsWith('destination') ? 'Destination' : prep.code === 'into-exists' ? 'the prepared copy' : 'Source';
|
|
477
|
+
say(`REFUSED (${prep.code}) field=${field}`);
|
|
478
|
+
say(prep.stop);
|
|
479
|
+
say('\nNothing was authored.');
|
|
480
|
+
throw new ExitSignal(1);
|
|
481
|
+
}
|
|
482
|
+
say(` tree ${prep.tree}`);
|
|
483
|
+
say(` kind ${prep.manifest.kind}`);
|
|
484
|
+
say(` seed ${prep.manifest.seed}`);
|
|
485
|
+
|
|
486
|
+
// ── item 33 close-out: the install gap, named at $0, and WAITED FOR (F182 fix,
|
|
487
|
+
// hamr's ruling 2026-09-14 option A, candidate direction (a)) ── `prepareSource`
|
|
488
|
+
// copies only git-tracked files, so a JS/TS repo's copy never carries
|
|
489
|
+
// `node_modules`. bareloop NEVER runs an install itself — it names the gap and
|
|
490
|
+
// the exact command, and the person runs it themselves, in another terminal,
|
|
491
|
+
// in the copy. This script now PAUSES right here and re-checks, so the
|
|
492
|
+
// "Run it now?" offer at hand-off (below) is actually reachable once the
|
|
493
|
+
// install finishes — F182: previously the interview fell straight through to
|
|
494
|
+
// the class questions and only re-checked once, too late to ever offer.
|
|
495
|
+
const depsGap = prep.manifest.kind === 'repo'
|
|
496
|
+
? missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '')
|
|
497
|
+
: null;
|
|
498
|
+
if (depsGap) {
|
|
499
|
+
say('');
|
|
500
|
+
say(` The copy above has no installed packages (${depsGap.reason}).`);
|
|
501
|
+
say(' bareloop never runs an install itself — run this in the copy, in another terminal:');
|
|
502
|
+
say(` cd ${prep.tree} && ${depsGap.command}`);
|
|
503
|
+
say('');
|
|
504
|
+
// Loops on the SAME check `missingDependencies` above already ran — never a
|
|
505
|
+
// second, hand-typed copy of the rule. `skip` (or end of input) carries on
|
|
506
|
+
// exactly as before this fix: the hand-off re-check further down still
|
|
507
|
+
// suppresses the offer and says so. Any other line (including a blank
|
|
508
|
+
// Enter) re-checks rather than being treated as a typo — the only way to
|
|
509
|
+
// stop waiting is the one word `skip`.
|
|
510
|
+
for (;;) {
|
|
511
|
+
prompt(' Press Enter once it has finished to check again, or type skip to carry on without it: ');
|
|
512
|
+
const l = await nextLine();
|
|
513
|
+
if (l === null || String(l).trim().toLowerCase() === 'skip') break;
|
|
514
|
+
const recheck = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
|
|
515
|
+
if (!recheck) { say(' packages found — carrying on.'); break; }
|
|
516
|
+
say(` still missing (${recheck.reason}) — try again, or type skip to carry on without it.`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── ruling 7 → D5 = A, amended 2026-09-21 (F191; PRD item 33 M3 piece 4,
|
|
521
|
+
// step S6): a non-repo source does not stop HERE — this script stays
|
|
522
|
+
// PROVIDER-FREE (D1) and has nothing of its own to stop for. D5 ORIGINALLY
|
|
523
|
+
// read "the honest no-checks-yet stop moves to AFTER the confirm turn": a
|
|
524
|
+
// plain folder's confirm turn crashed live instead (`classGuards` has no
|
|
525
|
+
// language to key off for one, run `mu4hc7sp`, F191) — the confirm turn is
|
|
526
|
+
// unreachable by construction for this kind of source, so it never runs one.
|
|
527
|
+
// `run-author.mjs` now stops immediately, at $0, with no confirm turn and no
|
|
528
|
+
// model call at all — this message says that truthfully, not the old promise.
|
|
529
|
+
const IS_PLAIN_FOLDER = prep.manifest.kind !== 'repo';
|
|
530
|
+
if (IS_PLAIN_FOLDER) {
|
|
531
|
+
say('');
|
|
532
|
+
say(`Source is not a code repository — it is a plain ${prep.manifest.kind} job. bareloop has no checks for this kind`);
|
|
533
|
+
say('of job yet (a later build). The form continues, but running run-author.mjs will stop right away, at $0 —');
|
|
534
|
+
say('no confirm turn, no model call — nothing spent, and your source is not changed.');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// From here on, EVERYTHING that used to read the original patient path reads
|
|
538
|
+
// the PREPARED COPY instead (`prep.tree`) — the original is never touched
|
|
539
|
+
// again (patients are copies, always).
|
|
540
|
+
const TREE = prep.tree;
|
|
541
|
+
// `writeScope` IS Destination's proven fence for a REPO source (ruling 2) —
|
|
542
|
+
// for a plain folder, Destination is an OUTPUT directory, never a fence, and
|
|
543
|
+
// this build authors no close for that kind of job at all (D5), so the
|
|
544
|
+
// draft below carries NO `writeScope` field for one (`PLAIN_FOLDER_DEFERRED_
|
|
545
|
+
// FIELDS`, `src/authorjob.js`) rather than a meaningless one derived from an
|
|
546
|
+
// output path.
|
|
547
|
+
const writeScope = IS_PLAIN_FOLDER ? null : destinationRaw.split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
|
|
548
|
+
|
|
549
|
+
const QUESTIONS = questionsFor(VERDICT);
|
|
550
|
+
const LABELS = labelsFor(VERDICT);
|
|
551
|
+
const REQUIRED = requiredAnswersFor(VERDICT);
|
|
552
|
+
say('');
|
|
553
|
+
say(` asks ${REQUIRED.length} frozen question(s) for this class, then the numbers and names the job spec needs`);
|
|
554
|
+
|
|
555
|
+
// ── 3. the class's own frozen questions, one at a time ───────────────────────
|
|
556
|
+
/** @type {Record<string, string>} */
|
|
557
|
+
const answers = {};
|
|
558
|
+
let asked = 0;
|
|
559
|
+
for (const n of REQUIRED) {
|
|
560
|
+
asked += 1;
|
|
561
|
+
say('');
|
|
562
|
+
say(`── ${asked} of ${REQUIRED.length} ${'─'.repeat(Math.max(0, 56 - String(asked).length))}`);
|
|
563
|
+
// the FIELD LABEL, from the signed table (PRD item 33 M3 piece 3's wording
|
|
564
|
+
// fix) — printed on its own line so the literal `${n}. ${QUESTIONS[n]}` below
|
|
565
|
+
// stays byte-identical to what the library holds, which is what the wizard
|
|
566
|
+
// test suite (`tests/run-interview.test.js`) asserts verbatim and in order.
|
|
567
|
+
// A key with no signed-table row prints no label.
|
|
568
|
+
if (LABELS[n]) say(LABELS[n]);
|
|
569
|
+
// the frozen wording, printed as the library holds it. Numbered by the library's
|
|
570
|
+
// own key, so the number a person sees is the number their answer is filed under.
|
|
571
|
+
say(`${n}. ${QUESTIONS[n]}`);
|
|
572
|
+
answers[n] = await readAnswer(`question ${n}`,
|
|
573
|
+
'that one is required — every question in this class\'s set has to be answered before a close can be authored from it. Again:');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ── 4. the OPERATOR's half of the spec — the part nothing authors for you ────
|
|
577
|
+
say('');
|
|
578
|
+
say('── THE JOB SPEC — the operator\'s half, and nothing here authors it for you ──');
|
|
579
|
+
say(' the close is what run-author writes; these are the money and the clock it runs under.');
|
|
580
|
+
say('');
|
|
581
|
+
say('The NAME of this job: kebab-case, letters and digits and dashes (e.g. `litectx-maintainer`).');
|
|
582
|
+
say('It names the spec file you sign, the branch the run works on, and the spine it writes.');
|
|
583
|
+
const jobName = await readAnswer('the job name',
|
|
584
|
+
'the job needs a name — it is what the spec file, the run\'s work branch and its spine are all called. Again:');
|
|
585
|
+
say('');
|
|
586
|
+
// THE GOAL QUESTION IS GONE (PRD item 33 M3, ruling 5's 2026-09-13 addendum,
|
|
587
|
+
// D2 = option B): it used to be asked HERE, as a near-duplicate of the form's
|
|
588
|
+
// own Goal field, right after the recap printed the person's own answer back
|
|
589
|
+
// (F87's overlap, named but not fixed at the time). It is not merged away —
|
|
590
|
+
// it is REPLACED: the confirm turn (`runConfirmTurn`, wired into
|
|
591
|
+
// `scripts/run-author.mjs`, step S4) drafts the signed goal sentence from
|
|
592
|
+
// the person's Goal/Success answers, shows every check it names, and the
|
|
593
|
+
// person confirms or fixes it there, within its own 2-round cap. This script
|
|
594
|
+
// writes NO `goal` field at all — `run-author.mjs` sets `draft.goal` from
|
|
595
|
+
// the confirm turn's accepted plan before assembling the spec. `goal` never
|
|
596
|
+
// joins `AUTHORED_SPEC_FIELDS` (it stays an operator field by the letter of
|
|
597
|
+
// the rule); it is simply not THIS script's to ask for any more.
|
|
598
|
+
say('');
|
|
599
|
+
say(`The JOB's budget, in dollars — what the RUN may spend. This is NOT the authoring ceiling above (${ceilingLine(CEILING_USD).replace(/^budget\s+/, '')}).`);
|
|
600
|
+
const budgetUsdRaw = await readNumber('the job budget', 'budgetUsd', (s) => Number(s));
|
|
601
|
+
// `readNumber` is called with no `allowNull` above, so its own loop never
|
|
602
|
+
// returns `null` for this call — but its signature is shared with the
|
|
603
|
+
// `wallMin` read below (which DOES allow it), so tsc sees `number|null`
|
|
604
|
+
// here regardless. A real runtime guard narrows it honestly, never `!`/
|
|
605
|
+
// `as any`/`@ts-ignore` (LIBRARY_CONVENTIONS).
|
|
606
|
+
if (budgetUsdRaw === null) throw new Error('unreachable: readNumber without allowNull never returns null');
|
|
607
|
+
const budgetUsd = budgetUsdRaw;
|
|
608
|
+
|
|
609
|
+
say('');
|
|
610
|
+
say('The WALL, in MINUTES — how long the run may take. There is no default anywhere in bareloop for this:');
|
|
611
|
+
say('an unbounded run is legal and must be a VISIBLE choice, so it is asked rather than assumed.');
|
|
612
|
+
say('Type a number of minutes, or the word `none` to run with no wall at all.');
|
|
613
|
+
const wallMin = await readNumber('the wall', 'maxWallMs', (s) => (/^none$/i.test(s) ? null : Number(s)), true);
|
|
614
|
+
|
|
615
|
+
// ── 5. what gets written ─────────────────────────────────────────────────────
|
|
616
|
+
// THE LIBRARY'S OWN READING of the answers, not the raw keystrokes: `runInterview`
|
|
617
|
+
// validates completeness against the class's required set and scrubs every answer at
|
|
618
|
+
// INGEST (an answer becomes a prompt ingredient, a spine record and a signed artefact
|
|
619
|
+
// all at once, and a log that captures a key captures it forever). `repoPath` is the
|
|
620
|
+
// PREPARED COPY (`TREE`), never the original Source.
|
|
621
|
+
const iv = runInterview({ answers, verdictType: VERDICT, repoPath: TREE });
|
|
622
|
+
if (!iv.ok) {
|
|
623
|
+
say('');
|
|
624
|
+
if (iv.refusal) {
|
|
625
|
+
say(`REFUSED (${iv.refusal.kind}) verb=${iv.refusal.verb ?? 'none'} path=${iv.refusal.path}`);
|
|
626
|
+
say(iv.refusal.detail);
|
|
627
|
+
for (const o of iv.refusal.options ?? []) say(` · ${o}`);
|
|
628
|
+
}
|
|
629
|
+
for (const red of iv.reds ?? []) say(`RED ${red.code} at ${red.path}: ${red.detail}`);
|
|
630
|
+
say('\nNothing was written — the interview the library reads is not the one that was answered.');
|
|
631
|
+
throw new ExitSignal(1);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** the OPERATOR half, and only that half. `close`, `closeDecl` and `verdictType` are
|
|
635
|
+
* what the pipeline authors (`AUTHORED_SPEC_FIELDS`); a draft carrying any of them is
|
|
636
|
+
* refused by `assembleSpec` rather than merged over, so this must not write one.
|
|
637
|
+
* `goal` is DELIBERATELY ABSENT too (PRD item 33 M3, ruling 5's addendum, step S5) —
|
|
638
|
+
* not because this script authors it, but because it no longer asks for it: the
|
|
639
|
+
* confirm turn drafts and confirms the goal sentence, and `run-author.mjs` writes
|
|
640
|
+
* `draft.goal` from that before assembling the spec (`CONFIRM_AUTHORED_FIELDS`). */
|
|
641
|
+
const draft = {
|
|
642
|
+
schema: 'job-v1',
|
|
643
|
+
job: jobName,
|
|
644
|
+
// a record LABEL, never a statement of intent — the confirm turn's own
|
|
645
|
+
// drafted goal sentence is where intent lives, and this field only has to
|
|
646
|
+
// say which job's file you are looking at. Against the PREPARED COPY,
|
|
647
|
+
// never the original Source.
|
|
648
|
+
description: `${jobName} — authored through the bareloop interview (${VERDICT}, ${LANG}) against ${TREE}`,
|
|
649
|
+
// bareloop is LLM-agnostic (PRD item 34 L17) — the operator's own pick, asked
|
|
650
|
+
// rather than defaulted, one vendor from the SAME table the worker draws from.
|
|
651
|
+
provider: PROVIDER,
|
|
652
|
+
// ABSENT when `--base-url` was never given (`baseUrlArg === null`) — never
|
|
653
|
+
// written as `null`/`''`; every provider constructor defaults its own
|
|
654
|
+
// endpoint on its own in that case (PRD item 28 ruling (d)).
|
|
655
|
+
...(baseUrlArg === null ? {} : { baseUrl: baseUrlArg }),
|
|
656
|
+
cadence: { unit: 'day', every: 1 },
|
|
657
|
+
budgetUsd,
|
|
658
|
+
...(wallMin === null ? {} : { maxWallMs: Math.round(wallMin * 60_000) }),
|
|
659
|
+
// ABSENT for a plain folder (`writeScope === null`, D5/step S6) — never a
|
|
660
|
+
// meaningless fence derived from an output directory.
|
|
661
|
+
...(writeScope === null ? {} : { writeScope }),
|
|
662
|
+
escalation: { mode: 'decision-ready' },
|
|
663
|
+
// `tools` is deliberately OMITTED: an omitted menu hashes as the concrete current
|
|
664
|
+
// TOOL_MENU (MED-1), which pins WHICH menu was signed and makes a widening flip the
|
|
665
|
+
// hash. Naming one here would freeze today's list into the operator's own half.
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// THE SAME VALIDATOR that will judge this after the paid call, run now for $0. Its
|
|
669
|
+
// reds about the AUTHORED half are expected — that half does not exist yet, by
|
|
670
|
+
// design — so they are filtered BY FIELD NAME off `AUTHORED_SPEC_FIELDS` rather than
|
|
671
|
+
// by re-listing them here. `CONFIRM_AUTHORED_FIELDS` (just `goal`) joins the same
|
|
672
|
+
// filter for the same reason: this draft has no goal yet either, and `validateJob`
|
|
673
|
+
// would otherwise red `missing-required` at a field the confirm turn — not this
|
|
674
|
+
// script — is what fills in (step S5). `PLAIN_FOLDER_DEFERRED_FIELDS` (just
|
|
675
|
+
// `writeScope`) joins it too (step S6): a repo draft always carries one, so this
|
|
676
|
+
// is a no-op there, and a plain-folder draft's own `missing-required` at
|
|
677
|
+
// `writeScope` is likewise expected — nothing in this build fills that in for a
|
|
678
|
+
// plain-folder job at all yet (M4). Everything else is a typo the person can fix
|
|
679
|
+
// in a second now, or pay a scout and a model call to discover.
|
|
680
|
+
const draftReds = validateJob(draft, { shellCapUsd: draft.budgetUsd }).reds
|
|
681
|
+
.filter((r) => ![...AUTHORED_SPEC_FIELDS, ...CONFIRM_AUTHORED_FIELDS, ...PLAIN_FOLDER_DEFERRED_FIELDS]
|
|
682
|
+
.some((f) => String(r.path) === f || String(r.path).startsWith(`${f}.`)));
|
|
683
|
+
if (draftReds.length) {
|
|
684
|
+
say('');
|
|
685
|
+
say(`THE SPEC DRAFT DOES NOT VALIDATE — ${draftReds.length} red(s), found for $0 rather than after a paid call:`);
|
|
686
|
+
for (const r of draftReds) say(` ${r.code} at ${r.path}: ${r.detail}`);
|
|
687
|
+
say('');
|
|
688
|
+
say('Nothing was written. Run the interview again with those answers corrected.');
|
|
689
|
+
throw new ExitSignal(1);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
mkdirSync(OUT, { recursive: true });
|
|
693
|
+
const answersFile = join(OUT, 'answers.json');
|
|
694
|
+
const draftFile = join(OUT, 'specdraft.json');
|
|
695
|
+
writeFileSync(answersFile, `${JSON.stringify(iv.answers, null, 2)}\n`);
|
|
696
|
+
writeFileSync(draftFile, `${JSON.stringify(draft, null, 2)}\n`);
|
|
697
|
+
|
|
698
|
+
say('');
|
|
699
|
+
say(`written ${answersFile} the ${REQUIRED.length} answers, in the shape run-author.mjs consumes`);
|
|
700
|
+
say(`written ${draftFile} the operator half — no close and no verdictType: those are what run-author authors`);
|
|
701
|
+
say(` job ${draft.job}`);
|
|
702
|
+
say(` budget $${draft.budgetUsd} for the RUN · wall ${draft.maxWallMs === undefined ? 'UNBOUNDED (you said none — no outside deadline)' : `${draft.maxWallMs / 60_000}min`}`);
|
|
703
|
+
say(` fence ${draft.writeScope ? draft.writeScope.join(', ') : '(none — a plain-folder job has no fence yet, M4)'}`);
|
|
704
|
+
if (draft.baseUrl !== undefined) say(` endpoint ${draft.baseUrl}`);
|
|
705
|
+
// no goal line here — the confirm turn (run-author.mjs, step S4) drafts and
|
|
706
|
+
// confirms the goal sentence next; this draft carries none yet
|
|
707
|
+
|
|
708
|
+
// The hard line, on the two files this run just wrote. Count and PATH only — echoing
|
|
709
|
+
// a matched secret to stdout is the same leak, one hop on.
|
|
710
|
+
const leaks = [answersFile, draftFile].flatMap((f) => scanSecrets(readFileSync(f, 'utf8')).map(() => f));
|
|
711
|
+
if (leaks.length) {
|
|
712
|
+
say('');
|
|
713
|
+
say(`LEAK: ${leaks.length} secret-shaped string(s) across ${new Set(leaks).size} written file(s) — the hard line is broken; do NOT author from these`);
|
|
714
|
+
throw new ExitSignal(3); // distinct from 2 (operator/config) and 1 (a refusal)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ── 6. the paid step, which is a DIFFERENT process under a DIFFERENT ceiling ─
|
|
718
|
+
const RUN_AUTHOR = fileURLToPath(new URL('../scripts/run-author.mjs', import.meta.url));
|
|
719
|
+
const childArgs = [
|
|
720
|
+
'--source', TREE, '--answers', answersFile, '--draft', draftFile,
|
|
721
|
+
'--verdict', VERDICT, '--out', OUT,
|
|
722
|
+
...(CEILING_USD === null ? [] : ['--budget', String(CEILING_USD)]),
|
|
723
|
+
];
|
|
724
|
+
say('');
|
|
725
|
+
// Repo and plain-folder sources hand off to genuinely different pipelines
|
|
726
|
+
// (D5 amended 2026-09-21, F191): a repo gets a real scout and stops at
|
|
727
|
+
// prepareSigning; a plain folder gets NEITHER a scout NOR a confirm turn —
|
|
728
|
+
// `run-author.mjs` stops immediately, at $0, no model call at all (the
|
|
729
|
+
// confirm turn is unreachable by construction for a source with no code
|
|
730
|
+
// language, see the D5 amendment above). Saying "scout", "confirm turn" or
|
|
731
|
+
// "prepareSigning" for a plain folder would describe a run that cannot
|
|
732
|
+
// happen on this source.
|
|
733
|
+
if (IS_REPO) {
|
|
734
|
+
say('NEXT — the paid step: a real scout over that repository and a real model filling the declaration form.');
|
|
735
|
+
say(`It runs under the AUTHORING ceiling (${CEILING_USD === null ? 'UNBOUNDED — you gave no --budget' : `$${CEILING_USD}`}), which is not the job's $${draft.budgetUsd}.`);
|
|
736
|
+
say('It stops at prepareSigning: it never signs, and it never runs the job.');
|
|
737
|
+
} else {
|
|
738
|
+
say('NEXT — running run-author.mjs on this source stops right away, at $0: no scout, no confirm turn, no model call.');
|
|
739
|
+
say('It never signs and never runs the job — bareloop has no checks for this kind of job yet.');
|
|
740
|
+
}
|
|
741
|
+
say('');
|
|
742
|
+
// THE KEY NAME FOLLOWS THE CHOSEN PROVIDER (PRD item 34 L17) — no more
|
|
743
|
+
// hardcoded `ANTHROPIC_API_KEY`. `PROVIDER` already passed `validateJob`
|
|
744
|
+
// below by the time this prints, so `resolveProvider` here cannot throw on
|
|
745
|
+
// anything this script itself let through; the try/catch is only for the
|
|
746
|
+
// off-menu case `validateJob` reds but does not stop the write for (an
|
|
747
|
+
// admitted-but-uncrafted table entry would otherwise crash this print).
|
|
748
|
+
let providerEntry = null;
|
|
749
|
+
try { providerEntry = resolveProvider(PROVIDER); } catch { providerEntry = null; }
|
|
750
|
+
const providerEnvKey = providerEntry?.envKey ?? null;
|
|
751
|
+
say(` ${providerEnvKey ?? 'YOUR_PROVIDER_API_KEY'}=... node scripts/run-author.mjs ${childArgs.join(' ')}`);
|
|
752
|
+
// An `openai-api` + `baseUrl` draft (DeepSeek, today's one secondary, reached
|
|
753
|
+
// this way) still reads its key from `OPENAI_API_KEY` — there is no separate
|
|
754
|
+
// "DeepSeek key" env var, and a person pointed only at the command above could
|
|
755
|
+
// reasonably miss that. Said once, plainly, never a key VALUE.
|
|
756
|
+
if (draft.baseUrl !== undefined && providerEnvKey) {
|
|
757
|
+
say(` (the endpoint above is reached through the "${PROVIDER}" table entry, so its key still goes in ${providerEnvKey} — there is no separate endpoint-specific key variable)`);
|
|
758
|
+
}
|
|
759
|
+
// NO KEY, NO OFFER. `run-author.mjs` refuses without one and exits 2 at its own
|
|
760
|
+
// door, before a spine exists — so with the shell unkeyed this question has
|
|
761
|
+
// exactly one possible outcome for the person, and putting it anyway spends
|
|
762
|
+
// their attention on a choice they do not have. What is actionable instead is
|
|
763
|
+
// the command above and the one line that says how to make it work.
|
|
764
|
+
// F181 — a key that IS set can still carry a shape run-author will refuse
|
|
765
|
+
// (a line break/control char/stray whitespace, e.g. a two-line secret-store
|
|
766
|
+
// entry). A malformed key must not count as KEYED: this offer must read the
|
|
767
|
+
// same "will it actually run" question run-author itself asks at its door.
|
|
768
|
+
const rawKeyValue = providerEnvKey ? env[providerEnvKey] : undefined;
|
|
769
|
+
const keyProblem = rawKeyValue ? apiKeyProblem(rawKeyValue) : null;
|
|
770
|
+
const KEYED = providerEnvKey !== null && Boolean(rawKeyValue) && !keyProblem;
|
|
771
|
+
if (!KEYED) {
|
|
772
|
+
say('');
|
|
773
|
+
if (providerEnvKey && keyProblem) {
|
|
774
|
+
say(` (${providerEnvKey} is set but ${keyProblem} — run-author refuses without a clean key; secrets are never trimmed or repaired, only reported)`);
|
|
775
|
+
} else {
|
|
776
|
+
say(providerEnvKey
|
|
777
|
+
? ` (${providerEnvKey} is not set in this shell — run-author refuses without it; secrets load from the environment, never the tree)`
|
|
778
|
+
: ` (provider "${PROVIDER}" is not in the runnable table — run-author will refuse it loud; this is not a key problem)`);
|
|
779
|
+
}
|
|
780
|
+
// The repair, in plain words and WITHOUT a command: which secret store a person
|
|
781
|
+
// keeps their key in is theirs, and printing one specific incantation would be
|
|
782
|
+
// this script guessing at their setup — while the one thing it must never do is
|
|
783
|
+
// put a key anywhere a command line can be read from.
|
|
784
|
+
if (providerEnvKey && !keyProblem) say(' set the key in the shell you run it from, e.g. from your secret store, then paste the command above.');
|
|
785
|
+
}
|
|
786
|
+
// re-checked at the hand-off, not just once right after prepareSource: this is
|
|
787
|
+
// the LAST $0 point before the offer below could spend on a run that would
|
|
788
|
+
// only instrument-stop at its first tool-needing close stage. Still fires ⇒
|
|
789
|
+
// no offer, the same shape the KEYED gate above already uses.
|
|
790
|
+
const depsGapAtHandoff = prep.manifest.kind === 'repo'
|
|
791
|
+
? missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '')
|
|
792
|
+
: null;
|
|
793
|
+
if (depsGapAtHandoff) {
|
|
794
|
+
say('');
|
|
795
|
+
say(` The copy still has no installed packages (${depsGapAtHandoff.reason}) — run-author would only instrument-stop on it.`);
|
|
796
|
+
say(` cd ${prep.tree} && ${depsGapAtHandoff.command}`);
|
|
797
|
+
say(' then run run-author.mjs yourself with the command above.');
|
|
798
|
+
}
|
|
799
|
+
say('');
|
|
800
|
+
// The default is NO, and it is the same lean the pause's doors take: the answer that
|
|
801
|
+
// costs nothing is the one you get by saying nothing. Only an explicit yes spends.
|
|
802
|
+
// Unkeyed, or with an unresolved install gap, the offer is never made and the
|
|
803
|
+
// answer is the default one — not a refusal typed on the person's behalf, but
|
|
804
|
+
// the only answer the state admits.
|
|
805
|
+
const OFFERABLE = KEYED && !depsGapAtHandoff;
|
|
806
|
+
let answer = '';
|
|
807
|
+
if (OFFERABLE) {
|
|
808
|
+
prompt('Run it now? [y/N] ');
|
|
809
|
+
const l = await nextLine();
|
|
810
|
+
answer = l === null ? '' : String(l).trim().toLowerCase();
|
|
811
|
+
}
|
|
812
|
+
// the reader lets go of stdin BEFORE the child is spawned: `stdio:'inherit'` hands
|
|
813
|
+
// the same descriptor over, and two readers on one terminal is a keystroke landing
|
|
814
|
+
// in whichever of them happens to be listening.
|
|
815
|
+
rl.close();
|
|
816
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
817
|
+
say('');
|
|
818
|
+
say(OFFERABLE
|
|
819
|
+
? 'Not run. The command above is yours to fire when you are ready — the two files are already on disk.'
|
|
820
|
+
: !KEYED
|
|
821
|
+
? 'Not offered — there is no key in this shell to run it with. The command above is yours to fire once you set one; the two files are already on disk.'
|
|
822
|
+
: 'Not offered — the copy still has no installed packages. Install them, then fire the command above yourself; the two files are already on disk.');
|
|
823
|
+
exitCode = 0;
|
|
824
|
+
} else {
|
|
825
|
+
say('');
|
|
826
|
+
const child = spawnSyncFn(process.execPath, [RUN_AUTHOR, ...childArgs], { stdio: 'inherit' });
|
|
827
|
+
if (child.error) {
|
|
828
|
+
say(`run-author could not be started (${child.error.message}) — the command above still stands.`);
|
|
829
|
+
exitCode = 2;
|
|
830
|
+
} else {
|
|
831
|
+
// the child's own exit is the answer: a refusal there is a RESULT, and flattening
|
|
832
|
+
// it to 0 here would report a stop as a success one process up.
|
|
833
|
+
exitCode = child.status ?? 2;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return exitCode;
|
|
838
|
+
} catch (/** @type {any} */ e) {
|
|
839
|
+
if (e instanceof ExitSignal) return e.code;
|
|
840
|
+
throw e;
|
|
841
|
+
}
|
|
842
|
+
}
|