@asrunjs/asrun 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.claude/commands/explore-case.md +8 -3
  2. package/.claude/skills/exploring-cases/SKILL.md +72 -14
  3. package/.claude/skills/exploring-cases/references/handoff-card.js +81 -7
  4. package/.claude/skills/exploring-cases/references/notes.md +42 -4
  5. package/.claude/skills/exploring-cases/references/open-questions.md +2 -2
  6. package/.claude/skills/exploring-cases/references/readiness.md +6 -0
  7. package/.claude/skills/exploring-cases/references/user-handoff.md +60 -27
  8. package/.claude/skills/generating-specs/SKILL.md +10 -3
  9. package/AGENTS.md +12 -4
  10. package/README.md +14 -2
  11. package/dist/lib/desktop-notify.d.ts +38 -0
  12. package/dist/lib/desktop-notify.js +158 -0
  13. package/dist/lib/explore-notes.d.ts +15 -0
  14. package/dist/lib/explore-notes.js +34 -1
  15. package/dist/lib/migrations.d.ts +43 -4
  16. package/dist/lib/migrations.js +39 -7
  17. package/dist/lib/paths.d.ts +9 -2
  18. package/dist/lib/paths.js +32 -5
  19. package/dist/lib/pdf.js +23 -3
  20. package/dist/lib/scaffold.d.ts +87 -0
  21. package/dist/lib/scaffold.js +220 -0
  22. package/dist/lib/term.d.ts +36 -0
  23. package/dist/lib/term.js +77 -0
  24. package/dist/lib/update-check.d.ts +1 -7
  25. package/dist/lib/update-check.js +3 -15
  26. package/dist/lib/upgrade-prompt.d.ts +84 -0
  27. package/dist/lib/upgrade-prompt.js +233 -0
  28. package/dist/migrations/index.d.ts +12 -4
  29. package/dist/migrations/index.js +40 -5
  30. package/dist/migrations/manifest.json +27 -1
  31. package/dist/scripts/build.js +4 -1
  32. package/dist/scripts/check-cases.js +3 -3
  33. package/dist/scripts/cli.js +11 -3
  34. package/dist/scripts/doctor.js +73 -4
  35. package/dist/scripts/explore-notes.js +19 -2
  36. package/dist/scripts/init.d.ts +6 -26
  37. package/dist/scripts/init.js +82 -101
  38. package/dist/scripts/migrate.js +84 -21
  39. package/dist/scripts/migrations-check.d.ts +1 -0
  40. package/dist/scripts/migrations-check.js +272 -0
  41. package/dist/scripts/migrations-freeze.d.ts +1 -0
  42. package/dist/scripts/migrations-freeze.js +126 -0
  43. package/dist/scripts/notify-test.d.ts +1 -0
  44. package/dist/scripts/notify-test.js +48 -0
  45. package/dist/scripts/upgrade.js +53 -10
  46. package/dist/ui/server/actions.js +4 -2
  47. package/dist/ui/server/ai-stream.d.ts +7 -1
  48. package/dist/ui/server/ai-stream.js +26 -7
  49. package/dist/ui/server/app.js +4 -0
  50. package/dist/ui/server/jobs.d.ts +10 -0
  51. package/dist/ui/server/jobs.js +41 -1
  52. package/dist/ui/shared/ai-commands.js +4 -2
  53. package/dist/ui/shared/types.d.ts +8 -0
  54. package/docs/agents/case-format.md +8 -1
  55. package/docs/agents/framework-dev.md +44 -5
  56. package/docs/ai-workflow.md +27 -10
  57. package/docs/cases.md +5 -5
  58. package/docs/commands.md +73 -15
  59. package/docs/install.md +18 -5
  60. package/docs/troubleshooting.md +17 -0
  61. package/docs/web-ui.md +23 -8
  62. package/package.json +5 -2
  63. package/templates/.claude/commands/explore-case.md +9 -3
  64. package/templates/.claude/commands/upgrade-workspace.md +21 -0
  65. package/templates/AGENTS.md +1 -1
  66. package/templates/package.json +1 -1
  67. package/ui/dist/assets/{dist-C46EmZuI.js → dist-B4pM62Hc.js} +1 -1
  68. package/ui/dist/assets/{dist-DGzg2gyA.js → dist-BFADGd4p.js} +1 -1
  69. package/ui/dist/assets/{dist-P9roUi1O.js → dist-Bd-TmWTD.js} +1 -1
  70. package/ui/dist/assets/{dist-DMiOs9Qj.js → dist-Bt053MSo.js} +1 -1
  71. package/ui/dist/assets/{dist-DrFzCwMc.js → dist-CD9RixNE.js} +1 -1
  72. package/ui/dist/assets/{dist-_0g6iC9K.js → dist-ChACtLiA.js} +1 -1
  73. package/ui/dist/assets/{dist-D9GmSkjm.js → dist-CiW9sEPF.js} +1 -1
  74. package/ui/dist/assets/{dist-Db_XhDui.js → dist-CxEpzoZU.js} +1 -1
  75. package/ui/dist/assets/{dist-BB2cF3XN.js → dist-Dh7um7By.js} +1 -1
  76. package/ui/dist/assets/{dist-Dy45nXf8.js → dist-gHwKkZ6g.js} +1 -1
  77. package/ui/dist/assets/{index-BN4ucmmn.js → index-Ckax27uf.js} +158 -158
  78. package/ui/dist/index.html +1 -1
@@ -271,11 +271,15 @@ const BROWSER_RUN_CODE = /browser_run_code/;
271
271
  * badge on.
272
272
  */
273
273
  const HANDOFF_CFG_TTL_MS = 300_000;
