From 226492fb17b15e74f119b51764e8f6f2177aa7db Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 18:42:35 -0700 Subject: [PATCH 1/2] Audit companion contracts and rationales; follow docs imports on Windows --- docs/prose-audit.md | 6 ++- docs/specs/website-docs.md | 2 +- package.json | 2 +- scripts/docs-surfaces.mjs | 26 ++++++++++++ scripts/docs-surfaces.test.mjs | 30 +++++++++++++ scripts/prose-audit.mjs | 30 +++++++++---- scripts/prose-audit.test.mjs | 77 ++++++++++++++++++++++++++++++++++ scripts/public-docs-lint.mjs | 22 ++-------- 8 files changed, 164 insertions(+), 31 deletions(-) create mode 100644 scripts/docs-surfaces.mjs create mode 100644 scripts/docs-surfaces.test.mjs create mode 100644 scripts/prose-audit.test.mjs diff --git a/docs/prose-audit.md b/docs/prose-audit.md index 7a5a9f8ac..810ee0ce4 100644 --- a/docs/prose-audit.md +++ b/docs/prose-audit.md @@ -11,13 +11,15 @@ pnpm audit:prose pnpm audit:prose --changed=origin/main ``` -The full pass proves corpus coverage. The changed pass selects any spec whose text or referenced code changed from the given base, plus working-tree changes. Use the stacked PR's actual base branch rather than assuming `main`. `--json` emits machine-readable results. +The full pass inventories the corpus; it does not prove that its claims match implementation. The changed pass selects any spec whose text, rationale, or code referenced by either changed from the given base, plus working-tree changes. Use the stacked PR's actual base branch rather than assuming `main`. `--json` emits machine-readable results. + +The inventory includes `AGENTS.md`, `SECURITY.md`, `SELF_HOST.md`, and `docs/compatible-agents.md`, plus each existing rationale and its references. Imported implementation files still need manual inspection: changed selection follows explicit references, not transitive imports. The command is dependency-free and advisory. Its thresholds intentionally favor recall: a hit is a review prompt, not a lint failure or permission to delete text. The default report caps each spec's detail; add `--all` or `--json` to inspect every hit. ## Review one cluster -1. Read each spec and the rationale sections matching the headings being touched. +1. Read each spec and its paired rationale. Review every behavior-bearing section, including sections with no advisory hit; follow implementation imports and check both spec claims against code and relevant code branches against the spec. 2. Inspect every resolved reference. Resolve any reported file-like reference manually; ambiguity often means the pointer itself can be clearer. A `Files` / `Code Map` section should offer useful entrypoints to follow through imports, while section-local `Source of truth:` pointers locate particular rules. Keep both when they serve those distinct jobs; check map paths and role descriptions against code without requiring exhaustive coverage or a map in every spec. 3. Give each hit one disposition: diff --git a/docs/specs/website-docs.md b/docs/specs/website-docs.md index bcc2f2561..785245f36 100644 --- a/docs/specs/website-docs.md +++ b/docs/specs/website-docs.md @@ -317,7 +317,7 @@ state and clear WCAG AA both at rest and on hover** (rationale). WCAG AA against the surface carrying it; never dim text with opacity** (rationale). `docsMutedTextForSurfaces` and `website/src/lib/docs-accent.test.ts` pin the base and every registered tinted surface composition across bundled themes; -`checkNoDimmedDocsText` pins the call sites, allowlisting what is not text. +`checkNoDimmedDocsText` pins the call sites, allowlisting what is not text. **Must follow transitive relative imports with repository-relative paths on every platform.** `scripts/docs-surfaces.test.mjs` pins the graph traversal. **Must prompt a reader to pick a theme until they answer, and dismiss both responsive placements together.** Picking one and closing the prompt both count. diff --git a/package.json b/package.json index 3d3e92f6d..be79415cf 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build:hosted": "pnpm --filter dormouse-hosted build", "test:hosted": "pnpm --filter remote-lib-common build && pnpm --filter dormouse-hosted test", "build": "pnpm run build:vscode && pnpm --filter dormouse-lib build:pocket && pnpm --filter dormouse-website build && pnpm build:hosted", - "test": "node scripts/spec-lint.mjs && node scripts/spec-lint-selftest.mjs && node scripts/public-docs-lint.mjs && node scripts/xterm-lint.mjs && node --test scripts/xterm-bump.test.mjs && node scripts/loopback-lint.mjs && node scripts/loopback-lint-selftest.mjs && node scripts/deploy-lint.mjs && node scripts/deploy-lint-selftest.mjs && node scripts/installer-verify-test.mjs && node scripts/ps1-cmdlet-lint.mjs && node scripts/ps1-cmdlet-lint-selftest.mjs && node scripts/e2e-lint.mjs && node scripts/e2e-lint-selftest.mjs && node scripts/clamp-issue-body-selftest.mjs && node --test scripts/sign-and-deploy.test.mjs && node --test scripts/workflow-audit.test.mjs && node --test scripts/pairing-walkthrough/proc.test.mjs && node --test scripts/security-audit.test.mjs && pnpm --filter dormouse-hosted test:deploy && pnpm --filter dormouse-hosted test:miniflare && pnpm -r --filter \"!dormouse-hosted\" run test", + "test": "node scripts/spec-lint.mjs && node scripts/spec-lint-selftest.mjs && node --test scripts/prose-audit.test.mjs scripts/docs-surfaces.test.mjs && node scripts/public-docs-lint.mjs && node scripts/xterm-lint.mjs && node --test scripts/xterm-bump.test.mjs && node scripts/loopback-lint.mjs && node scripts/loopback-lint-selftest.mjs && node scripts/deploy-lint.mjs && node scripts/deploy-lint-selftest.mjs && node scripts/installer-verify-test.mjs && node scripts/ps1-cmdlet-lint.mjs && node scripts/ps1-cmdlet-lint-selftest.mjs && node scripts/e2e-lint.mjs && node scripts/e2e-lint-selftest.mjs && node scripts/clamp-issue-body-selftest.mjs && node --test scripts/sign-and-deploy.test.mjs && node --test scripts/workflow-audit.test.mjs && node --test scripts/pairing-walkthrough/proc.test.mjs && node --test scripts/security-audit.test.mjs && pnpm --filter dormouse-hosted test:deploy && pnpm --filter dormouse-hosted test:miniflare && pnpm -r --filter \"!dormouse-hosted\" run test", "lint:specs": "node scripts/spec-lint.mjs && node scripts/spec-lint-selftest.mjs", "lint:public-docs": "node scripts/public-docs-lint.mjs", "audit:prose": "node scripts/prose-audit.mjs", diff --git a/scripts/docs-surfaces.mjs b/scripts/docs-surfaces.mjs new file mode 100644 index 000000000..5741e6113 --- /dev/null +++ b/scripts/docs-surfaces.mjs @@ -0,0 +1,26 @@ +import { posix } from 'node:path'; + +/** Follow relative TypeScript imports from the route-owned entrypoints. + * Git paths use forward slashes on every OS; native path.join on Windows + * silently drops imported components from the checked documentation surfaces. + */ +export function collectDocsSurfaces(seeds, files, read) { + const tracked = new Set(files); + const seen = new Set(); + const queue = [...seeds]; + while (queue.length > 0) { + const rel = queue.shift(); + if (seen.has(rel) || !tracked.has(rel)) continue; + seen.add(rel); + for (const [, spec] of read(rel).matchAll(/from\s+["'](\.[^"']+)["']/g)) { + const resolved = posix.join(posix.dirname(rel), spec); + for (const ext of ['.tsx', '.ts']) { + if (tracked.has(resolved + ext)) queue.push(resolved + ext); + } + } + } + return { + surfaces: [...seen].filter(rel => rel.endsWith('.tsx')).sort(), + missingSeeds: seeds.filter(rel => !tracked.has(rel)), + }; +} diff --git a/scripts/docs-surfaces.test.mjs b/scripts/docs-surfaces.test.mjs new file mode 100644 index 000000000..ed6afe650 --- /dev/null +++ b/scripts/docs-surfaces.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { collectDocsSurfaces } from './docs-surfaces.mjs'; + +test('docs checks reach nested components through TS bridges and cyclic imports on every OS', () => { + const source = { + 'website/src/pages/Guide.tsx': `import { theme } from '../lib/theme'; import { Page } from '../components/Page';`, + 'website/src/lib/theme.ts': `export { ThemeControl } from '../components/ThemeControl';`, + 'website/src/components/Page.tsx': `import { Guide } from '../pages/Guide'; import React from 'react';`, + 'website/src/components/ThemeControl.tsx': '', + }; + const reads = []; + const result = collectDocsSurfaces(['website/src/pages/Guide.tsx'], Object.keys(source), rel => { + reads.push(rel); + return source[rel]; + }); + assert.deepEqual(result, { + surfaces: ['website/src/components/Page.tsx', 'website/src/components/ThemeControl.tsx', 'website/src/pages/Guide.tsx'], + missingSeeds: [], + }); + assert.equal(new Set(reads).size, reads.length); +}); + +test('missing seeds are reported and absent imports are never read', () => { + const source = { 'src/Page.tsx': `import { Absent } from './Absent'; import { Package } from 'package';` }; + assert.deepEqual(collectDocsSurfaces(['src/Page.tsx', 'src/Missing.tsx'], Object.keys(source), rel => { + assert.ok(Object.hasOwn(source, rel)); + return source[rel]; + }), { surfaces: ['src/Page.tsx'], missingSeeds: ['src/Missing.tsx'] }); +}); diff --git a/scripts/prose-audit.mjs b/scripts/prose-audit.mjs index 65ad9c4d1..3d973f84e 100644 --- a/scripts/prose-audit.mjs +++ b/scripts/prose-audit.mjs @@ -16,7 +16,7 @@ const unknown = args.filter((arg) => !['--all', '--json', '--help', '--changed'] if (args.includes('--help')) { console.log(`Usage: pnpm audit:prose [--changed[=]] [--all] [--json] -Without --changed, audits every spec and the code files it references. +Without --changed, audits every spec, companion contract, rationale, and referenced code. --changed audits specs or references changed from (default: origin/main), including staged, unstaged, and untracked files. --all expands the concise report. Findings are advisory.`); @@ -29,7 +29,7 @@ if (unknown.length > 0) { const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); const git = (...gitArgs) => execFileSync('git', gitArgs, { cwd: ROOT, encoding: 'utf8' }); -const tracked = git('ls-files').trim().split('\n').filter(Boolean); +const tracked = git('ls-files', '--cached', '--others', '--exclude-standard').trim().split('\n').filter(Boolean); const trackedSet = new Set(tracked); const basenameIndex = new Map(); for (const rel of tracked) { @@ -43,7 +43,7 @@ const specFiles = readdirSync(join(ROOT, 'docs/specs')) .filter((name) => name.endsWith('.md') && !name.endsWith('.rationale.md')) .map((name) => `docs/specs/${name}`) .sort(); -specFiles.push('SELF_HOST.md'); +specFiles.push('AGENTS.md', 'SECURITY.md', 'SELF_HOST.md', 'docs/compatible-agents.md'); const budgets = JSON.parse(read('scripts/spec-word-budgets.json')); const codeExtensions = new Set(SOURCE_EXTENSIONS.map((ext) => `.${ext}`)); const extension = (rel) => /\.[^.\/]+$/.exec(rel)?.[0] ?? ''; @@ -211,27 +211,38 @@ function changedFiles(base) { let reports = specFiles.map((spec) => { const text = read(spec); - const rationale = spec === 'SELF_HOST.md' ? null : spec.replace(/\.md$/, '.rationale.md'); - const { refs, unresolved } = referencesOf(spec, text); + const rationalePath = spec.replace(/\.md$/, '.rationale.md'); + const rationaleText = existsSync(join(ROOT, rationalePath)) ? read(rationalePath) : null; + const specReferences = referencesOf(spec, text); + const rationaleReferences = rationaleText === null ? { refs: [], unresolved: [] } : referencesOf(rationalePath, rationaleText); + const refs = [...new Set([...specReferences.refs, ...rationaleReferences.refs])].sort(); + const unresolved = [...new Set([...specReferences.unresolved, ...rationaleReferences.unresolved])].sort(); const prose = proseCandidates(text); - const code = codeCandidates(text, refs); + const rationale = rationaleText === null ? null : { + path: rationalePath, + words: wordCount(rationaleText), + references: rationaleReferences.refs, + unresolved: rationaleReferences.unresolved, + prose: proseCandidates(rationaleText), + }; + const code = codeCandidates(text + '\n' + (rationaleText ?? ''), refs); return { spec, words: wordCount(text), budget: budgets[spec], - rationale: rationale && existsSync(join(ROOT, rationale)) ? { path: rationale, words: wordCount(read(rationale)) } : null, + rationale, references: refs, unresolved, prose, code, - score: prose.length + code.reduce((sum, file) => sum + file.findings.length, 0), + score: prose.length + (rationale?.prose.length ?? 0) + code.reduce((sum, file) => sum + file.findings.length, 0), }; }); if (changedArg) { const base = changedArg.includes('=') ? changedArg.slice(changedArg.indexOf('=') + 1) : 'origin/main'; const changed = changedFiles(base); - reports = reports.filter((report) => changed.has(report.spec) || changed.has(report.rationale?.path) || report.references.some((ref) => changed.has(ref))); + reports = reports.filter((report) => changed.has(report.spec) || changed.has(report.spec.replace(/\.md$/, '.rationale.md')) || report.references.some((ref) => changed.has(ref))); } if (json) { @@ -245,6 +256,7 @@ for (const report of reports) { const rationale = report.rationale ? `; rationale ${report.rationale.words}w` : ''; console.log(`${report.spec} — ${report.words}/${budget}w; ${report.references.length} refs${rationale}; ${report.score} hit(s)`); const hits = report.prose.map((hit) => ({ ...hit, path: report.spec })); + for (const hit of report.rationale?.prose ?? []) hits.push({ ...hit, path: report.rationale.path }); for (const file of report.code) { for (const hit of file.findings) hits.push({ ...hit, path: file.path }); } diff --git a/scripts/prose-audit.test.mjs b/scripts/prose-audit.test.mjs new file mode 100644 index 000000000..8ef1e7700 --- /dev/null +++ b/scripts/prose-audit.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, appendFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +const source = dirname(fileURLToPath(import.meta.url)); +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'dormouse-prose-audit-')); + assert.equal(dirname(root), tmpdir()); + t.after(() => rmSync(root, { recursive: true, force: true })); + const put = (path, text) => { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), text); + }; + for (const name of ['prose-audit.mjs', 'spec-md.mjs']) { + mkdirSync(join(root, 'scripts'), { recursive: true }); + copyFileSync(join(source, name), join(root, 'scripts', name)); + } + put('scripts/spec-word-budgets.json', '{}'); + put('docs/specs/example.md', '# Example\n\nSee \x60src/direct.ts\x60.\n'); + put('src/direct.ts', 'export const direct = true;\n'); + put('src/rationale.ts', 'export const rationale = true;\n'); + for (const path of ['AGENTS.md', 'SECURITY.md', 'SELF_HOST.md', 'docs/compatible-agents.md']) put(path, '# Contract\n'); + put('docs/specs/example.rationale.md', '# Rationale\n\nSee \x60src/rationale.ts\x60.\n\n' + 'Measured because this boundary matters. '.repeat(35) + '\n'); + put('SELF_HOST.rationale.md', '# Installer evidence\n\nSee \x60src/rationale.ts\x60.\n'); + const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + git('init', '-q'); + git('add', '.'); + git('-c', 'user.name=Audit Fixture', '-c', 'user.email=audit@example.invalid', 'commit', '-qm', 'fixture'); + const run = (...args) => JSON.parse(execFileSync(process.execPath, ['scripts/prose-audit.mjs', '--json', ...args], { cwd: root, encoding: 'utf8' })); + return { root, run, git }; +} + +test('full inventory includes companion contracts and installer rationale', t => { + const { run } = fixture(t); + const { specs } = run(); + assert.deepEqual(specs.map(report => report.spec).sort(), ['AGENTS.md', 'SECURITY.md', 'SELF_HOST.md', 'docs/compatible-agents.md', 'docs/specs/example.md'].sort()); + assert.equal(specs.find(report => report.spec === 'SELF_HOST.md').rationale.path, 'SELF_HOST.rationale.md'); +}); + +test('rationale prose and its source references participate in the inventory', t => { + const { run } = fixture(t); + const report = run().specs.find(report => report.spec === 'docs/specs/example.md'); + assert.deepEqual(report.references, ['src/direct.ts', 'src/rationale.ts']); + assert.deepEqual(report.rationale.references, ['src/rationale.ts']); + assert.ok(report.rationale.prose.some(hit => hit.kind === 'LONG')); + assert.ok(report.rationale.prose.some(hit => hit.kind === 'RATIONALE')); + assert.equal(report.score, report.prose.length + report.rationale.prose.length + report.code.reduce((sum, file) => sum + file.findings.length, 0)); +}); + +test('changed selection includes code referenced only by rationale', t => { + const { root, run } = fixture(t); + appendFileSync(join(root, 'src/rationale.ts'), 'export const changed = true;\n'); + assert.deepEqual(run('--changed=HEAD').specs.map(report => report.spec).sort(), ['SELF_HOST.md', 'docs/specs/example.md']); +}); + +test('removed rationale still selects its owning spec', t => { + const { root, run } = fixture(t); + unlinkSync(join(root, 'docs/specs/example.rationale.md')); + const reports = run('--changed=HEAD').specs; + assert.deepEqual(reports.map(report => report.spec), ['docs/specs/example.md']); + assert.equal(reports[0].rationale, null); +}); + +test('changed selection resolves a newly present untracked referenced source', t => { + const { root, run, git } = fixture(t); + appendFileSync(join(root, 'docs/specs/example.md'), 'See \x60src/new.ts\x60.\n'); + git('add', 'docs/specs/example.md'); + git('-c', 'user.name=Audit Fixture', '-c', 'user.email=audit@example.invalid', 'commit', '-qm', 'future reference'); + writeFileSync(join(root, 'src/new.ts'), '// Newly implemented source.\n'); + const reports = run('--changed=HEAD').specs; + assert.deepEqual(reports.map(report => report.spec), ['docs/specs/example.md']); + assert.ok(reports[0].references.includes('src/new.ts')); +}); diff --git a/scripts/public-docs-lint.mjs b/scripts/public-docs-lint.mjs index 22a870f87..f6b2007a2 100644 --- a/scripts/public-docs-lint.mjs +++ b/scripts/public-docs-lint.mjs @@ -24,6 +24,7 @@ import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { repoRoot, readRepoFile, trackedFiles } from './lint-kit.mjs'; +import { collectDocsSurfaces } from './docs-surfaces.mjs'; import { hasScheme, inlineToText, @@ -78,24 +79,9 @@ function docsSurfaces() { `${WEBSITE_SRC}/components/DocsLayout.tsx`, ...DOCS_PAGES.map((page) => `${WEBSITE_SRC}/${page.module.replace(/^\.\//, '')}`), ]; - const tracked = new Set(trackedFiles()); - const seen = new Set(); - const queue = [...seed]; - while (queue.length > 0) { - const rel = queue.shift(); - if (seen.has(rel) || !tracked.has(rel)) continue; - seen.add(rel); - for (const [, spec] of readRepoFile(rel).matchAll(/from\s+["'](\.[^"']+)["']/g)) { - const resolved = join(dirname(rel), spec); - for (const ext of ['.tsx', '.ts']) { - if (tracked.has(resolved + ext)) queue.push(resolved + ext); - } - } - } - for (const rel of seed) { - if (!tracked.has(rel)) fail(`${rel}: docsSurfaces seed names a file that does not exist`); - } - return [...seen].filter((rel) => rel.endsWith('.tsx')).sort(); + const { surfaces, missingSeeds } = collectDocsSurfaces(seed, trackedFiles(), readRepoFile); + for (const rel of missingSeeds) fail(`${rel}: docsSurfaces seed names a file that does not exist`); + return surfaces; } /** Each source read once and parsed once, then shared by every check. */ From 5587eac6ffa9942a8831d724e9d25446b4a40eeb Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 18:42:35 -0700 Subject: [PATCH 2/2] Reconcile Surface and Workspace specs; preserve Tool recovery and drop ownership --- docs/specs/glossary.md | 28 ++++++------- docs/specs/layout.md | 40 ++++++++----------- docs/specs/layout.rationale.md | 6 +-- docs/specs/shortcuts.md | 8 ++-- docs/specs/tiling-engine.md | 35 ++++++++-------- lib/src/components/Wall.tsx | 1 + lib/src/components/WorkspaceWindow.test.tsx | 28 +++++++++++++ .../wall/WorkspaceSelectionOverlay.tsx | 2 +- .../wall/keyboard/handle-dual-tap.ts | 2 +- lib/src/components/wall/lath-wall-store.ts | 8 ++-- .../wall/use-session-persistence.ts | 6 ++- lib/src/lib/lath/layout.ts | 4 +- lib/src/lib/ring-geometry.ts | 2 +- lib/src/lib/session-restore.test.ts | 26 ++++++++++++ lib/src/lib/session-restore.ts | 2 +- scripts/spec-word-budgets.json | 4 +- 16 files changed, 124 insertions(+), 78 deletions(-) diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 9b4e2f098..60acd9f26 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -16,7 +16,7 @@ A **Surface** is the durable occupant of a Pane — the content in a slot. Three A **Pane** is one Lath leaf, a slot in the tiling layout (`docs/specs/tiling-engine.md`); `lib/src/components/Wall.tsx` owns Panes and Surfaces both. -A Pane holds exactly one Surface today, but the model reserves several (a future in-pane surface strip), so **`dor` targets content — `read` / `send` / `await` / `kill` — by Surface ref (`surface:N`)**, holding Pane refs back for layout-only commands (rationale). +A Pane holds one primary Surface today; helpers share its Pane ([Containers](#containers)). The model reserves several primary Surfaces per Pane (a future in-pane surface strip), so **`dor` targets content by Surface ref (`surface:N`)**, reserving Pane refs for layout-only commands (rationale). **Surface kinds** — the `kind` a `dor` handle reports, derived from the Pane's params, never stored on the id: @@ -30,13 +30,9 @@ A Pane holds exactly one Surface today, but the model reserves several (a future | Surface | Persisted `surfaceType` (`docs/specs/transport.md`) | `renderMode` (`docs/specs/dor-browser.md`) | CLI `kind` | CLI `render_mode` | |---|---|---|---|---| -| tool Session | `'tool'` | `iframe`, `agent-browser-screencast` or `playwright-screencast` when serving | `tool` | renderer or `null` | +| tool Session | `'tool'` | Tool renderer when serving | `tool` | renderer or `null` | | terminal Session | `'terminal'` (default, omitted) | — | `terminal` | `null` | -| browser · iframe | `'browser'` | `iframe` | `browser` | `iframe` | -| browser · screencast | `'browser'` | `agent-browser-screencast` | `browser` | `agent-browser-screencast` | -| browser · popped out | `'browser'` | `agent-browser-popout` | `browser` | `agent-browser-popout` | -| browser · playwright screencast | `'browser'` | `playwright-screencast` | `browser` | `playwright-screencast` | -| browser · playwright popout | `'browser'` | `playwright-popout` | `browser` | `playwright-popout` | +| browser Surface | `'browser'` | Browser renderer | `browser` | same as `renderMode` | **Kinds are capability sets, not exclusive categories** — terminal and browser carry one capability each, `tool` both. **Operations gate on the capability they need, never on the kind enum** ([Liskov contract](#liskov-contract)): `read` / `send` / `await` / port scans need the terminal, nav / render-mode / agent-browser verbs the browser. **`dor list --json` rows always emit `has_terminal` and `has_browser`** (rationale). **Must declare each kind's capabilities in the `hasTerminal` / `hasBrowser` table.** Persistence keeps its own `PersistedSurfaceType` discriminant (`docs/specs/transport.md`). @@ -59,7 +55,7 @@ The containment hierarchy `dor` handles commit to (`docs/specs/dor-cli.md`): Window ⊃ Workspace ⊃ Pane ⊃ Surface (terminal = Session | browser) ``` -**Surface identity:** a primary Surface's id is its Lath leaf id; a helper receives its Lath leaf only on promotion. A terminal Surface's *is* its `SessionId`, stable (I1); browser replacement and relaunch have different identity effects (I10). +**Surface identity:** a primary Surface's id is its Lath leaf id; a helper receives its Lath leaf only on promotion. A terminal Surface's id is its `SessionId`, stable (I1); browser replacement and relaunch have different identity effects (I10). ## Containers @@ -153,7 +149,7 @@ A **Session** is the tuple of its `SessionId` plus one state per layer (I1). | `Paned` | Rendered in the content area: a primary Lath leaf or its shown auxiliary helper | | `Zoomed` | Subset of `Paned` — the passthrough-focused pane is maximized; acquiring zoom gives focus, losing focus returns it to `Paned` | | `Doored` | Rendered as a door on the baseboard. DOM survival is a rendering decision, not part of this state: browser DOM retention follows **parking** (`docs/specs/tiling-engine.md` → "Parked leaves"); a terminal Surface unmounts its element (Registry: `Orphaned`) and remounts the same xterm on reattach — nothing replays | -| `Hidden` | In neither pane nor door — webview closed or mid-transition. A Surface in a hidden Workspace is **not** `Hidden`: it stays `Paned` or `Doored`, mounted and live. Process and Activity unaffected. | +| `Hidden` | In neither Pane nor Door — webview closed or mid-transition. A Surface in a hidden Workspace retains its `Paned` or `Doored` View state; Registry behavior follows `docs/specs/layout.md` → Workspace lifecycle. Process and Activity unaffected. | ### Link @@ -214,7 +210,7 @@ A system verb is a lifecycle transition driven by the runtime. | Verb | Effect | |---|---| | `register` / `dispose` | Create / destroy a Registry entry | -| `release` | Destroy a Registry entry **without** killing its Process (Registry: Mounted → Disposed, Process: Live → Live). The one verb a `transferWorkspace` runs, and never reachable from an unmount. | +| `release` | Destroy a `Mounted` or `Orphaned` Registry entry **without** killing its `Live` or `Exited` Process. Explicit Workspace transfer and refused-adoption cleanup only, never an unmount. | | `mount` / `unmount` | Attach / detach the persistent DOM element (low-level op; the Registry entry survives `unmount`). A **parked** leaf stays mounted while `Doored` or `Hidden` (`docs/specs/tiling-engine.md` → "Parked leaves") | | `exit` | Host observes process death (Process: Live → Exited) | | `resume` | Webview reopens over retained PTYs (Link: Severed → Resuming → Live; Registry rebuilt from replay data; Process stays Live/Exited) | @@ -227,14 +223,14 @@ A system verb is a lifecycle transition driven by the runtime. | Category | Valid when | Examples | |---|---|---| -| **Universal** | any state combination | `kill`, `rename`, state queries | -| **View-gated** | `View ≠ Hidden` | `focusSession` | +| **Universal** | no layer-state gate | `kill`, `rename`, state queries | +| **View-gated** | `View ≠ Hidden` | `focusSession(id, true)` | | **Process-gated** | `Process = Live` | `writePty`, `resizePty` | | **Registry-gated** | `Registry = Mounted` | `refitSession` | | **Terminal-gated** | Surface has a terminal ([Panes and Surfaces](#panes-and-surfaces)) | `dor read` / `send` / `await`, port scans | | **Browser-gated** | Surface has a browser | browser nav / render-mode ops | -**Must check the relevant precondition for gated operations; universal operations accept every layer state.** Missing Registry entries silently no-op, while `dor` capability gates return an error. Uniform typed precondition errors are staged ([Future](#future)). +**Must satisfy gated operations' layer preconditions at the call site or in the operation; universal operations have no layer-state gate.** Missing Registry entries silently no-op, while `dor` capability gates return an error. Uniform typed precondition errors are staged ([Future](#future)). Source of truth: `focusSession` / `refitSession` in `lib/src/lib/terminal-lifecycle.ts`; `requireTerminalSurface` / `requireBrowserSurface` in `lib/src/components/wall/use-dor-control.ts`. @@ -242,9 +238,9 @@ Source of truth: `focusSession` / `refitSession` in `lib/src/lib/terminal-lifecy - I1: `SessionId` is immutable for the life of a Session and stable across `resume` / `restore`. - I2: Process state is independent of Registry, View, and Link. A `Live` process may be `Doored` or `Hidden`; an `Exited` process may still be `Paned`. -- I3: Activity state survives `minimize` / `reattach`. `ALERT_RINGING` fires only on a *fresh* transition, never on `mount` or `reattach`. +- I3: **Must preserve Activity through the layout-only `minimize` / `reattach` transition.** User engagement may acknowledge a ring (`docs/specs/alert.md` → Engagement). `ALERT_RINGING` fires only on a fresh transition, never on `mount` or `reattach`. - I4: `Registry: Orphaned` outlives no Session state except `View: Doored` or a Surface in a hidden Workspace — at rest every other entry is `Mounted` or `Disposed`, so an `Orphaned` entry that is neither is a leak. -- I5: `kill` is universally valid and always ends at `View: Hidden`; its per-kind effects are the [User verbs](#user-verbs) row. +- I5: `kill` accepts every layer state; a successful closure ends at `View: Hidden`. Closure guards belong to `docs/specs/terminal-context.md` → Promotion and source closure and `docs/specs/dor-tool.md` → Closing unsaved Tools; per-kind effects are the [User verbs](#user-verbs) row. - I6: `rename` is universally valid including when `Process = Exited` and `View = Doored`. - I7: Every Surface sits in exactly one Pane; every Pane and its Surfaces belong to exactly one Workspace; every Workspace belongs to one Window. - I8: **Must preserve Process and Activity during `switchWorkspace`, without firing a fresh ring** (I3). A switch reattaches terminal elements but resumes and restores nothing, so no ring can fire (`docs/specs/layout.md` → Workspaces). @@ -280,7 +276,7 @@ Remote-only vocabulary (**Viewer**, and the wire-level `DirectoryEntry` projecti - A persisted type is `Persisted` where `` is the glossary noun (`PersistedPane`, `PersistedDoor`, `PersistedWorkspace`, `PersistedWindow`). - A handle type is `State` (`ActivityState`, not `SessionUiState`). - Surface kinds are lowercase strings; [Panes and Surfaces](#panes-and-surfaces) is canonical for how persisted `surfaceType`, `renderMode`, and CLI `kind` / `render_mode` relate. -- Container names are `PascalCase` nouns (`Workspace`, `Window`); a Workspace's id type is `WorkspaceId`. A Window carries no id today — `dor` addresses it only through the reserved `window:` ref (`docs/specs/dor-cli.md`), with `WindowId` reserved for when it needs one. Container verbs suffix the container (`createWorkspace`, `switchWorkspace`), distinct from the layer-agnostic Session `rename`. +- Container names are `PascalCase` nouns (`Workspace`, `Window`); a Workspace's id type is `WorkspaceId`. `dor` addresses a Window by its host label (`window: