From 202d4bc458fd70e6f079c9d65e8460ef64760a8f Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 26 Aug 2026 22:22:08 -0400 Subject: [PATCH] Judging takes two calls now: the CLI carries a confirm token and prints how many judgments a run would take --- src/cli/pinloop.ts | 529 ++++++++++++++++++++++++++++++++++++++- src/shared/guide-text.ts | 106 +++++++- 2 files changed, 622 insertions(+), 13 deletions(-) diff --git a/src/cli/pinloop.ts b/src/cli/pinloop.ts index af17f11..7a403eb 100644 --- a/src/cli/pinloop.ts +++ b/src/cli/pinloop.ts @@ -924,6 +924,457 @@ function printJson(answer: Record): void { process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`); } +// --------------------------------------------------------------------------- +// The first of the two calls five commands now take +// (specs/feature-judge-confirmation.md, AGREED 2026-08-26) +// --------------------------------------------------------------------------- + +/** + * The option every one of those five commands takes, declared in one place so + * the five spellings cannot drift apart. + */ +const CONFIRM_OPTION_HELP = + 'the token the first call printed; only a call carrying it judges or stores anything'; + +/** + * Whether the server stopped this call to have the person asked first, rather + * than doing what it was asked. + * + * A call that really judged or really stored something carries no confirm token + * at all, and that is the one thing that tells the two answers apart. + */ +function stoppedToAsk(json: unknown): boolean { + const token = (json as { confirm_token?: unknown } | undefined)?.confirm_token; + return typeof token === 'string' && token.trim() !== ''; +} + +/** + * Which of the five commands a stopped call is being reported for, plus every + * piece of context each command already has that the server's answer does not + * carry: the routine name a person typed after `--routine`, for the two + * automation commands that name a routine directly. `schedule put` and `watch + * put` are the only two that need it. `routine put`'s block names the schedule + * or watch already running the routine instead, which is server-known rather + * than typed on this call, and `judge` and `routine run` never name a routine + * in their block at all (approved-gate-text.md, drafted 2026-08-26). + * + * Every field beyond `kind` is also what `nextCommandLine` below reads to + * print the whole next command a person can copy and run, so each is the + * resolved value the second call actually needs — not necessarily the exact + * text the person typed. A judge call's `ids` is the full resolved list even + * when the first call read them from a pipe, and a `routine run` call's + * `within` is the resolved comma-separated set even when the person typed `-` + * to read it from a pipe, because a command line has no pipe to read from a + * second time and still has to work when pasted on its own. + */ +type ConfirmGate = + | { kind: 'judge'; ids: readonly string[]; quick: boolean } + | { kind: 'routine-run'; name: string; within?: string } + | { + kind: 'schedule-put'; + routineName: string; + name: string; + everyHours: string; + firstDueAt?: string; + } + | { kind: 'watch-put'; routineName: string; name: string } + | { kind: 'routine-put'; name: string; description?: string; steps?: string }; + +/** "26" and "1,000": how every count of judgments is written out. */ +function withCommas(n: number): string { + return n.toLocaleString('en-US'); +} + +/** "1 full judgment" / "4 full judgments" / "1,000 quick judgments". */ +function judgmentPhrase(count: number, kind: string): string { + return `${withCommas(count)} ${kind} judgment${count === 1 ? '' : 's'}`; +} + +/** + * How many judgments of one kind an account has left to run this month, as + * text: a real number is written out with commas, and the one non-numeric + * value the server ever sends here, `UNLIMITED` (`src/server/limits.ts`, + * `'unlimited'`), is printed exactly as sent rather than forced through + * `Number()`, which would otherwise turn it into "NaN". + */ +function leftAmount(remaining: unknown): { text: string; plural: boolean } { + const asNumber = Number(remaining); + if (Number.isFinite(asNumber)) return { text: withCommas(asNumber), plural: asNumber !== 1 }; + return { text: String(remaining), plural: true }; +} + +/** The exact-figure rate a fact is stated with: "once an hour" / "once every 24 hours". */ +function rateFactual(hours: number): string { + return hours === 1 ? 'once an hour' : `once every ${hours} hours`; +} + +/** + * The rate as it is spoken to the person, colloquially, inside the + * coding-agent line: "once an hour" and "once a day" are the two the approved + * text gives verbatim; anything else falls back to the exact figure. + */ +function rateColloquial(hours: number): string { + if (hours === 1) return 'once an hour'; + if (hours === 24) return 'once a day'; + return rateFactual(hours); +} + +/** + * "up to 100 full judgments" for a routine holding one kind of judging step, or + * "up to 1,000 quick judgments and up to 100 full judgments" for one holding + * both, quick named first, matching the approved text's own ordering. Never + * one combined figure: a full judgment and a quick judgment cost this account's + * month different amounts, so adding them first would mean converting both back + * into Pinloop's own bookkeeping (specs/feature-judge-confirmation.md). + */ +function ceilingPhrase(judgments: Record): string { + return ['quick', 'full'] + .filter((kind) => kind in judgments) + .map((kind) => `up to ${judgmentPhrase(judgments[kind]!, kind)}`) + .join(' and '); +} + +/** + * "full judge step" / "quick-judge step" / "judge step and a quick-judge step": + * how the naming line (point 1 of a stored schedule, watch, or routine's block) + * names which step, or steps, the routine reaches. + */ +function stepLabelNaming(kinds: string[]): string { + if (kinds.length > 1) return 'judge step and a quick-judge step'; + return kinds[0] === 'quick' ? 'quick-judge step' : 'full judge step'; +} + +/** + * "judge step" / "quick-judge step" / "judge step and quick-judge step": how + * the free-alternative sentence (point 4 of that same block) names the step + * being left out. Never carries "full", because leaving out "the judge step" is + * unambiguous on its own; "quick-judge step" keeps its own word because "the + * judge step" alone would misname it. + */ +function stepLabelLeftOut(kinds: string[]): string { + if (kinds.length > 1) return 'judge step and quick-judge step'; + return kinds[0] === 'quick' ? 'quick-judge step' : 'judge step'; +} + +/** "1 hour" / "2 hours": how the token's own lifetime is spoken. */ +function hoursPhrase(hours: number): string { + return `${hours} hour${hours === 1 ? '' : 's'}`; +} + +/** + * The "matches only ..." clause under the token, naming exactly what a second + * call has to match for that token to work: different at each of the five + * commands, because each parks its token under different arguments + * (src/server/confirm.ts, confirmSubjectForJudge and confirmSubjectForRow). + */ +function tokenScope(gate: ConfirmGate): string { + switch (gate.kind) { + case 'judge': + return 'these exact postings'; + case 'routine-run': + return 'this exact routine at this exact step'; + case 'schedule-put': + return 'this exact schedule name, routine name, and cadence'; + case 'watch-put': + return 'this exact watch name and routine name'; + case 'routine-put': + return 'this exact routine name and these exact steps'; + } +} + +/** + * One word of a shell command line, quoted only when it needs to be. + * + * A posting id, a routine name, a schedule name, and a cadence in hours are + * all plain letters, digits, dashes, and commas — the approved gate text + * prints those bare, unquoted, exactly as a person would type them. Anything + * else (a `--description` a person wrote in their own words, or a `--steps` + * JSON blob) can hold spaces, quotes, or other characters a shell would + * otherwise split on, so it is wrapped in single quotes, with any single + * quote inside it closed out and reopened around an escaped one. + */ +function shellArg(value: string): string { + if (/^[A-Za-z0-9._,:@/-]+$/.test(value)) return value; + return `'${value.split("'").join(`'\\''`)}'`; +} + +/** + * The whole next command, ready to copy and run, ending in `--confirm` and + * the token — served on a silver platter rather than left for a coding agent + * to rebuild and risk getting wrong (Andrew, 2026-08-26). Built from the real + * arguments the caller actually used, per `ConfirmGate`'s own doc comment, + * never from the placeholder-ish forms the approved gate text shows for + * `schedule put` and `routine put`. + * + * A judge call's posting ids are never shortened, even when there are as many + * as a hundred of them: a long line is a smaller problem than a coding agent + * rebuilding the list wrong (Andrew, 2026-08-26). + * + * `routine put`'s steps carry into the line only when the caller gave them + * directly as `--steps`: that text is already in hand, so the printed + * command is self-sufficient. Steps piped in on standard input are left out + * instead, because a single command line has no pipe to read from a second + * time — running the printed line as it stands then needs `--steps` or a + * pipe supplied again. Either way, the printed command always carries the + * routine's own name, and its `--description` when one was given. + */ +function nextCommandLine(gate: ConfirmGate, token: string): string { + const words: string[] = ['pinloop']; + switch (gate.kind) { + case 'judge': + words.push('judge', ...gate.ids.map(shellArg)); + if (gate.quick) words.push('--quick'); + break; + case 'routine-run': + words.push('routine', 'run', shellArg(gate.name)); + if (gate.within !== undefined) words.push('--within', shellArg(gate.within)); + break; + case 'schedule-put': + words.push( + 'schedule', + 'put', + shellArg(gate.name), + '--routine', + shellArg(gate.routineName), + '--every-hours', + shellArg(gate.everyHours), + ); + if (gate.firstDueAt !== undefined) words.push('--first-due-at', shellArg(gate.firstDueAt)); + break; + case 'watch-put': + words.push('watch', 'put', shellArg(gate.name), '--routine', shellArg(gate.routineName)); + break; + case 'routine-put': + words.push('routine', 'put', shellArg(gate.name)); + if (gate.description !== undefined) words.push('--description', shellArg(gate.description)); + if (gate.steps !== undefined) words.push('--steps', shellArg(gate.steps)); + break; + } + words.push('--confirm', token); + return words.join(' '); +} + +/** + * Prints what the second call would do and the whole next command a person + * can copy and run to make it. + * + * Under --json it is one JSON object on standard output and nothing else, so a + * following command reads the token and every number straight out of it — the + * server's own CONFIRM_REFUSAL sentence rides along there under + * `confirm_message`, unchanged. Read by a person it is plain lines on + * standard error, built to the wording Andrew approved (approved-gate-text.md, + * drafted 2026-08-26 against specs/feature-judge-confirmation.md and + * .claude/skills/printed-message/SKILL.md): what would really be judged and by + * which model, what that would spend and what would be left, what a judgment + * gives that reading the material yourself does not, the free path open to a + * coding agent itself, one plain sentence for that agent to put to the + * person, and the whole next command, ending in --confirm and the token, with + * a note underneath on how long the token lasts and what it matches. The + * server's CONFIRM_REFUSAL sentence is not printed a second time here: this + * block already says, in its own words, that nothing was judged or stored and + * what the coding agent does next, so repeating that server sentence on top + * would say the same thing a third time (Andrew, 2026-08-26). + */ +function reportConfirmation(json: any, asJson: boolean, gate: ConfirmGate): void { + const judgments = (json?.confirm_judgments ?? {}) as Record; + const left = (json?.confirm_left ?? {}) as Record; + const token = String(json?.confirm_token ?? ''); + const tokenHours = Number(json?.confirm_token_hours ?? 1); + + if (asJson) { + printJson({ + confirm_token: token, + confirm_message: String(json?.confirm_message ?? ''), + confirm_judgments: judgments, + confirm_token_hours: tokenHours, + ...(json?.confirm_sending === undefined ? {} : { confirm_sending: json.confirm_sending }), + ...(json?.confirm_left === undefined ? {} : { confirm_left: left }), + ...(json?.confirm_model === undefined ? {} : { confirm_model: json.confirm_model }), + ...(json?.confirm_named === undefined ? {} : { confirm_named: json.confirm_named }), + ...(json?.confirm_skipped === undefined ? {} : { confirm_skipped: json.confirm_skipped }), + ...(json?.confirm_cadence_hours === undefined + ? {} + : { confirm_cadence_hours: json.confirm_cadence_hours }), + ...(json?.confirm_fired_by === undefined ? {} : { confirm_fired_by: json.confirm_fired_by }), + }); + return; + } + + const lines: string[] = []; + const kinds = Object.keys(judgments); + + if (gate.kind === 'judge' || gate.kind === 'routine-run') { + // A judging run holds exactly one kind: a call is either a full judgement + // or a quick screen, never both at once. + const kind = kinds[0] ?? 'full'; + const count = judgments[kind] ?? 0; + const remaining = left[kind]; + const sending = Number(json?.confirm_sending ?? count); + const named = Number(json?.confirm_named ?? sending); + const skipped = Number(json?.confirm_skipped ?? 0); + const model = typeof json?.confirm_model === 'string' ? json.confirm_model : undefined; + const skippedClause = + skipped > 0 ? `; ${withCommas(skipped)} of ${withCommas(named)} already have a saved judgment and would be skipped` : ''; + + // Point 1: what would really be sent, and to which model. + if (gate.kind === 'judge') { + const verb = kind === 'quick' ? 'would quickly screen' : 'would judge'; + const modelClause = kind === 'full' && model ? ` with ${model}` : ''; + lines.push( + `${verb} ${withCommas(sending)} of ${withCommas(named)} named postings${modelClause}${skippedClause}`, + ); + } else { + const verb = kind === 'quick' ? 'would quickly screen' : 'would judge'; + const modelClause = kind === 'full' && model ? ` with ${model}` : ''; + const stepWord = kind === 'quick' ? 'quick-judge step' : 'judge step'; + lines.push( + `${verb} ${withCommas(sending)} of ${withCommas(named)} postings${modelClause} at the ` + + `routine's ${stepWord}${skippedClause}`, + ); + } + + // Point 2: what confirming would spend, and what would still be left. + const remainingAmount = leftAmount(remaining); + lines.push( + `confirming would use ${judgmentPhrase(count, kind)}, leaving room for ` + + `${remainingAmount.text} more ${kind} judgment${remainingAmount.plural ? 's' : ''} this month`, + ); + + // Point 3: what a judgment gives that reading the material yourself does + // not. Left out entirely for a quick screen, which never sends job + // description text and so gives nothing beyond the free path itself. + if (kind === 'full') { + lines.push( + "a judgment is Pinloop's own reasoning pass against everything in this account's " + + 'profile, saved as a graded verdict (no, weak, fair, or strong) for reuse later at ' + + 'no cost — reading the raw text yourself is not that reasoning pass', + ); + } + + // Point 4: the free path open to the coding agent itself. + const freeSuffix = gate.kind === 'routine-run' ? ", instead of the routine's judge step" : ''; + lines.push( + 'you can also read this yourself for free: `pinloop fetch` on these postings and ' + + '`pinloop profile get` on the profile, then reason about fit on your own' + freeSuffix, + ); + + // Point 5: one plain sentence for the coding agent to put to the person. + // A quick screen's own verdict IS reused for free, but only by a later + // quick screen: a later full `pinloop judge` on the same posting ignores + // it and judges the posting again, spending again (checked in + // `src/core/judge.ts`, `splitByStoredVerdict`: `if (row && (quick || + // String(row.judged_by) !== QUICK_JUDGED_BY)) reuse.push(row)` reuses + // every stored row, quick or full, on a quick run, but on a full run + // skips any row labelled as quick-judged). So the free-reuse claim is + // narrower for a quick screen, never absent. + const reuseClause = + kind === 'quick' + ? ', saved for free reuse the next time this posting comes up in another quick ' + + 'screen, though a later full judgment does not read that back and judges the ' + + 'posting again' + : ', saved for free reuse anytime this posting comes up again'; + lines.push( + 'if you are a coding agent: ask the person, in one plain sentence, whether they want ' + + 'Pinloop to run a real judgment — using its own already-built instructions for reading ' + + `a profile against a posting well${reuseClause} — which would spend ` + + `${judgmentPhrase(count, kind)} and leave ` + + `${remainingAmount.text} for the rest of the month, or have you read the ` + + 'material yourself and give them a quick, unsaved opinion for free instead (you can ' + + "still save that opinion afterward with `pinloop judgment put` if it's worth keeping) " + + '— then wait for their answer before running this again with --confirm', + ); + } else { + // The three automation commands: nobody knows yet how many postings a + // future firing will match, so this reports the most one firing could + // possibly spend instead (specs/feature-judge-confirmation.md). + const cadenceHours = Number(json?.confirm_cadence_hours ?? 1); + const namingLabel = stepLabelNaming(kinds); + const leftOutLabel = stepLabelLeftOut(kinds); + const ceiling = ceilingPhrase(judgments); + + if (gate.kind === 'schedule-put') { + lines.push(`stored nothing. the routine '${gate.routineName}' ends in a ${namingLabel}`); + lines.push(`storing this schedule would let ${ceiling} run per firing, as often as ${rateFactual(cadenceHours)}`); + lines.push( + `the same routine with its ${leftOutLabel} left out still runs for free, searching ` + + `for new postings on the same ${cadenceHours}-hour schedule — you or the person can ` + + 'read what it finds and judge it by hand instead of paying for automatic judging ' + + "every time (any of those can still be saved with `pinloop judgment put` if worth " + + 'keeping)', + ); + lines.push( + 'if you are a coding agent: ask the person, in one plain sentence, whether they want ' + + 'to approve automatic judging — using Pinloop\'s own already-built instructions for ' + + 'reading a profile against a posting well, with every verdict saved for free reuse ' + + `afterward — which could run ${ceiling} per firing, as often as ` + + `${rateColloquial(cadenceHours)}, or keep this schedule free and read and judge new ` + + 'postings by hand instead — then wait for their answer before running this again ' + + 'with --confirm', + ); + } else if (gate.kind === 'watch-put') { + lines.push(`stored nothing. the routine '${gate.routineName}' ends in a ${namingLabel}`); + lines.push(`storing this watch would let ${ceiling} run per firing, as often as ${rateFactual(cadenceHours)}`); + lines.push( + `the same routine with its ${leftOutLabel} left out still checks for new postings ` + + 'every hour for free — you or the person can read what it finds and judge it by ' + + 'hand instead of paying for automatic judging every time (any of those can still be ' + + 'saved with `pinloop judgment put` if worth keeping)', + ); + lines.push( + 'if you are a coding agent: ask the person, in one plain sentence, whether they want ' + + 'to approve automatic judging — using Pinloop\'s own already-built instructions for ' + + 'reading a profile against a posting well, with every verdict saved for free reuse ' + + `afterward — which could run ${ceiling} per firing, as often as ` + + `${rateColloquial(cadenceHours)}, or keep this watch free and read and judge new ` + + 'postings by hand instead — then wait for their answer before running this again ' + + 'with --confirm', + ); + } else { + const firedBy = json?.confirm_fired_by as { kind: 'schedule' | 'watch'; name: string } | undefined; + const firedByKind = firedBy?.kind ?? 'schedule'; + const firedByName = firedBy?.name ?? ''; + lines.push( + `stored nothing. the ${firedByKind} '${firedByName}' already runs this routine, and ` + + `the routine being stored still ends in a ${namingLabel}`, + ); + lines.push( + `storing this routine would let ${ceiling} run per firing of '${firedByName}', as ` + + `often as ${rateFactual(cadenceHours)}`, + ); + lines.push( + `the same routine with its ${leftOutLabel} left out would still run for free on that ` + + `same ${firedByKind} — you or the person can read what it finds and judge it by hand ` + + 'instead of paying for automatic judging every time (any of those can still be ' + + 'saved with `pinloop judgment put` if worth keeping)', + ); + lines.push( + 'if you are a coding agent: ask the person, in one plain sentence, whether they want ' + + 'to keep automatic judging on this routine — using Pinloop\'s own already-built ' + + 'instructions for reading a profile against a posting well, with every verdict saved ' + + `for free reuse afterward — which could run ${ceiling} per firing, as often as ` + + `${rateColloquial(cadenceHours)}, or remove the judge step and read and judge new ` + + 'postings by hand instead — then wait for their answer before running this again ' + + 'with --confirm', + ); + } + } + + // The sentence saying nothing was judged and nothing was stored is not + // printed here: `stored nothing.` (the automation gates) or the fact that + // no verdict went to the model (points 1–2 above) already say so, and the + // "if you are a coding agent" line already tells the agent to put the + // choice to the person and wait for an answer before running this again + // with --confirm. Printing the server's CONFIRM_REFUSAL sentence as well + // would say the same thing a third time. CONFIRM_REFUSAL itself is + // untouched: it is still the one sentence every one of the five commands + // answers with when the token a second call carries is missing, invented, + // made for something else, or stale (src/server/confirm.ts), and it still + // rides along unprinted in the JSON form of this same answer. + lines.push(`next command: ${nextCommandLine(gate, token)}`); + lines.push(`(token expires in ${hoursPhrase(tokenHours)}, matches only ${tokenScope(gate)})`); + process.stderr.write(`${lines.join('\n')}\n`); +} + /** * Reads everything given to this command on its standard input, to the end of * the input, whether that input is another command's output through a pipe or a @@ -2924,6 +3375,7 @@ function addJudgeCommands(program: Command): void { 'facts and without their job description text', ) .option('--json', 'print one JSON object holding the verdicts, instead of blocks') + .option('--confirm ', CONFIRM_OPTION_HELP) .action(async (ids: string[], options: Record) => { const pass = readPass(); const wanted = await idsToJudge(ids); @@ -2935,6 +3387,8 @@ function addJudgeCommands(program: Command): void { if (keep !== undefined) body['keep'] = keep; if (options['again']) body['again'] = true; if (quick) body['quick'] = true; + const confirming = textOption(options['confirm']); + if (confirming !== undefined) body['confirm'] = confirming; // A real run asks the server to say what is happening while it happens, // and prints one line per thing on standard error. A preview asks nobody @@ -2965,6 +3419,13 @@ function addJudgeCommands(program: Command): void { String((json as { message?: unknown }).message ?? 'the judge run failed; try again'), ); } + // Nothing was judged: this is the first of the two calls, and what it + // printed is what the second call would do plus the token that confirms it + // (specs/feature-judge-confirmation.md, AGREED 2026-08-26). + if (stoppedToAsk(json)) { + reportConfirmation(json, options['json'] === true, { kind: 'judge', ids: wanted, quick }); + return; + } const rows = rowsOf(json); if (options['preview']) { @@ -3742,14 +4203,32 @@ function addRoutineCommands(program: Command): void { .option('--description ', 'what this routine is for') .option('--steps ', 'the steps as a JSON list, instead of standard input') .option('--json', 'print the stored routine, and the version it replaced, as JSON') + .option('--confirm ', CONFIRM_OPTION_HELP) .action(async (name: string, options: Record) => { const pass = readPass(); const steps = await stepsToStore(name, options); const body: Record = { steps }; const description = textOption(options['description']); if (description !== undefined) body['description'] = description; + const confirming = textOption(options['confirm']); + if (confirming !== undefined) body['confirm'] = confirming; const { json } = await callAsAccount(pass, routinePath(name), { method: 'POST', body }); + // Nothing was stored: a schedule or a watch already fires this routine and + // the version being stored can judge, so the person is asked first. + if (stoppedToAsk(json)) { + // The steps go into the reproduced next command only when they were + // given directly as --steps: the piped form has no pipe to read from + // a second time (ConfirmGate's own doc comment above). + const stepsGiven = textOption(options['steps']); + reportConfirmation(json, options['json'] === true, { + kind: 'routine-put', + name, + description, + steps: stepsGiven, + }); + return; + } if (options['json']) { printJson({ rows: rowsOf(json), cursor: cursorOf(json) }); return; @@ -3837,6 +4316,7 @@ function addRoutineCommands(program: Command): void { `them from the JSON piped in; ${LARGE_SETS_MUST_BE_PIPED} (the - form)`, ) .option('--json', 'print one JSON object holding the rows, instead of cards') + .option('--confirm ', CONFIRM_OPTION_HELP) .action(async (name: string, options: Record) => { const pass = readPass(); const body: Record = {}; @@ -3844,6 +4324,8 @@ function addRoutineCommands(program: Command): void { if (typeof within === 'string' && within !== '') { body['within'] = await withinSet(within, 'pinloop routine run'); } + const confirming = textOption(options['confirm']); + if (confirming !== undefined) body['confirm'] = confirming; // A run of a stored sequence can take minutes, so it asks the server to // say which step it has reached. A person watching reads that on the one @@ -3884,6 +4366,21 @@ function addRoutineCommands(program: Command): void { String((json as { message?: unknown }).message ?? `the run of your '${name}' routine failed`), ); } + // The run stopped at a judge step so the person can be asked first. It + // judged nothing and spent nothing (specs/feature-judge-confirmation.md). + if (stoppedToAsk(json)) { + // The resolved set of ids, not the raw `--within` text: when the + // person typed `-` to read them from a pipe, that pipe is gone by the + // time this line is read, so the reproduced command carries the ids + // themselves instead (ConfirmGate's own doc comment above). + const resolvedWithin = typeof body['within'] === 'string' ? body['within'] : undefined; + reportConfirmation(json, options['json'] === true, { + kind: 'routine-run', + name, + within: resolvedWithin, + }); + return; + } const rows = rowsOf(json); // What each step did goes to standard error, so that standard output @@ -4022,16 +4519,32 @@ function addScheduleCommands(program: Command): void { 'the time of day it lands on is the time of day every later run lands on', ) .option('--json', 'print the stored schedule, and the version it replaced, as JSON') + .option('--confirm ', CONFIRM_OPTION_HELP) .action(async (name: string, options: Record) => { const pass = readPass(); + const routineName = textOption(options['routine']) ?? ''; const body: Record = { - routine: textOption(options['routine']), + routine: routineName, every_hours: textOption(options['everyHours']), }; const firstDueAt = textOption(options['firstDueAt']); if (firstDueAt !== undefined) body['first_due_at'] = firstDueAt; + const confirming = textOption(options['confirm']); + if (confirming !== undefined) body['confirm'] = confirming; const { json } = await callAsAccount(pass, schedulePath(name), { method: 'POST', body }); + // No row was written: the routine this schedule names reaches a judge + // step, so the person is asked before it starts firing on its own. + if (stoppedToAsk(json)) { + reportConfirmation(json, options['json'] === true, { + kind: 'schedule-put', + routineName, + name, + everyHours: textOption(options['everyHours']) ?? '', + firstDueAt, + }); + return; + } if (options['json']) { printJson({ rows: rowsOf(json), cursor: cursorOf(json) }); return; @@ -4209,6 +4722,7 @@ function addWatchCommands(program: Command): void { .hideHelp(), ) .option('--json', 'print the stored watch, and the version it replaced, as JSON') + .option('--confirm ', CONFIRM_OPTION_HELP) .action(async (name: string, options: Record) => { const routine = textOption(options['routine']) ?? ''; if (options['everyHours'] !== undefined || options['firstDueAt'] !== undefined) { @@ -4216,10 +4730,21 @@ function addWatchCommands(program: Command): void { } const pass = readPass(); + const confirming = textOption(options['confirm']); const { json } = await callAsAccount(pass, watchPath(name), { method: 'POST', - body: { routine }, + body: { routine, ...(confirming === undefined ? {} : { confirm: confirming }) }, }); + // No row was written: the routine this watch names reaches a judge step, + // so the person is asked before it starts looking every hour on its own. + if (stoppedToAsk(json)) { + reportConfirmation(json, options['json'] === true, { + kind: 'watch-put', + routineName: routine, + name, + }); + return; + } if (options['json']) { printJson({ rows: rowsOf(json), cursor: cursorOf(json) }); return; diff --git a/src/shared/guide-text.ts b/src/shared/guide-text.ts index b6f47cc..a9f66ce 100644 --- a/src/shared/guide-text.ts +++ b/src/shared/guide-text.ts @@ -11,15 +11,23 @@ * naming that command, so nobody can add a command to Pinloop and leave it * undocumented by accident. * - * Three things are deliberately not in this file. There is no count of what one + * Two things are deliberately not in this file. There is no count of what one * account has spent this month, because a number written here would be compiled * into a copy of the program somebody installed weeks ago and would be wrong; * the guide names `pinloop` typed on its own as the place those numbers are - * read. There are no dollar amounts anywhere. And there is no instruction - * telling an agent to ask the person before spending money (Andrew, 2026-08-18, - * emphatic, reaffirmed 2026-08-19): what protects the person's money is the - * allowance the server enforces, and a sentence asking permission protects - * nobody while making every run slower. + * read. And there are no dollar amounts anywhere. + * + * A third thing was deliberately absent until 2026-08-26 and is here now: the + * instruction telling an agent to ask the person before spending. Andrew ruled + * against such an instruction on 2026-08-18, emphatic, reaffirmed 2026-08-19, + * on the grounds that the allowance the server enforces is what protects the + * person's money. He reversed that on 2026-08-26, after a coding agent ran one + * `pinloop judge` command over a hundred postings on his own account with + * nothing said to him first. A run that stays inside the allowance the whole way + * is never refused, so nothing told him anything had happened + * (specs/feature-judge-confirmation.md, AGREED 2026-08-26). CONFIRM_EXPLANATION + * below is that instruction, written once and put into all five of the sections + * whose commands now take two calls. * * The two numbers the "limits" section does state — how many requests an * account may make in a minute, and the most postings one command may pull — @@ -203,6 +211,52 @@ The copy you are holding is version ${saved}, and this Pinloop ships version ${n Tell the person their saved Pinloop instruction file is being replaced, then run "pinloop skill" and save the text it prints over the old file.`; } +/** + * The two-call pattern, written once and put into all five of the sections whose + * commands now take two calls: judge, routine run, routine put, schedule put and + * watch put (specs/feature-judge-confirmation.md, AGREED 2026-08-26). + * + * It is one block reused five times rather than five paragraphs saying the same + * thing, because five copies are five places that can quietly drift apart, and a + * coding agent reading only one section still has to learn the whole rule. + * + * It names no dollar figure and never uses the word Pinloop's own bookkeeping is + * counted in. Everything it states is a count of judgments, full or quick + * (Andrew, 2026-08-26). + */ +export const CONFIRM_EXPLANATION = `This command now takes two calls. The first call does nothing at all: it judges +nothing, it stores nothing, it takes nothing out of this account's month, and it +prints what the second call would do along with a token, which is a random piece +of text. Only a second call carrying that same piece of text after --confirm does +the work. + +The first call prints how many judgments the work would run, full or quick, and +how many more of that same kind this account has left this month. For a schedule +or a watch it prints the most one firing of the routine could possibly run +instead, because nobody knows yet how many postings a future run will find. + +Put the choice to the person in one plain sentence before you run the second +call. Say how much of the month confirming would use, in the count the first call +printed. Say what a Pinloop judgment gives that reading the postings yourself +does not: Pinloop follows its own worked-out instructions for reading a profile +against a posting, and it keeps the model's written reasoning. A full judgment's +verdict is saved for free reuse anytime that posting comes up again. A quick +screen's verdict is saved too, and a later quick screen on the same posting +reads it back for free, but a later full judgment ignores it and judges that +posting again, taking more out of the month. Then say what the free way is, +which is open to you as well: +run "pinloop list", "pinloop fetch", "pinloop filter" and "pinloop profile get", +read the same postings and the same profile without taking anything out of the +month, form your own opinion, and store that opinion with "pinloop judgment put" +if it is worth keeping, after which Pinloop counts that posting judged exactly as +one it judged itself. Wait for the person to answer, then run the same command +again with --confirm and the token. + +A token works once, for one hour, and only for the exact request that printed it. +Naming a different posting, a different number of postings, the same ones in a +different order, or a different number of hours between firings all mean the +token no longer matches, and then the first call has to be run again.`; + /** * One entry per command and per non-command part. * @@ -656,7 +710,9 @@ A run bigger than what is left of the month's usage judges as much as that covers and ends by naming how many postings it did not attempt and why; the same postings are listed in the machine-readable output with that reason on each one. A verdict read back rather than bought counts against nothing. Type "pinloop" on its own -to read how much is left.`, +to read how much is left. + +${CONFIRM_EXPLANATION}`, companies: `Finds employers by the words in their name, the one with the most postings first. It hands back employers rather than postings, and each line carries the employer @@ -772,7 +828,14 @@ rather than the bare words, and a name of more than one word carries an underscore, as in "posted_after". An argument a command does not take is refused when the routine is stored, in a sentence naming the step, the argument and every argument that command does take, so a wrong name is found once rather than -every night.`, +every night. + +Storing a routine is stopped and asked about only when a schedule or a watch this +account owns already fires that routine AND the version being stored holds a +judge step. A routine nothing fires yet is stored on the first call, whatever is +in it, because nothing can run it until something fires it. + +${CONFIRM_EXPLANATION}`, 'routine get': `Shows one stored routine and the steps it holds, in order.`, @@ -785,7 +848,13 @@ refusal names what is still pointing at it.`, produced. --within gives the first step a set of posting ids to work inside, either as ids or as JSON piped in. --json prints rows a following command can read. Every step's outcome is kept, so a run that stopped part way says which -step stopped it.`, +step stopped it. + +A run whose steps reach a judge step is stopped at that step the first time, the +same way a typed "pinloop judge" is. A run of a routine with no judge step in it +runs on the first call as it always has. + +${CONFIRM_EXPLANATION}`, 'routine results': `Shows what the most recent run of one routine produced, read fresh from the corpus rather than from a copy taken at the time. --json prints rows a following command @@ -800,7 +869,15 @@ keyboard. --routine names the routine and --every-hours is a whole number of hours from 1 to 168, both required. --first-due-at fixes when the first run happens, and the time of day it lands on is the time of day every later run lands on. Storing a schedule needs an account that pays for a subscription, and -"pinloop upgrade" starts one. An account may hold 10 schedules.`, +"pinloop upgrade" starts one. An account may hold 10 schedules. + +Storing a schedule whose routine reaches a judge step is stopped and asked about, +because storing the row is the one moment somebody is at the keyboard before it +starts firing on its own. Every such call is asked about, including one that +changes nothing but the number of hours between firings. A schedule for a routine +with no judge step in it is stored on the first call as it always has been. + +${CONFIRM_EXPLANATION}`, 'schedule get': `Shows one schedule, when it next runs, and how its last run went.`, @@ -816,7 +893,14 @@ be a search or a list, and is required. A watch has no cadence of its own; it looks about once an hour. Nothing arriving means nothing runs. Storing a watch needs an account that pays for a subscription, and "pinloop upgrade" starts one. An account may hold 10 watches. A routine whose last step adds to a tab is what -fills a list of new finds overnight.`, +fills a list of new finds overnight. + +Storing a watch whose routine reaches a judge step is stopped and asked about, +because storing the row is the one moment somebody is at the keyboard before it +starts looking every hour on its own. A watch for a routine with no judge step in +it is stored on the first call as it always has been. + +${CONFIRM_EXPLANATION}`, 'watch get': `Shows one watch, when it next looks, and how its last firing went, including how many postings had arrived and how many of them matched.`,