274
- /** `message: '...'` of the options call that comes right before the card. */
275
- function handoffMessage(code) {
276
- const match = /message\s*:\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1/.exec(code);
274
+ /** A plain string literal of the options call that comes right before the card (`message: '...'`, `finish: '...'`). */
275
+ function handoffText(code, key) {
276
+ const match = new RegExp(`(?:^|[^\\w$])${key}\\s*:\\s*(['"\`])((?:\\\\.|(?!\\1)[^\\\\])*)\\1`).exec(code);
277
277
  return match ? match[2].replace(/\\(['"`\\])/g, '$1').trim() || null : null;
278
278
  }
279
+ /** `message: '...'` of the options call that comes right before the card. */
280
+ const handoffMessage = (code) => handoffText(code, 'message');
281
+ /** `finish: '...'` of the options call that comes right before the card. */
282
+ const handoffFinish = (code) => handoffText(code, 'finish');
279
283
  const codeOf = (input) => {
280
284
  if (!input || typeof input !== 'object')
281
285
  return '';
@@ -306,8 +310,12 @@ export class AiStreamFormatter {
306
310
  pending = new Map();
307
311
  cardCalls = new Set();
308
312
  waitingForUser = null;
309
- /** The message of the last options call, kept so a card that runs after unrelated calls still carries it. */
313
+ /**
314
+ * The message and finish condition of the last options call, kept so a card that runs after unrelated calls still
315
+ * carries them.
316
+ */
310
317
  lastHandoffMessage = null;
318
+ lastHandoffFinish = null;
311
319
  lastEventAt = Date.now();
312
320
  lastWaitAt = 0;
313
321
  /** The last call the agent finished, so a quiet spell can say what it is doing instead of only that nothing came. */
@@ -348,18 +356,29 @@ export class AiStreamFormatter {
348
356
  this.waitingForUser = null;
349
357
  else {
350
358
  this.cardCalls.add(id);
351
- // `??=` on purpose: the options call before it already knows the message and when the wait started.
352
- this.waitingForUser ??= { message: handoffMessage(codeOf(input)) ?? this.lastHandoffMessage, since: Date.now() };
359
+ // `??=` on purpose: the options call before it already knows the message, the finish condition and the start.
360
+ this.waitingForUser ??= {
361
+ message: handoffMessage(codeOf(input)) ?? this.lastHandoffMessage,
362
+ finish: handoffFinish(codeOf(input)) ?? this.lastHandoffFinish,
363
+ since: Date.now(),
364
+ };
353
365
  }
354
366
  return null;
355
367
  }
356
368
  if (kind === 'options') {
357
369
  const message = handoffMessage(codeOf(input));
370
+ const finish = handoffFinish(codeOf(input));
358
371
  if (message)
359
372
  this.lastHandoffMessage = message;
373
+ if (finish)
374
+ this.lastHandoffFinish = finish;
360
375
  // Keep the start time when the wait is already set: a second report of the same handoff must not announce it twice.
361
376
  const since = this.waitingForUser?.since ?? Date.now();
362
- this.waitingForUser = { message: message ?? this.lastHandoffMessage, since };
377
+ this.waitingForUser = {
378
+ message: message ?? this.lastHandoffMessage,
379
+ finish: finish ?? this.lastHandoffFinish,
380
+ since,
381
+ };
363
382
  return null;
364
383
  }
365
384
  // Anything else ends the wait, unless it was set too recently to be an abandoned one: the card runs right after it.
@@ -15,6 +15,7 @@ import { JobConflictError, jobs } from './jobs.js';
15
15
  import { listAiModels } from './ai-models.js';
16
16
  import { fileEvents } from './watch-files.js';
17
17
  import { refreshUpdate, updateEvents, updateInfo } from './update.js';
18
+ import { upgradePrompt } from '../../lib/upgrade-prompt.js';
18
19
  const REPORTS_DIR = path.join(ROOT, 'reports');
19
20
  // Built by `prepack` and shipped with the package; the framework repository builds it with `pnpm ui`.
20
21
  const WEB_DIST = path.join(FRAMEWORK_ROOT, 'ui', 'dist');
@@ -240,6 +241,9 @@ export function createApp({ port }) {
240
241
  void refreshUpdate(true);
241
242
  return c.json(info);
242
243
  });
244
+ // What an upgrade leaves open, and the prompt that hands it to an agent. Read-only; the dialog asks for it only when
245
+ // it opens, because it reads the installed templates and can be long.
246
+ api.get('/upgrade/prompt', (c) => c.json(upgradePrompt(ROOT)));
243
247
  api.get('/reports', (c) => {
244
248
  if (!fs.existsSync(REPORTS_DIR))
245
249
  return c.json([]);
@@ -102,6 +102,16 @@ declare class JobManager {
102
102
  private verifyLeftoverStopped;
103
103
  /** Publishes a change of "waiting for the person" (a handoff card) like any other status change. */
104
104
  private syncAwaiting;
105
+ /**
106
+ * Shows a desktop notification now. Neither the agent (whose shell the CLI may refuse) nor the page (not open, not
107
+ * focused, not allowed) can be relied on for this, so the server shows it itself.
108
+ */
109
+ private notifyWaiting;
110
+ /**
111
+ * Why no notification appeared, written where the person reads next: never a reason to fail the job, and the next
112
+ * run of the same kind leaves no line at all when the notifier works.
113
+ */
114
+ private pushNotificationFailure;
105
115
  /**
106
116
  * Stops a run whose agent has said nothing for `STALL_MS`: a tool call that never returns leaves the CLI waiting for
107
117
  * ever, and the log would only keep repeating "still waiting for ...". The block above the cancel names what to do.
@@ -5,6 +5,7 @@ import fs from 'node:fs';
5
5
  import { StringDecoder } from 'node:string_decoder';
6
6
  import { ROOT } from '../../lib/paths.js';
7
7
  import { spawnTarget } from '../../lib/command.js';
8
+ import { notifyDesktop } from '../../lib/desktop-notify.js';
8
9
  import { AiStreamFormatter } from './ai-stream.js';
9
10
  import { parseResultState } from './result-state.js';
10
11
  import { appendChunkNow, chunksFile, ensureJobDir, logFile, pruneJobs, readChunks, readJobs, writeRecord, } from './job-store.js';
@@ -244,6 +245,11 @@ class JobManager {
244
245
  finished = true;
245
246
  this.finish(record, code, signal);
246
247
  });
248
+ // A login waits for a person at the browser window it just opened: say so on the desktop, where they are looking.
249
+ if (spec.kind === 'signin') {
250
+ this.notifyWaiting(record, 'AsRun — log in', `Log in to ${[spec.profile, spec.env].filter(Boolean).join(' @ ')}, then press "Done, save the session" on ` +
251
+ 'the card in the browser window.');
252
+ }
247
253
  this.events.emit('job', record.info);
248
254
  return record.info;
249
255
  }
@@ -476,9 +482,39 @@ class JobManager {
476
482
  return;
477
483
  if (now && before && before.since === new Date(now.since).toISOString())
478
484
  return;
479
- info.awaitingUser = now ? { message: now.message, since: new Date(now.since).toISOString() } : null;
485
+ info.awaitingUser = now
486
+ ? { message: now.message, finish: now.finish, since: new Date(now.since).toISOString() }
487
+ : null;
480
488
  this.persist(record);
481
489
  this.events.emit('job', info);
490
+ // A handoff that starts right now: the person is told on the desktop, because the browser window is somewhere else
491
+ // and the page may be closed, focused or not allowed to notify. The dedupe keeps one handoff to one notification.
492
+ if (!before && now) {
493
+ this.notifyWaiting(record, 'AsRun — your turn', `${info.caseId ? `${info.caseId}: ` : ''}${now.message ?? 'A step in the browser window waits for you.'}` +
494
+ (now.finish ? ` Done when ${now.finish}.` : ''));
495
+ }
496
+ }
497
+ /**
498
+ * Shows a desktop notification now. Neither the agent (whose shell the CLI may refuse) nor the page (not open, not
499
+ * focused, not allowed) can be relied on for this, so the server shows it itself.
500
+ */
501
+ notifyWaiting(record, title, body) {
502
+ notifyDesktop({ title, body, sound: true }, (reason) => this.pushNotificationFailure(record, reason));
503
+ }
504
+ /**
505
+ * Why no notification appeared, written where the person reads next: never a reason to fail the job, and the next
506
+ * run of the same kind leaves no line at all when the notifier works.
507
+ */
508
+ pushNotificationFailure(record, reason) {
509
+ const line = `\n[no desktop notification shown: ${reason}. "pnpm asrun notify-test" checks the notifier of this machine]\n`;
510
+ // The job may have ended while the notifier was still running: its log stream is closed then, so the files get the
511
+ // line directly (a running job is told through its stream, so the page shows it at once).
512
+ if (record.info.status === 'running' && record.logStream) {
513
+ this.pushPart(record, { kind: 'hint', at: Date.now(), data: line });
514
+ }
515
+ else {
516
+ this.pushRecovered(record, line);
517
+ }
482
518
  }
483
519
  /**
484
520
  * Stops a run whose agent has said nothing for `STALL_MS`: a tool call that never returns leaves the CLI waiting for
@@ -586,6 +622,10 @@ class JobManager {
586
622
  : `[process exited with code ${code}]`);
587
623
  if (record.spec.ai && info.status === 'succeeded')
588
624
  info.resultState = parseResultState(resultText(record));
625
+ // The agent stopped with questions in the case file: that waits for a person exactly like a handoff card does.
626
+ if (info.resultState === 'needs-input') {
627
+ this.notifyWaiting(record, 'AsRun — answers needed', `${info.caseId ? `${info.caseId}: ` : 'The case: '}the agent wrote open questions into the case file.`);
628
+ }
589
629
  this.push(record, 'system', `\n${summary}\n`);
590
630
  record.logStream?.end();
591
631
  record.chunkStream?.end();
@@ -134,8 +134,10 @@ const ATTENDED = 'Attended run: a person is at the machine and the browser windo
134
134
  'need a choice to them and wait, as the skill describes. Start every handoff (also a second one in the same run) ' +
135
135
  'with its own call that sets __handoffCfg, before the call that runs handoff-card.js, and never change those options ' +
136
136
  'inside the call that runs the card: the web UI sees that a person is needed only from that call, so without it ' +
137
- 'there is no "Your turn" badge and no notification. Nobody can answer questions in the chat, so write open ' +
138
- `questions into the case file as the skill describes. ${RESULT_LINE_RULE}`;
137
+ 'there is no "Your turn" badge and no notification. Put one action per string in `steps` (the card numbers them), ' +
138
+ 'keep `message` to one sentence, write the finish condition into `finish` as plain text, and set `doneText` to the ' +
139
+ 'exact text that appears when the step is done so the card ends by itself. Nobody can answer ' +
140
+ `questions in the chat, so write open questions into the case file as the skill describes. ${RESULT_LINE_RULE}`;
139
141
  /** The prompt of a one-shot run. Every command may leave open questions in the case, so every one ends with a RESULT line. */
140
142
  export function headlessPrompt(command, caseId, options) {
141
143
  const slash = slashCommand(command, caseId, options);
@@ -60,6 +60,8 @@ export interface JobInfo {
60
60
  export interface AwaitingUserInfo {
61
61
  /** What the person is asked to do, as the agent wrote it on the card; null when unknown. */
62
62
  message: string | null;
63
+ /** The finish condition the card shows in its "DONE WHEN" block; null when the agent gave none. */
64
+ finish?: string | null;
63
65
  since: string;
64
66
  }
65
67
  /** `needs-input`: the agent wrote open questions into the case file and cannot go on without answers. */
@@ -346,6 +348,12 @@ export interface ReportInfo {
346
348
  counts: Record<ResultStatus, number> | null;
347
349
  }
348
350
  export type { ParsedError, ResultStatus, RunSettings, RunSummary, TestRunResult } from '../../lib/run-summary.js';
351
+ /**
352
+ * What an upgrade leaves to a person or an AI, and the prompt that hands it over. Built by `lib/upgrade-prompt.ts`,
353
+ * the same module `pnpm asrun upgrade --prompt --json` prints.
354
+ */
355
+ export type { ScaffoldDrift, ScaffoldState } from '../../lib/scaffold.js';
356
+ export type { OutstandingFile, OutstandingMigration, UpgradeOutstanding, UpgradePromptPayload } from '../../lib/upgrade-prompt.js';
349
357
  export interface InfoResponse {
350
358
  root: string;
351
359
  envs: string[];
@@ -94,7 +94,8 @@ Wrong: `- Create a draft named ... and delete it at the end.` (a write → a tag
94
94
  - A question block is indented under the sentence it belongs to: 4 spaces under a numbered step, 2 under a bullet.
95
95
  The parent line is the suggestion, written as a finished sentence; the `TODO` line holds one question and optional
96
96
  `(evidence)`; the block ends with `- ✍️ Answer:` at one level deeper and that line is the last line of the block.
97
- Hang it under the immediate sentence, never under a broader one above it, and never inside the `## Values` yaml.
97
+ Hang it under the immediate sentence, never under a broader sentence that merely contains it, and never inside the
98
+ `## Values` yaml.
98
99
  Details: `.claude/skills/exploring-cases/references/open-questions.md`.
99
100
  - A marker (`🔍 to be confirmed: <what a person must decide>`) is the alternative to a question, not a companion: it
100
101
  says nobody has to decide, only to check. `(seen on <profile>)` is a plain note the run checks by itself. Its
@@ -148,6 +149,8 @@ One card per distinct thing a check looks at:
148
149
  - `What`, `Where` and `Identified by` are required for a card that is not blocking.
149
150
  - `Reference <profile-id>:` must point under `.explore/targets/<case-id>/` (git-ignored), or be `none: <reason>`.
150
151
  A missing file on this machine is a warning, not a failure: a fresh checkout has the card but not the screenshot.
152
+ A card carries one line per profile that was looked at separately: an `--lead` exploration adds its own line for a
153
+ profile whose element differs, and `none: <reason>` for one that shares the lead's screenshot.
151
154
  - A card may hold a `🚫 TODO [blocking]` block. Such a card is not complete; `pnpm case:check` does not fail on it,
152
155
  and the check that uses it is not generated yet.
153
156
  - Every `[T<n>]` in a step, a ready signal or a check resolves to a card. `pnpm case:check` fails on a reference with
@@ -207,6 +210,10 @@ test.describe('<Case title>', {tag: caseTags('<id>')}, () => {
207
210
  by `pnpm asrun rules`) and the stamp is the run's UTC time; register it with `records.track` right after creating
208
211
  it, remove it newest first (also after a failure), turn retries off with `configureCase`, and keep email steps
209
212
  behind `emailStepAllowed(profile, '<step title>')`.
213
+ - `/explore-case --lead` explores one profile in full and the others only where they can differ. What a diff profile
214
+ adds to the case is its `## Values` entry and its `Reference <profile-id>:` line, never a second copy of the steps;
215
+ `/gen-spec` writes those profiles' parts from the lead's note and names them `not verified`. A step whose recipe
216
+ would differ per profile has no per-profile form (`- Recipe:` belongs to the step): split the case, or ask.
210
217
 
211
218
  ## Checks to run before reporting
212
219
 
@@ -124,16 +124,55 @@ A release can require an *existing* workspace to change; `docs/commands.md` has
124
124
  - `version` is the release that introduces the change. `pnpm asrun migrate` runs an entry when
125
125
  `migrations.since < version <= installed`, in version order, and moves `since` only after every entry of that
126
126
  release has been processed, so a failed run repeats the release instead of skipping its other entries.
127
+ - **Never write a version by hand.** Release Please names the version in its release pull request, after the change is
128
+ merged, so an author cannot know it (and a guessed version that is too high never runs: `since` moves past it and the
129
+ workspace is silently skipped for a release). Write `id: 'unreleased-<slug>'` and `version: 'unreleased'`.
130
+ `resolveMigrations()` turns the sentinel into the installed release's own version — the release that carries the
131
+ code — so the runtime and the built manifest are always right; `pnpm migrations:freeze` rewrites the sentinel into a
132
+ real version and id, and `.github/workflows/release-freeze.yml` runs it on the release pull request so the id a
133
+ workspace sees in `doctor` names a release. That frozen version has no tag yet (the tag comes with the merge), so
134
+ `migrations:check` accepts the version `package.json` declares as the release being prepared — see below.
127
135
  - `auto: true` with an `apply` runs after the install, when `detect` says the workspace is not migrated yet. Keep it
128
136
  idempotent: a run that failed repeats the entry.
129
137
  - Anything a person has to do is `manual: string[]`. The id is remembered in `.asrun.json` (`migrations.manual`) and
130
- `doctor` keeps reminding until `pnpm asrun migrate --ack <id>`.
131
- - `breaking: true` makes `pnpm asrun upgrade` ask before installing anything. The pre-install check reads
132
- `dist/migrations/manifest.json` out of the target tarball, so `pnpm build` has to write it (`scripts/build.ts`
133
- serialises `MIGRATIONS` with `toManifest`). `verify:pack` asserts that a packed tarball carries the manifest and that
134
- the reader in `lib/tar.ts` finds it.
138
+ `doctor` keeps reminding until `pnpm asrun migrate --ack <id>`; every output prints that command with the real id.
139
+ - `files: string[]` names the project files the change touches. A once-only scaffold file there (`AGENTS.md`,
140
+ `.mcp.json`, ...) also has its template baseline advanced when the migration is acknowledged, so one `migrate --ack`
141
+ clears both the manual step and the file from the upgrade's `NEXT` block.
142
+ - `advisory: true` is for a release that touched a surface but asks nothing of an existing workspace (documentation, a
143
+ clarified contract, a new optional flag). It makes `pnpm migrations:check` pass without inventing manual steps, and
144
+ `migrate` reports it and moves on.
145
+ - `breaking: true` makes `pnpm asrun upgrade` ask before installing anything (`Proceed? [y/N]`; `--yes`/`-y` for an
146
+ unattended run). The pre-install check reads `dist/migrations/manifest.json` out of the target tarball, so
147
+ `pnpm build` has to write it (`scripts/build.ts` serialises `MIGRATIONS` with `toManifest`) and `verify:pack` asserts
148
+ the packed manifest carries real versions.
135
149
  - Append only: never delete or renumber an entry, because a workspace on an older release may still need it. A change
136
150
  that only concerns brand-new workspaces belongs in `templates/`, not in a migration.
151
+
152
+ **`pnpm migrations:check` is the gate.** It compares the working tree with the newest release tag and fails when a
153
+ change touched a surface that needs an entry (`templates/**` of a once-only file, the case document format, the rule
154
+ ids, the peer dependency range) and no entry names it, or when an entry is malformed (a duplicate id, an id that does
155
+ not start with the version it declares, neither `manual` nor `auto` nor `advisory`). It runs in `verify.yml`, so a
156
+ forgotten entry stops the pull request. `/check-migrations` in Claude Code walks the same steps and writes the entry.
157
+
158
+ An entry's version must be `unreleased` or a release that already has a tag. The single exception is the version
159
+ `package.json` declares: on the release pull request release-please has bumped it and `release-freeze.yml` has frozen
160
+ the entries to it, while the tag only appears when that pull request is merged. So `migrations:check` reads
161
+ `package.json` as the release being prepared — that version needs no tag and every entry has to stay at or below it,
162
+ which is exactly the "a guess that is too high never runs" rule. `doctor` warns about an entry above the installed
163
+ release for the same reason: `migrate` filters at the installed version and would silently skip it.
164
+
165
+ A command this repository uses itself lives in **both** `.claude/commands/` and `.opencode/commands/`: Claude Code reads
166
+ only the first, OpenCode only the second (it reloads them without a restart). The two files are identical and neither is
167
+ shipped for a workspace's own commands — those come from `templates/.claude/commands/` through `GENERATED`, which writes
168
+ each one into both folders of the workspace.
169
+
170
+ **The scaffold baseline** (`lib/scaffold.ts`, `.asrun.json` → `scaffold`) is what tells "the template changed and nobody
171
+ edited the file" (`replace`) from "both changed" (`merge`) and "no baseline yet" (`unknown-base`). `init` records it
172
+ when it writes a once-only file; `init --ack <file>` records it after a merge; nothing else writes it. A release that
173
+ changes a `templates/**` file a workspace owns therefore needs an entry with `files`, or the change reaches the user as
174
+ silent drift that only the baseline comparison reports.
175
+
137
176
  - `scripts/migrate.ts` applies the *installed* package's registry, so it never runs an entry from a release that is not
138
177
  installed. `scripts/upgrade.ts` installs first, then runs `init --update`, `migrate` and `doctor` through the newly
139
178
  installed bin (`bin/asrun.mjs`), never through the process that started the upgrade.
@@ -3,16 +3,19 @@
3
3
  Four commands carry a test case from rough notes to a Playwright spec, and repair it when the product changes. They
4
4
  work the same in Claude Code and OpenCode: each command is a thin shell that loads a skill from the framework package,
5
5
  so the procedure lives in one place and a project can refine it (see
6
- [Customizing the prompts](#customizing-the-prompts)).
6
+ [Customizing the prompts](#customizing-the-prompts)). A fifth, `/upgrade-workspace`, keeps the workspace itself in step
7
+ with the release it runs; it needs no environment and never touches what a test case means.
7
8
 
8
9
  | Command | What it does |
9
10
  |---|---|
10
11
  | `/make-case <notes-or-path>` | Turn raw notes into case documents. Optional step. |
11
- | `/explore-case <case-id> <env> [--attended] [--profiles a,b] [--hints <file>]` | Open the real pages and write what they show into the case. |
12
+ | `/explore-case <case-id> <env> [--attended] [--profiles a,b] [--lead <profile-id>] [--hints <file>]` | Open the real pages and write what they show into the case. |
12
13
  | `/gen-spec <case-id> <env> [--force] [--hints <file>]` | Write `tests/<case-id>.spec.ts` and fill the case's `## Values`. |
13
14
  | `/heal-spec <case-id> <env> [--profiles a,b] [--report <run-id>] [--hints <file>]` | Repair a failing spec, or refine a case and its spec on request, from the last report and what you asked for. |
15
+ | `/upgrade-workspace` | Run `pnpm asrun upgrade --prompt` and follow it: merge the scaffold text of a project-owned file, fix case structure, run the checks. It never writes data, runs tests or acknowledges a migration step. |
14
16
 
15
- The environment is **required** by every command except `/make-case`, which does not open pages. There is no default.
17
+ The environment is **required** by every command except `/make-case` and `/upgrade-workspace`, which do not open
18
+ pages. There is no default.
16
19
 
17
20
  `--profiles` exists on `/explore-case` and `/heal-spec`, not on `/gen-spec`: a spec runs once per profile in the
18
21
  environment, so it is generated from every profile the case holds information for, never from a chosen subset.
@@ -88,10 +91,23 @@ publishes, shares or deletes anything, in staging or in production. It:
88
91
  data-call paths it saw. `/gen-spec` reads it to verify instead of rediscovering, and a page that only exists after a
89
92
  write step is written from it. Delete it at any time: without it, generation replays the case.
90
93
 
91
- With many profiles, `--profiles a,b` opens only those (ids from `profiles.yaml`, comma-separated). The others are not
92
- opened and nothing is written about them, so a case can be explored in passes: A and B first, C later. Runs add up; the
93
- case keeps what the earlier runs recorded. In the web UI the profile picker of the toolbar does the same: every profile
94
- that is logged in in that environment is ticked, and the list you tick is remembered for the next run there.
94
+ With many profiles, `--profiles a,b` opens only those (ids from `profiles.yaml`, comma-separated), each in its own
95
+ session; without the flag the agent opens one logged-in profile per domain. Two different hosts are two domains even
96
+ when they serve the same product, so the list is never collapsed into one profile. The profiles the list leaves out are
97
+ not opened and nothing is written about them, so a case can be explored in passes: A and B first, C later. Runs add up;
98
+ the case keeps what the earlier runs recorded. In the web UI the profile picker of the toolbar does the same: every
99
+ profile that is logged in in that environment is ticked, and the list you tick is passed to the run and remembered for
100
+ the next one there.
101
+
102
+ `--lead <profile-id>` stops paying for the same flow on every profile. The lead profile is explored in full — every
103
+ step, every handoff, the recipe lines, an exploration note with every step — and each other selected profile only
104
+ where it can differ: its own `## Values` entries, its target screenshots and the steps that reach them. A case states
105
+ the same behaviour for every profile, so values and target screenshots are the only place a difference can live. A
106
+ profile whose page structure or flow turns out to differ from the lead's is explored in full instead, and the report
107
+ says so; a differing check that cannot be reached is a blocking question, never "same as the lead". A write step that a
108
+ differing check needs is still a handoff on that profile (test data is usually private to its account), so the person
109
+ still creates it — the saving is the pages and steps around it, not the write. Keep lead and diff profiles in one run:
110
+ the notes are written at its end and share one `caseSha256`, so a split run makes the earlier notes stale.
95
111
 
96
112
  **To keep what the case says, delete the whole block.** **To answer, write after `Answer:`.** Then run the command
97
113
  again. In the web UI the panel calls the first action **Keep what the case says**. The run's **What to do next** bar
@@ -104,9 +120,10 @@ restarted.
104
120
  Use it for a case that creates or edits data, or needs a choice (an existing document, items). Stay at the computer:
105
121
  the browser window opens and the agent waits while you do those steps.
106
122
 
107
- - The window shows a small draggable card in the bottom-left corner with what to do, **Done** (hand control back) and
108
- **Cannot do this**. The agent also continues by itself when the finish condition it named shows up (for example a
109
- "Saved" message); the card turns green when control is back. It waits up to 10 minutes per step.
123
+ - The window shows a small draggable card in the bottom-left corner: one sentence about the step, the steps numbered,
124
+ the texts to copy, and a green **DONE WHEN** block with the finish condition. **Done** hands control back, and
125
+ **Cannot do this** gives the step up. The agent also continues by itself when the finish condition shows up (for
126
+ example a "Saved" message); the card turns green when control is back. It waits up to 10 minutes per step.
110
127
  - Recording starts and stops by itself. If Playwright's own recording toolbar shows at the top of the page, leave it
111
128
  alone: it can stop the recording.
112
129
  - The card records what you did, and the agent writes it into the case under the step as **general recipe lines**
package/docs/cases.md CHANGED
@@ -113,11 +113,11 @@ report and the trace read like the case.
113
113
  exploration: positions instead of names of changeable data (`select items #1 to #3`), created names (project prefix
114
114
  plus run stamp), control labels but no selectors. Several lines per step are normal. A tagged step with no recipe is
115
115
  not generated: `/gen-spec` stops with `needs-input`.
116
- - **Open questions** go in a `🚫 TODO [blocking]` or `❓ TODO [non-blocking]` block under the sentence they are about,
117
- with the agent's suggestion written above and a last line `- ✍️ Answer:`. Accepting a question means deleting the
118
- whole block; answering means writing after `Answer:`. In the web panel the two actions are **Keep what the case
119
- says** and **Write your answer**. Hang the block under the sentence that makes the claim, not under a broader one
120
- above it, and one claim gets one block.
116
+ - **Open questions** go in a `🚫 TODO [blocking]` or `❓ TODO [non-blocking]` block under the sentence they are about:
117
+ that sentence is the agent's suggestion, and the block's last line is `- ✍️ Answer:`. Accepting a question means
118
+ deleting the whole block; answering means writing after `Answer:`. In the web panel the two actions are **Keep what
119
+ the case says** and **Write your answer**. Hang the block under the sentence that makes the claim, not under a
120
+ broader sentence that merely contains it, and one claim gets one block.
121
121
  - **What the AI saw** is marked by what happens if it is wrong: `(seen on <profile>)` is a plain note the run checks by
122
122
  itself, and `(observed on <profile>, 🔍 to be confirmed: <what a person must decide>)` is a line that would pass even
123
123
  if it were wrong, so a person settles it. A marker may name a screenshot (`evidence: .explore/.../x.png`) so nobody
package/docs/commands.md CHANGED
@@ -8,7 +8,8 @@ thin wrappers, so `pnpm asrun`, `pnpm signin` and the rest work as below. The sa
8
8
  | I want to... | Command |
9
9
  |---|---|
10
10
  | Install the framework in a project | `pnpm dlx @asrunjs/asrun init --install` |
11
- | Upgrade the framework and migrate the workspace | `pnpm asrun upgrade [--version <v>] [--dry-run]` |
11
+ | Upgrade the framework and migrate the workspace | `pnpm asrun upgrade [--version <v>] [--dry-run] [--yes]` |
12
+ | Let an AI finish what an upgrade left to the project | `pnpm asrun upgrade --prompt`, or `/upgrade-workspace` |
12
13
  | Prepare or check the workspace | `pnpm asrun init [--update]`, `pnpm asrun doctor` |
13
14
  | See the effective rules and project settings | `pnpm asrun rules [--json] [--case <case-id>]` |
14
15
  | See or apply the workspace migrations after a manual install | `pnpm asrun migrate [--list] [--dry-run]` |
@@ -16,6 +17,7 @@ thin wrappers, so `pnpm asrun`, `pnpm signin` and the rest work as below. The sa
16
17
  | Start the web UI | `pnpm go`, `pnpm ui`, `pnpm ui:dev` |
17
18
  | See which profiles are logged in | `pnpm status [--env <env>]` |
18
19
  | Log in a profile (a person, with 2FA) | `pnpm signin <profile-id>` |
20
+ | Check that desktop notifications reach you | `pnpm asrun notify-test` |
19
21
  | Run tests | `pnpm asrun --env <env> [filters]` |
20
22
  | Open the last report | `pnpm report` |
21
23
  | Look around a page as a profile | `pnpm explore <profile-id> [path]` |
@@ -116,6 +118,23 @@ pnpm signin example-staging
116
118
  The login is done by a person, including 2FA; the session is saved to `.auth/<profile-id>.json` and verified. See
117
119
  [profiles.md](./profiles.md).
118
120
 
121
+ ## Notifications
122
+
123
+ ```bash
124
+ pnpm asrun notify-test
125
+ ```
126
+
127
+ The UI server shows a **desktop notification** when a run waits for you: an AI run handed a step to the browser window,
128
+ a login opened its window, or a run ended with open questions in the case file. It is the server's own call
129
+ (`osascript` on macOS, `notify-send` on Linux, a PowerShell toast on Windows), so it arrives whether or not the UI page
130
+ is open, focused or allowed to notify. The browser's bell is separate: it is for "a run ended", while the tab is in the
131
+ background (see [web-ui.md](./web-ui.md)).
132
+
133
+ `notify-test` shows one through the same code and prints the command it ran. Exit code 0 with nothing on screen means
134
+ the operating system dropped the notification, not that asrun failed: allow the app that runs the command (Terminal,
135
+ iTerm2, ...) in its notifications settings. The job log says `[no desktop notification shown: ...]` when the command
136
+ itself failed.
137
+
119
138
  ## Cases and checks
120
139
 
121
140
  ```bash
@@ -155,10 +174,12 @@ step, error, call log and screenshots for a case, and whether the report is stal
155
174
  first.
156
175
 
157
176
  `explore:notes` reads the **exploration note** that `/explore-case` writes for a case under
158
- `.explore/notes/<case-id>/<profile-id>.json`. It prints one line per profile (fresh or stale, the steps and targets it
159
- covers) and `--json` prints the parsed notes for a run that reads them. A note is local, git-ignored and optional:
177
+ `.explore/notes/<case-id>/<profile-id>.json`. It prints one line per profile (fresh or stale, `full` or `diff` with the
178
+ lead profile it leans on, and the steps and targets it covers) and `--json` prints the parsed notes for a run that reads
179
+ them. A note is local, git-ignored and optional:
160
180
  `/gen-spec` uses a fresh one to verify the locators and request patterns instead of rediscovering them, and replays the
161
- case when it is missing or stale. `--check` fails on a malformed note and warns about a stale one (it prints the current
181
+ case when it is missing or stale. `--check` fails on a malformed note and warns about a stale one or a `diff` note whose
182
+ lead has no note (it prints the current
162
183
  `caseSha256`); `--hash` prints that hash. Deleting `.explore/notes/` is always safe.
163
184
 
164
185
  ## Rules
@@ -224,15 +245,47 @@ pnpm asrun upgrade --version 0.9.0
224
245
  `installed -> target`;
225
246
  2. reads the **migration manifest of the target release out of its tarball, before installing anything**, and prints
226
247
  the migrations and breaking changes between the workspace's baseline (`.asrun.json` → `migrations.since`) and that
227
- release. A breaking change needs a person's confirmation (type `upgrade`); an unattended run passes `--yes`. A
228
- release that ships no manifest (it predates the manifest) cannot be checked in advance: `upgrade` says so and asks
229
- before it installs;
248
+ release. A breaking change needs a person's confirmation (`Proceed? [y/N]`, an empty line means no); an unattended
249
+ run passes `--yes` (or `-y`). A release that ships no manifest (it predates the manifest) cannot be checked in
250
+ advance: `upgrade` says so and asks before it installs;
230
251
  3. installs `@asrunjs/asrun@<target>` (it first exempts a fresh release from pnpm's release age, or pnpm would install
231
252
  the previous one);
232
253
  4. runs `init --update`, `migrate` and `doctor` through the package that was just installed.
233
254
 
234
255
  `--dry-run` stops after step 2. `--version` pins a release; `upgrade` never downgrades.
235
256
 
257
+ ### What an upgrade leaves to a person or an AI
258
+
259
+ `init --update` refreshes only the files the framework generates. A release can also change a file the *project* owns
260
+ (`AGENTS.md`, `CLAUDE.md`, `.mcp.json`, `opencode.jsonc`, `tsconfig.json`, `pnpm-workspace.yaml`, `profiles.yaml`,
261
+ `.claude/settings.json`, `cases/_template.md`) or ask for a manual migration step. Both are recorded, and `upgrade` ends
262
+ with a framed `NEXT` block that lists them with the exact command for each. When there is nothing to do it prints one
263
+ quiet line instead.
264
+
265
+ `pnpm asrun upgrade --prompt` prints that work as a task for an AI agent, and changes nothing (it reads the workspace
266
+ and the installed package only, so it works offline):
267
+
268
+ ```bash
269
+ pnpm asrun upgrade --prompt # the task, as Markdown
270
+ pnpm asrun upgrade --prompt --json # the same answer as JSON (the web UI uses this)
271
+ pnpm asrun migrate --prompt # the same, after a manual "pnpm add" + "init --update"
272
+ ```
273
+
274
+ In Claude Code or OpenCode, `/upgrade-workspace` does the same: it runs the command above and follows its output.
275
+
276
+ The prompt is the same for every release; the release-specific part is data — which migrations are outstanding, and
277
+ which project files the templates moved under. For each project file it says how to resolve it:
278
+
279
+ | File state | Meaning | Command |
280
+ |---|---|---|
281
+ | `replace` | the template changed and nobody edited the file | `pnpm asrun init --force <file>` |
282
+ | `merge` | the template and the project both changed it | merge the template's part, then `pnpm asrun init --ack <file>` |
283
+ | `unknown-base` | the workspace has no recorded template for the file | compare and report, then `pnpm asrun init --ack <file>` |
284
+ | `missing` | the template has the file and the workspace does not | `pnpm asrun init` |
285
+
286
+ `init --ack <file>` only records that the file is aligned with the installed templates; it never edits the file. The
287
+ baseline lives in `.asrun.json` (`scaffold`) and is committed, so the whole team sees the same state after a `git pull`.
288
+
236
289
  Every `asrun` command also checks the registry for a newer release, at most once a day (the answer is cached in `.tmp/`
237
290
  and shared with the web UI), and prints one line on stderr before it starts:
238
291
  `@asrunjs/asrun 0.8.0 is available (installed 0.7.0). Upgrade: pnpm asrun upgrade`. On a terminal the line carries an
@@ -257,11 +310,14 @@ can simply be repeated. A migration marked `auto` runs by itself; one with `manu
257
310
  until a person does it and acknowledges it. `init --update` never advances the baseline, which is why a manual
258
311
  `pnpm add -D @asrunjs/asrun@latest` followed by `pnpm asrun init --update` still leaves its migrations pending.
259
312
 
313
+ A migration that names project files (`files: ['AGENTS.md']`) also advances those files' template baseline when it is
314
+ acknowledged, so one command clears both the manual step and the `NEXT` block reminder about the file.
315
+
260
316
  ```bash
261
317
  pnpm asrun migrate # apply what the installed release brings
262
318
  pnpm asrun migrate --dry-run # show it, change nothing
263
319
  pnpm asrun migrate --list # every migration of the installed release, and its state
264
- pnpm asrun migrate --ack <id> # a manual step is done (--ack-all for every one)
320
+ pnpm asrun migrate --ack <id> # a manual step is done (--ack-all for every one); no file is touched
265
321
  pnpm asrun migrate --to <v> # stop at that release
266
322
  ```
267
323
 
@@ -271,20 +327,22 @@ migration is the installed package. `doctor` warns while migrations or manual st
271
327
  ## Workspace
272
328
 
273
329
  ```bash
274
- pnpm asrun init [--update] [--force] [--force <file>] [--reset-rules] [--root <dir>] [--no-env] [--no-extras] [--install [spec]]
330
+ pnpm asrun init [--update] [--force] [--force <file>] [--reset-rules] [--ack <file>] [--root <dir>] [--no-env] [--no-extras] [--install [spec]]
275
331
  pnpm asrun doctor
276
- pnpm asrun upgrade [--version <v>] [--dry-run] [--yes]
277
- pnpm asrun migrate [--list] [--dry-run] [--yes] [--ack <id>|--ack-all]
332
+ pnpm asrun upgrade [--version <v>] [--dry-run] [--yes|-y] [--prompt [--json]]
333
+ pnpm asrun migrate [--list] [--dry-run] [--yes|-y] [--ack <id>|--ack-all] [--prompt [--json]]
278
334
  pnpm asrun reset [--root <dir>] [--dry-run] [--yes] [--state] [--lockfile]
279
335
  ```
280
336
 
281
337
  `init` writes the workspace files; `--update` refreshes only the generated ones, and `--force` rewrites every file
282
338
  init writes (a single file with `--force <file>`). `--force` still keeps `profiles.yaml` and `package.json`; name
283
- `--force profiles.yaml` to replace profiles, and add `--reset-rules` to replace `AGENTS.md` or `CLAUDE.md`. It also
284
- adds the `allowBuilds` entries `pnpm-workspace.yaml` lacks, and installs the optional packages this machine lacks
285
- (`ffmpeg-static`; `--no-extras` skips them).
339
+ `--force profiles.yaml` to replace profiles, and add `--reset-rules` to replace `AGENTS.md` or `CLAUDE.md`. `--ack
340
+ <file>` records that a project-owned file is aligned with the installed templates without editing it (after a merge).
341
+ It also adds the `allowBuilds` entries `pnpm-workspace.yaml` lacks, and installs the optional packages this machine
342
+ lacks (`ffmpeg-static`; `--no-extras` skips them).
286
343
  `doctor` checks profiles, `tsconfig.json`, `playwright.config.ts`, the generated files, the installed version, the
287
- project rule table, the skill overrides and the workspace migrations. `upgrade` and `migrate` are described above.
344
+ project rule table, the skill overrides, the workspace migrations and the PDF renderer (a missing or unloadable
345
+ `pdfjs-dist` fails every PDF check). `upgrade` and `migrate` are described above.
288
346
 
289
347
  `reset` removes `node_modules` and the files `init` writes, for a clean reinstall. It deletes a once-only file only
290
348
  when it is still identical to its template, so edited files stay; `cases/`, specs, baselines, `profiles.yaml`,
package/docs/install.md CHANGED
@@ -6,7 +6,7 @@ and is never edited: a project commits only its own test content.
6
6
 
7
7
  ## Requirements
8
8
 
9
- - **Node.js 22 or newer** and **pnpm**.
9
+ - **Node.js 22.13 or newer** and **pnpm**.
10
10
  - **ffmpeg** joins the recordings of one test (a new browser tab has its own video) into one video for the report.
11
11
  `pnpm go` installs it with Homebrew on macOS or winget on Windows; on Linux run `sudo apt install ffmpeg`. When it is
12
12
  missing, `pnpm go` / `pnpm asrun init` install the bundled `ffmpeg-static` package into the workspace; if neither
@@ -123,10 +123,22 @@ pnpm asrun upgrade
123
123
  One command installs the newest release, refreshes the generated files to its templates, applies the workspace
124
124
  migrations and runs `doctor`. Before it installs anything it reads the migration manifest of the target release and
125
125
  prints the breaking changes and migrations between the workspace's baseline and that release; a breaking change needs
126
- a person's confirmation (type `upgrade`), and an unattended run passes `--yes`. `--dry-run` shows the same plan and
127
- changes nothing; `--version <v>` pins a release; it never downgrades.
126
+ a person's confirmation (`Proceed? [y/N]`, an empty line cancels), and an unattended run passes `--yes` (or `-y`).
127
+ `--dry-run` shows the same plan and changes nothing; `--version <v>` pins a release; it never downgrades.
128
128
 
129
- `upgrade` arrives with this release, so a workspace that predates it updates once by hand:
129
+ The upgrade ends with a `NEXT` block when the release changed something the project owns (`AGENTS.md`, `.mcp.json`,
130
+ `cases/_template.md`, ...) or recorded manual migration steps. Each line carries the exact command; an AI can do the
131
+ whole block:
132
+
133
+ ```bash
134
+ pnpm asrun upgrade --prompt
135
+ ```
136
+
137
+ That prints the task as Markdown and changes nothing. Paste it into any agent, or — in Claude Code or OpenCode — run
138
+ `/upgrade-workspace`, which does the same. The prompt is the same for every release: the release-specific part (which
139
+ files moved, which steps are open) is read from the workspace and the installed package.
140
+
141
+ `upgrade` arrives with this release, so a workspace that predates `upgrade` updates once by hand:
130
142
 
131
143
  ```bash
132
144
  pnpm add -D @asrunjs/asrun@latest
@@ -142,7 +154,8 @@ you when the workspace is behind the installed version, and what is still to mig
142
154
 
143
155
  An upgrade never replaces what belongs to the project: `cases/`, `tests/` with their baselines, `profiles.yaml`,
144
156
  `AGENTS.md`, `CLAUDE.md`, the skill overrides and `.claude/settings.json` stay as they are. A release that needs a
145
- project-owned file to change says so as a migration with manual steps.
157
+ project-owned file to change says so as a migration with manual steps, and the `NEXT` block (or
158
+ `pnpm asrun upgrade --prompt`) tells a person or an agent what to merge.
146
159
 
147
160
  To go back to an older release, install it yourself and refresh:
148
161
 
@@ -45,6 +45,23 @@ bundled `ffmpeg-static` package into the workspace; if neither works, the report
45
45
 
46
46
  **The UI port is busy** — set `ASRUN_UI_PORT` (and `ASRUN_UI_WEB_PORT` for `pnpm ui:dev`).
47
47
 
48
+ ## Notifications
49
+
50
+ **"It is my turn" showed no notification** — run `pnpm asrun notify-test`. It shows one notification through the same
51
+ code the UI server uses and prints the command behind it, so the two failures are told apart:
52
+
53
+ - It prints `No notification: ...` — the command itself failed (not installed, refused, timed out). The job log has the
54
+ same reason as `[no desktop notification shown: ...]`.
55
+ - It prints `Done` and nothing appeared — the operating system dropped it. Allow the app that runs it (Terminal, iTerm2,
56
+ the editor's terminal on macOS; Windows PowerShell on Windows) in the notifications settings of the system, and turn
57
+ off Focus assist / Do Not Disturb. asrun cannot see or fix this: the notification reached the system.
58
+ - Linux without `libnotify` has no `notify-send`: install it (`sudo apt install libnotify-bin`).
59
+
60
+ **A run that ended showed no browser notification** — that is the bell in the top bar, and it only fires while its tab is
61
+ in the background: the switch must be on, the browser allowed to notify for this site, and the browser itself allowed in
62
+ the operating system. Its tooltip says when the browser or the system refuses it. Nothing that waits for you depends on
63
+ it: a login, a handoff card and open questions are desktop notifications from the server.
64
+
48
65
  ## Cases and specs
49
66
 
50
67
  **`Missing values: cases/<id>.md has no "## Values" for profile ...`** — add the profile's values (or a `default`