console: Play App content card Saves are the agent's (declaration-save; Send changes for review, Submit, Publish, Restart review stay forbidden), one owner batch per plan with the IARC Terms tick before the run, console plan play-app-content --all, New App fields typed not set, play.appId from the Create app redirect (console verify --url --write) and storebound play app-id across app list pages

This commit is contained in:
CRTkafa 2026-10-13 05:43:56 +08:00
parent 1366e7e231
commit f741c15829
29 changed files with 1029 additions and 165 deletions

View file

@ -8,8 +8,8 @@
import { playCategoryOf, type ShipConfig } from './config';
import type { ProjectSignals } from './project-signals';
import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, isPlanOnly, type PlanFormId, type Store, valueText } from './console-answers';
import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, startLabel } from './console-play-plans';
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, SEND_FOR_REVIEW, SEND_FOR_REVIEW_WHERE, startLabel } from './console-play-plans';
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, PLAY_NEVER_PRESS, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
import { PLAY_CREATE_CAPTURE } from './console-verify';
import { APPLE_PURPOSES } from './console-answers';
@ -50,10 +50,24 @@ export interface PlanStep {
type?: string;
}
/** One thing only the owner does (an answer no file proves, an agreement, a final button), in the order the agent needs it. */
export interface OwnerStep {
n: number;
/** before: the owner does it before the agent starts (so the run never waits mid-way); after: once the agent is done. */
when: 'before' | 'after';
kind: 'answers' | 'agreement' | 'credentials' | 'permission' | 'final';
/** What to do, in plain words. */
text: string;
/** Where exactly (the page, the control). */
where?: string;
}
export interface ConsolePlan {
form: PlanFormId;
store: Store;
title: string;
/** Every owner step of this plan in ONE batch, in the order the agent needs them (printed at the top). */
ownerBatch: OwnerStep[];
/** Where to start: a deep link when the ids are known, else the console and how to reach the form. */
start: { url: string; deepLink: boolean; reach: string[] };
steps: PlanStep[];
@ -65,7 +79,8 @@ export interface ConsolePlan {
neverPress: string[];
/** The final button the owner presses; the agent stops before it and tells the owner. */
final: string;
/** Buttons the agent may press: navigation and draft saves (nothing public, nothing sent). */
/** Buttons the agent may press: navigation, draft saves and Play App content declaration saves (nothing public,
* nothing sent: a declaration goes to Google only with the owner's Send changes for review). */
allowed: { button: string; class: string; where: string }[];
/** The rule in one line. */
rule: string;
@ -168,9 +183,11 @@ const DRIVING = [
'Use your own browser tool (Claude in Chrome, a built-in browser, Playwright). Read the page as text or an accessibility tree first; click by visible text; adapt when the page differs from these steps (the console changes without notice), but never press anything in buttons.neverPress.',
'Long flows run INSIDE the page: one Runtime.evaluate / javascript call times out at about 45 s on big Play pages (the IARC questionnaire, App content), so never loop over many controls in one call. Paste plan.inPageLoop once per page (one short call; it defines window.__sb), start the job with window.__sb.run([{ role: "radio", label: "No", within: "<question text>" }, { role: "checkbox", label: "<box label>", want: true }, ...]) (it returns at once), then poll window.__log.slice(-20) and window.__done in short calls every 2-5 s until the log says done. The helper only picks radios and ticks boxes (never agreements); press Next / Save yourself afterwards, by their visible text.',
'Wait for each step to render (poll for the next heading or input, up to ~10 s) before acting; never act on a step you have not read.',
'Text fields: click the field and TYPE the text (key presses), then read the value back. Never set it programmatically (form_input, el.value = …): App Store Connect\'s New App kept its Name and SKU empty after a value set (verified 2026-10-13).',
'Owner steps: hand the owner plan.ownerBatch in ONE message before you start (the "before" items: answers, the IARC Terms of Use box) and run without stopping until the "after" items (the final button).',
'An answer with status ask-owner: ask the owner in chat before filling it (AGENTS.md rule 5), or leave it empty and tell the owner. Never guess.',
'Stop before the final button (buttons.final) and tell the owner exactly what to press; then run the verify command.',
'Never sign in, type a password, accept an agreement or tick a declaration: the owner does that in the browser.',
'Never sign in (never type the owner\'s own password), accept an agreement or tick a declaration: the owner does that in the browser. The one password you type is the App Review demo account from .storebound/credentials.json (reviewDemoAccount) on Play\'s Sign in details card, never printed.',
];
/**
@ -315,12 +332,14 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
case 'apple-app-record':
add({ text: 'Open App Store Connect > Apps.' });
add({ text: 'Click the add button (+, labelled "Add Apps") at the top left, then "New App" in the menu. The New App dialog opens.', click: ['+', 'New App'] });
add({ text: 'Fill the New App dialog: tick iOS, type the Name, choose the Primary Language and the Bundle ID, type the SKU, pick User Access.', answers: all(() => true) });
add({ text: 'Fill the New App dialog: tick iOS, type the Name, choose the Primary Language and the Bundle ID, type the SKU, pick User Access. Name and SKU: click the field and TYPE the text (your browser tool\'s type action, key by key); never set the value programmatically (form_input, el.value = …): the dialog then keeps an empty name / SKU (verified 2026-10-13). Read both fields back.', answers: all(() => true) });
add({ text: 'Stop. Do NOT press Create: tell the owner to review and press Create (it creates the app record for good; the bundle id and SKU cannot change).' });
add({ text: 'Verify: `storebound console verify apple-app-record` reads the new record from the App Store Connect API (or pass the App Information page text with --from-text -).' });
return {
start: { url: `${ASC}/apps`, deepLink: false, reach: ['the + button > New App'] }, steps,
uiFacts: ['Modeled on Apple\'s "Add a new app" help page, not recorded: adapt to the page.', 'User Access is a plain heading with two radios (Full Access, Limited Access), not an ARIA group.', 'The Bundle ID list only has ids registered in Certificates, Identifiers & Profiles (`storebound apple bundle` registers it).', 'The name must be unique on the App Store; "already being used": ask the owner for a variant and set apple.name.'],
uiFacts: ['Modeled on Apple\'s "Add a new app" help page, not recorded: adapt to the page.',
'Verified 2026-10-13: type, don\'t set value. A programmatic value set (form_input, el.value = …) on the Name and SKU textboxes left the dialog\'s React state empty (""): Create then sees no name / SKU. Click the field and type (key presses); `console fill` types them the same way.',
'User Access is a plain heading with two radios (Full Access, Limited Access), not an ARIA group.', 'The Bundle ID list only has ids registered in Certificates, Identifiers & Profiles (`storebound apple bundle` registers it).', 'The name must be unique on the App Store; "already being used": ask the owner for a variant and set apple.name.'],
verify: { command: 'storebound console verify apple-app-record', source: 'api-or-page-text', capture: PAGE_TEXT, expect: ['An app with the bundle id exists, with the name, primary language and SKU of the answer sheet.'] },
};
case 'apple-age-rating':
@ -337,7 +356,7 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
add({ text: 'Choose the Default language; pick App or game and Free or paid. These are <material-radio> elements: click the inner [role=radio] (a DOM click from a script works in a background window), then check that aria-checked is "true" and that the "Select app or game" / "Select free or paid" hint is gone.', answers: all(a => ['language', 'type', 'price'].includes(a.id)) });
add({ text: 'Leave the two declarations (Developer Program Policies, US export laws) unticked: they are agreements the owner reads and ticks.', answers: all(a => a.status === 'agreement') });
add({ text: 'Stop. Do NOT press Create app: tell the owner to tick the declarations, check Free or paid (a free app can never become paid) and press Create app.' });
add({ text: 'After Create app the app opens at /console/u/0/developers/<developer id>/app/<app id>/app-dashboard: put that app id in play.appId (the other Play forms deep-link from it).' });
add({ text: 'After Create app the app opens at /console/u/0/developers/<developer id>/app/<app id>/app-dashboard. Read location.href and run `storebound console verify play-app-record --url "<that URL>"`: it reads the app id from the URL and shows the play.appId change as a dry diff; then the same command with `--write` saves it into storebound.config.ts (an upsert: the other Play forms deep-link from it). No need to hunt through the paginated app list (`storebound play app-id` is the fallback when the redirect was missed).' });
add({ text: `Owner step, never done by an agent or storebound (a permission grant): app-level permissions of the service account do NOT cover apps created later. The owner opens ${PLAY_SA_PATH}, then runs \`storebound apps check --store play\` to confirm the API sees the app.` });
add({ text: 'Verify before the owner presses Create app: run the capture snippet (verify.capture) in the page and pipe its JSON to `storebound console verify play-app-record --from-text -`.' });
return {
@ -348,7 +367,7 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
'Fields: App name (textbox, max 30); App package name (textbox, max 150) with a "Check availability" button (read-only, safe to press) that reports "Package name available"; Default language (dropdown, default "English (United States) – en-US"); App or game (material-radio App / Game); Free or paid (material-radio Free / Paid); Declarations: two checkboxes (Developer Program Policies, US export laws), owner only.',
'Gotcha: in a background window, computer-tool clicks on the material radios did nothing, and setting the native inputs (form_input) did not update the Angular model: the form kept showing "Select app or game". Only a click on the inner [role=radio] of each material-radio (via JS: el.click()) updated it. Then verify aria-checked="true" and that no "Select …" hint is left.',
'Text inputs set with form_input update the character counters correctly.',
'After Create app the app lives at /console/u/0/developers/<developerId>/app/<appId>/app-dashboard (verified): the app id deep-links the other Play forms (play.appId; storebound also remembers it).',
'After Create app the app lives at /console/u/0/developers/<developerId>/app/<appId>/app-dashboard (verified): the app id deep-links the other Play forms (play.appId; storebound also remembers it). Take it from that redirect URL (`console verify play-app-record --url <url> --write`): the app list shows 10 apps per page and a new app is not on page 1 (verified 2026-10-13).',
`A service account's app-level permissions do NOT cover apps created later: the owner adds the new app (${PLAY_SA_PATH}). Agents never do this (a permission grant; auto mode blocks it too).`,
'The package name is fixed for good once the app is created. Free can never become Paid.',
PLAY_MATERIAL_FACT,
@ -359,14 +378,14 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
case 'play-content-rating': {
const section = (sec: string) => all(a => a.id.startsWith(`d:${sec}:`));
add({ text: 'Open App content > Content ratings (/app-content/content-rating-overview), then "Start questionnaire": it opens /app-content/content-rating-iarc-questionnaire.', click: ['Start questionnaire'] });
add({ text: 'Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an agreement: never tick it; the owner ticks it (it is in the batch). Then Next.', click: ['Next'], answers: ['email', 'category', 'agree:iarcTerms'] });
add({ text: 'Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an agreement: never tick it. The owner ticked it before the run (owner batch); read its aria-checked. Still unticked: Next does nothing (verified 2026-10-13), so do not press it again and again: say once that the box is waiting for the owner and poll aria-checked every 5 s (up to 15 min). Then Next.', click: ['Next'], answers: ['email', 'category', 'agree:iarcTerms'] });
add({ text: 'The questionnaire is ONE long page of sections (Violence, Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor, Digital Purchases, Miscellaneous). Answer each section\'s Yes / No as the sheet says; each Yes reveals follow-ups right below it, so answer top to bottom and re-read the page after every Yes. Run it as one in-page job (plan.inPageLoop) and poll window.__log.', answers: all(a => a.id.startsWith('q:')) });
add({ text: 'Violence follow-ups (after Yes): the humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds.', answers: section('violence') });
add({ text: 'Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor follow-ups (after Yes): answer the questions shown as the sheet says (details.<section>.other).', answers: ['fear', 'sexuality', 'gambling', 'language', 'controlledSubstances', 'crudeHumor'].flatMap(section) });
add({ text: 'Digital Purchases follow-ups (after "purchases of digital goods" Yes): the random items (loot box) question, then trading between players.', answers: section('digitalPurchases') });
add({ text: 'Miscellaneous: user interaction (after Yes: block, report, moderation, friends only), location sharing, Nazi symbols, Korea history, terrorism, crimes.', answers: [...section('userInteraction'), ...section('miscellaneous')] });
add({ text: 'Press Save (stores the draft; nothing goes to IARC yet), then Next: the Summary shows the rating per authority (PEGI, ESRB, USK, ClassInd, Google Play ...).', click: ['Save', 'Next'] });
add({ text: 'Summary: stop. Do NOT press its Save (the final button here is "Save", not "Submit"): tell the owner the ratings shown and let them press Save (it applies the IARC ratings).' });
add({ text: `Summary: read the ratings per authority, then press its Save yourself (a declaration save, "Save", not "Submit"; verified 2026-10-13: it stores the rating declaration, which Google gets only with the next "${SEND_FOR_REVIEW}" in Publishing overview, the owner's). Tell the owner the ratings in the final report.`, click: ['Save'] });
add({ text: 'Verify: read the Summary page as text and pipe it to `storebound console verify play-content-rating --from-text -`.' });
return {
start: playStart(form, config, p), steps,
@ -374,8 +393,9 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
'Verified 2026-10-12 (an agent with a browser tool on a real game):',
'App content > Content ratings is /app-content/content-rating-overview; "Start questionnaire" opens /app-content/content-rating-iarc-questionnaire.',
'Page 1: email address, the category radio, and an IARC Terms of Use checkbox: an agreement, the owner ticks it, never the agent.',
'Verified 2026-10-13: page 1\'s Next does nothing until the IARC Terms of Use box is ticked. So the box is in the ONE owner batch before the run (the owner ticks it on page 1 first), never a stop in the middle.',
'The questionnaire is one long page of sections; each Yes reveals follow-ups (Violence: humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds; Digital Purchases: digital goods, then loot boxes, then player trading; Miscellaneous: user interaction then block / report / moderation / friends-only, location sharing, Nazi symbols, Korea history, terrorism, crimes). Follow-up wording here is storebound\'s: match by topic and the console\'s own labels.',
'Save on the questionnaire stores it; Next goes to the Summary with per-authority ratings. The Summary\'s final button is "Save" (not "Submit"): the owner\'s.',
`Save on the questionnaire stores it; Next goes to the Summary with per-authority ratings. The Summary\'s button is "Save" (not "Submit"). Saving an App content declaration is not public (verified 2026-10-13): Google gets it only with the next "${SEND_FOR_REVIEW}" in Publishing overview (the owner\'s), so the agent presses this Save.`,
'Example result (the game of that run): PEGI 7, ESRB E10+, USK 12, ClassInd 18 (paid random items alone made it 18), Google Play 7+.',
'The email address field gets the full app.contactEmail from the config (printed masked here).',
PLAY_MATERIAL_FACT, PLAY_DASHBOARD_FACT,
@ -389,11 +409,11 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
add({ text: 'Target age: tick exactly the age groups of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back). Next.', click: ['Next'], answers: all(a => a.id.startsWith('age:')) });
if (answers.some(a => a.id === 'appeal')) add({ text: `Appeal to children: pick the answer, then Next.${teens ? ' With 13+ age groups only the page skipped this question and went straight to the Summary (verified 2026-10-12): answer it only if it shows.' : ''}`, click: ['Next'], answers: ['appeal'] });
add({ text: 'Ads (also App content > Ads, /app-content/ads-declaration): pick whether the app contains ads, if this section shows it.', answers: ['ads'] });
add({ text: 'Summary: stop. Do NOT press Save: tell the owner to review and press Save (Google reviews the declaration).' });
add({ text: `Summary: press Save (the agent's button, a declaration save: Google gets it only with the next "${SEND_FOR_REVIEW}" in Publishing overview, the owner's). Read the summary back.`, click: ['Save'] });
add({ text: 'Verify: read the section\'s summary as text and pipe it to `storebound console verify play-target-audience --from-text -`.' });
return {
start: playStart(form, config, p), steps,
uiFacts: ['Verified 2026-10-12: /app-content/target-audience-content; age groups are checkboxes; with 13+ age groups only it went straight to the Summary (no appeal-to-children question); Save there is the final button.', 'Any age group under 13 brings the Families policy.', PLAY_MATERIAL_FACT, PLAY_DASHBOARD_FACT],
uiFacts: [`Verified 2026-10-12: /app-content/target-audience-content; age groups are checkboxes; with 13+ age groups only it went straight to the Summary (no appeal-to-children question). Its Save stores the declaration (the agent's: Google gets it only with the owner's "${SEND_FOR_REVIEW}", verified 2026-10-13).`, 'Any age group under 13 brings the Families policy.', PLAY_MATERIAL_FACT, PLAY_DASHBOARD_FACT],
verify: { command: 'storebound console verify play-target-audience --from-text -', source: 'page-text', capture: PAGE_TEXT, expect: ['The target age groups and the ads answer equal the answer sheet.'] },
};
}
@ -407,36 +427,66 @@ function askOwnerOf(answers: PlanAnswer[], prefix = ''): string[] {
.map(a => `${prefix}${a.question}: ${a.status === 'agreement' ? 'an agreement, the owner ticks it' : a.status === 'owner-check' ? `${a.text} (owner check: ${a.note ?? ''})` : `ask the owner${a.note ? ` (${a.note})` : ''}`}`);
}
const ruleText = (draftSaves: string[], final: string) => `Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links) and draft saves (${draftSaves.join(', ') || 'none in this form'}). Never press ${final} or anything that publishes, submits, creates, sends or accepts; stop and tell the owner.`;
const ruleText = (draftSaves: string[], final: string, declarationSaves: string[] = []) => `Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links), draft saves (${draftSaves.join(', ') || 'none in this form'})${declarationSaves.length ? ` and the App content declaration saves (${declarationSaves.join(', ')}: Google gets them only with the owner's ${SEND_FOR_REVIEW})` : ''}. Never press ${final} or anything that publishes, submits, creates, sends, restarts a review or accepts; stop and tell the owner.`;
/** The App content overview: every card in the page's order, its page, its plan, and one batch of owner questions. */
function appContentPlan(cfg: ShipConfig, p: PlanIds): ConsolePlan {
const IARC_TERMS = 'Tick the IARC Terms of Use box';
/** Where the IARC Terms box is (verified 2026-10-12 / 2026-10-13). */
function iarcWhere(cfg: ShipConfig, p: PlanIds): string {
const dev = p.playDeveloperId ?? cfg.play?.developerId, app = p.playAppId ?? cfg.play?.appId;
const url = dev && app ? playAppPage(dev, app, 'app-content/content-rating-iarc-questionnaire') : '/app-content/content-rating-iarc-questionnaire';
return `Play Console > the app > Policy and programs > App content > Content ratings > "Start questionnaire" (${url}), page 1: the IARC Terms of Use checkbox under the Email address and the Category. Tick it, leave the tab open; the agent fills the rest of page 1 and presses Next (Next does nothing until it is ticked).`;
}
/** The owner batch of a plan: open answers first, then what the owner does before the agent starts, then the final steps. */
function ownerBatchOf(form: PlanFormId, cfg: ShipConfig, p: PlanIds, answers: PlanAnswer[], final: string): OwnerStep[] {
const out: Omit<OwnerStep, 'n'>[] = [];
const open = answers.filter(a => (a.status === 'ask-owner' || a.status === 'owner-check') && !a.id.endsWith('credentials'));
if (open.length) out.push({ when: 'before', kind: 'answers', text: `Answer ${open.length} question(s) in ONE batch (askOwner), then write the answers into storebound.config.ts and rerun the plan: ${open.map(a => a.question).join('; ')}` });
if (answers.some(a => a.status === 'ask-owner' && a.id.endsWith('credentials'))) out.push({ when: 'before', kind: 'credentials', text: 'Put the App Review demo account into .storebound/credentials.json (reviewDemoAccount: username, password) so the agent types it without printing it; or type the username and password into the Sign in details card yourself', where: 'App content > Sign in details > Add instructions' });
const iarc = answers.some(a => a.status === 'agreement' && /iarcTerms$/.test(a.id));
if (iarc) out.push({ when: 'before', kind: 'agreement', text: IARC_TERMS, where: iarcWhere(cfg, p) });
if (form === 'play-app-record') {
out.push({ when: 'after', kind: 'agreement', text: 'Tick the two declarations (Developer Program Policies, US export laws) and check Free or paid (a free app can never become paid)', where: 'the Create app form, bottom' });
out.push({ when: 'after', kind: 'final', text: 'Press Create app', where: 'the Create app form' });
out.push({ when: 'after', kind: 'permission', text: `Give the service account access to the new app: ${PLAY_SA_PATH}` , where: 'Users and permissions' });
} else if (final === SEND_FOR_REVIEW) out.push({ when: 'after', kind: 'final', text: `Press ${SEND_FOR_REVIEW} once every App content card and store setting is done`, where: SEND_FOR_REVIEW_WHERE });
else if (form === 'play-production-release') out.push({ when: 'after', kind: 'final', text: `Press "Submit N changes for review", then ${SEND_FOR_REVIEW}`, where: SEND_FOR_REVIEW_WHERE });
else if (final) out.push({ when: 'after', kind: 'final', text: `Press ${final}` });
return out.sort((a, b) => (a.when === b.when ? 0 : a.when === 'before' ? -1 : 1)).map((x, i) => ({ n: i + 1, ...x }));
}
/** The App content overview: every card in the page's order (the order the agent fixes them in), its page, its plan,
* and one owner batch. `all`: every card's steps inline, ending with the one owner list. */
function appContentPlan(cfg: ShipConfig, p: PlanIds, all = false): ConsolePlan {
const cards = APP_CONTENT_CARDS.map(card => ({ card, plan: card.form ? consolePlan(card.form, cfg, p) : null }));
const steps: PlanStep[] = [];
const add = (s: Omit<PlanStep, 'n'>) => steps.push({ n: steps.length + 1, ...s });
const answers: PlanAnswer[] = cards.flatMap(({ card, plan }) => (plan ? plan.answers.map(a => ({ ...a, id: `${plan.form}/${a.id}`, where: `${card.name}${a.where ? `: ${a.where}` : ''}` })) : []));
add({ text: 'Before the run: ask the owner every question of askOwner in ONE batch and write the answers into storebound.config.ts (rerun this plan: they turn certain).' });
add({ text: 'Open App content (/app-content/overview). Each card has a button aria-label="Start <Name> declaration"; a card already done shows its answers and a Manage / Edit link instead.' });
const ownerBatch = ownerBatchOf('play-app-content', cfg, p, answers, SEND_FOR_REVIEW);
add({ text: `Before the run: hand the owner the owner batch (ownerBatch, ${ownerBatch.length} item(s)) in ONE message: the open answers (write them into storebound.config.ts and rerun this plan: they turn certain) and what the owner does before you start (the IARC Terms of Use box). Then drive every card without stopping.` });
add({ text: 'Open App content (/app-content/overview). Each card has a button aria-label="Start <Name> declaration"; a card already done shows its answers and a Manage / Edit link instead (skip it unless its answers differ from the sheet).' });
for (const { card, plan } of cards) {
if (!plan) { add({ text: `${card.name}: ${card.via}. Dry run first, then the real run; then read the card back.` }); continue; }
add({ text: `${card.name}: open /app-content/${card.slug} (the button "${startLabel(card.name)}") and follow \`storebound console plan ${plan.form}\`: ${plan.steps.length} steps, final button ${plan.buttons.final}.`, click: [startLabel(card.name)], answers: answers.filter(a => a.id.startsWith(`${plan.form}/`)).map(a => a.id) });
if (!plan) { add({ text: `${card.name}: ${card.via}. Dry run first, then the real run yourself; then read the card back.` }); continue; }
if (!all) { add({ text: `${card.name}: open /app-content/${card.slug} (the button "${startLabel(card.name)}") and follow \`storebound console plan ${plan.form}\`: ${plan.steps.length} steps; its Save is yours (a declaration save).`, click: [startLabel(card.name)], answers: answers.filter(a => a.id.startsWith(`${plan.form}/`)).map(a => a.id) }); continue; }
add({ text: `${card.name}: open /app-content/${card.slug} (the button "${startLabel(card.name)}").`, click: [startLabel(card.name)] });
for (const st of plan.steps.slice(1)) add({ text: `${card.name}: ${st.text}`, ...(st.click ? { click: st.click } : {}), ...(st.answers ? { answers: st.answers.map(id => `${plan.form}/${id}`) } : {}) });
}
add({ text: 'Stop before each card\'s final Save (the content rating Summary\'s Save too): list the filled cards for the owner, who presses them (or says yes in chat for the listed cards; then the agent presses them).' });
add({ text: 'Verify: run the capture snippet (verify.capture) on the overview and pipe its JSON to `storebound console verify play-app-content --from-text -`.' });
const neverPress = [...new Set(cards.flatMap(c => c.plan?.buttons.neverPress ?? []))];
add({ text: `Verify: run the capture snippet (verify.capture) on the overview and pipe its JSON to \`storebound console verify play-app-content --from-text -\`. Then stop: the owner list is ${ownerBatch.filter(o => o.kind !== 'answers').map(o => `[${o.when}: ${o.text}]`).join(', ')}; tell the owner the ${SEND_FOR_REVIEW} step (the before steps are done already).` });
const neverPress = [...new Set([SEND_FOR_REVIEW, ...cards.flatMap(c => c.plan?.buttons.neverPress ?? [])])];
const allowed = [{ button: 'Start <Name> declaration', class: 'navigation', where: 'App content overview: opens the card' },
...cards.flatMap(c => c.plan?.buttons.allowed ?? [])].filter((b, i, l) => l.findIndex(x => x.button === b.button) === i);
return {
form: 'play-app-content', store: 'play', title: 'Play Console: App content (every declaration)',
form: 'play-app-content', store: 'play', title: `Play Console: App content (every declaration${all ? ', all cards inline' : ''})`, ownerBatch,
start: appContentStart(cfg, p), steps, answers,
askOwner: cards.flatMap(({ card, plan }) => (plan ? plan.askOwner.map(l => `${card.name}: ${l}`) : [])),
buttons: {
neverPress, final: 'Save (each card; the content rating Summary\'s Save)',
allowed: [{ button: 'Start <Name> declaration', class: 'navigation', where: 'App content overview: opens the card' },
...cards.flatMap(c => c.plan?.buttons.allowed ?? []).filter((b, i, l) => l.findIndex(x => x.button === b.button) === i)],
rule: ruleText(['Save draft / Save on the IARC questionnaire page', 'Save in Store settings'], 'a card\'s final Save'),
neverPress, final: SEND_FOR_REVIEW, allowed,
rule: ruleText(['Save draft / Save on the IARC questionnaire page'], SEND_FOR_REVIEW, ['each card\'s Save', 'the content rating Summary\'s Save']),
},
uiFacts: [
'Verified 2026-10-12 on /console/u/0/developers/<developerId>/app/<appId>/app-content/overview: each card has a button aria-label="Start <Name> declaration". The cards, in order:',
...APP_CONTENT_CARDS.map((c, i) => `${i + 1}. ${c.name}: /app-content/${c.slug}${c.verified ? '' : ' (slug modeled, not recorded)'}; ${c.via}.`),
`Verified 2026-10-13: saving a card is not public; every saved card goes to Google only with the next "${SEND_FOR_REVIEW}" in Publishing overview. So the agent presses each card's Save; the owner presses ${SEND_FOR_REVIEW} once at the end.`,
'The App category is not an App content card: Store settings (`storebound console plan play-store-settings`).',
PLAY_MATERIAL_FACT,
],
@ -447,14 +497,17 @@ function appContentPlan(cfg: ShipConfig, p: PlanIds): ConsolePlan {
};
}
export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {}): ConsolePlan {
/** Plan options: `all` (play-app-content): every card's steps inline. */
export interface PlanOpts { all?: boolean }
export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {}, o: PlanOpts = {}): ConsolePlan {
const cfg = p.appleAppId && config.apple && !config.apple.appId ? { ...config, apple: { ...config.apple, appId: p.appleAppId } } : config;
if (form === 'play-app-content') return appContentPlan(cfg, p);
if (form === 'play-app-content') return appContentPlan(cfg, p, o.all);
if (isPlanOnly(form)) {
const part = playOnlyPart(form, cfg, p);
return {
form, store: 'play', title: part.title, start: part.start, steps: part.steps, answers: part.answers, askOwner: askOwnerOf(part.answers),
buttons: { neverPress: part.neverPress, final: part.final, allowed: part.allowed, rule: ruleText(part.allowed.filter(b => b.class === 'draft-save').map(b => b.button), part.final) },
form, store: 'play', title: part.title, ownerBatch: ownerBatchOf(form, cfg, p, part.answers, part.final), start: part.start, steps: part.steps, answers: part.answers, askOwner: askOwnerOf(part.answers),
buttons: { neverPress: part.neverPress, final: part.final, allowed: part.allowed, rule: ruleText(part.allowed.filter(b => b.class === 'draft-save').map(b => b.button), part.final, part.allowed.filter(b => b.class === 'declaration-save').map(b => b.button)) },
uiFacts: part.uiFacts, driving: DRIVING, inPageLoop: IN_PAGE_LOOP, verify: part.verify, fillable: true, guidance: part.guidance,
};
}
@ -463,17 +516,18 @@ export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {
const answers = sheet(form, raw);
const part = form === 'apple-app-privacy' ? appPrivacyPlan(answers, cfg.apple?.appId) : otherPlan(form, cfg, answers, p);
const rules = FORM_BUTTONS[form];
const neverPress = [...new Set([...rules.filter(r => !r.auto && r.class === 'final-submit').flatMap(r => r.button.replace(/ \((?:form|and its confirmation dialog)\)$/, '').split(' / ')), ...NEVER_PRESS])];
// the content rating's questionnaire Save is a draft save; only the Summary's Save is final (verified 2026-10-12)
const final = form === 'play-content-rating' ? 'Save (Summary page)' : meta.stopBefore;
const neverPress = [...new Set([...rules.filter(r => !r.auto && r.class === 'final-submit').flatMap(r => r.button.replace(/ \((?:form|and its confirmation dialog)\)$/, '').split(' / ')), ...NEVER_PRESS, ...(meta.store === 'play' ? PLAY_NEVER_PRESS : [])])];
// the content rating's questionnaire Save is a draft save and its Summary's Save a declaration save (verified
// 2026-10-12 / 2026-10-13): the final button is the owner's Send changes for review in Publishing overview
const final = meta.stopBefore;
const teens = form === 'play-target-audience' && raw.filter(a => a.id.startsWith('age:')).every(a => a.certain && (a.value !== true || ['age:13-15', 'age:16-17', 'age:18-over'].includes(a.id)));
const askOwner = askOwnerOf(answers).map(l => (teens && l.startsWith('Could your app unintentionally appeal') ? `${l} (only if the page asks it: with 13+ age groups only it did not, 2026-10-12)` : l));
return {
form, store: meta.store, title: meta.title, ...part, answers, askOwner,
form, store: meta.store, title: meta.title, ownerBatch: ownerBatchOf(form, cfg, p, answers, final), ...part, answers, askOwner,
buttons: {
neverPress, final,
allowed: rules.filter(r => r.auto).map(r => ({ button: r.button, class: r.class, where: r.where })),
rule: ruleText(rules.filter(r => r.class === 'draft-save').map(r => r.button), final),
rule: ruleText(rules.filter(r => r.class === 'draft-save').map(r => r.button), final, rules.filter(r => r.class === 'declaration-save').map(r => r.button)),
},
driving: DRIVING, inPageLoop: IN_PAGE_LOOP,
fillable: meta.fillable,
@ -483,7 +537,10 @@ export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {
/** The plan as text for the terminal. */
export function planText(pl: ConsolePlan): string {
const L: string[] = [`${pl.title}${pl.fillable ? '' : ' (guide only)'}`, `start: ${pl.start.url}${pl.start.deepLink ? ' (deep link)' : ''}`, ...pl.start.reach.map(r => ` then: ${r}`), '', 'Steps:'];
const L: string[] = [`${pl.title}${pl.fillable ? '' : ' (guide only)'}`];
if (pl.ownerBatch.length) L.push('', 'Owner batch (everything the owner does, in ONE message, in the order the agent needs it):',
...pl.ownerBatch.map(o => ` ${o.n}. [${o.when === 'before' ? 'before the run' : 'after the run'}] ${o.text}${o.where ? ` @ ${o.where}` : ''}`), '');
L.push(`start: ${pl.start.url}${pl.start.deepLink ? ' (deep link)' : ''}`, ...pl.start.reach.map(r => ` then: ${r}`), '', 'Steps:');
for (const s of pl.steps) L.push(` ${s.n}. ${s.text}${s.click?.length ? ` [click: ${s.click.map(c => `"${c}"`).join(', ')}]` : ''}`);
L.push('', 'Answer sheet:');
for (const a of pl.answers) {