diff --git a/doc/launch/issue-16/README.md b/doc/launch/issue-16/README.md new file mode 100644 index 00000000..a5b0e04d --- /dev/null +++ b/doc/launch/issue-16/README.md @@ -0,0 +1,76 @@ +# Public capture kit + +This directory defines the sanitized source material and recording contract for +Markover's focused-preview screenshots and demo. It supports roadmap issue #16 +without using a developer's real reviews, repositories, thread history, or +application state. + +## Prepare the capture instance + +Start from the exact clean commit that should appear in the capture receipt, +then run: + +```sh +npm run capture:stage +``` + +The command resets only its marked directory at +`/tmp/markover-public-capture`, creates five invented reviews across three +disposable Git repositories, applies Ember Light, builds an addressed Markover +bundle, and launches it with production branding. It uses the distinct +`markover-capture` scheme, suppresses protocol registration, and never reads or +writes canonical or development review state. + +Use `npm run capture:stage -- --prepare-only` when validating the fixture +without launching the app. The generated `session.json` records the exact +commit, fixture revision, theme, window size, and stable review IDs. + +## Capture stills + +The four replacement screenshots keep their existing names and Retina contract +so current README and Pages consumers do not need an asset migration. Arrange +these real UI states in the prepared app: + +1. `markover-review-editor@2x.png` — Inbox, Needs me, primary launch brief, + heading selected, feedback and both labeled attachments visible. +2. `markover-annotation-browser@2x.png` — Projects, All annotations, primary + launch brief with varied feedback and attachment context. +3. `markover-source-edit@2x.png` — primary opening paragraph selected, Source + expanded, word-level proposal visible. +4. `markover-review-context@2x.png` — primary review with Review context open, + showing only invented project, pull request, commit, and thread metadata. + +Each still is 2360×1520 at 144 dpi, captured from the app's 1180×760 logical +window at 2× scale. Keep notifications and unrelated application chrome out of +frame. + +## Record the demo + +Follow [demo-storyboard.md](demo-storyboard.md). Use `capture:cli` for every +agent-facing Markover command shown in the recording; it addresses only the +running capture service and refuses to start the canonical app implicitly. + +```sh +npm --silent run capture:cli -- get mko_capture01 \ + | jq -f doc/launch/issue-16/handoff-summary.jq +``` + +Export a silent, captioned H.264 MP4 at 1920×1080, 30 fps, 30–60 seconds, with +fast-start metadata. The capture PR records the final output path and checksums; +the later media PR owns the actual screenshot and movie binaries. + +## Safety checks + +Before publishing any visual: + +- confirm `session.json` names the intended clean commit; +- confirm Ember + Light and the 1180×760 logical window; +- scan every visible path, repository, pull request, review ID, thread ID, and + machine label against `capture-manifest.json`; +- inspect the four stills and every movie frame for notifications, private + paths, credentials, authorization material, or unrelated application chrome; +- run the deterministic checks documented by the media PR before upload. + +The marker file is the deletion boundary. The staging command refuses to +replace an unmarked directory, a symlink, or a fixture whose capture service is +still running. diff --git a/doc/launch/issue-16/capture-manifest.json b/doc/launch/issue-16/capture-manifest.json new file mode 100644 index 00000000..098cb839 --- /dev/null +++ b/doc/launch/issue-16/capture-manifest.json @@ -0,0 +1,85 @@ +{ + "format": "markover-public-capture", + "version": 1, + "status": "foundation", + "source": { + "redesignPullRequest": 195, + "designBrief": "doc/design/markover-design-brief.md", + "fixtureRevision": 1, + "captureCommit": null + }, + "staging": { + "command": "npm run capture:stage", + "stateRoot": "/tmp/markover-public-capture", + "appName": "Markover", + "scheme": "markover-capture", + "palette": "ember", + "appearance": "light", + "registersProtocol": false, + "usesNetworkContent": false + }, + "window": { + "logicalWidth": 1180, + "logicalHeight": 760, + "pixelWidth": 2360, + "pixelHeight": 1520, + "densityDpi": 144, + "scaleFactor": 2 + }, + "fixture": { + "projectRoot": "/tmp/markover-public-capture/projects", + "repositories": [ + "https://github.com/markover-demo/atlas-studio.git", + "https://github.com/markover-demo/field-research.git", + "https://github.com/markover-demo/review-protocol.git" + ], + "machine": "capture-mac", + "threadHost": "t3code", + "provider": "codex", + "notifications": "disabled" + }, + "screenshots": [ + { + "filename": "markover-review-editor@2x.png", + "state": "inbox-needs-me-editor-with-two-labeled-attachments", + "consumers": ["README.md", "docs/user/index.html"] + }, + { + "filename": "markover-annotation-browser@2x.png", + "state": "projects-all-annotations-with-attachment-context", + "consumers": ["docs/user/index.html"] + }, + { + "filename": "markover-source-edit@2x.png", + "state": "expanded-source-card-with-word-level-proposal", + "consumers": ["docs/user/index.html"] + }, + { + "filename": "markover-review-context@2x.png", + "state": "review-context-with-sanitized-project-and-thread-metadata", + "consumers": ["docs/user/index.html"] + } + ], + "movie": { + "minimumSeconds": 30, + "maximumSeconds": 60, + "width": 1920, + "height": 1080, + "framesPerSecond": 30, + "videoCodec": "h264", + "audio": false, + "fastStart": true, + "captions": "burned-in", + "transcript": "required" + }, + "privacy": { + "allowOnlyInventedFixtureData": true, + "forbiddenContent": [ + "real home-directory paths", + "real repositories or pull requests", + "real review or thread identifiers", + "credentials or authorization material", + "notifications or unrelated application chrome" + ] + } +} diff --git a/doc/launch/issue-16/demo-storyboard.md b/doc/launch/issue-16/demo-storyboard.md new file mode 100644 index 00000000..1cf6fe97 --- /dev/null +++ b/doc/launch/issue-16/demo-storyboard.md @@ -0,0 +1,77 @@ +# Markover focused-preview demo storyboard + +## Recording setup + +- Prepare the exact clean capture commit with `npm run capture:stage`. +- Record only the sanitized capture instance and a clean terminal at 1920×1080, + 30 fps. +- Keep Ember Light selected, notifications off, and all unrelated application + chrome out of frame. +- Export a silent H.264 MP4 between 30 and 60 seconds with burned-in captions + and fast-start metadata. +- Use calm, descriptive language. Do not imply release guarantees or platform + support beyond the current repository documentation. + +## Timeline + +### 0:00–0:07 — Begin with the review inbox + +Show the prepared Inbox with **Needs me** active and open **Launch readiness +brief**. Let the three-pane Ember Light layout establish the review, selected +document block, and feedback pane. + +Caption: **Review agent-written Markdown as a document tree.** + +### 0:07–0:19 — Add feedback with visual context + +Select the heading and reveal its feedback with the two fixture-owned images, +**workflow overview** and **annotation details**. Keep their rewritten Markdown +references visible long enough to read. + +Caption: **Attach labeled visual context to one exact block.** + +### 0:19–0:30 — Propose an exact source change + +Select the opening paragraph and expand Source. Show the existing proposal that +narrows the rollout from everyone to the design-partner group. Hold on the real +word-level diff. + +Caption: **Propose an exact edit without changing the source document.** + +### 0:30–0:39 — Review every annotation together + +Switch to Projects and then **All annotations**. Briefly show the four varied +annotations and their block context in the redesigned browser. + +Caption: **Scan the complete review before handing it back.** + +### 0:39–0:52 — Retrieve one structured handoff + +Return to the terminal and run: + +```sh +npm --silent run capture:cli -- get mko_capture01 \ + | jq -f doc/launch/issue-16/handoff-summary.jq +``` + +Show the projected status, feedback, attachment IDs and labels, and source +proposal. The capture CLI addresses only the fixture service and does not start +or inspect canonical Markover state. + +Caption: **Return one structured handoff to the agent.** + +### 0:52–0:56 — End card + +Show the Markover lockup, **Structured review for Markdown**, and +`github.com/lastobelus/markover`. + +## Pages transcript + +Markover opens a sanitized launch brief in an Ember Light review inbox. The +reviewer selects one Markdown block, adds precise feedback, and keeps two +labeled images attached to that context. On the opening paragraph, Markover +shows an exact source proposal as a word-level diff without changing the source +document. The reviewer switches to All annotations to scan the complete review, +then returns to the agent's terminal. One fixture-only command retrieves a +structured handoff containing the feedback, attachment labels, and source +proposal. diff --git a/doc/launch/issue-16/handoff-summary.jq b/doc/launch/issue-16/handoff-summary.jq new file mode 100644 index 00000000..b9200472 --- /dev/null +++ b/doc/launch/issue-16/handoff-summary.jq @@ -0,0 +1,32 @@ +def reviewed_blocks: + [ + .. + | objects + | select((.id? // "") | startswith("block-")) + | select( + ((.feedback? // "") | length) > 0 + or (.sourceEdit? != null) + ) + | { id } + + (if ((.feedback? // "") | length) > 0 + then { feedback } + else {} + end) + + (if ((.attachments? // []) | length) > 0 + then { + attachments: [ + .attachments[] | { id, label } + ] + } + else {} + end) + + (if .sourceEdit? != null + then { sourceEdit } + else {} + end) + ]; + +{ + review: { status: .review.status }, + blocks: reviewed_blocks +} diff --git a/doc/launch/issue-16/launch-brief.md b/doc/launch/issue-16/launch-brief.md new file mode 100644 index 00000000..bd044a5a --- /dev/null +++ b/doc/launch/issue-16/launch-brief.md @@ -0,0 +1,25 @@ +# Launch readiness brief + +Ship the review pilot to everyone as soon as the first workflow is complete. + +## Success criteria + +- Every handoff names an owner and next action. +- Review feedback stays attached to the relevant Markdown block. +- Source proposals remain explicit and reversible. +- Attachments use clear labels. +- The pilot expands only after the loop is reliable. + +## Rollout plan + +Start with the design-partner group, observe three complete review loops, and +record blocked or deferred work before expanding. + +| Signal | Ready when | +| --- | --- | +| Ownership | Every note has a named owner | +| Reliability | Three handoffs complete without recovery | + +```ts +await openReview({ retry: true }) +``` diff --git a/package.json b/package.json index f20b6061..fd4702d8 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "build": "npm run clean && tsc -p tsconfig.build.json && node build/scripts/copy-build-assets.js && node build/scripts/build-app.js", "build:icon:mac": "npm run build --silent && node build/scripts/build-macos-icon.js", "build:cli": "npm run build --silent && node build/scripts/build-cli.js", + "capture:cli": "npm run build --silent && node build/scripts/capture-cli.js", + "capture:stage": "npm run build --silent && node build/scripts/capture-media.js", "clean": "node -e \"require('node:fs').rmSync('build', { recursive: true, force: true })\"", "package:mac": "install-electron --no && npm run build:icon:mac && npm run notices:check:built && node build/scripts/package-macos.js --trust-mode=ad-hoc", "release:preflight": "npm run build --silent && node build/scripts/release-preflight.js", diff --git a/scripts/capture-cli.ts b/scripts/capture-cli.ts new file mode 100644 index 00000000..84d9cfc4 --- /dev/null +++ b/scripts/capture-cli.ts @@ -0,0 +1,53 @@ +import path from 'node:path' + +import { + CAPTURE_ROOT, + CAPTURE_ROOT_MARKER, + CAPTURE_SESSION_RECEIPT +} from './capture-media' +import { main as markoverMain } from './markover' +import { probeService } from '../src/local-client' + +async function requireCaptureService(): Promise { + const endpointPath = path.join(CAPTURE_ROOT, 'service.json') + try { + await probeService(endpointPath) + } catch { + throw new Error('The capture instance is not running. Start it with npm run capture:stage.') + } +} + +async function main(args = process.argv.slice(2)): Promise { + delete process.env.CLAUDE_CODE_SESSION_ID + delete process.env.CLAUDE_SESSION_ID + delete process.env.CODEX_THREAD_ID + delete process.env.T3CODE_THREAD_ID + delete process.env.T3_THREAD_ID + process.env.MARKOVER_INVOCATION = 'npm --silent run capture:cli --' + await Promise.all([ + import('node:fs/promises').then(({ access }) => access(path.join( + CAPTURE_ROOT, + CAPTURE_ROOT_MARKER + ))), + import('node:fs/promises').then(({ access }) => access(path.join( + CAPTURE_ROOT, + CAPTURE_SESSION_RECEIPT + ))) + ]).catch(() => { + throw new Error('The capture fixture is missing. Prepare it with npm run capture:stage.') + }) + await markoverMain(args, { + endpointPath: path.join(CAPTURE_ROOT, 'service.json'), + ensure: requireCaptureService, + loadRemoteProfile: () => Promise.resolve(null), + settingsPath: path.join(CAPTURE_ROOT, 'settings.json') + }) +} + +if (require.main === module) { + void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`markover capture: ${message}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/capture-media.ts b/scripts/capture-media.ts new file mode 100644 index 00000000..6f3d752d --- /dev/null +++ b/scripts/capture-media.ts @@ -0,0 +1,795 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +import { + addressedDevelopmentExecutable, + buildAddressedDevelopmentBundle +} from './development-bundle' +import { + captureResolvedInstance, + developmentStateRoot, + parseResolvedInstance, + RESOLVED_INSTANCE_ENVIRONMENT, + resolvedInstanceEnvironment, + type ResolvedInstance +} from '../src/instance' +import { probeService } from '../src/local-client' +import { SUPPRESS_PROTOCOL_REGISTRATION_ENVIRONMENT } from '../src/protocol-registration' +import { reviewChecksum } from '../src/review-format' +import { ReviewStore, type ReviewArtifact } from '../src/review-store' +import { serviceDirectory } from '../src/service-endpoint' +import { SettingsStore } from '../src/settings-store' +import { findNode, parseMarkdown, visitNodes } from '../src/tree' +import { WorkspaceStore } from '../src/workspace-store' + +export const CAPTURE_FIXTURE_REVISION = 1 +export const CAPTURE_ROOT = '/tmp/markover-public-capture' +export const CAPTURE_ROOT_MARKER = '.markover-public-capture-root.json' +export const CAPTURE_SESSION_RECEIPT = 'session.json' + +const fixtureFormat = 'markover-public-capture-root' +const sessionFormat = 'markover-public-capture-session' +const fixtureTimestamp = '2026-08-29T12:00:00.000Z' +const captureReviewIds = [ + 'mko_capture01', + 'mko_capture02', + 'mko_capture03', + 'mko_capture04', + 'mko_capture05' +] as const +const sessionEnvironmentKeys = [ + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_SESSION_ID', + 'CODEX_THREAD_ID', + 'T3CODE_THREAD_ID', + 'T3_THREAD_ID' +] as const + +interface CaptureSource { + commit: string + dirty: false +} + +export interface CaptureSessionReceipt { + appearance: 'light' + commit: string + dirty: false + fixtureRevision: number + format: typeof sessionFormat + generatedAt: string + palette: 'ember' + reviewIds: string[] + stateRoot: string + version: 1 + window: { + height: 760 + width: 1180 + } +} + +interface CaptureRepository { + branch: string + commit: string + logicalRoot: string + name: string + remote: string + sources: Record +} + +interface CaptureReviewDefinition { + branch: string + contextSummary: string + document: string + id: string + project: CaptureRepository + pullRequestNumber: number + status: 'editing' | 'pending-agent' + threadId: string + threadTitle: string +} + +export interface PrepareCaptureOptions { + checkout: string + generatedAt?: Date + root?: string + serviceRunning?: (endpointPath: string) => Promise + source: CaptureSource +} + +export interface PreparedCapture { + instance: ResolvedInstance + receipt: CaptureSessionReceipt +} + +function command( + workingDirectory: string, + executable: string, + args: string[], + environment: NodeJS.ProcessEnv = process.env +): string { + return execFileSync(executable, args, { + cwd: workingDirectory, + encoding: 'utf8', + env: environment, + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +export function captureSource(checkout: string): CaptureSource { + const commit = command(checkout, 'git', ['rev-parse', 'HEAD']) + const status = command(checkout, 'git', [ + 'status', + '--porcelain', + '--untracked-files=all' + ]) + if (!/^[a-f0-9]{40}$/.test(commit)) { + throw new Error('Capture requires an exact Git commit.') + } + if (status) { + throw new Error('Capture requires a clean Git worktree; commit or remove every change first.') + } + return { commit, dirty: false } +} + +function captureMarker(): string { + return `${JSON.stringify({ + format: fixtureFormat, + version: 1, + fixtureRevision: CAPTURE_FIXTURE_REVISION + }, null, 2)}\n` +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch (error) { + if ( + error !== null && + typeof error === 'object' && + Reflect.get(error, 'code') === 'ENOENT' + ) return false + throw error + } +} + +async function defaultServiceRunning(endpointPath: string): Promise { + try { + await probeService(endpointPath) + return true + } catch { + return false + } +} + +async function assertResettableCaptureRoot( + root: string, + serviceRunning: (endpointPath: string) => Promise +): Promise { + let stats + try { + stats = await fs.lstat(root) + } catch (error) { + if ( + error !== null && + typeof error === 'object' && + Reflect.get(error, 'code') === 'ENOENT' + ) return + throw error + } + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error(`Capture root is not an owned directory: ${root}`) + } + const markerPath = path.join(root, CAPTURE_ROOT_MARKER) + const marker = await fs.readFile(markerPath, 'utf8').catch(() => '') + if (marker !== captureMarker()) { + throw new Error(`Refusing to replace an unrecognized capture root: ${root}`) + } + if (await serviceRunning(path.join(root, 'service.json'))) { + throw new Error('The existing capture instance is running; quit Markover before resetting it.') + } +} + +function safeCaptureRoot(value: string): string { + const root = path.resolve(value) + if ( + !path.isAbsolute(value) || + !path.basename(root).startsWith('markover-public-capture') || + root === path.parse(root).root + ) { + throw new Error(`Unsafe capture root: ${value}`) + } + return root +} + +async function writeSource( + physicalRoot: string, + relativePath: string, + source: string +): Promise { + const filePath = path.join(physicalRoot, relativePath) + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, source, 'utf8') +} + +async function createRepository({ + branch, + logicalRoot, + name, + remote, + sources +}: Omit, physicalRoot: string): Promise { + await fs.mkdir(physicalRoot, { recursive: true }) + for (const [relativePath, source] of Object.entries(sources)) { + await writeSource(physicalRoot, relativePath, source) + } + command(physicalRoot, 'git', ['init', '--quiet', `--initial-branch=${branch}`]) + command(physicalRoot, 'git', ['config', 'user.name', 'Markover Capture']) + command(physicalRoot, 'git', ['config', 'user.email', 'capture@example.invalid']) + command(physicalRoot, 'git', ['remote', 'add', 'origin', remote]) + command(physicalRoot, 'git', ['add', '.']) + const gitEnvironment = { + ...process.env, + GIT_AUTHOR_DATE: fixtureTimestamp, + GIT_COMMITTER_DATE: fixtureTimestamp + } + command( + physicalRoot, + 'git', + ['commit', '--quiet', '-m', `Seed ${name} capture fixture`], + gitEnvironment + ) + return { + branch, + commit: command(physicalRoot, 'git', ['rev-parse', 'HEAD']), + logicalRoot, + name, + remote, + sources + } +} + +function attachmentSvg(title: string, accent: string, detail: string): Buffer { + return Buffer.from(` + + + + + ${title} + ${detail} + + + +\n`, 'utf8') +} + +function nodeByText(tree: ReviewTree, text: string): ReviewNode { + const matches: ReviewNode[] = [] + visitNodes(tree.root, (node) => { + if (node.text.includes(text)) matches.push(node) + }) + const match = matches[0] + if (!match) throw new Error(`Capture fixture node not found: ${text}`) + return match +} + +function supportingSource(title: string, paragraphs: string[]): string { + return [ + `# ${title}`, + '', + ...paragraphs.flatMap((paragraph) => [paragraph, '']) + ].join('\n') +} + +async function createT3TitleDatabase( + physicalPath: string, + titles: Array<{ id: string; title: string }> +): Promise { + await fs.mkdir(path.dirname(physicalPath), { recursive: true }) + const database = new DatabaseSync(physicalPath) + try { + database.exec(` + CREATE TABLE projection_threads ( + thread_id TEXT PRIMARY KEY, + title TEXT, + deleted_at TEXT + ) + `) + const insert = database.prepare(` + INSERT INTO projection_threads (thread_id, title, deleted_at) + VALUES (?, ?, NULL) + `) + for (const title of titles) insert.run(title.id, title.title) + } finally { + database.close() + } +} + +function threadMetadata(threadId: string): ReviewAgentThread { + return { + id: `agent_${threadId}`, + threadHost: { + kind: 't3code', + provider: 'codex', + threadId, + machine: 'capture-mac' + } + } +} + +async function seedReview( + store: ReviewStore, + definition: CaptureReviewDefinition, + logicalRoot: string, + physicalRoot: string +): Promise { + const source = definition.project.sources[definition.document] + if (source === undefined) throw new Error(`Missing fixture source ${definition.document}.`) + const logicalPath = path.join(definition.project.logicalRoot, definition.document) + const tree = parseMarkdown(source, reviewChecksum(source), { + name: definition.document, + path: logicalPath + }) + + if (definition.id === captureReviewIds[0]) { + const heading = nodeByText(tree, 'Launch readiness brief') + const criteria = nodeByText(tree, 'Success criteria') + const table = nodeByText(tree, 'Signal | Ready when') + const code = nodeByText(tree, 'await openReview') + const opening = nodeByText(tree, 'Ship the review pilot') + heading.feedback = '**Tighten the opening before the pilot.** Compare [!img-1] with [!img-2] and name the audience explicitly.' + criteria.feedback = 'Make the met, blocked, and deferred signals measurable before the pilot expands.' + table.feedback = 'Keep the manual-file workflow visible here so the launch brief does not imply automation we have not shipped.' + code.feedback = 'Explain why this retry is idempotent and what the agent should retain between attempts.' + opening.sourceEdit = { + original: opening.raw, + current: 'Ship a focused pilot to the design-partner group, then expand only after the review loop is reliable and every handoff names an owner.' + } + } else { + nodeByText(tree, definition.document === 'release-notes.md' + ? 'Release notes' + : definition.document === 'participant-guide.md' + ? 'Participant guide' + : definition.document === 'interview-synthesis.md' + ? 'Interview synthesis' + : 'Review protocol').feedback = definition.status === 'editing' + ? 'Clarify the next action and name the person who owns it.' + : 'This handoff is with the agent; keep the supporting evidence attached.' + } + + const artifact = await store.create({ + tree, + contextSummary: definition.contextSummary, + agentThread: threadMetadata(definition.threadId), + git: { + repositoryUrl: definition.project.remote, + branch: definition.branch, + commit: definition.project.commit + }, + pullRequest: { + number: definition.pullRequestNumber, + url: `${definition.project.remote.replace(/\.git$/, '')}/pull/${String(definition.pullRequestNumber)}` + }, + pullRequestStatus: 'open' + }) + + if (definition.id === captureReviewIds[0]) { + const first = await store.saveAttachmentFile( + artifact.review.id, + 'svg', + attachmentSvg('Workflow overview', '#c94e1f', 'Open → annotate → hand off') + ) + const second = await store.saveAttachmentFile( + artifact.review.id, + 'svg', + attachmentSvg('Annotation details', '#6d211f', 'Block-level feedback with evidence') + ) + const current = await store.load(artifact.review.id) + const heading = findNode(current.root, nodeByText(tree, 'Launch readiness brief').id) + if (!heading) throw new Error('Primary capture heading is unavailable.') + heading.attachments = [ + { + id: first.id, + type: 'image', + label: 'workflow overview', + path: path.join(logicalRoot, path.relative(physicalRoot, first.path)), + mimeType: 'image/svg+xml' + }, + { + id: second.id, + type: 'image', + label: 'annotation details', + path: path.join(logicalRoot, path.relative(physicalRoot, second.path)), + mimeType: 'image/svg+xml' + } + ] + await store.updateTree(artifact.review.id, current) + } + if (definition.status === 'pending-agent') { + return store.transition(artifact.review.id, 'pending-agent', 'open') + } + return store.load(artifact.review.id) +} + +function reviewViewState(selectedBlockId: string | null): WorkspaceReviewViewState { + return { + selectedBlockId, + annotatedOnly: false, + annotationView: 'selected', + sourceCollapsed: false, + collapsedBlockIds: [] + } +} + +async function fixtureRepositories( + physicalRoot: string, + logicalRoot: string, + launchBriefSource: string +): Promise { + const definitions = [ + { + name: 'atlas-studio', + branch: 'feature/review-pilot', + remote: 'https://github.com/markover-demo/atlas-studio.git', + sources: { + 'launch-readiness.md': launchBriefSource, + 'release-notes.md': supportingSource('Release notes', [ + 'Describe the focused preview without implying signing, notarization, or Intel support.', + 'Link every claim to the tested release evidence.' + ]) + } + }, + { + name: 'field-research', + branch: 'research/pilot-notes', + remote: 'https://github.com/markover-demo/field-research.git', + sources: { + 'participant-guide.md': supportingSource('Participant guide', [ + 'Invite design partners to review one short Markdown brief.', + 'Ask participants to label screenshots before returning feedback.' + ]), + 'interview-synthesis.md': supportingSource('Interview synthesis', [ + 'The document tree kept long review sessions oriented.', + 'Participants wanted the handoff owner visible at every step.' + ]) + } + }, + { + name: 'review-protocol', + branch: 'docs/structured-handoff', + remote: 'https://github.com/markover-demo/review-protocol.git', + sources: { + 'review-protocol.md': supportingSource('Review protocol', [ + 'Open once, retain the review ID, and stop for human feedback.', + 'Retrieve the complete handoff before revising the source.' + ]) + } + } + ] + const repositories: CaptureRepository[] = [] + for (const definition of definitions) { + const repositoryPhysicalRoot = path.join(physicalRoot, 'projects', definition.name) + const repositoryLogicalRoot = path.join(logicalRoot, 'projects', definition.name) + repositories.push(await createRepository({ + ...definition, + logicalRoot: repositoryLogicalRoot + }, repositoryPhysicalRoot)) + } + return repositories +} + +async function seedCaptureState( + physicalRoot: string, + logicalRoot: string, + source: CaptureSource, + generatedAt: Date, + launchBriefSource: string +): Promise { + const repositories = await fixtureRepositories( + physicalRoot, + logicalRoot, + launchBriefSource + ) + const byName = new Map(repositories.map((repository) => [repository.name, repository])) + const atlas = byName.get('atlas-studio') as CaptureRepository + const field = byName.get('field-research') as CaptureRepository + const protocol = byName.get('review-protocol') as CaptureRepository + const definitions: CaptureReviewDefinition[] = [ + { + id: captureReviewIds[0], + project: atlas, + document: 'launch-readiness.md', + contextSummary: 'Prepare the Atlas Studio focused review pilot.', + branch: atlas.branch, + pullRequestNumber: 42, + status: 'editing', + threadId: 'thread_public_atlas', + threadTitle: 'Shape the focused review pilot' + }, + { + id: captureReviewIds[1], + project: atlas, + document: 'release-notes.md', + contextSummary: 'Check focused-preview release language.', + branch: atlas.branch, + pullRequestNumber: 42, + status: 'pending-agent', + threadId: 'thread_public_atlas', + threadTitle: 'Shape the focused review pilot' + }, + { + id: captureReviewIds[2], + project: field, + document: 'participant-guide.md', + contextSummary: 'Refine the design-partner participant guide.', + branch: field.branch, + pullRequestNumber: 18, + status: 'editing', + threadId: 'thread_public_research', + threadTitle: 'Prepare design-partner research' + }, + { + id: captureReviewIds[3], + project: field, + document: 'interview-synthesis.md', + contextSummary: 'Synthesize the first review interviews.', + branch: field.branch, + pullRequestNumber: 18, + status: 'pending-agent', + threadId: 'thread_public_research', + threadTitle: 'Prepare design-partner research' + }, + { + id: captureReviewIds[4], + project: protocol, + document: 'review-protocol.md', + contextSummary: 'Verify the structured handoff protocol.', + branch: protocol.branch, + pullRequestNumber: 7, + status: 'editing', + threadId: 'thread_public_protocol', + threadTitle: 'Document the structured handoff' + } + ] + const timestamps = definitions.map((_, index) => ( + new Date(generatedAt.getTime() - index * 18 * 60 * 1000).toISOString() + )) + let idIndex = 0 + let timeIndex = 0 + const store = new ReviewStore(path.join(physicalRoot, 'reviews'), { + idFactory: () => captureReviewIds[idIndex++] as string, + now: () => timestamps[Math.min(timeIndex++, timestamps.length - 1)] as string + }) + const artifacts: ReviewArtifact[] = [] + for (const definition of definitions) { + artifacts.push(await seedReview(store, definition, logicalRoot, physicalRoot)) + } + + const titleDatabasePath = path.join(physicalRoot, 'fixture', 't3.sqlite') + await createT3TitleDatabase(titleDatabasePath, definitions.map((definition) => ({ + id: definition.threadId, + title: definition.threadTitle + })).filter((entry, index, entries) => ( + entries.findIndex(({ id }) => id === entry.id) === index + ))) + const settings = new SettingsStore(path.join(physicalRoot, 'settings.json')) + await settings.update({ + palette: 'ember', + appearance: 'light', + showKeyboardHelp: false, + openLeftPane: true, + incomingReviewActivationPolicy: 'never', + reviewLinkActivationPolicy: 'never', + discoverAgentThreadFromLocalSessions: false, + remoteCanonicalGatewayEnabled: false, + t3ThreadTitlesEnabled: true, + t3MetadataDatabasePath: path.join(logicalRoot, 'fixture', 't3.sqlite'), + codexThreadTitlesEnabled: false, + claudeThreadTitlesEnabled: false, + inboxTitlePreference: 'review-purpose' + }) + + const primary = artifacts[0] as ReviewArtifact + const selectedHeading = nodeByText(primary, 'Launch readiness brief').id + const projectKeys = repositories.map((repository) => ( + `remote:github.com/markover-demo/${repository.name}` + )) + const threadKeys = definitions.map((definition) => ({ + projectKey: `remote:github.com/markover-demo/${definition.project.name}`, + threadKey: `t3code:${definition.threadId}`, + expanded: true + })).filter((entry, index, entries) => ( + entries.findIndex((candidate) => ( + candidate.projectKey === entry.projectKey && candidate.threadKey === entry.threadKey + )) === index + )) + const workspace = new WorkspaceStore(path.join(physicalRoot, 'workspace.json')) + await workspace.replace({ + format: 'markover-workspace', + version: 3, + initialized: true, + navigationMode: 'inbox', + projectExpansion: projectKeys.map((projectKey) => ({ + projectKey, + expanded: true + })), + threadExpansion: threadKeys, + activeReviewId: captureReviewIds[0], + rightPaneWidth: 390, + reviews: Object.fromEntries(captureReviewIds.map((reviewId) => [ + reviewId, + reviewViewState(reviewId === captureReviewIds[0] ? selectedHeading : null) + ])) + }) + + const receipt: CaptureSessionReceipt = { + format: sessionFormat, + version: 1, + fixtureRevision: CAPTURE_FIXTURE_REVISION, + commit: source.commit, + dirty: false, + generatedAt: generatedAt.toISOString(), + stateRoot: logicalRoot, + palette: 'ember', + appearance: 'light', + window: { width: 1180, height: 760 }, + reviewIds: [...captureReviewIds] + } + await fs.writeFile( + path.join(physicalRoot, CAPTURE_SESSION_RECEIPT), + `${JSON.stringify(receipt, null, 2)}\n`, + 'utf8' + ) + return receipt +} + +async function textFiles(root: string): Promise { + const files: string[] = [] + const visit = async (directory: string): Promise => { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const filePath = path.join(directory, entry.name) + if (entry.isDirectory()) { + if (entry.name !== 'objects') await visit(filePath) + } else if ( + entry.isFile() && + /(?:\.json|\.md|\.svg|\.git\/(?:config|HEAD)|\/config|\/HEAD)$/.test(filePath) + ) { + files.push(filePath) + } + } + } + await visit(root) + return files +} + +export async function assertSanitizedCaptureState( + root: string, + forbiddenValues: readonly string[] +): Promise { + const needles = forbiddenValues.map((value) => value.trim()).filter((value) => ( + value.length >= 4 && value !== root + )) + for (const filePath of await textFiles(root)) { + const source = await fs.readFile(filePath, 'utf8') + for (const needle of needles) { + if (source.includes(needle)) { + throw new Error(`Private capture value found in ${path.relative(root, filePath)}.`) + } + } + } +} + +export async function prepareCaptureState({ + checkout, + generatedAt = new Date(), + root: requestedRoot = CAPTURE_ROOT, + serviceRunning = defaultServiceRunning, + source +}: PrepareCaptureOptions): Promise { + const root = safeCaptureRoot(requestedRoot) + const resolvedCheckout = path.resolve(checkout) + if (root === serviceDirectory() || root === developmentStateRoot(resolvedCheckout)) { + throw new Error('Capture root must differ from canonical and development state.') + } + await fs.mkdir(path.dirname(root), { recursive: true }) + await assertResettableCaptureRoot(root, serviceRunning) + const stagingRoot = await fs.mkdtemp(path.join( + path.dirname(root), + `.${path.basename(root)}-stage-` + )) + try { + await fs.writeFile(path.join(stagingRoot, CAPTURE_ROOT_MARKER), captureMarker(), { + encoding: 'utf8', + mode: 0o600 + }) + const launchBriefSource = await fs.readFile(path.join( + resolvedCheckout, + 'doc', + 'launch', + 'issue-16', + 'launch-brief.md' + ), 'utf8') + const receipt = await seedCaptureState( + stagingRoot, + root, + source, + generatedAt, + launchBriefSource + ) + await assertSanitizedCaptureState(stagingRoot, [ + os.homedir(), + resolvedCheckout, + developmentStateRoot(resolvedCheckout), + serviceDirectory(), + ...sessionEnvironmentKeys.flatMap((key) => process.env[key] || []) + ]) + if (await pathExists(root)) { + await fs.rm(root, { recursive: true, force: true }) + } + await fs.rename(stagingRoot, root) + const instance = captureResolvedInstance(resolvedCheckout, root) + if (!parseResolvedInstance(instance)) { + throw new Error('The capture instance contract is invalid.') + } + return { instance, receipt } + } finally { + await fs.rm(stagingRoot, { recursive: true, force: true }) + } +} + +export function captureLaunchEnvironment( + instance: ResolvedInstance, + environment: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + const childEnvironment: NodeJS.ProcessEnv = { + ...environment, + [RESOLVED_INSTANCE_ENVIRONMENT]: resolvedInstanceEnvironment(instance), + [SUPPRESS_PROTOCOL_REGISTRATION_ENVIRONMENT]: '1' + } + delete childEnvironment.ELECTRON_RUN_AS_NODE + delete childEnvironment.CLAUDE_CODE_SESSION_ID + delete childEnvironment.CLAUDE_SESSION_ID + delete childEnvironment.CODEX_THREAD_ID + delete childEnvironment.T3CODE_THREAD_ID + delete childEnvironment.T3_THREAD_ID + return childEnvironment +} + +async function main(args = process.argv.slice(2)): Promise { + if (process.platform !== 'darwin') { + throw new Error('Public media capture requires macOS.') + } + const unknown = args.filter((argument) => argument !== '--prepare-only') + if (unknown.length) throw new Error(`Unknown capture argument: ${unknown[0] as string}`) + const checkout = path.resolve(__dirname, '../..') + const prepared = await prepareCaptureState({ + checkout, + source: captureSource(checkout) + }) + process.stdout.write(`${JSON.stringify(prepared.receipt)}\n`) + if (args.includes('--prepare-only')) return + await buildAddressedDevelopmentBundle(prepared.instance) + const executable = addressedDevelopmentExecutable(prepared.instance) + const { spawn } = await import('node:child_process') + const child = spawn(executable, [], { + env: captureLaunchEnvironment(prepared.instance), + stdio: 'inherit' + }) + child.on('exit', (code) => { + process.exitCode = code ?? 0 + }) + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => child.kill(signal)) + } +} + +if (require.main === module) { + void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`markover capture: ${message}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/development-bundle.ts b/scripts/development-bundle.ts index ccd5c552..2ae9d492 100644 --- a/scripts/development-bundle.ts +++ b/scripts/development-bundle.ts @@ -21,6 +21,7 @@ import { parseMacosArchitecture } from './macos-release-contract' import { + CAPTURE_INSTANCE_KEY, DEVELOPMENT_BUNDLE_ID_PREFIX, developmentGeneratedRoot, type ResolvedInstance @@ -82,6 +83,9 @@ function identityBundleId(instance: ResolvedInstance): string { if (instance.identity.kind === 'canonical') { return `${DEVELOPMENT_BUNDLE_ID_PREFIX}.canonical` } + if (instance.identity.kind === 'capture') { + return `${DEVELOPMENT_BUNDLE_ID_PREFIX}.${CAPTURE_INSTANCE_KEY}` + } positivePullRequestNumber(instance.identity.pullRequestNumber) return `${DEVELOPMENT_BUNDLE_ID_PREFIX}.pr${String( instance.identity.pullRequestNumber diff --git a/src/instance.ts b/src/instance.ts index 45ba8025..98707bfb 100644 --- a/src/instance.ts +++ b/src/instance.ts @@ -13,6 +13,8 @@ import { export const CANONICAL_INSTANCE_KEY = 'canonical' export const CANONICAL_INSTANCE_SCHEME = 'markover' +export const CAPTURE_INSTANCE_KEY = 'capture' +export const CAPTURE_INSTANCE_SCHEME = 'markover-capture' export const DEVELOPMENT_BUNDLE_ID_PREFIX = 'com.lastobelus.markover.development' export const CANONICAL_DESCRIPTOR_NAME = 'canonical-instance.json' @@ -39,6 +41,11 @@ export interface CanonicalInstanceIdentity { key: typeof CANONICAL_INSTANCE_KEY } +export interface CaptureInstanceIdentity { + kind: 'capture' + key: typeof CAPTURE_INSTANCE_KEY +} + export interface DevelopmentInstanceIdentity { kind: 'development' key: `pr-${number}` @@ -47,6 +54,7 @@ export interface DevelopmentInstanceIdentity { export type AddressedInstanceIdentity = | CanonicalInstanceIdentity + | CaptureInstanceIdentity | DevelopmentInstanceIdentity export interface InstanceBranding { @@ -189,6 +197,9 @@ export function parseRuntimeInstanceIdentity( if (value.kind === 'canonical' && value.key === CANONICAL_INSTANCE_KEY) { return { kind: 'canonical', key: CANONICAL_INSTANCE_KEY } } + if (value.kind === 'capture' && value.key === CAPTURE_INSTANCE_KEY) { + return { kind: 'capture', key: CAPTURE_INSTANCE_KEY } + } if ( value.kind !== 'development' || !positiveInteger(value.pullRequestNumber) || @@ -344,6 +355,26 @@ function serviceContract(root: string): ResolvedInstance['service'] { } } +export function captureResolvedInstance( + checkout: string, + stateRoot: string +): ResolvedInstance { + const resolvedCheckout = path.resolve(checkout) + const resolvedStateRoot = path.resolve(stateRoot) + return { + version: 1, + identity: { kind: 'capture', key: CAPTURE_INSTANCE_KEY }, + stateRoot: resolvedStateRoot, + checkout: resolvedCheckout, + service: serviceContract(resolvedStateRoot), + scheme: CAPTURE_INSTANCE_SCHEME, + process: { status: 'stopped' }, + coldStart: { eligible: true, blockedBy: null }, + branding: canonicalBranding(), + pullRequest: null + } +} + function canonicalRuntimeInstance( options: ServiceDirectoryOptions = {} ): ResolvedInstance { @@ -425,6 +456,14 @@ export function parseResolvedInstance(value: unknown): ResolvedInstance | null { value.pullRequest !== null || (value.checkout !== null && typeof value.checkout !== 'string') ) return null + } else if (identity.kind === 'capture') { + if ( + value.scheme !== CAPTURE_INSTANCE_SCHEME || + value.pullRequest !== null || + typeof value.checkout !== 'string' || + !path.isAbsolute(value.checkout) || + JSON.stringify(value.branding) !== JSON.stringify(canonicalBranding()) + ) return null } else { if ( typeof value.checkout !== 'string' || diff --git a/test/public-capture.test.ts b/test/public-capture.test.ts new file mode 100644 index 00000000..cc4bd893 --- /dev/null +++ b/test/public-capture.test.ts @@ -0,0 +1,357 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { + CAPTURE_ROOT_MARKER, + assertSanitizedCaptureState, + captureLaunchEnvironment, + captureSource, + prepareCaptureState +} from '../scripts/capture-media' +import { + addressedDevelopmentBundle +} from '../scripts/development-bundle' +import { parseResolvedInstance } from '../src/instance' +import { discoverReviewProjectContext } from '../src/review-project-context' +import { ReviewStore } from '../src/review-store' +import { SettingsStore } from '../src/settings-store' +import { visitNodes } from '../src/tree' +import { WorkspaceStore } from '../src/workspace-store' + +const root = path.resolve(__dirname, '../..') +const manifestPath = path.join( + root, + 'doc', + 'launch', + 'issue-16', + 'capture-manifest.json' +) +const launchBriefPath = path.join( + root, + 'doc', + 'launch', + 'issue-16', + 'launch-brief.md' +) +const handoffSummaryPath = path.join( + root, + 'doc', + 'launch', + 'issue-16', + 'handoff-summary.jq' +) + +interface CaptureManifest { + format: string + fixture: { + projectRoot: string + repositories: string[] + } + movie: { + audio: boolean + maximumSeconds: number + minimumSeconds: number + } + screenshots: Array<{ + consumers: string[] + filename: string + state: string + }> + staging: { + appearance: string + appName: string + command: string + palette: string + registersProtocol: boolean + scheme: string + stateRoot: string + usesNetworkContent: boolean + } + version: number + window: { + densityDpi: number + logicalHeight: number + logicalWidth: number + pixelHeight: number + pixelWidth: number + scaleFactor: number + } +} + +function manifest(): CaptureManifest { + return JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as CaptureManifest +} + +test('public capture contract fixes the isolated Ember Light staging boundary', () => { + const capture = manifest() + assert.equal(capture.format, 'markover-public-capture') + assert.equal(capture.version, 1) + assert.deepEqual(capture.staging, { + command: 'npm run capture:stage', + stateRoot: '/tmp/markover-public-capture', + appName: 'Markover', + scheme: 'markover-capture', + palette: 'ember', + appearance: 'light', + registersProtocol: false, + usesNetworkContent: false + }) + assert.equal(capture.fixture.projectRoot.startsWith('/tmp/'), true) + assert.equal(capture.fixture.repositories.every((repository) => ( + repository.startsWith('https://github.com/markover-demo/') + )), true) +}) + +async function captureFixture(t: test.TestContext): Promise<{ + root: string + prepared: Awaited> +}> { + const parent = await fsp.mkdtemp(path.join(os.tmpdir(), 'markover-capture-test-')) + t.after(() => fsp.rm(parent, { recursive: true, force: true })) + const captureRoot = path.join(parent, 'markover-public-capture-test') + const prepared = await prepareCaptureState({ + checkout: root, + generatedAt: new Date('2026-08-29T12:00:00.000Z'), + root: captureRoot, + serviceRunning: () => Promise.resolve(false), + source: { commit: 'a'.repeat(40), dirty: false } + }) + return { root: captureRoot, prepared } +} + +test('capture instance uses production branding with an isolated identity', async (t) => { + const fixture = await captureFixture(t) + const instance = fixture.prepared.instance + assert.deepEqual(parseResolvedInstance(instance), instance) + assert.deepEqual(instance.identity, { kind: 'capture', key: 'capture' }) + assert.equal(instance.scheme, 'markover-capture') + assert.equal(instance.stateRoot, fixture.root) + assert.deepEqual(instance.branding, { + appName: 'Markover', + headerBadge: null, + iconLabel: null, + iconSvgPath: 'design/brand/markover-app-icon.svg', + iconPngPath: 'design/brand/markover-app-icon.png', + iconIcnsPath: 'design/brand/markover-app-icon.icns' + }) + const address = addressedDevelopmentBundle(instance) + assert.equal(address.appBundleId, 'com.lastobelus.markover.development.capture') + assert.equal(address.appName, 'Markover') + assert.equal(address.scheme, 'markover-capture') +}) + +test('capture fixture is deterministic, populated, and ready for the four media states', async (t) => { + const first = await captureFixture(t) + const store = new ReviewStore(path.join(first.root, 'reviews')) + const artifacts = (await store.list()).sort((left, right) => ( + left.review.id.localeCompare(right.review.id) + )) + assert.deepEqual(artifacts.map(({ review }) => [review.id, review.status]), [ + ['mko_capture01', 'editing'], + ['mko_capture02', 'pending-agent'], + ['mko_capture03', 'editing'], + ['mko_capture04', 'pending-agent'], + ['mko_capture05', 'editing'] + ]) + + const primary = artifacts[0] + assert.ok(primary) + assert.equal( + primary.sourceDocument.content, + fs.readFileSync(launchBriefPath, 'utf8') + ) + let annotations = 0 + let sourceEdits = 0 + const attachments: ReviewAttachment[] = [] + visitNodes(primary.root, (node) => { + if (node.feedback.trim()) annotations += 1 + if (node.sourceEdit) sourceEdits += 1 + attachments.push(...node.attachments || []) + }) + assert.equal(annotations, 4) + assert.equal(sourceEdits, 1) + assert.deepEqual(attachments.map(({ id, label }) => ({ id, label })), [ + { id: 'img-1', label: 'workflow overview' }, + { id: 'img-2', label: 'annotation details' } + ]) + for (const attachment of attachments) { + assert.ok(attachment.path?.startsWith(`${first.root}${path.sep}`)) + assert.equal(await fsp.stat(attachment.path as string).then((value) => value.isFile()), true) + } + + const settings = await new SettingsStore(path.join(first.root, 'settings.json')).read() + assert.equal(settings.palette, 'ember') + assert.equal(settings.appearance, 'light') + assert.equal(settings.discoverAgentThreadFromLocalSessions, false) + assert.equal(settings.remoteCanonicalGatewayEnabled, false) + assert.equal(settings.t3MetadataDatabasePath.startsWith(first.root), true) + const workspace = await new WorkspaceStore(path.join(first.root, 'workspace.json')).load() + assert.equal(workspace.activeReviewId, 'mko_capture01') + assert.equal(workspace.navigationMode, 'inbox') + assert.equal(workspace.rightPaneWidth, 390) + assert.equal(Object.keys(workspace.reviews).length, 5) + + const contexts = await Promise.all(artifacts.map((artifact) => ( + discoverReviewProjectContext(artifact) + ))) + assert.deepEqual(contexts.map((context) => ({ + evidence: context.projectEvidence, + name: context.project?.name, + source: context.sourceState + })), [ + { evidence: 'verified', name: 'atlas-studio', source: 'unchanged' }, + { evidence: 'verified', name: 'atlas-studio', source: 'unchanged' }, + { evidence: 'verified', name: 'field-research', source: 'unchanged' }, + { evidence: 'verified', name: 'field-research', source: 'unchanged' }, + { evidence: 'verified', name: 'review-protocol', source: 'unchanged' } + ]) + + const serialized = artifacts.map((artifact) => JSON.stringify(artifact)).join('\n') + assert.doesNotMatch(serialized, new RegExp(os.homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + assert.doesNotMatch(serialized, /lastobelus|t3code-[a-f0-9]+/i) + + const semanticSnapshot = JSON.stringify({ + artifacts, + settings, + workspace, + receipt: first.prepared.receipt + }) + await prepareCaptureState({ + checkout: root, + generatedAt: new Date('2026-08-29T12:00:00.000Z'), + root: first.root, + serviceRunning: () => Promise.resolve(false), + source: { commit: 'a'.repeat(40), dirty: false } + }) + const resetStore = new ReviewStore(path.join(first.root, 'reviews')) + const resetArtifacts = (await resetStore.list()).sort((left, right) => ( + left.review.id.localeCompare(right.review.id) + )) + const resetSettings = await new SettingsStore(path.join(first.root, 'settings.json')).read() + const resetWorkspace = await new WorkspaceStore(path.join(first.root, 'workspace.json')).load() + const resetReceipt: unknown = JSON.parse(await fsp.readFile( + path.join(first.root, 'session.json'), + 'utf8' + )) + assert.equal(JSON.stringify({ + artifacts: resetArtifacts, + settings: resetSettings, + workspace: resetWorkspace, + receipt: resetReceipt + }), semanticSnapshot) +}) + +test('capture reset requires its exact marker and a stopped service', async (t) => { + const parent = await fsp.mkdtemp(path.join(os.tmpdir(), 'markover-capture-reset-')) + t.after(() => fsp.rm(parent, { recursive: true, force: true })) + const captureRoot = path.join(parent, 'markover-public-capture-test') + await fsp.mkdir(captureRoot) + await fsp.writeFile(path.join(captureRoot, 'keep.txt'), 'not owned\n', 'utf8') + await assert.rejects(prepareCaptureState({ + checkout: root, + root: captureRoot, + serviceRunning: () => Promise.resolve(false), + source: { commit: 'b'.repeat(40), dirty: false } + }), /unrecognized capture root/) + assert.equal(fs.existsSync(path.join(captureRoot, 'keep.txt')), true) + + await fsp.rm(captureRoot, { recursive: true }) + const first = await prepareCaptureState({ + checkout: root, + root: captureRoot, + serviceRunning: () => Promise.resolve(false), + source: { commit: 'b'.repeat(40), dirty: false } + }) + assert.equal(fs.existsSync(path.join(captureRoot, CAPTURE_ROOT_MARKER)), true) + await assert.rejects(prepareCaptureState({ + checkout: root, + root: captureRoot, + serviceRunning: () => Promise.resolve(true), + source: { commit: 'b'.repeat(40), dirty: false } + }), /capture instance is running/) + assert.equal(first.receipt.commit, 'b'.repeat(40)) +}) + +test('capture launch strips private session identity and suppresses protocol registration', async (t) => { + const fixture = await captureFixture(t) + const environment = captureLaunchEnvironment(fixture.prepared.instance, { + CLAUDE_CODE_SESSION_ID: 'private-claude', + CODEX_THREAD_ID: 'private-codex', + ELECTRON_RUN_AS_NODE: '1', + KEEP_ME: 'safe' + }) + assert.equal(environment.CLAUDE_CODE_SESSION_ID, undefined) + assert.equal(environment.CODEX_THREAD_ID, undefined) + assert.equal(environment.ELECTRON_RUN_AS_NODE, undefined) + assert.equal(environment.KEEP_ME, 'safe') + assert.equal(environment.MARKOVER_SUPPRESS_PROTOCOL_REGISTRATION, '1') + assert.match(environment.MARKOVER_RESOLVED_INSTANCE || '', /"kind":"capture"/) +}) + +test('capture source rejects dirty Git state', async (t) => { + const repository = await fsp.mkdtemp(path.join(os.tmpdir(), 'markover-capture-git-')) + t.after(() => fsp.rm(repository, { recursive: true, force: true })) + execFileSync('git', ['init', '--quiet'], { cwd: repository }) + execFileSync('git', ['config', 'user.name', 'Capture Test'], { cwd: repository }) + execFileSync('git', ['config', 'user.email', 'capture@example.invalid'], { cwd: repository }) + await fsp.writeFile(path.join(repository, 'tracked.txt'), 'clean\n', 'utf8') + execFileSync('git', ['add', '.'], { cwd: repository }) + execFileSync('git', ['commit', '--quiet', '-m', 'Fixture'], { cwd: repository }) + assert.equal(captureSource(repository).dirty, false) + await fsp.writeFile(path.join(repository, 'untracked.txt'), 'dirty\n', 'utf8') + assert.throws(() => captureSource(repository), /clean Git worktree/) +}) + +test('capture sanitization rejects an injected private value', async (t) => { + const fixture = await captureFixture(t) + const injected = path.join(fixture.root, 'injected.json') + await fsp.writeFile(injected, JSON.stringify({ secret: 'private-thread-value' }), 'utf8') + await assert.rejects( + assertSanitizedCaptureState(fixture.root, ['private-thread-value']), + /Private capture value found/ + ) +}) + +test('public capture contract preserves the four current media states', () => { + const capture = manifest() + assert.deepEqual(capture.screenshots.map(({ filename }) => filename), [ + 'markover-review-editor@2x.png', + 'markover-annotation-browser@2x.png', + 'markover-source-edit@2x.png', + 'markover-review-context@2x.png' + ]) + assert.equal(capture.screenshots.every(({ state }) => state.length > 0), true) + assert.deepEqual(capture.screenshots[0]?.consumers, [ + 'README.md', + 'docs/user/index.html' + ]) +}) + +test('public capture contract retains the Retina still and demo constraints', () => { + const capture = manifest() + assert.deepEqual(capture.window, { + logicalWidth: 1180, + logicalHeight: 760, + pixelWidth: 2360, + pixelHeight: 1520, + densityDpi: 144, + scaleFactor: 2 + }) + assert.equal(capture.movie.minimumSeconds, 30) + assert.equal(capture.movie.maximumSeconds, 60) + assert.equal(capture.movie.audio, false) +}) + +test('demo handoff projection keeps only publishable review fields', () => { + const filter = fs.readFileSync(handoffSummaryPath, 'utf8') + assert.match(filter, /review: \{ status: \.review\.status \}/) + assert.match(filter, /attachments: \[/) + assert.match(filter, /\.attachments\[\] \| \{ id, label \}/) + assert.match(filter, /sourceEdit/) + assert.doesNotMatch(filter, /agentThread|authorization|machine|path|pullRequest/) +})