Judging takes two calls now: the CLI carries a confirm token and prints how many judgments a run would take

This commit is contained in:
Andrew
2026-08-26 22:22:08 -04:00
parent 31faf6525c
commit 202d4bc458
2 changed files with 622 additions and 13 deletions
+527 -2
View File
@@ -924,6 +924,457 @@ function printJson(answer: Record<string, unknown>): 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, number>): 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<string, number>;
const left = (json?.confirm_left ?? {}) as Record<string, number | string>;
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 <token>', CONFIRM_OPTION_HELP)
.action(async (ids: string[], options: Record<string, string | boolean | undefined>) => {
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 <text>', 'what this routine is for')
.option('--steps <json>', '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 <token>', CONFIRM_OPTION_HELP)
.action(async (name: string, options: Record<string, string | boolean | undefined>) => {
const pass = readPass();
const steps = await stepsToStore(name, options);
const body: Record<string, unknown> = { 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 <token>', CONFIRM_OPTION_HELP)
.action(async (name: string, options: Record<string, string | boolean | undefined>) => {
const pass = readPass();
const body: Record<string, unknown> = {};
@@ -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 <token>', CONFIRM_OPTION_HELP)
.action(async (name: string, options: Record<string, string | boolean | undefined>) => {
const pass = readPass();
const routineName = textOption(options['routine']) ?? '';
const body: Record<string, unknown> = {
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 <token>', CONFIRM_OPTION_HELP)
.action(async (name: string, options: Record<string, string | boolean | undefined>) => {
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;
+95 -11
View File
@@ -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.`,