diff --git a/.github/workflows/docs-links.yml b/.github/workflows/docs.yml similarity index 71% rename from .github/workflows/docs-links.yml rename to .github/workflows/docs.yml index bff30d29ed..f8e7882058 100644 --- a/.github/workflows/docs-links.yml +++ b/.github/workflows/docs.yml @@ -1,7 +1,7 @@ -name: Docs Links +name: Docs -# ci.yml ignores docs paths, so docs-only PRs need their own lane. The gate -# builds the site and checks the rendered pages, so the rspress version +# ci.yml ignores docs paths, so docs-only PRs need their own lane. The links +# gate builds the site and checks the rendered pages, so the rspress version # (website/package.json, the lockfile) and config re-run it as well. on: pull_request: @@ -12,12 +12,14 @@ on: - 'README.md' - 'scripts/check-doc-links.ts' - 'scripts/__tests__/check-doc-links.test.ts' + - 'scripts/check-doc-prose.ts' + - 'scripts/__tests__/check-doc-prose.test.ts' - 'scripts/check-affected/**' - 'package.json' - 'pnpm-lock.yaml' - '.github/actions/run-gate/action.yml' - '.github/actions/setup-node-pnpm/action.yml' - - '.github/workflows/docs-links.yml' + - '.github/workflows/docs.yml' push: branches: - main @@ -28,19 +30,21 @@ on: - 'README.md' - 'scripts/check-doc-links.ts' - 'scripts/__tests__/check-doc-links.test.ts' + - 'scripts/check-doc-prose.ts' + - 'scripts/__tests__/check-doc-prose.test.ts' - 'scripts/check-affected/**' - 'package.json' - 'pnpm-lock.yaml' - '.github/actions/run-gate/action.yml' - '.github/actions/setup-node-pnpm/action.yml' - - '.github/workflows/docs-links.yml' + - '.github/workflows/docs.yml' permissions: contents: read jobs: check: - name: Internal links and heading anchors + name: Prose, internal links, and heading anchors runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -50,6 +54,11 @@ jobs: - name: Setup Node and pnpm uses: ./.github/actions/setup-node-pnpm + # Reads the Markdown sources only, so it fails before the site build. + - name: Check docs prose + uses: ./.github/actions/run-gate + with: { gate: docs-prose } + # Builds the site (root install covers the website workspace), then checks it. - name: Check docs links uses: ./.github/actions/run-gate diff --git a/package.json b/package.json index 464144b351..6f5c187910 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "check:package": "node --experimental-strip-types scripts/check-package.ts", "check:command-docs": "vitest run --project unit-core src/__tests__/command-doc-coverage.test.ts", "check:doc-links": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/__tests__/check-doc-links.test.ts && pnpm --dir website build && node --experimental-strip-types scripts/check-doc-links.ts", + "check:doc-prose": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/__tests__/check-doc-prose.test.ts && node --experimental-strip-types scripts/check-doc-prose.ts", "check:agent-guidance": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/__tests__/agent-guidance-contract.test.ts", "check:replay-compat": "node --experimental-strip-types scripts/check-replay-compat-provenance.ts", "check:daemon-wire-compat": "node --experimental-strip-types scripts/wire-compat/run.ts", @@ -174,7 +175,7 @@ "check:quick": "pnpm lint && pnpm typecheck", "sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs", "check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check", - "check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:gate-manifest:test && pnpm check:gate-manifest && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:xctest-selection && pnpm check:packaged-runner-swift && pnpm check:mcp-metadata && pnpm check:doc-links && pnpm build && pnpm check:bundle-owner-files && pnpm check:package", + "check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:gate-manifest:test && pnpm check:gate-manifest && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:xctest-selection && pnpm check:packaged-runner-swift && pnpm check:mcp-metadata && pnpm check:doc-prose && pnpm check:doc-links && pnpm build && pnpm check:bundle-owner-files && pnpm check:package", "check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "npm_config_dry_run=false pnpm release:prepare", diff --git a/scripts/__tests__/check-doc-prose.test.ts b/scripts/__tests__/check-doc-prose.test.ts new file mode 100644 index 0000000000..6343d77bbb --- /dev/null +++ b/scripts/__tests__/check-doc-prose.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { checkDocProse, PROSE_RULES } from '../check-doc-prose.ts'; + +function hits(text: string): string[] { + return checkDocProse('page.md', text).map( + ({ line, column, word }) => `${line}:${column} ${word}`, + ); +} + +test('banned words match case-insensitively as whole words, with line and column', () => { + assert.deepEqual(hits('Simply run it.\nIt is Just fine, and EASY.'), [ + '1:1 simply', + '2:7 just', + '2:22 easy', + ]); + assert.deepEqual(hits('Justify the adjustment; robustness and uneasy are fine.'), []); +}); + +test('phrases match across any run of whitespace', () => { + assert.deepEqual(hits('Support is coming soon. As of now it is newly added.'), [ + '1:12 coming soon', + '1:26 as of now', + '1:42 newly added', + ]); +}); + +test('bare new, now, and current are allowed', () => { + assert.deepEqual(hits('The new session is now the current one.'), []); +}); + +test('each violation names the file, position, matched text, and reason', () => { + const [violation] = checkDocProse('README.md', 'It is robust.'); + assert.equal( + violation?.message, + 'README.md:1:7: "robust" — marketing adjective: state what the feature does instead', + ); +}); + +test('every rule is a lowercase word or phrase with a reason', () => { + for (const { word, reason } of PROSE_RULES) { + assert.match(word, /^[a-z]+(?: [a-z]+)*$/); + assert.ok(reason.length > 0, word); + } +}); + +test('YAML frontmatter at the top is skipped, but a later thematic break is prose', () => { + assert.deepEqual(hits('---\ntitle: Just a page\n---\nText.\n\n---\n\nSimply.'), ['8:1 simply']); +}); + +test('fenced code is skipped until a matching closer of at least the opening length', () => { + const text = [ + '````md', + 'just', + '```', + 'still code: simply', + '````', + '~~~', + 'easy', + '~~~~', + 'Prose: obviously.', + ].join('\n'); + assert.deepEqual(hits(text), ['9:8 obviously']); +}); + +test('a backtick fence is not closed by tildes', () => { + assert.deepEqual(hits('```\n~~~\njust\n```\nJust.'), ['5:1 just']); +}); + +test('a fence inside a blockquote is skipped through its quoted closer', () => { + assert.deepEqual(hits('> ```sh\n> just build\n> ```\n> Simply.'), ['4:3 simply']); +}); + +test('a fence inside an HTML comment does not swallow the prose after the comment', () => { + assert.deepEqual(hits('\nJust.'), ['4:1 just']); +}); + +test('inline code spans are skipped, matching backtick run lengths', () => { + assert.deepEqual(hits('Run `just build` or ``a ` just`` then just go.'), ['1:39 just']); +}); + +test('an inline code span that wraps across lines is skipped', () => { + assert.deepEqual(hits('Run `just\nsimply` here.'), []); +}); + +test('a stray backtick does not mask past the end of its paragraph', () => { + assert.deepEqual(hits('A ` tick.\n\nJust `x`.'), ['3:1 just']); +}); + +test('a phrase wrapped across lines matches at its first word', () => { + assert.deepEqual(hits('Support is coming\nsoon.'), ['1:12 coming soon']); +}); + +test('HTML tags, comments, link destinations, and bare URLs are skipped', () => { + const text = [ + 'Easy', + 'See [the guide](./just-start.md) and ![logo](robust.svg).', + 'Visit https://example.com/simply/easy now.', + 'Before after.', + ' robust', + ].join('\n'); + assert.deepEqual(hits(text), ['1:19 easy', '7:11 robust']); +}); + +test('link text is prose', () => { + assert.deepEqual(hits('[Simply start](./start.md)'), ['1:2 simply']); +}); + +test('CRLF line endings are scanned like LF', () => { + const text = ['---', 'title: Simply the best', '---', '', 'You just run it.', ''].join('\r\n'); + assert.deepEqual( + checkDocProse('page.md', text).map(({ line, word }) => `${line}:${word}`), + ['5:just'], + ); +}); diff --git a/scripts/check-affected/checks.ts b/scripts/check-affected/checks.ts index 7ea0d1406c..f543a17ba6 100644 --- a/scripts/check-affected/checks.ts +++ b/scripts/check-affected/checks.ts @@ -117,6 +117,7 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ gate('fixture-fallback', 'Fixture-app cache-failure fallback', 'test:fixture-fallback'), gate('command-docs', 'Command reference doc coverage', 'check:command-docs'), gate('docs-links', 'Docs internal links and heading anchors', 'check:doc-links'), + gate('docs-prose', 'Docs filler, marketing, and time-relative words', 'check:doc-prose'), gate('agent-guidance', 'Agent guidance ownership and context budgets', 'check:agent-guidance'), // Parses ios.yml and the Swift sources; no Xcode, no simulator, so it runs anywhere. gate( diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index 6e84359283..5036aca8dc 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -205,20 +205,25 @@ test('docs-only change selects no checks and records the docs paths', () => { assert.equal(result.docsOnlyPaths.length, 3); }); -test('user docs pages and README select the docs-links check', () => { +test('user docs pages and README select the docs-links and docs-prose checks', () => { for (const file of [ 'README.md', 'website/docs/index.md', 'website/docs/docs/sessions.md', 'website/docs/404.mdx', - 'website/package.json', - 'website/rspress.config.ts', ]) { const result = plan([file]); - assert.deepEqual(result.checks, ['docs-links'], file); + assert.deepEqual(result.checks, ['docs-links', 'docs-prose'], file); assert.deepEqual(result.docsOnlyPaths, []); } - assert.deepEqual(plan(['website/docs/docs/commands.md']).checks, ['command-docs', 'docs-links']); + for (const file of ['website/package.json', 'website/rspress.config.ts']) { + assert.deepEqual(plan([file]).checks, ['docs-links'], file); + } + assert.deepEqual(plan(['website/docs/docs/commands.md']).checks, [ + 'command-docs', + 'docs-links', + 'docs-prose', + ]); assert.deepEqual(plan(['website/docs/public/logo.svg']).checks, []); }); diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index b381872385..6721806f5c 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -76,6 +76,7 @@ export const ALL_CHECKS = [ 'fixture-fallback', 'command-docs', 'docs-links', + 'docs-prose', 'agent-guidance', 'xctest-selection', 'packaged-runner-swift', @@ -543,16 +544,13 @@ function isAgentGuidance(file: string): boolean { return AGENT_GUIDANCE.has(file) || file.startsWith('docs/agents/'); } -// The pages and README whose internal links and heading anchors `check:doc-links` resolves, and -// the website manifest and config that decide what the built site renders. -const DOC_LINKS_INPUTS = new Set([ - 'README.md', - 'website/package.json', - 'website/rspress.config.ts', -]); +// The user docs: README and the docs site pages. `check:doc-prose` reads them; `check:doc-links` +// resolves their links and also reruns when the website manifest or config that decide what the +// built site renders change. +const SITE_BUILD_INPUTS = new Set(['website/package.json', 'website/rspress.config.ts']); -function isLinkedDoc(file: string): boolean { - return DOC_LINKS_INPUTS.has(file) || (file.startsWith('website/docs/') && /\.mdx?$/.test(file)); +function isUserDocPage(file: string): boolean { + return file === 'README.md' || (file.startsWith('website/docs/') && /\.mdx?$/.test(file)); } const docsOwnership: OwnershipRule = ({ file }) => { @@ -577,7 +575,7 @@ const docsOwnership: OwnershipRule = ({ file }) => { ), ); } - if (isLinkedDoc(file)) { + if (isUserDocPage(file) || SITE_BUILD_INPUTS.has(file)) { selections.push( reason( 'docs-links', @@ -587,6 +585,16 @@ const docsOwnership: OwnershipRule = ({ file }) => { ), ); } + if (isUserDocPage(file)) { + selections.push( + reason( + 'docs-prose', + file, + 'own:docs-prose', + 'user docs avoid filler, marketing, and time-relative words', + ), + ); + } return selections; }; diff --git a/scripts/check-doc-prose.ts b/scripts/check-doc-prose.ts new file mode 100644 index 0000000000..6c02229c98 --- /dev/null +++ b/scripts/check-doc-prose.ts @@ -0,0 +1,129 @@ +// `pnpm check:doc-prose` — flags filler, marketing, and time-relative words in the user docs +// (website/docs/**/*.md(x) and README.md). Regex masks on purpose: no Markdown parser. It blanks +// YAML frontmatter, HTML comments, fenced code blocks, inline code spans, HTML tags, link +// destinations, and bare URLs, so commands and identifiers never trip a rule. Destinations with +// nested parentheses are not masked. +// +// There is no allowlist: a legitimate hit ('just' meaning 'only', a quoted message) gets its sentence +// rewritten. + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '..'); +const DOCS_DIR = 'website/docs'; + +const FILLER = 'condescending filler: if it were simple, the reader would not be reading the docs'; +const MARKETING = 'marketing adjective: state what the feature does instead'; +const TIME_RELATIVE = 'time-relative: rots as soon as it ships; state the behavior or a version'; + +/** Banned words and phrases, matched case-insensitively as whole words. */ +export const PROSE_RULES: readonly { readonly word: string; readonly reason: string }[] = [ + { word: 'just', reason: FILLER }, + { word: 'simply', reason: FILLER }, + { word: 'easy', reason: FILLER }, + { word: 'easily', reason: FILLER }, + { word: 'obvious', reason: FILLER }, + { word: 'obviously', reason: FILLER }, + { word: 'powerful', reason: MARKETING }, + { word: 'seamless', reason: MARKETING }, + { word: 'seamlessly', reason: MARKETING }, + { word: 'robust', reason: MARKETING }, + { word: 'intuitive', reason: MARKETING }, + { word: 'currently', reason: TIME_RELATIVE }, + { word: 'recently', reason: TIME_RELATIVE }, + { word: 'nowadays', reason: TIME_RELATIVE }, + { word: 'coming soon', reason: TIME_RELATIVE }, + { word: 'as of now', reason: TIME_RELATIVE }, + { word: 'newly added', reason: TIME_RELATIVE }, +]; + +export type ProseViolation = { + readonly line: number; + readonly column: number; + readonly word: string; + readonly message: string; +}; + +const RULE_PATTERNS = PROSE_RULES.map((rule) => ({ + ...rule, + pattern: new RegExp(String.raw`\b${rule.word.replaceAll(' ', String.raw`\s+`)}\b`, 'gi'), +})); + +const FRONTMATTER = /^---\n[\s\S]*?\n---(?=\n|$)/; +const HTML_COMMENT = //g; +// A fence line, optionally inside blockquotes: the marker run, then the info string or nothing. +const FENCE_LINE = /^ {0,3}(?:> ?)*(`{3,}|~{3,})(.*)$/; +// Spans that are not prose. Code spans stop at a blank line, as paragraphs do. +const NON_PROSE = [ + /(?]*>/g, // HTML or JSX tag + /\]\([^)]*\)/g, // link or image destination + /\b(?:https?:\/\/|mailto:|www\.)\S+/g, +]; + +/** Spaces in place of everything but newlines, so line and column math stays exact. */ +function blank(text: string): string { + return text.replaceAll(/[^\n]/g, ' '); +} + +/** Blank every fenced code block, fence lines included; an unclosed fence runs to the end. */ +function blankFences(text: string): string { + let fence: string | null = null; + const lines = text.split('\n').map((line) => { + const [, marker = '', info = ''] = FENCE_LINE.exec(line) ?? []; + if (fence === null && marker === '') return line; + if (fence === null) fence = marker; + else if (marker[0] === fence[0] && marker.length >= fence.length && info.trim() === '') { + fence = null; + } + return blank(line); + }); + return lines.join('\n'); +} + +function proseOnly(text: string): string { + const masked = blankFences(text.replace(FRONTMATTER, blank).replaceAll(HTML_COMMENT, blank)); + return NON_PROSE.reduce((prose, pattern) => prose.replaceAll(pattern, blank), masked); +} + +/** Violations in one Markdown or MDX file. `fileName` prefixes each message. */ +export function checkDocProse(fileName: string, text: string): ProseViolation[] { + // A Windows checkout with core.autocrlf has CRLF endings; the scan assumes LF. + const prose = proseOnly(text.replaceAll('\r\n', '\n')); + const violations = RULE_PATTERNS.flatMap(({ word, reason, pattern }) => + [...prose.matchAll(pattern)].map((match) => { + const line = prose.slice(0, match.index).split('\n').length; + const column = match.index - prose.lastIndexOf('\n', match.index - 1); + const where = `${fileName}:${line}:${column}`; + return { line, column, word, message: `${where}: "${match[0]}" — ${reason}` }; + }), + ); + return violations.sort((a, b) => a.line - b.line || a.column - b.column); +} + +function docFiles(): string[] { + const pages = (fs.readdirSync(path.join(REPO_ROOT, DOCS_DIR), { recursive: true }) as string[]) + .map((entry) => `${DOCS_DIR}/${entry.split(path.sep).join('/')}`) + .filter((file) => /\.mdx?$/.test(file)); + return ['README.md', ...pages.sort()]; +} + +function main(): number { + const files = docFiles(); + const violations = files.flatMap((file) => + checkDocProse(file, fs.readFileSync(path.join(REPO_ROOT, file), 'utf8')), + ); + if (violations.length === 0) { + process.stdout.write(`doc prose: ${files.length} files clean.\n`); + return 0; + } + for (const { message } of violations) process.stderr.write(`${message}\n`); + process.stderr.write( + `doc prose: ${violations.length} hit(s). Rewrite the sentence; the rules live in PROSE_RULES (scripts/check-doc-prose.ts).\n`, + ); + return 1; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) process.exit(main()); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 15bfd7f9e1..1deccfd4ca 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -285,7 +285,7 @@ agent-device prepare ios-runner --platform ios --timeout 240000 - After a successful build, agent-device removes the build scratch from the new cache key (intermediates, precompiled and module caches, logs) and keeps `Build/Products` and the metadata file, which is all reuse and launch read; one iOS simulator key measured 165.7 MB before the trim and 5.9 MB after. A key that fails certification is rebuilt from scratch either way, and a runner source or Xcode change mints a new key, so the scratch is never used again. Only the key this build gets with no path override is trimmed, compared by resolved real path; a `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` pointing anywhere else, including inside the managed runner cache, is never trimmed, because a fixed path rebuilds into the same tree incrementally. - agent-device versions from before the Apple runner rename kept their cache in `~/.agent-device/ios-runner`. Current versions never read it, and it is not removed automatically because an older version installed on the same machine may still use it. Delete it with `rm -rf ~/.agent-device/ios-runner` once no old version runs. - Runner reuse is authorized only by the cache metadata's content manifest: a restored tree whose files no longer match the recorded digests, modes, or symlink targets is discarded and rebuilt. A cache key must stay exact — the runtime never falls back to a broader cache. -- Every runner source or Xcode change creates a new cache key under `~/.agent-device/apple-runner/derived//`, where the folder names the platform and device kind (`ios-simulator`, `ios-device`, `tvos-simulator`, `tvos-device`, `macos`, `visionos-simulator`, `visionos-device`). After a build, agent-device deletes, on a best-effort basis and without delaying the start, keys beside the new one in the same folder that are neither among the 3 most recently used (the new key included) nor used in the last day, and never one a runner lease not proven dead points at or a build is holding. A key that cannot be deleted is left in place and reported as a `runner_xctestrun_cache_eviction_failed` warning diagnostic. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=` changes that count and `0` keeps every key. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never swept. +- Every runner source or Xcode change creates a new cache key under `~/.agent-device/apple-runner/derived//`, where the folder names the platform and device kind (`ios-simulator`, `ios-device`, `tvos-simulator`, `tvos-device`, `macos`, `visionos-simulator`, `visionos-device`). After a build, agent-device deletes, on a best-effort basis and without delaying the start, keys beside the new one in the same folder that are neither among the 3 last used (the new key included) nor used in the last day, and never one a runner lease not proven dead points at or a build is holding. A key that cannot be deleted is left in place and reported as a `runner_xctestrun_cache_eviction_failed` warning diagnostic. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=` changes that count and `0` keeps every key. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never swept. - Certification is fail-closed: when a product tree cannot be certified at all — a product escaping the derived-data root, an unreadable subtree, a file over 128 MB, or a non-regular entry such as a socket — the build fails with `runner_cache_uncertifiable` naming the path instead of launching uncertified bytes. Point `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` at a plain directory the current user owns; replacing the tree (the error's hint says how) clears a refusal. - Runner build/start output is written to the session's `runner.log`. The top-level `daemon.log` is reserved for daemon lifecycle/startup issues. @@ -758,7 +758,7 @@ agent-device settings permission reset screen-recording --platform macos - On macOS, `settings permission grant ...` checks or requests access and opens System Settings guidance when needed; it does not grant TCC permissions silently. - On macOS, `settings permission deny ...` is unsupported. - Android uses `pm grant|revoke` for runtime permissions (`reset` maps to revoke) and `appops` for notifications. Every permission mutation names the foreground user explicitly (`--user `, resolved with `am get-current-user`): `pm` defaults these operations to user 0, so on a device whose foreground user is nonzero an unscoped revoke would edit user 0 and leave the running app's permission untouched. Resolving that user is a prerequisite — if `am get-current-user` does not answer, `settings permission` fails with `COMMAND_FAILED` and changes nothing rather than applying the mutation to user 0. -- Android kills a running app whenever a runtime permission it currently holds is revoked, so `settings permission deny|reset` after a grant leaves the session app no longer running. The response reports the prior state of the revoked permission for the acting user as `priorGrantState: granted | not_granted | unknown`, and carries a warning naming `open --relaunch` for both `granted` and `unknown` — `unknown` means the device did not report a readable state, not that the app was left alone. Revoking a permission the app does not hold (`not_granted`) is harmless and warns nothing. +- Android kills a running app whenever a runtime permission it holds is revoked, so `settings permission deny|reset` after a grant leaves the session app no longer running. The response reports the prior state of the revoked permission for the acting user as `priorGrantState: granted | not_granted | unknown`, and carries a warning naming `open --relaunch` for both `granted` and `unknown` — `unknown` means the device did not report a readable state, not that the app was left alone. Revoking a permission the app does not hold (`not_granted`) is harmless and warns nothing. - `full|limited` mode is supported only for iOS `photos`; other targets reject mode. - Use `match`/`nonmatch` to simulate valid/invalid Face ID, Touch ID, and Android fingerprint outcomes. diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index 8c3f78f8f5..c76c2e3fcc 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -135,4 +135,4 @@ The daemon refuses to start when it finds a legacy lock file or can't verify who Packaged installs use `~/.agent-device` by default; source checkouts use a per-worktree directory under `~/.agent-device/dev/`. Set `AGENT_DEVICE_STATE_DIR` or pass `--state-dir` to override either default. -In a source checkout, `pnpm clean:daemon --prune-dev` first stops the current checkout's daemon, even if its state directory was used recently. It then finds development state directories with no activity for 14 days (by the newest modification time of the directory and its immediate children) and removes daemon registrations it can confirm are abandoned. It keeps the directories, session artifacts, and logs. +In a source checkout, `pnpm clean:daemon --prune-dev` first stops the current checkout's daemon; the 14-day inactivity rule below does not apply to it. It then finds development state directories with no activity for 14 days (by the newest modification time of the directory and its immediate children) and removes daemon registrations it can confirm are abandoned. It keeps the directories, session artifacts, and logs.