console: agent-first mode (console plan / console verify), verified App Privacy and Play Create app facts

- `storebound console plan <form> [--json] [--lookup]`: read-only plan for an AI agent that drives the browser
  itself: start URL (deep links), ordered steps, answer sheet with the console's exact labels (certain / ask owner,
  curly quotes noted), never-press list, verified UI facts, driving guidance (in-page async loop + window.__log),
  verify instructions. All six forms.
- `storebound console verify <form> [--from-text <file|->]`: App Privacy per-type summaries from page text, Play
  Create app from input states, content rating / target audience summaries, App Store record and age rating from
  the API; exit 1 on a mismatch.
- MCP: console_plan, console_verify (read-only); console fill stays off MCP.
- App Privacy (verified 2026-10-12): two information steps, unlabeled purpose checkboxes (nearest parent text),
  "Definitions and Examples" never pressed; fixture and engine follow.
- Play Create app (verified 2026-10-12): /developers/<id>/create-new-app, App package name + read-only Check
  availability, material-radio inner [role=radio] click, app dashboard URL and play.appId, service account
  "Add app" as an owner step.
- console-autofill skill rewritten agent-first; agents-core rule 10; README; pitfalls 12, 18 (confirmed sign-out),
  19; CHANGELOG; play-first-release.
This commit is contained in:
CRTkafa 2026-10-12 08:22:03 +08:00
parent a9261a8052
commit c9a7f095c4
34 changed files with 1430 additions and 113 deletions

View file

