diff --git a/packages/runner/src/runtime/jev-executor.mjs b/packages/runner/src/runtime/jev-executor.mjs index 9e0d1cd..c05a943 100644 --- a/packages/runner/src/runtime/jev-executor.mjs +++ b/packages/runner/src/runtime/jev-executor.mjs @@ -6,8 +6,8 @@ import { renderJourneySteps } from "@shipbash/contracts/runtime/journey-steps.mj import { splitWaiting } from "./action-requests.mjs"; import { chooseJevAction, closingOptions, pageOptions } from "./jev-actions.mjs"; import { judge, movedBeforeHandBack, record, settle, stepRemains } from "./jev-checklist.mjs"; -import { readSteps, whichStep } from "./jev-step-questions.mjs"; -import { attribute, refuse, stepLedger, walkInputs } from "./jev-steps.mjs"; +import { readStepText, readSteps, TEXT_LIFTED_BELOW, whichStep } from "./jev-step-questions.mjs"; +import { attribute, liftTextWall, recheckable, refuse, stepLedger, walkInputs } from "./jev-steps.mjs"; import { checkInterruption, movedFrom, readPage, waitOut } from "./jev-page-reading.mjs"; import { withRoute } from "./jev-route.mjs"; import { interruption, JEV_REASONS } from "./jev-stops.mjs"; @@ -182,6 +182,27 @@ async function stillOnPage(run, state, action) { return url.trim() === state.url && pageState(page) === pageState(state.page) && namesLiveElement(page, selector); } +/** + * The ledger's refusal of `action` (jev-steps.mjs refuse), unless the step + * that stops it is a text wall whose own command has run and ended: that step + * is read once more, on the terminal (readStepText), and below + * TEXT_LIFTED_BELOW it stops the walk no longer — a step that types text only + * on a condition its command did not print. Recorded either way. + */ +async function refusal(run, state, step, action) { + const refused = refuse(run.ledger, step, action); + if (refused?.code !== "step_needs_text" || !recheckable(refused.step, state.terminal)) return refused; + refused.step.rechecked = true; + const read = { steps: run.instructions, history: run.history, terminal: state.terminal }; + const { needsText } = await run.readStepText(read, refused.step.number - 1, run.request); + run.requests += 1; + const lifted = needsText !== null && needsText < TEXT_LIFTED_BELOW; + await record(run, "recheck", { choice: lifted ? "lifted" : "kept", step: refused.step.number, needsText }); + if (!lifted) return refused; + liftTextWall(run.ledger, refused.step); + return refuse(run.ledger, step, action); +} + /** * Takes the chosen action. One read off the page is checked against the page * first: it may have moved on while Jev chose, or remounted the element the @@ -189,8 +210,8 @@ async function stillOnPage(run, state, action) { * moved does not discard it, and the screen is not read again: a spinner * redraws it between any two readings. Beside that read, the step the action * performs is named (jev-walk-steps.mjs stepFor), and the ledger may refuse it - * (jev-steps.mjs refuse): the walk then hands that step back before the - * action reaches the page. + * (refusal): the walk then hands that step back before the action reaches the + * page. * @param {boolean} fromPage whether the action was read off the page */ async function act(run, state, action, fromPage) { @@ -206,7 +227,7 @@ async function act(run, state, action, fromPage) { run.guidance = "Page changed during selection; previous choice was NOT executed."; return outcome(null); } - const refused = refuse(run.ledger, step, action); + const refused = await refusal(run, state, step, action); await record(run, "step", { choice: refused?.code ?? step ?? "current", step: refused?.step.number ?? step }); if (refused) return outcome(refused.code); await perform(run, action); @@ -288,6 +309,7 @@ function newRun(input, options) { terminalSteps, choose: options.choose ?? chooseJevAction, readSteps: options.readSteps ?? readSteps, + readStepText: options.readStepText ?? readStepText, whichStep: options.whichStep ?? whichStep, onDecision: options.onDecision ?? (() => {}), onCheckpoint: options.onCheckpoint ?? (() => {}), diff --git a/packages/runner/src/runtime/jev-step-questions.mjs b/packages/runner/src/runtime/jev-step-questions.mjs index 0d2fea2..77300cd 100644 --- a/packages/runner/src/runtime/jev-step-questions.mjs +++ b/packages/runner/src/runtime/jev-step-questions.mjs @@ -24,6 +24,15 @@ import { acceptedNoul, JEV_MODEL } from "./jev-target.mjs"; */ export const NEEDS_TEXT_AT = 0.23; export const NO_ACTION_BELOW = 0.13; +/** + * A step read again once its command has ended (readStepText) stops nothing + * below TEXT_LIFTED_BELOW. On the 25 production walls of a conditional setup + * step ("if its first JSON line has need_user_action, … run the + * resume_command it prints") whose condition never showed, it read 0.05–0.06 + * (75 readings); with that condition's line on the screen, 0.81–0.89 (75). A + * step that types a value it gives in backticks read 0.41–0.46 and stays a wall. + */ +export const TEXT_LIFTED_BELOW = 0.2; const needsText = (step) => ({ type: "noul", @@ -68,6 +77,27 @@ const needsAction = (step) => ({ }, }); +const stillNeedsText = (step) => ({ + type: "noul", + instructions: `\`${step}\` was read as typing text it does not give. Its command has run: does it still need that text typed, as \`terminal\` shows the command now?`, + criteria: { + true: { + what: "The step types text it does not give whatever the command printed; or it types that text only if a condition it names is shown — a status, a prompt or a message — and `terminal.screen` shows that condition; or the command is still running (`terminal.running` is true); or `terminal.screen` does not show the command's result.", + examples: [ + 'step "Run `acme setup --agent`. If its first JSON line has "status":"need_user_action", open the url it prints, then run the resume_command it prints"; `terminal.screen` shows {"step":"auth","status":"need_user_action","url":"https://acme.test/cli/auth?ticket=8f2","resume_command":"acme setup --agent --login-ticket 8f2"}', + 'step "Run `acme login` and enter the one-time code it prints"; `terminal.screen` shows "Enter code: "', + ], + }, + false: { + what: "The step types its unquoted text only if a condition it names is shown, the command has ended (`terminal.running` is false), and `terminal.screen` shows another result where that condition would be.", + examples: [ + 'step "Run `acme setup --agent`. If its first JSON line has "status":"need_user_action", open the url it prints, then run the resume_command it prints"; `terminal.screen` shows {"step":"auth","status":"ok"} then {"step":"done","status":"ok"} and the shell prompt', + 'step "Run `acme init`; if it asks for a project name, type the name the owner gave"; `terminal.screen` shows "Initialized project acme-web" and the shell prompt', + ], + }, + }, +}); + /** The answers to `questions` about `state`, and how long they took. */ async function ask(state, questions, { jev, signal = undefined, redact = (text) => text, fetchImpl = fetch }) { const started = Date.now(); @@ -148,3 +178,17 @@ export async function stepsNeedingText(instructions, jev) { return null; } } + +/** + * Whether a step read as typing text it does not give (readSteps) still needs + * that text once its command has run, asked on what the terminal shows: a + * step that types it only on a condition the command did not print is no wall + * to the steps after it. `state` carries `steps`, `history` and `terminal`. + * An unanswered reading is null. + * @param {{ steps: ReadonlyArray, history: unknown[], terminal: unknown }} state + * @param {number} index the step's 0-based index in `state.steps` + */ +export async function readStepText(state, index, request) { + const { result, durationMs } = await ask(state, { text: stillNeedsText(`steps[${String(index)}]`) }, request); + return { durationMs, needsText: acceptedNoul(result, "text") }; +} diff --git a/packages/runner/src/runtime/jev-steps.mjs b/packages/runner/src/runtime/jev-steps.mjs index 1cc8996..777d7fe 100644 --- a/packages/runner/src/runtime/jev-steps.mjs +++ b/packages/runner/src/runtime/jev-steps.mjs @@ -11,7 +11,7 @@ import { JEV_REASONS } from "./jev-stops.mjs"; import { terminalSteps, withoutCodeSpans } from "./jev-terminal.mjs"; /** - * @typedef {{ number: number, id: string, instruction: string, values: string[], commands: string[], inputs: string[], keys: string[], needsText: number | null, needsAction: number | null, actions: string[], pressed: string[], remembered?: boolean }} LedgerStep + * @typedef {{ number: number, id: string, instruction: string, values: string[], commands: string[], inputs: string[], keys: string[], needsText: number | null, needsAction: number | null, actions: string[], pressed: string[], remembered?: boolean, rechecked?: boolean, lifted?: boolean }} LedgerStep * @typedef {{ code: "step_needs_text" | "step_skipped" | "steps_not_taken", step: LedgerStep }} HandBack * @typedef {{ steps: LedgerStep[], handedBack: HandBack | null, completed: boolean }} Ledger */ @@ -67,8 +67,8 @@ export function readInto(ledger, { needsText, needsAction }) { /** Whether a step types text it does not give; unanswered counts as yes. */ // A step that types text it does not quote, unless the walk typed for it what the Journey's newest passing walk -// typed there (jev-route.mjs: a remembered value). -const typesUnquoted = (step) => !step.remembered && (step.needsText === null || step.needsText >= NEEDS_TEXT_AT); +// typed there (jev-route.mjs: a remembered value), or its own command showed it no longer needs it (liftTextWall). +const typesUnquoted = (step) => !step.remembered && !step.lifted && (step.needsText === null || step.needsText >= NEEDS_TEXT_AT); /** Whether a step needs an action of its own; unanswered or unsure counts as yes. */ const needsAction = (step) => step.needsAction === null || step.needsAction >= NO_ACTION_BELOW; @@ -104,6 +104,22 @@ export function refuse(ledger, number, action) { return handBack; } +/** + * Whether the step a text wall stopped at may be read again on the terminal + * (the executor's recheck): once, after the walk ran the step's own command + * and that command has ended. + * @param {{ running?: boolean } | null | undefined} terminal the round's terminal reading + */ +export function recheckable(step, terminal) { + return !step.rechecked && step.actions.length > 0 && terminal?.running === false; +} + +/** A step whose command showed it no longer needs its unquoted text stops nothing from now on. */ +export function liftTextWall(ledger, step) { + step.lifted = true; + ledger.handedBack = null; +} + /** * The step an action belongs to when code knows it: the one step whose own * text the action types or runs. Undefined when the action types nothing, or