diff --git a/.claude/skills/add-imessage/SKILL.md b/.claude/skills/add-imessage/SKILL.md index 72e59d7ca..6245da302 100644 --- a/.claude/skills/add-imessage/SKILL.md +++ b/.claude/skills/add-imessage/SKILL.md @@ -131,10 +131,10 @@ Set up remote iMessage via Photon: Then collect the two values: -```nc:prompt server_url when:mode=remote validate:^https?:// +```nc:prompt server_url when:mode=remote validate:^https?:// flags:i reuse:IMESSAGE_SERVER_URL Your Photon server URL — starts with http:// or https:// (e.g. https://photon.example.com). ``` -```nc:prompt api_key secret when:mode=remote +```nc:prompt api_key secret when:mode=remote reuse:IMESSAGE_API_KEY Your Photon API key — from the Photon dashboard. ``` diff --git a/.claude/skills/add-teams/SKILL.md b/.claude/skills/add-teams/SKILL.md index 7c4c889d4..bd2aaa272 100644 --- a/.claude/skills/add-teams/SKILL.md +++ b/.claude/skills/add-teams/SKILL.md @@ -90,7 +90,7 @@ reach this machine's webhook server (port 3000) at `/api/webhooks/teams`. If you don't have a tunnel running yet, start one in another terminal first — e.g. `ngrok http 3000` gives you `https://abcd1234.ngrok.io`. -```nc:prompt public_url validate:^https:// +```nc:prompt public_url validate:^https:// normalize:rstrip-slash Paste your public base URL (https://…, no trailing path) — e.g. https://abcd1234.ngrok.io. ``` @@ -129,7 +129,7 @@ Create the client secret: 4. COPY THE VALUE NOW — Azure only shows it once (the Value column, not the Secret ID). ``` -```nc:prompt app_password secret +```nc:prompt app_password secret min:20 Paste the client secret Value — Certificates & secrets (shown only once). ``` diff --git a/scripts/skill-apply.test.ts b/scripts/skill-apply.test.ts index 6398bcd16..771d33012 100644 --- a/scripts/skill-apply.test.ts +++ b/scripts/skill-apply.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { applySkill, removeSkill, planSkill, fullyApplied, firstFailureHint, stepLabel, type Prompter, type StepReporter } from './skill-apply.js'; +import { applySkill, removeSkill, planSkill, fullyApplied, firstFailureHint, stepLabel, type Prompter, type PromptOpts, type StepReporter } from './skill-apply.js'; import { parseDirectives, validate } from './skill-directives.js'; // A synthetic skill exercising the fs handlers for real (no network), plus one @@ -1075,3 +1075,74 @@ describe('firstFailureHint', () => { expect(firstFailureHint(res)).toBeUndefined(); }); }); + +// PromptOpts: `nc:prompt` attrs (flags/min/error/normalize). `normalize:` is +// applied DETERMINISTICALLY at engine bind — to BOTH an `inputs` value and an +// interactive answer — so they land identically (a deliberate behavior change). +// flags/min/error are carried through to the prompter (their interactive +// enforcement is asserted in skill-driver.test.ts against promptValidator). +const NORMALIZE_SKILL = `# normalize demo + +## Collect a base URL +\`\`\`nc:prompt public_url normalize:rstrip-slash +Paste your base URL. +\`\`\` +\`\`\`nc:env-set +PUBLIC_URL={{public_url}} +\`\`\` +`; + +describe('nc:prompt PromptOpts (normalize at bind, opts threading)', () => { + let nroot: string; + let nskill: string; + beforeEach(() => { + nskill = mkdtempSync(join(tmpdir(), 'nc-opts-skill-')); + nroot = mkdtempSync(join(tmpdir(), 'nc-opts-proj-')); + writeFileSync(join(nskill, 'SKILL.md'), NORMALIZE_SKILL); + writeFileSync(join(nroot, '.env'), ''); + writeFileSync(join(nroot, 'package.json'), '{"name":"scratch"}'); + }); + + it('normalize:rstrip-slash strips a trailing slash on an inputs value (bound + consumed downstream)', async () => { + const res = await applySkill(nskill, nroot, { inputs: { public_url: 'https://x.ngrok.io/' }, exec: () => {} }); + expect(res.vars.public_url).toBe('https://x.ngrok.io'); // slash stripped at bind + expect(readFileSync(join(nroot, '.env'), 'utf8')).toContain('PUBLIC_URL=https://x.ngrok.io'); + }); + + it('normalize:rstrip-slash strips a trailing slash on an interactive answer too (same bind path)', async () => { + const res = await applySkill(nskill, nroot, { prompter: headless({ public_url: 'https://x.ngrok.io/' }), exec: () => {} }); + expect(res.vars.public_url).toBe('https://x.ngrok.io'); // identical to the inputs path + expect(readFileSync(join(nroot, '.env'), 'utf8')).toContain('PUBLIC_URL=https://x.ngrok.io'); + }); + + it('threads validate + PromptOpts (flags/min/normalize) through to the prompter, then normalizes the answer at bind', async () => { + writeFileSync( + join(nskill, 'SKILL.md'), + '# o\n\n```nc:prompt url validate:^https?:// flags:i min:5 normalize:rstrip-slash\nURL?\n```\n', + ); + let seenValidate: string | undefined; + let seenOpts: PromptOpts | undefined; + const prompter: Prompter = { + async ask(_n, _q, _s, validate, opts) { + seenValidate = validate; + seenOpts = opts; + return 'HTTPS://x.io/'; // the prompter returns the raw answer; the engine normalizes + }, + }; + const res = await applySkill(nskill, nroot, { prompter, exec: () => {} }); + expect(seenValidate).toBe('^https?://'); + expect(seenOpts).toMatchObject({ flags: 'i', min: 5, normalize: 'rstrip-slash' }); + // normalize applied at bind (trailing slash gone); case preserved (lower not set) + expect(res.vars.url).toBe('HTTPS://x.io'); + }); + + it('normalize:lower and trim also bind deterministically', async () => { + writeFileSync( + join(nskill, 'SKILL.md'), + '# n\n\n```nc:prompt a normalize:lower\nA?\n```\n```nc:prompt b normalize:trim\nB?\n```\n', + ); + const res = await applySkill(nskill, nroot, { inputs: { a: 'MixedCASE', b: ' spaced ' }, exec: () => {} }); + expect(res.vars.a).toBe('mixedcase'); + expect(res.vars.b).toBe('spaced'); + }); +}); diff --git a/scripts/skill-apply.ts b/scripts/skill-apply.ts index e76681838..33f52267b 100644 --- a/scripts/skill-apply.ts +++ b/scripts/skill-apply.ts @@ -27,11 +27,27 @@ import { readFileSync, existsSync, writeFileSync, appendFileSync, copyFileSync, import { join, dirname } from 'node:path'; import { parseDirectives, promptVar, type Directive } from './skill-directives.js'; +// Optional per-prompt UX hints an `nc:prompt`'s attrs carry. Every field is +// trailing-optional, so an existing `async ask(name)` test fake is untouched. +// • flags regex flags for `validate` (e.g. `i` → case-insensitive match) +// • min a minimum length the interactive prompter enforces (re-asks if short) +// • error the validation message the prompter surfaces on a mismatch +// • normalize a deterministic transform applied AT BIND (see `normalizeValue`) to +// BOTH `inputs` and interactive answers: trim | rstrip-slash | lower +export interface PromptOpts { + flags?: string; + min?: number; + error?: string; + normalize?: string; +} + export interface Prompter { // Return the value, or undefined to DEFER (headless rebuild collects these). // `validate` is an optional regex (from `nc:prompt … validate:`) the - // interactive prompter enforces, re-asking until the answer matches. - ask(varName: string, question: string, secret: boolean, validate?: string): Promise; + // interactive prompter enforces, re-asking until the answer matches. `opts` + // carries the trailing-optional PromptOpts (flags/min/error — normalize is the + // engine's, applied at bind, not the prompter's). A fake may ignore both. + ask(varName: string, question: string, secret: boolean, validate?: string, opts?: PromptOpts): Promise; // Show an `nc:operator` block to the human operator (a clack note in setup, a // channel message when a coding agent relays). Absent ⇒ no operator present // (headless rebuild), so the instructions are simply skipped. @@ -385,6 +401,38 @@ export function stepLabel(d: Directive, md: string): string | null { return (effect && byEffect[effect]) || 'Running'; } +// Deterministic input normalization applied AT BIND to every prompt value — +// `inputs` AND interactive answers alike — driven by `nc:prompt normalize:`: +// trim strip leading/trailing whitespace +// rstrip-slash drop trailing slash(es) — a base URL with no trailing path +// lower lowercase +// Absent/unknown ⇒ a no-op (lint gates the known set). Doing it here, not in the +// prompter, means a programmatic `inputs` value and a typed answer land identically. +function normalizeValue(value: string, normalize: string | undefined): string { + switch (normalize) { + case 'trim': + return value.trim(); + case 'rstrip-slash': + return value.replace(/\/+$/, ''); + case 'lower': + return value.toLowerCase(); + default: + return value; + } +} + +// The PromptOpts an `nc:prompt`'s attrs carry. Stripped with the fence when a +// skill degrades to prose — invisible to the agent — so a plain re-read still +// reads as a normal question. `min` parses to a number; the rest pass through. +function promptOptsOf(d: Directive): PromptOpts { + const opts: PromptOpts = {}; + if (typeof d.attrs.flags === 'string') opts.flags = d.attrs.flags; + if (typeof d.attrs.error === 'string') opts.error = d.attrs.error; + if (typeof d.attrs.normalize === 'string') opts.normalize = d.attrs.normalize; + if (typeof d.attrs.min === 'string' && /^\d+$/.test(d.attrs.min)) opts.min = Number(d.attrs.min); + return opts; +} + function substitute(value: string, vars: Map): string { return value.replace(VAR_REF, (_, name) => { const v = vars.get(name); @@ -646,13 +694,17 @@ export async function applySkill(skillDir: string, root: string, opts: ApplyOpti if (d.kind === 'prompt') { const v = promptVar(d)!; const secret = d.args.includes('secret'); + const validate = typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined; + const promptOpts = promptOptsOf(d); // Pre-supplied inputs win (fully-programmatic apply); fall back to the // interactive prompter; still undefined ⇒ defer (headless, no answer). let val = opts.inputs?.[v]; - const validate = typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined; - if (val === undefined) val = await opts.prompter?.ask(v, d.body.join(' '), secret, validate); + if (val === undefined) val = await opts.prompter?.ask(v, d.body.join(' '), secret, validate, promptOpts); if (val === undefined) res.deferred.push(v); - else vars.set(v, { value: val, secret }); + // normalize: binds DETERMINISTICALLY for both inputs and answers, so + // an `inputs` value and a typed one land identically (a trailing slash + // stripped, whitespace trimmed) — see normalizeValue. + else vars.set(v, { value: normalizeValue(val, promptOpts.normalize), secret }); continue; } if (d.kind === 'operator') { diff --git a/scripts/skill-directives.test.ts b/scripts/skill-directives.test.ts index c2b7e12fc..c96aaf71e 100644 --- a/scripts/skill-directives.test.ts +++ b/scripts/skill-directives.test.ts @@ -243,3 +243,45 @@ describe('when: guard + multi-field capture', () => { expect(validate(parseDirectives(md))).toEqual([]); }); }); + +describe('prompt PromptOpts attrs (flags/min/normalize/error/reuse)', () => { + it('parses flags/min/normalize/reuse into attrs alongside the var + secret flag', () => { + const md = [ + '```nc:prompt server_url secret validate:^https?:// flags:i min:8 normalize:rstrip-slash reuse:IMESSAGE_SERVER_URL', + 'URL?', + '```', + ].join('\n'); + const [d] = parseDirectives(md); + expect(promptVar(d)).toBe('server_url'); // the var, not `secret` + expect(d.attrs.flags).toBe('i'); + expect(d.attrs.min).toBe('8'); + expect(d.attrs.normalize).toBe('rstrip-slash'); + expect(d.attrs.reuse).toBe('IMESSAGE_SERVER_URL'); + expect(validate([d])).toEqual([]); // a well-formed prompt with all attrs lints clean + }); + + it('accepts validate: combined with flags:i (a case-insensitive regex is still valid)', () => { + const md = ['```nc:prompt u validate:^https?:// flags:i', 'URL?', '```'].join('\n'); + expect(validate(parseDirectives(md))).toEqual([]); + }); + + it('flags a non-numeric min:', () => { + const md = ['```nc:prompt u min:lots', 'q', '```'].join('\n'); + expect(validate(parseDirectives(md)).some((p) => /min:lots must be a non-negative integer/.test(p.message))).toBe(true); + }); + + it('flags an unknown normalize: value', () => { + const md = ['```nc:prompt u normalize:uppercase', 'q', '```'].join('\n'); + expect(validate(parseDirectives(md)).some((p) => /normalize:uppercase must be one of/.test(p.message))).toBe(true); + }); + + it('flags a reuse: that is not a valid ENV_KEY', () => { + const md = ['```nc:prompt u reuse:not-an-env-key', 'q', '```'].join('\n'); + expect(validate(parseDirectives(md)).some((p) => /reuse:not-an-env-key must be a valid ENV_KEY/.test(p.message))).toBe(true); + }); + + it('flags illegal regex flags:', () => { + const md = ['```nc:prompt u validate:^x flags:zzz', 'q', '```'].join('\n'); + expect(validate(parseDirectives(md)).some((p) => /is not a valid regex/.test(p.message))).toBe(true); + }); +}); diff --git a/scripts/skill-directives.ts b/scripts/skill-directives.ts index 6c5a1dc88..c5e5e4565 100644 --- a/scripts/skill-directives.ts +++ b/scripts/skill-directives.ts @@ -44,9 +44,20 @@ // a non-zero exit bounces to an agent (degrade, not crash) and, via the // run-health gate, blocks the dangerous side effects that follow it (a // restart, a pairing/QR step, a wire). An unresolved {{var}} defers. -// prompt [secret] [validate:] body: the question → binds {{var}} skip if satisfied +// prompt [secret] [validate:] [flags:] [min:] +// [normalize:trim|rstrip-slash|lower] [error:] [reuse:] +// body: the question → binds {{var}} skip if satisfied // validate: is a regex the interactive prompter enforces (e.g. // validate:^xoxb- to require a Slack bot token); `inputs` bypass it. +// flags: are regex flags applied to validate (e.g. flags:i → a +// case-insensitive scheme match). min: is a minimum length the prompter +// enforces (re-asks if shorter; `inputs` bypass it). error: overrides +// the message shown on a validation miss (single token — no spaces). +// normalize: deterministically transforms the value AT BIND — for BOTH +// `inputs` and interactive answers — one of trim | rstrip-slash | lower. +// reuse: lets a re-run offer an existing .env value for a credential +// a HELPER SCRIPT owns (written by effect:external, not nc:env-set) — the +// masked reuse offer the env-set→ENV_KEY inference can't otherwise see. // operator body: instructions for the human operator output-only // The SKILL.md is addressed to the coding agent; `operator` delineates the // parts meant for the HUMAN (e.g. clicking through the Slack UI). Lead it @@ -224,17 +235,35 @@ export function validate(directives: Directive[], ctx?: { chatVersion?: string } } break; } - case 'prompt': + case 'prompt': { if (!promptVar(d)) flag(d, 'prompt requires a variable name, e.g. `nc:prompt token`'); if (d.body.length === 0) flag(d, 'prompt requires a question in its body'); + const flags = typeof d.attrs.flags === 'string' ? d.attrs.flags : undefined; if (typeof d.attrs.validate === 'string') { try { - new RegExp(d.attrs.validate); + new RegExp(d.attrs.validate, flags); } catch { - flag(d, `prompt validate:${d.attrs.validate} is not a valid regex`); + flag(d, `prompt validate:${d.attrs.validate}${flags ? ` flags:${flags}` : ''} is not a valid regex`); + } + } else if (flags !== undefined) { + // flags without validate: still verify they're legal regex flags. + try { + new RegExp('', flags); + } catch { + flag(d, `prompt flags:${flags} are not valid regex flags`); } } + if (typeof d.attrs.min === 'string' && !/^\d+$/.test(d.attrs.min)) { + flag(d, `prompt min:${d.attrs.min} must be a non-negative integer`); + } + if (typeof d.attrs.normalize === 'string' && !['trim', 'rstrip-slash', 'lower'].includes(d.attrs.normalize)) { + flag(d, `prompt normalize:${d.attrs.normalize} must be one of trim|rstrip-slash|lower`); + } + if (typeof d.attrs.reuse === 'string' && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(d.attrs.reuse)) { + flag(d, `prompt reuse:${d.attrs.reuse} must be a valid ENV_KEY`); + } break; + } case 'operator': if (d.body.length === 0) flag(d, 'operator requires instructions for the human in its body'); break; diff --git a/setup/lib/skill-driver.test.ts b/setup/lib/skill-driver.test.ts index d508a80d7..c045de8e0 100644 --- a/setup/lib/skill-driver.test.ts +++ b/setup/lib/skill-driver.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { runSkill, hostExec, hostExecStream, type RunSkillOptions } from './skill-driver.js'; +import { runSkill, hostExec, hostExecStream, promptValidator, type RunSkillOptions } from './skill-driver.js'; import { fullyApplied, type Prompter, type StepReporter } from '../../scripts/skill-apply.js'; // A small SKILL.md exercising the three things the driver wires: an operator @@ -148,4 +148,53 @@ describe('thin skill driver', () => { expect(asked).toContain('bot_token'); // declined → prompted expect(cmds).toContain('use NEWLY-PASTED'); }); + + // A cred a HELPER SCRIPT owns (written by effect:external, not nc:env-set) has no + // env-set→ENV_KEY linkage to infer. An explicit `nc:prompt … reuse:` + // restores the masked reuse offer — the imessage Photon case. + function helperReuseScratch(): { root: string; skill: string } { + const root = mkdtempSync(join(tmpdir(), 'reuse-helper-')); + const skill = mkdtempSync(join(tmpdir(), 'reuse-helper-skill-')); + writeFileSync(join(root, 'package.json'), '{"name":"scratch"}'); + // present in .env, but NOT written by any nc:env-set in the skill below + writeFileSync(join(root, '.env'), 'IMESSAGE_SERVER_URL=https://photon.example.com\n'); + writeFileSync( + join(skill, 'SKILL.md'), + '# helper reuse demo\n\n```nc:prompt server_url validate:^https?:// reuse:IMESSAGE_SERVER_URL\nYour Photon server URL.\n```\n```nc:run effect:external\nbash configure.sh "{{server_url}}"\n```\n', + ); + return { root, skill }; + } + + it('reuse: offers an existing .env value for a HELPER-owned cred (no env-set linkage)', async () => { + const { root, skill } = helperReuseScratch(); + const asked: string[] = []; + const cmds: string[] = []; + const confirmed: string[] = []; + const prompter: Prompter = { + async ask(n) { + asked.push(n); + return 'https://typed.example'; + }, + async confirm(msg) { + confirmed.push(msg); + return true; // yes, reuse the existing helper-owned value + }, + }; + await runSkill(skill, { projectRoot: root, prompter, reuse: true, exec: (c) => void cmds.push(c) }); + expect(confirmed.some((m) => /IMESSAGE_SERVER_URL/.test(m))).toBe(true); // the reuse: link surfaced the offer + expect(asked).not.toContain('server_url'); // accepted → never re-prompted + expect(cmds).toContain('bash configure.sh "https://photon.example.com"'); // reused value flowed downstream + }); + + it('promptValidator honors flags:i (case-insensitive) and min (rejects short); error overrides the message', () => { + const ci = promptValidator('^https://', { flags: 'i' }); + expect(ci).toBeDefined(); + expect(ci!('HTTPS://example.com')).toBeUndefined(); // case-insensitive match passes + expect(ci!('ftp://example.com')).toBeTruthy(); // non-match rejected + const min = promptValidator(undefined, { min: 20 }); + expect(min!('short')).toBeTruthy(); // below the minimum length → rejected + expect(min!('x'.repeat(20))).toBeUndefined(); // at the minimum → passes + expect(promptValidator('^x', { error: 'Bad token format' })!('y')).toBe('Bad token format'); // custom message + expect(promptValidator(undefined, undefined)).toBeUndefined(); // no regex + no min ⇒ no validator + }); }); diff --git a/setup/lib/skill-driver.ts b/setup/lib/skill-driver.ts index c9c68cd86..f63d91199 100644 --- a/setup/lib/skill-driver.ts +++ b/setup/lib/skill-driver.ts @@ -13,8 +13,8 @@ import { join } from 'node:path'; import * as p from '@clack/prompts'; -import { applySkill, fullyApplied, type ApplyResult, type Prompter, type StepOutcome, type StepReporter } from '../../scripts/skill-apply.js'; -import { parseDirectives } from '../../scripts/skill-directives.js'; +import { applySkill, fullyApplied, type ApplyResult, type Prompter, type PromptOpts, type StepOutcome, type StepReporter } from '../../scripts/skill-apply.js'; +import { parseDirectives, promptVar } from '../../scripts/skill-directives.js'; import { startSpinner } from './runner.js'; /** @@ -23,15 +23,37 @@ import { startSpinner } from './runner.js'; * note. The prompt that follows an operator block is the natural barrier — the * user can't paste a token before they've done the steps. */ +/** + * Build the clack `validate` callback an `nc:prompt` carries — the interactive + * enforcement of `validate:` (with `flags:`), `min:`, and the `error:` message. + * Returns undefined when the prompt has neither a regex nor a min (no validation to + * do). Exported so the policy is unit-testable without a TTY. Normalization is NOT + * here: it's deterministic, applied at bind by the engine (skill-apply + * `normalizeValue`), so it lands the same for `inputs` and typed answers. + */ +export function promptValidator( + validate: string | undefined, + opts: PromptOpts | undefined, +): ((v: string | undefined) => string | undefined) | undefined { + const re = validate ? new RegExp(validate, opts?.flags) : undefined; + const min = opts?.min; + if (!re && min === undefined) return undefined; + return (v) => { + const s = (v ?? '').trim(); + if (min !== undefined && s.length < min) return opts?.error ?? `Must be at least ${min} characters.`; + if (re && !re.test(s)) return opts?.error ?? `That doesn't match the expected format.`; + return undefined; + }; +} + export function clackPrompter(): Prompter { return { - async ask(_varName, question, secret, validate) { - const re = validate ? new RegExp(validate) : undefined; - const check = re - ? (v: string | undefined) => (re.test((v ?? '').trim()) ? undefined : `That doesn't match the expected format.`) - : undefined; + async ask(_varName, question, secret, validate, opts) { + const check = promptValidator(validate, opts); + // clearOnError wipes a rejected secret so the operator re-pastes cleanly + // (a half-pasted token isn't left masked in the field). const ans = secret - ? await p.password({ message: question, validate: check }) + ? await p.password({ message: question, validate: check, clearOnError: true }) : await p.text({ message: question, validate: check }); if (p.isCancel(ans)) return undefined; // cancelled ⇒ defer const v = String(ans).trim(); @@ -82,10 +104,21 @@ async function reuseFromEnv( } const varToKey = new Map(); for (const d of parseDirectives(md)) { - if (d.kind !== 'env-set') continue; - for (const line of d.body) { - const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/); - if (m) varToKey.set(m[2], m[1]); // var → ENV_KEY + // 1st pass: infer var → ENV_KEY from env-set directives (KEY={{var}}). + if (d.kind === 'env-set') { + for (const line of d.body) { + const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/); + if (m) varToKey.set(m[2], m[1]); // var → ENV_KEY + } + } + // 2nd pass: an explicit `nc:prompt … reuse:` links a prompt to a + // credential a HELPER SCRIPT owns — written by effect:external, not nc:env-set + // (e.g. imessage's Photon IMESSAGE_SERVER_URL / IMESSAGE_API_KEY). The env-set + // inference above can't see those, so the prompt states the linkage to regain + // the masked reuse offer on a re-run. + if (d.kind === 'prompt' && typeof d.attrs.reuse === 'string') { + const v = promptVar(d); + if (v) varToKey.set(v, d.attrs.reuse); // var → ENV_KEY (explicit) } } let env: Record = {};