@ -97,6 +97,7 @@ export function formDef(id: FormId, answers: Answer[]): FormDef {
case 'apple-age-rating': return { id, sections: [] };
case 'play-app-record': return { id, sections: [{ title: 'Create app', fields: [
{ id: 'name', kind: 'text', names: ['App name', 'Uygulama adı'] },
{ id: 'packageName', kind: 'text', names: ['App package name', 'Package name', 'Uygulama paket adı', 'Paket adı'] },
{ id: 'language', kind: 'select', names: ['Default language', 'Varsayılan dil'] },
{ id: 'type', kind: 'radio', names: ['App or game', 'Uygulama veya oyun'], options: { App: ['App', 'Uygulama'], Game: ['Game', 'Oyun'] } },
{ id: 'price', kind: 'radio', names: ['Free or paid', 'Ücretsiz veya ücretli'], options: { Free: ['Free', 'Ücretsiz'], Paid: ['Paid', 'Ücretli'] } },
@ -141,13 +142,19 @@ export function formDef(id: FormId, answers: Answer[]): FormDef {
* Turkish console wording). English words match whole words; Turkish stems match word starts (agglutinative suffixes).
*/
const EN = ['save', 'saved', 'submit', 'publish', 'create', 'send', 'apply', 'confirm', 'done', 'release', 'rollout', 'roll out', 'start', 'delete',
'remove', 'next', 'continue', 'finish', 'agree', 'accept', 'upload', 'deploy', 'discard', 'update', 'add for review', 'review', 'sign', 'log in', 'login'];
'remove', 'next', 'continue', 'finish', 'agree', 'accept', 'upload', 'deploy', 'discard', 'update', 'add for review', 'review', 'sign', 'log in', 'login',
// App Privacy's tracking step: a help panel, never needed (verified on the real dialog, October 2026)
'definitions and examples'];
const TR = ['kaydet', 'gönder', 'yayınla', 'yayımla', 'oluştur', 'onayla', 'tamam', 'bitti', 'bitir', 'sil', 'kaldır', 'başlat', 'sürüm',
'ileri', 'devam', 'sonraki', 'kabul', 'yükle', 'güncelle', 'incelemeye', 'giriş'];
const L = '\\p{L}\\p{N}';
// "uygula" (apply) is refused in every form, but not "uygulama" (app: Yeni Uygulama, Uygulama içeriği)
const FORBIDDEN = new RegExp(`(?<![${L}])(?:(?:${EN.map(esc).join('|')})(?![${L}])|(?:${TR.map(esc).join("|")}|uygula(?!ma)))`, 'iu');
/** Controls never pressed by storebound or by an agent following `console plan`, whatever the form: help panels and
* the final buttons (the agent-facing never-press list is built from this and FORM_BUTTONS). */
export const NEVER_PRESS = ['Publish', 'Submit', 'Create', 'Create app', 'Send', 'Release', 'Definitions and Examples', '?', 'More information'];
/** true when storebound must not click a control with this accessible name. */
export const forbiddenControl = (name: string): boolean => FORBIDDEN.test(name.toLocaleLowerCase('tr').normalize('NFC')) || FORBIDDEN.test(name.toLowerCase());
@ -284,10 +291,12 @@ export function navPlan(id: FormId, config: ShipConfig, bases: NavBases = NAV_BA
{ kind: 'wait', label: 'the New App dialog', roles: ['dialog'], names: ['New App', 'Yeni Uygulama', /^new app\b/i, /^yeni uygulama\b/iu] },
];
case 'apple-age-rating': return [];
case 'play-app-record': return [
config.play?.developerId
? { kind: 'goto', label: 'open the app list of the developer account', url: playAppList(play, config.play.developerId) }
: { kind: 'goto', label: 'open Play Console', url: play },
case 'play-app-record': return config.play?.developerId ? [
// verified 2026-10-12: the Create app form of one developer account has its own URL
{ kind: 'goto', label: 'open the Create app form of the developer account', url: playCreateApp(play, config.play.developerId) },
{ kind: 'wait', label: 'the Create app form', roles: ['textbox'], names: CREATE_FORM_FIELD },
] : [
{ kind: 'goto', label: 'open Play Console', url: play },
{ kind: 'click', label: 'Create app on the app list', roles: ['button', 'link'], names: CREATE_APP_OPENERS, createApp: true },
{ kind: 'wait', label: 'the Create app form', roles: ['textbox'], names: CREATE_FORM_FIELD },
];
@ -359,12 +368,14 @@ const EDIT: Name[] = ['Edit', 'Düzenle'];
const SAVE: Name[] = ['Save', 'Kaydet'];
const SAVE_DRAFT: Name[] = ['Save draft', 'Taslağı kaydet', 'Taslak olarak kaydet'];
const DONE: Name[] = ['Done', 'Bitti', 'Tamam'];
/** Play Create app: the read-only package name check next to App package name (verified 2026-10-12). */
const CHECK_AVAILABILITY: Name[] = ['Check availability', 'Kullanılabilirliği kontrol et', 'Uygunluğu kontrol et'];
/** The heading of App Privacy's Data Types section (Edit and the data types sit in it). */
export const DATA_TYPES_HEADING: Name[] = ['Data Types', 'Veri Türleri', 'Veri türleri'];
/** The App Privacy page heading. */
export const APP_PRIVACY_HEADING: Name[] = ['App Privacy', 'Uygulama Gizliliği', /^app privacy$/i];
/** Names the engine looks for. */
export const FLOW_NAMES = { next: NEXT, save: SAVE, saveDraft: SAVE_DRAFT, getStarted: GET_STARTED, edit: EDIT };
export const FLOW_NAMES = { next: NEXT, save: SAVE, saveDraft: SAVE_DRAFT, getStarted: GET_STARTED, edit: EDIT, checkAvailability: CHECK_AVAILABILITY };
/** Every button of every console form, classified (the console-autofill skill's table is written from this). */
export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
@ -376,6 +387,7 @@ export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
{ button: '? / More information', class: 'navigation', where: 'next to each heading and data type: help only (never needed)', auto: false },
{ button: 'Next / Continue', class: 'navigation', where: 'questionnaire dialogs', auto: true },
{ button: 'Back', class: 'navigation', where: 'questionnaire dialogs (never needed)', auto: false },
{ button: 'Definitions and Examples', class: 'navigation', where: 'a data type\'s tracking step: help only (never clicked)', auto: false },
{ button: 'Save', class: 'draft-save', where: 'questionnaire dialogs: stores the answers, not public until Publish', auto: true },
{ button: 'Publish (and its confirmation dialog)', class: 'final-submit', where: 'top right of the App Privacy page', auto: false },
],
@ -391,6 +403,7 @@ export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
],
'play-app-record': [
{ button: 'Create app (app list)', class: 'navigation', where: 'Home / All apps: opens the Create app form', auto: true },
{ button: 'Check availability', class: 'navigation', where: 'the Create app form, next to App package name: read-only check ("Package name available")', auto: true },
{ button: 'Developer Program Policies / US export laws boxes', class: 'final-submit', where: 'the Create app form: agreements, never ticked', auto: false },
{ button: 'Create app (form)', class: 'final-submit', where: 'the Create app form: creates the app', auto: false },
],
@ -430,6 +443,7 @@ export function flowClass(form: FormId, name: string, role: string, where: FlowW
if (!n || !['button', 'link'].includes(role)) return null;
if (FINAL.test(n.toLocaleLowerCase('tr')) || FINAL.test(n)) return 'final-submit';
if (nameIs(n, NEXT) || nameIs(n, BACK)) return 'navigation';
if (form === 'play-app-record' && nameIs(n, CHECK_AVAILABILITY)) return 'navigation';
if (form === 'apple-app-privacy') {
if (nameIs(n, GET_STARTED) && where.privacyPage && !where.inDialog) return 'navigation';
if (nameIs(n, EDIT) && where.inDataTypes && !where.inDialog) return 'navigation';
@ -479,9 +493,19 @@ export function flowAllowed(form: FormId, name: string, role: string, where: Flo
// the team menu (exact name), is a link / menu item / option / row / button, and is never a sign-out or a deny-listed
// control (forbiddenControl stays absolute, so a studio named "Next Games" is not clicked: the owner picks it).
/** Play Console's Create app form of one developer account (verified 2026-10-12). */
export const playCreateApp = (playBase: string, developerId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/create-new-app`;
/** Play Console's app list of one developer account. */
export const playAppList = (playBase: string, developerId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/app-list`;
/** The Play Console app id in a URL (/developers/<id>/app/<digits>/...), or null. */
export function playAppIdOf(url: string): string | null {
try { return new URL(url).pathname.match(/\/developers\/\d{1,25}\/app\/(\d{1,25})(?:\/|$)/)?.[1] ?? null; } catch { return null; }
}
/** An app's Play Console dashboard (verified 2026-10-12: where Create app lands). */
export const playAppDashboard = (playBase: string, developerId: string, appId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/app/${encodeURIComponent(appId)}/app-dashboard`;
/** The developer account id in a Play Console URL (/developers/<digits>), or null. */
export function playDeveloperId(url: string): string | null {
try { return new URL(url).pathname.match(/\/developers\/(\d{1,25})(?:\/|$)/)?.[1] ?? null; } catch { return null; }