feat(desktop): DSH Electron desktop shell — harness internals visualized

Minimal Electron shell over the DSH JSON-RPC runtime — a first-look at
what a ChatGPT.app-style host on top of the DeepSeek Harness looks
like, with the harness's normally-invisible internals (trace timeline,
context surface, subagent tree, compaction, plugin registry, rubrics)
brought forward as first-class UI surfaces so plugin authors and
researchers can see what the agent is actually doing.

Runs against three keyless-to-live profiles (stdio-echo works on
master out of the box; daemon-echo / daemon-vibe-echo activate once
the daemon-demo lands; stdio-deepseek and daemon-vibe hit the real
DeepSeek API when you supply a key). HARNESS_DEV auto-resolves to the
in-repo runtime when this shell ships under examples/desktop/, so a
fresh clone launches without config; env DSH_DEV_ROOT overrides for
custom layouts, and a sibling deepseek-harness-dev/ checkout is the
original dev workflow.

Cold-clone gate (P0 fixes for first-time-clone usability):
- HARNESS_DEV: 3-candidate resolver (env → walk-up in-repo marker →
  sibling), unit-tested via mock fs so ordering is locked without
  needing either real layout on disk.
- config yml leaves rewritten at assemble time so the sibling-clone
  paths (../../deepseek-harness-dev/examples/echo-agent/…) become
  the in-repo paths (../../echo-agent/…) in the released tree —
  source yml stays usable for local dev, released tree ships a
  working shape.
- pnpm-workspace.yaml allowBuilds.electron = true (was placeholder).
- missing-key card in stdio-deepseek offers a one-click switch to
  stdio-echo (the keyless profile that works on master) rather than
  daemon-echo (blocked on the not-yet-shipped daemon-demo).
- assemble-oss-release.sh rewrites the source-side breadcrumb name
  'dsh-desktop-demo' → 'dsh-desktop' for the released package.json.

FOUC guard on the onboarding gate (41fc5df carried) keeps the
first-launch splash from flashing before the runtime probe finishes.

Test suite (1634 tests in source, 3990 in the runtime repo) covers
resolver ordering, renderer classifiers, trace timeline shape,
compaction diff rendering, rubric parity, and the missing-key
onboarding paths.
This commit is contained in:
ZiyaZhang
2026-07-18 12:59:34 -07:00
parent 67053d1cf6
commit e8f5c0b51b
378 changed files with 104431 additions and 0 deletions

View File

@@ -0,0 +1,467 @@
#!/usr/bin/env node
/*
* comment-sweep-apply.js
*
* Mechanical stripper. Reads every .js file under src/main, src/preload,
* src/renderer and applies the artifact-reference strip recipe from
* tools/comment-sweep-rules.md.
*
* The stripping is conservative: it edits only comment TEXT (the run of
* characters after `//` or inside `/* … *\/`), not code. If a comment line
* becomes empty after stripping we delete the line; otherwise we rewrite
* the comment in-place, preserving the leading `//` or ` * ` / `/*` / `*\/`
* shape.
*
* Usage:
* node tools/comment-sweep-apply.js --dry-run # print planned edits, don't write
* node tools/comment-sweep-apply.js --apply # write files in place
* node tools/comment-sweep-apply.js --scope=main # main+preload only
* node tools/comment-sweep-apply.js --scope=renderer # renderer only
*
* Scope split maps 1:1 onto the two commits (main+preload / renderer) the
* team-lead brief calls for.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const SCOPES = {
main: ['src/main', 'src/preload'],
renderer: ['src/renderer'],
all: ['src/main', 'src/preload', 'src/renderer'],
};
// -----------------------------------------------------------------------------
// Strip recipe. Each rule is (pattern, replacement, description). Applied in
// order; a rule may match zero, one, or multiple times per line. Rules 1-4
// (LEADING) run only against the START of the comment text.
// -----------------------------------------------------------------------------
// LEADING artifact-prefix rules. Each captures the prefix + trailing
// delimiter (`:` / `—` / `.` / `,`). Applied only if the match starts at
// column 0 of the comment text.
const LEADING_STRIPPERS = [
// Fresh-eyes P0 (2026-07-18, review-fresh-eyes.md #4 [+ team-lead follow-up]):
{ name: 'fresh-eyes-p0-full', re: /^\s*Fresh[- ]eyes\s+P0\s*\([^)]*review[- ]fresh[- ]eyes\.md[^)]*\)\s*[::—]?\s*/i },
// Fresh-eyes P0 (2026-07-18): — dated but no review-md coord
{ name: 'fresh-eyes-p0-dated', re: /^\s*Fresh[- ]eyes\s+P0\s*\([^)]*\)\s*[::—]\s*/i },
// Fresh-eyes P0: (bare)
{ name: 'fresh-eyes-p0-bare', re: /^\s*Fresh[- ]eyes\s+P0\s*[::—]\s*/i },
// Ticket #NNN [X] [(2026-07-17)] [phase 2 (pi §2.3)]: prefix (matches ticket-letter and ticket-num)
{ name: 'ticket-num-prefix', re: /^\s*Ticket\s+#?\d+(?:\s+[A-Z])?(?:\s*\([^)]*\))?(?:\s+step\s+\d+|\s+phase\s+\d+(?:\s*\([^)]*\))?)?\s*[::—]\s*/i },
{ name: 'ticket-letter-prefix', re: /^\s*Ticket\s+[A-Z](?:\s*\([^)]*\))?\s*[::—]\s*/ },
// task #NNN [/ trace-viz §X.Y] [(2026-07-17…)] [rec 22-bis[…]] [phase 2 (pi §2.3)]:
//
// The `/ …` continuation matches compound refs like
// task #201 / trace-viz §4d:
// task #158 / density-spec §3:
// where the reader wanted "here is what the constraint is called in two
// artifact systems". We drop the whole pileup.
//
// Widened 2026-07-18: also accept a small trailing tag word or two before
// the delimiter, and `.` as an end-of-prefix delim in addition to `:`/`:`/`—`.
// Handles the pi-style `Task #49 lane:`, `Task #225 selfie seam:`,
// `Task #103 P0-4 (2026-07-16).` shapes.
{ name: 'task-num-prefix', re: /^\s*(?:Task|task)\s*#\d+(?:\s*\/\s*[\w §.a-z-]+)?(?:\s+[\w-]+(?:\s+[\w-]+)?)?(?:\s*\([^)]*\))?(?:\s+rec\s+[\d-]+(?:-bis)?)?(?:\s+phase\s+\d+(?:\s*\([^)]*\))?)?\s*[::—.]\s*/ },
// F-N (2026-07-18 e2e audit): prefix
{ name: 'finding-letter-prefix', re: /^\s*F-\d+\s*\([^)]*(?:audit|e2e)[^)]*\)\s*[::—]\s*/i },
// team-lead §X.Y [ruling|正面参照|dispatch verbatim] [(...)]:
{ name: 'team-lead-prefix', re: /^\s*[Tt]eam[- ]lead\s+§[\d.]+(?:\s+\w+)?(?:\s*\([^)]*\))?\s*[::—]\s*/ },
// rec 22-bis [(2026-07-17|pi §X.Y)]:
{ name: 'rec-num-prefix', re: /^\s*rec\s+\d+(?:-bis)?(?:\s+phase\s+\d+)?(?:\s*\([^)]*\))?\s*[::—]\s*/i },
// Round-visual N1 (2026-07-16):
{ name: 'round-visual-prefix', re: /^\s*Round[- ]visual\s+[A-Z]?\d+(?:\s*\([^)]*\))?\s*[::—]\s*/i },
// Clickability audit D3 [fix] (2026-07-17):
{ name: 'audit-batch-prefix', re: /^\s*(?:Clickability\s+audit|hygiene\s+batch|trace[- ]parity\s+batch)\s+[\w\d]+(?:\s+\w+)?(?:\s*\([^)]*\))?\s*[::—]\s*/i },
// 2026-07-XX round-N shot NN: (dated e2e/round prefix)
{ name: 'dated-round-prefix', re: /^\s*20\d{2}-\d{2}-\d{2}\s+(?:round|QA|e2e|老板|walkthrough|trace[- ]parity)[^::]*[::—]\s*/ },
// 2026-07-XX delta: / 2026-07-XX addendum (...): — generic process
// banner starting with a date + one-word tag.
{ name: 'dated-tag-prefix', re: /^\s*20\d{2}-\d{2}-\d{2}\s+\w+(?:\s*\([^)]*\))?\s*[::—]\s*/ },
// Clickability audit fills (...2026-07-17): — audit-batch-prefix widened
// to accept the trailing "fills"/word before the paren+delim.
{ name: 'audit-batch-fills-prefix', re: /^\s*Clickability\s+audit\s+\w+(?:\s*\([^)]*\))?\s*[::—]?\s*/i },
// Density-spec L0 budget: — density-spec followed by L\d level ref
// instead of §N (LangSmith-style level naming).
{ name: 'density-spec-level-prefix', re: /^\s*[Dd]ensity[- ]spec\s+L\d[^::]*[::]\s*/ },
];
// TRAILING (parenthetical) rules. Applied to the whole comment text; may
// match at any position. Non-greedy.
const TRAILING_STRIPPERS = [
// (Fresh-eyes P0 (2026-07-18, review-fresh-eyes.md #N)) — nested variant
{ name: 'paren-fresh-eyes', re: /\s*\(\s*Fresh[- ]eyes\s+P0[^()]*review[- ]fresh[- ]eyes\.md[^()]*\)/gi },
// (review-fresh-eyes.md #N) / (review-*.md #N)
{ name: 'paren-review-md', re: /\s*\(review-[a-z][\w-]*\.md\s*#\d+\)/gi },
// (QA round-N shot NN)
{ name: 'paren-qa-round', re: /\s*\(QA\s+round-\d+(?:\s+shot\s+\d+)?\)/gi },
// (round-N) / (round-N shot NN)
{ name: 'paren-round', re: /\s*\(round-\d+(?:\s+shot\s+\d+)?\)/gi },
// (2026-07-17|老板实测|team-lead §X.Y|正面参照) — mixed process paren
{ name: 'paren-dated-process', re: /\s*\((?:20\d{2}-\d{2}-\d{2})[^()]*(?:老板|team[- ]lead|正面|指令|walkthrough|e2e[- ]audit|round-\d)[^()]*\)/gi },
// (Ticket #N) / (task #N) as trailing tag, incl. trailing tokens like
// (task #103 P0-4, 2026-07-16)
// (task #37 layer 1)
// (2026-07-17, task #49) — leading-date variant
{ name: 'paren-ticket-num', re: /\s*\((?:Ticket|task)\s*#?\d+(?:[,\s][^()]*)?\)/gi },
{ name: 'paren-dated-task', re: /\s*\(20\d{2}-\d{2}-\d{2}\s*,\s*(?:Ticket|task)\s*#?\d+(?:[,\s][^()]*)?\)/gi },
// 205-Δ3 / 205-Δ4 boss-delta bare tag with trailing punctuation
{ name: 'inline-boss-delta', re: /\s*\b205-Δ\d+\b/g },
// (F-1 …)
{ name: 'paren-finding', re: /\s*\(F-\d+(?:\s*[,;]\s*\d{4}-\d{2}-\d{2}[^)]*)?\)/g },
// packages/x/y/z.ts:33-52 → strip. If the coord sat inside its own paren
// `(packages/foo.ts:33-52)`, drop the paren too so we don't leave empty
// "wire () carries..." debris.
{ name: 'upstream-path-paren', re: /\s*\(packages\/[\w-]+\/[\w-]+\/src\/[\w./-]+\.ts:\d+(?:-\d+)?\)/g },
{ name: 'upstream-path', re: /\s*\bpackages\/[\w-]+\/[\w-]+\/src\/[\w./-]+\.ts:\d+(?:-\d+)?/g },
// Compound "QA round-N shot NN" as a bare inline tag (must run BEFORE
// inline-round so we don't leave "QA shot NN" debris).
{ name: 'inline-qa-round-shot', re: /\s*\bQA\s+round-\d+\s+shot\s+\d+\b/gi },
// rec 22-bis / rec 22 as a mid-sentence tag (bare word)
{ name: 'inline-rec', re: /\s*\brec\s+\d+(?:-bis)?/g },
// Round-N tag mid-sentence
{ name: 'inline-round', re: /\s*\bround-\d+\b/gi },
// Orphan closing `)` LEFT AS THE ENTIRE line body after an upstream-path
// strip (source line was `path.ts:295-296):` — the strip removed the path
// but not the parenthesis because it opened on a prior line). Only fires
// when the body reduces to `)` or `):` with no other content.
{ name: 'orphan-close-paren', re: /^\s*\)\s*[::]?\s*$/ },
// Mid-sentence `Ticket #NNN [A-Z] [(2026-…)]` reference — a leftover after
// the leading-prefix strippers didn't fire because the artifact ref sat
// inside a longer sentence like:
// `— Ticket #140 explicitly says "推倒重来"`
// `Ticket #140. Data source is the growth-v2 IPC`
// `raw-inject.js — Ticket #15 B (2026-07-17) envelope:'raw' classifier.`
// Distinct from paren-ticket-num because the artifact ref is bare (no
// enclosing parens). Bare `Ticket #NNN` is an unambiguous internal artifact
// reference (unlike bare `#NNN` which per team-lead (B) 2026-07-18 stays as
// an OSS-verifiable official-repo PR anchor).
{ name: 'inline-ticket-ref', re: /\s*\bTicket\s+#\d+(?:\s+[A-Z])?(?:\s*\([^)]{0,50}\))?(?:\s+(?:step|phase)\s+\d+)?/g },
// Mid-sentence `team-lead §X.Y [tag]` reference.
{ name: 'inline-team-lead-ref', re: /\s*\b[Tt]eam[- ]lead\s+§[\d.]+(?:\s+\w+)?/g },
// Mid-sentence `task #NNN` — the leading-prefix stripper needs a `:`
// delimiter; sentences like `strategy list, task #136` or
// `mergeRecentSessions (task #69 — foo)` leak through.
{ name: 'inline-task-num-ref', re: /\s*[,;]?\s*\btask\s+#\d+\b/g },
];
// INTERNAL doc references. The OSS release pipeline strips
// docs/design-refs/ from the shipped artefact, so a reader who follows a
// `density-spec §N` or `style-guide` reference hits a 404 in the published
// tree. Team-lead directive (2026-07-18): dangling references are worse than
// missing ones — strip both. INTERNAL_DOC_KEEP is intentionally empty; the
// STRIP list covers every internal-only doc pointer we know about.
const INTERNAL_DOC_KEEP = [];
const INTERNAL_DOC_STRIP = [
{ re: /\s*\bpi[- ]agent[- ]ui[- ]study(?:\.md)?\s*§[\d.]+/gi, name: 'pi-study' },
{ re: /\s*\bLangSmith\s+study\s+§[\d.]+(?:\s+rec\s+\d+)?/gi, name: 'langsmith-study' },
{ re: /\s*\btrace[- ]parity(?:\s+batch)?(?:\s+task\s+\d+)?/gi, name: 'trace-parity-batch' },
{ re: /\s*\btrace[- ]viz\s+§[\d.a-z]+/gi, name: 'trace-viz-ref' },
// density-layering spec references — repo-internal doc, stripped from OSS
// artefact so references would 404. Matches `density-spec §N`, `density spec §N.M`.
{ re: /\s*\bdensity[- ]spec\s*§[\d.a-z]+(?:\s*·\s*[\w-]+)?/gi, name: 'density-spec' },
{ re: /\s*\bdensity[- ]layering[- ]spec\s*§[\d.a-z]+/gi, name: 'density-layering-spec' },
// style-guide references — same reason.
{ re: /\s*\bstyle[- ]guide\s*(?:§[\d.a-z]+)?/gi, name: 'style-guide' },
];
// -----------------------------------------------------------------------------
// Comment-aware line rewriter.
//
// A file is edited by walking its comment spans (produced identically to
// comment-sweep-scan.js) and rewriting each span's text. Two shapes:
// - // line comment → text = everything after //
// - /* … */ block → each internal line is treated independently; the
// leading ` * ` (or ` `) prefix is preserved.
//
// After rewriting, the file is emitted with:
// - Empty // lines → line removed entirely (including trailing \n).
// - Empty ` * ` lines in a block comment → line removed if the block still
// has non-empty lines around it; if the whole block becomes empty, the
// entire /* */ span is removed.
// -----------------------------------------------------------------------------
function rewriteCommentText(text) {
const originalLeadingWs = (text.match(/^\s*/) || [''])[0];
let out = text;
// Sentinel-guard PRE-EXISTING empty parens `()` so the paren-cleanup below
// (added to collapse `(density-spec §3)` → gone after INTERNAL_DOC_STRIP
// consumes the content) doesn't clobber legitimate function-call references
// inside comments, e.g. `daemon.ensureUp()` / `refreshSessionList()`.
// Any `()` that survives all strips into the output is one we created; the
// sentinel-restored ones were there in the source and stay.
const EMPTY_PAREN_MARK = 'EMPTYPAREN';
out = out.replace(/\(\s*\)/g, EMPTY_PAREN_MARK);
let leadStripped = false;
// Leading strippers first. Track whether any leading rule fired so cleanup
// knows whether to consume leading whitespace.
for (const rule of LEADING_STRIPPERS) {
const before = out;
out = out.replace(rule.re, '');
if (out !== before) leadStripped = true;
}
// Trailing / inline strippers (do NOT flip leadStripped — they don't touch
// the line's leading whitespace).
for (const rule of TRAILING_STRIPPERS) {
out = out.replace(rule.re, '');
}
for (const rule of INTERNAL_DOC_STRIP) {
const before = out;
out = out.replace(rule.re, '');
// If the strip fired AT THE START of the (possibly-already-partially-
// stripped) text — INTERNAL_DOC_STRIP entries are inline-anywhere, so a
// leading occurrence like `density-spec §4: rows focusable` used to leave
// a `: rows focusable` orphan. Treat leading-position hits as leadStripped
// so the cleanup below eats the stray delimiter.
if (out !== before) {
// Detect leading position by checking whether the pre-strip text started
// (after any whitespace) with a match of this same rule.
const leadingRe = new RegExp('^' + rule.re.source.replace(/^\\s\*/, '\\s*'), rule.re.flags.replace('g', ''));
if (leadingRe.test(before)) leadStripped = true;
}
}
if (leadStripped) {
// A leading strip fired. Clean up the debris (whitespace + orphan
// delimiters) it may have left, then restore the ORIGINAL leading
// whitespace so a bullet in ` - foo` inside a block comment keeps
// its indent.
out = out.replace(/^\s+/, '').replace(/^[::—]\s*/, '');
if (out) out = originalLeadingWs + out;
}
// Clean up empty parens `()` left behind when a strip consumed the sole
// content of a parenthetical (`(packages/foo.ts:33-52)` → `()`). Guarded
// above: pre-existing empty parens were replaced with a sentinel first.
out = out.replace(/\s*\(\s*\)/g, '');
// Restore any pre-existing empty parens.
out = out.split(EMPTY_PAREN_MARK).join('()');
// Deliberately NOT collapsing " ." → "." — the transform must never touch
// spacing that predates our strip. Leaving occasional " ." debris is fine;
// reviewers can spot and fix by hand, and we don't risk munging code-shape
// comments like `// [a, b, ...]` where the space matters.
if (/^[\s.:,;—]*$/.test(out)) return '';
return out;
}
function rewriteFile(src) {
// Walk char-by-char (mirror of comment-sweep-scan's extractComments) and
// build a new string, replacing each comment's TEXT via rewriteCommentText.
//
// State tracking:
// - inStr = string literal char (' " `) currently open, or null.
// - inRegex = true iff currently inside a /.../ literal.
// - lastMeaningful = last non-whitespace char emitted; used to disambiguate
// `/` as regex-start vs division. If lastMeaningful is one of
// `=(,;!&|?:{}[` or empty (BOF) or after a keyword, `/` starts a regex.
// Otherwise (after ident/number/)/]) it's division.
const N = src.length;
let out = '';
let i = 0;
let inStr = null;
let inRegex = false;
let inRegexClass = false;
let lastMeaningful = '';
const REGEX_START_AFTER = new Set([
'', '=', '(', ',', ';', '!', '&', '|', '?', ':', '{', '}', '[',
'+', '-', '*', '%', '^', '~', '<', '>',
]);
const KEYWORD_BEFORE_REGEX = /(?:^|[^\w$])(?:return|typeof|instanceof|in|of|delete|void|new|throw|yield|await|case|do|else)$/;
function isRegexStartContext() {
if (lastMeaningful === '') return true;
if (REGEX_START_AFTER.has(lastMeaningful)) return true;
// Check for keyword ending at `out`. Cheap: last 10 chars.
const tail = out.slice(-12);
if (KEYWORD_BEFORE_REGEX.test(tail)) return true;
return false;
}
const stripped = { lines: 0, blocks: 0, inlineEdits: 0 };
while (i < N) {
const c = src[i];
const next = src[i + 1];
if (inStr) {
out += c;
if (c === '\\' && i + 1 < N) { out += src[i + 1]; i += 2; continue; }
if (c === inStr) { inStr = null; lastMeaningful = c; }
i++;
continue;
}
if (inRegex) {
out += c;
if (c === '\\' && i + 1 < N) { out += src[i + 1]; i += 2; continue; }
if (c === '[') inRegexClass = true;
else if (c === ']') inRegexClass = false;
else if (c === '/' && !inRegexClass) { inRegex = false; lastMeaningful = c; }
i++;
continue;
}
if (c === "'" || c === '"' || c === '`') {
inStr = c;
out += c;
lastMeaningful = c;
i++;
continue;
}
if (c === '/' && next === '/') {
// Line comment. Find the // start's "leading whitespace on this line".
let lineStart = out.lastIndexOf('\n') + 1;
const leading = out.slice(lineStart);
// Find end of comment (next newline).
let j = i + 2;
while (j < N && src[j] !== '\n') j++;
const commentBody = src.slice(i + 2, j);
const originalWasEmpty = commentBody.trim() === '';
const rewritten = rewriteCommentText(commentBody);
if (originalWasEmpty) {
// Preserve empty `//` separator lines verbatim — the file's author
// put them there as visual padding between paragraphs, not as a
// side-effect of any strip.
out += '//' + commentBody;
} else if (rewritten === '') {
// The line's body was 100% artifact-reference AND non-empty. Drop
// the whole line (including leading indent + trailing newline).
out = out.slice(0, lineStart);
if (src[j] === '\n') j++;
stripped.lines++;
} else {
// The rewritten text already includes whatever leading whitespace
// rewriteCommentText decided to preserve; we just prepend `//`.
const newComment = '//' + rewritten;
if (newComment !== '//' + commentBody) stripped.inlineEdits++;
out += newComment;
}
i = j;
continue;
}
if (c === '/' && next === '*') {
// Block comment. Collect the entire span first.
let j = i + 2;
while (j < N && !(src[j] === '*' && src[j + 1] === '/')) j++;
const raw = src.slice(i + 2, j);
const closeAt = j + 2;
// Split into lines, rewrite each internal line while preserving the
// ` * ` / ` *` / ` ` prefix shape.
const lines = raw.split('\n');
const rewrittenLines = [];
let anyRealText = false;
for (let idx = 0; idx < lines.length; idx++) {
const l = lines[idx];
// Detect ` * ` prefix (typical). Preserve indent + optional " * ".
const m = l.match(/^(\s*)(\*\s?)?(.*)$/);
const indent = m[1] || '';
const star = m[2] || '';
const body = m[3] || '';
if (!body.trim()) {
// Blank comment line — keep as-is (structural padding).
rewrittenLines.push(l);
continue;
}
const rewritten = rewriteCommentText(body);
if (rewritten === '') {
// Line body was 100% artifact reference. Drop it.
// If the next line is also empty or drops, we compress; otherwise
// we emit nothing here.
stripped.blocks++;
continue;
}
rewrittenLines.push(indent + star + rewritten);
anyRealText = true;
}
if (!anyRealText && rewrittenLines.every((ll) => !ll.trim())) {
// The entire block became empty (only structural blanks). Remove
// the whole `/* … */` and its trailing newline if present.
// Also remove the leading indentation on the line the /* opened on.
let lineStart = out.lastIndexOf('\n') + 1;
out = out.slice(0, lineStart);
let k = closeAt;
if (src[k] === '\n') k++;
i = k;
stripped.blocks++;
continue;
}
out += '/*' + rewrittenLines.join('\n') + '*/';
i = closeAt;
continue;
}
// Regex-literal detection: `/` that is not `//` (line comment) nor `/*`
// (block comment) AND appears in a regex-start context begins a `/…/` and
// must be skipped so its internal `"` / `'` chars don't fool the string
// tracker (bug: `/model\s+"([^"]+)"/i` was leaving the walker "in string"
// for the rest of the file).
if (c === '/' && next !== '/' && next !== '*' && isRegexStartContext()) {
inRegex = true;
inRegexClass = false;
out += c;
lastMeaningful = c;
i++;
continue;
}
out += c;
if (!/\s/.test(c)) lastMeaningful = c;
i++;
}
return { src: out, stripped };
}
// -----------------------------------------------------------------------------
// File discovery + CLI
// -----------------------------------------------------------------------------
function listJsFiles(scopes) {
const results = [];
for (const dir of scopes) {
const abs = path.join(ROOT, dir);
if (!fs.existsSync(abs)) continue;
walk(abs, results);
}
return results;
}
function walk(dir, out) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, ent.name);
if (ent.isDirectory()) { walk(p, out); continue; }
if (!ent.isFile()) continue;
if (!/\.(js|mjs|cjs)$/.test(ent.name)) continue;
out.push(p);
}
}
function main() {
const args = process.argv.slice(2);
const apply = args.includes('--apply');
const dry = args.includes('--dry-run') || !apply;
const scopeArg = (args.find((a) => a.startsWith('--scope=')) || '--scope=all').split('=')[1];
const scopes = SCOPES[scopeArg];
if (!scopes) {
console.error(`unknown --scope=${scopeArg}; use main|renderer|all`);
process.exit(2);
}
const files = listJsFiles(scopes);
let totalLines = 0;
let totalBlocks = 0;
let totalInline = 0;
let touched = 0;
for (const f of files) {
const src = fs.readFileSync(f, 'utf8');
const { src: out, stripped } = rewriteFile(src);
if (out === src) continue;
touched++;
totalLines += stripped.lines;
totalBlocks += stripped.blocks;
totalInline += stripped.inlineEdits;
const rel = path.relative(ROOT, f);
const summary = `(-${stripped.lines} full-line, -${stripped.blocks} block-line, ~${stripped.inlineEdits} inline)`;
if (dry) {
process.stdout.write(`~ ${rel} ${summary}\n`);
} else {
fs.writeFileSync(f, out);
process.stdout.write(`M ${rel} ${summary}\n`);
}
}
process.stdout.write(`---\n`);
process.stdout.write(`${touched} files ${dry ? 'would be' : ''} touched; -${totalLines} full-line deletions, -${totalBlocks} block-line deletions, ~${totalInline} inline edits\n`);
}
if (require.main === module) main();
module.exports = { rewriteCommentText, rewriteFile };

View File

@@ -0,0 +1,141 @@
# comment-sweep — classification rules
Owner: `lane-fresh-eyes-p0` Status: DRAFT for team-lead review before baseline
lands. Scope: `src/main/**`, `src/preload/**`, `src/renderer/**` (`.js` only;
tests intentionally out — test names carry ticket anchors used for the
regression audit trail).
## Bucket contract
Every scan hit lands in one of three buckets:
- **keep-stripped** — the surrounding comment carries a real constraint (why
this order matters, what state exists, which wire contract is honored).
Only the *artifact-reference prefix* is removed. The sentence that follows
is preserved verbatim, minus grammatical stitching (leading colon, spaces,
connective particles).
- **delete-line** — the entire comment line is a process narrative that
carries no invariant. Remove the line, close the gap.
- **delete-block** — a multi-line comment whose SOLE purpose is process
narrative (e.g. a header repeating "this file was refactored per §X.Y").
Remove the whole `/* … */` or contiguous `// … // …` run.
- **skip** — do not touch. See §Skip below.
## Family → default action
The scanner emits 18 families. Defaults, each overridable per-hit during
review:
| family | default action | notes |
|---------------------|-----------------|-------|
| fresh-eyes-p0 | keep-stripped | strip `Fresh-eyes P0 (date, review-fresh-eyes.md #N):` |
| fresh-eyes-plain | keep-stripped | strip `Fresh-eyes P0:` prefix |
| review-md-ref | keep-stripped | strip `(review-*.md #N)` parenthetical |
| ticket-letter | keep-stripped | strip `Ticket A/B/…:` — retain the constraint |
| ticket-num | keep-stripped | strip `Ticket #NNN:` — retain the constraint |
| task-num | keep-stripped | strip `task #NNN` mentions — retain the constraint |
| bare-hash-num | context-dependent | if it's the only anchor a reader has for context (e.g. `#93` names a *design tension* explained elsewhere), keep the hash but note "kernel ticket #93" style — otherwise strip |
| round-shot | keep-stripped | drop `(QA round-N shot NN)` parenthetical |
| shot-num | keep-stripped | drop `shot NN` |
| rec-num | keep-stripped | strip `rec 22-bis` etc. |
| team-lead-ref | keep-stripped | strip `team-lead §X.Y` prefix — retain the described rule |
| boss-delta | keep-stripped | strip `老板实测 delta` / `205-Δ3` |
| date-prefix | keep-stripped | strip leading `YYYY-MM-DD` on comments whose body still makes sense without the date; delete-line if the date IS the whole comment |
| internal-doc-ref | keep-stripped | all internal-doc refs are stripped: `density-spec §N`, `style-guide §N`, `pi-agent-ui-study.md`, `LangSmith study §N rec M`, `trace-parity batch`, `trace-viz §N` — the OSS release pipeline excludes docs/design-refs/, so references would 404 for readers (team-lead 2026-07-18) |
| finding-letter | keep-stripped | `F-1 / F-2 / F-3 / F-4` are audit-round codes, strip |
| upstream-path | keep-stripped | `packages/foo/bar.ts:33-52` → replace with a concept name ("wire protocol v2 (`session/interrupt`)") — reader has no access to that path |
| phase-of-rec | keep-stripped | strip `phase N (pi/rec/task)` |
| port-audit | keep-stripped | strip `port 9224 wire audit` — the constraint isn't tied to a port number |
| e2e-audit | keep-stripped | strip `e2e audit` marker |
| audit-batch | keep-stripped | strip `Clickability audit D3` / `hygiene batch task 3` |
## Skip
Do not touch:
1. Any file NOT under `src/main/`, `src/preload/`, `src/renderer/`.
2. Test files (already excluded from the scan by SCAN_DIRS).
3. Any comment whose text is inside a string literal (`extractComments`
already guards this).
4. Any hit where the artifact reference IS the identifier ("kernel `#218`"
naming a specific PR whose numeric identity is the meaningful concept
the reader needs — mark `bare-hash-num` skip per-case).
5. Any header banner comment at line 1 that documents the file's PUBLIC
contract (imports, exported functions, module role) — even if it mentions
a ticket, the reader wanted the file overview. Strip only the ticket
reference from within, don't touch the banner shape.
## Stripping recipe (mechanical)
The stripper reads a keep-stripped line and applies the following in order.
Each step is a pure text transform on the comment text (not the leading
`//` / ` * ` prefix — those are preserved to keep the block-comment shape).
```
1. Drop leading parenthetical ^\s*\(([\w §.,#/-]+?)\)\s*[:—-]?\s*
IF the parenthetical is composed entirely of family patterns.
2. Drop leading artifact-prefix + delimiter:
^\s*(Fresh-eyes P0[^:]*:\s*)
^\s*(Ticket #?\w+[^:]*:\s*)
^\s*(task #\d+[^:]*:\s*)
^\s*(F-\d+ \([^)]+\):\s*)
^\s*(20\d{2}-\d{2}-\d{2}\s*[·—-]?\s*)
^\s*(rec \d+[- ]?bis[^:]*:\s*)
^\s*(team[- ]lead §[\d.]+[^:]*:\s*)
3. Drop trailing artifact suffix:
\s*\((?:round-\d+ ?shot ?\d+|QA round-\d+|review-[a-z-]+\.md #\d+|Ticket [A-Z#\d]+|task #\d+|Fresh-eyes P0)[^)]*\)\s*$
4. Collapse remaining "(task #N):"/"(#NNN)"/"(§X)" fragments in the middle
of the sentence by dropping the parenthetical only, keeping the rest.
5. Normalise whitespace: trim, collapse multi-space, ensure one trailing
line-ending style match.
6. If the remaining text is empty → mark for delete-line.
7. If the remaining text is a fragment (starts with lowercase and lacks a
verb) → prepend a period-and-capital shim ONLY when the original had a
sentence-starting shape; otherwise keep the fragment as-is (many are
header labels like `-- forkChildLabel readability fix ----`).
```
Steps 1-4 are the load-bearing ones. Step 5-7 are conservative — if in
doubt, leave the line to the human bucket pass.
## Two-commit split (per team-lead brief)
- **Commit A** — `src/main/*.js` + `src/preload/*.js` only. ~13 files.
Explicit `git add src/main/... src/preload/preload.js` per file.
Message: `chore(comment-sweep): main+preload artifact-reference strip`
followed by the per-family counters and total lines touched.
- **Commit B** — `src/renderer/*.js` only. ~50 files. Explicit git add.
Message: `chore(comment-sweep): renderer artifact-reference strip`
followed by the per-family counters and total lines touched.
## Acceptance for each commit
1. `node --check` passes on every changed `.js` file.
2. `node --test 'test/*.test.js' 2>&1 | tail -5` shows the same pass count
as baseline (regression: 0 tests changed).
3. `git diff -w --stat` line count == count of stripped comment lines +
count of deleted comment lines. `git diff -w` output contains NO source
lines (only lines starting with `-` or `+` inside a comment context).
4. Per-file `git show --stat` audit line: no file gains code or loses code;
the sole shape of any diff hunk is comment-line change.
5. Commit message carries the per-family stats produced by
`node tools/comment-sweep-scan.js --stats` before → after.
## Machine-readable classification
The classification bucket for every hit is stored in
`tools/comment-sweep-plan.jsonl` (one JSON object per line, keys:
`file`, `line`, `family`, `bucket`, `action`, `note`). The mechanical
executor reads this and applies the strip recipe or deletion. Any hit
whose bucket is `skip` or `human-review` is not touched.
## Reconciliation with test-real HEAD
The scan output above is against my worktree HEAD `6a44b8c` (branch
`fix/fresh-eyes-p0`). The final baseline for this batch is test-real HEAD
AFTER `fix/oss-prep` + `fix/oss-clean` merge in. Team-lead notifies when
that HEAD is ready. Between now and then, the classification rules
themselves don't move; only the row set and line numbers do. The
mechanical pass re-runs the scan on the new baseline and applies the
per-family rules; per-hit `human-review` decisions are re-matched by
(family, file, approximate line context).

View File

@@ -0,0 +1,271 @@
#!/usr/bin/env node
/*
* comment-sweep-scan.js
*
* Scan src/main, src/preload, src/renderer for "process-metadata" comment
* prefixes — internal artifact references that leak into source (task/ticket
* numbers, review filenames, dated fresh-eyes markers, team-lead section
* pointers, in-house code-review IDs, round-N/shot-M etc.). Output one line
* per hit as pipe-separated fields so downstream classification can be done
* mechanically:
*
* family|path|lineno|match|context
*
* The classification (keep-stripped / delete / skip) is NOT decided here.
* This is a listing tool; humans (or a second pass) split the buckets.
*
* Usage:
* node tools/comment-sweep-scan.js # print tsv
* node tools/comment-sweep-scan.js --json # print jsonl
* node tools/comment-sweep-scan.js --stats # count by family
* node tools/comment-sweep-scan.js --stats-file # count by family x file
*
* Scope: only .js files under src/main, src/preload, src/renderer. Tests are
* intentionally skipped (test names carry ticket anchors used for reconciling
* regression coverage — stripping them would break the audit trail).
*
* Patterns are anchored to comment territory: we require the match to sit
* inside //, /* * / (block open/mid), or trailing after a code line preceded
* by //. Strings that happen to contain "task #157" won't match.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const SCAN_DIRS = ['src/main', 'src/preload', 'src/renderer'];
const EXCLUDE_BASENAMES = new Set([]); // test files live under test/, already out of SCAN_DIRS
// -----------------------------------------------------------------------------
// Pattern families
//
// Each family has a probe regex (matched against comment TEXT, case-insensitive
// unless noted) plus a short "why". The scan does not decide keep/delete —
// families exist so the sort/classify pass can group by shape.
// -----------------------------------------------------------------------------
const FAMILIES = [
// Fresh-eyes P0 dated review reference. Shape:
// Fresh-eyes P0 (2026-07-18, review-fresh-eyes.md #N ...):
// Fresh-eyes (2026-07-18, review-fresh-eyes.md #N):
{ id: 'fresh-eyes-p0', re: /fresh[- ]eyes[^\n]*review[- ]fresh[- ]eyes\.md[^\n]*#\d+/i, why: 'F-11 dated review-file marker' },
{ id: 'fresh-eyes-plain', re: /\bfresh[- ]eyes\s+p0\b/i, why: 'F-11 fresh-eyes P0 prefix (no review-file coord)' },
// Explicit review-file references (any review-*.md #N form).
{ id: 'review-md-ref', re: /review-[a-z0-9][\w-]*\.md\s*(?:#|item\s*)?\d*/i, why: 'F-11 review-*.md filename reference' },
// Ticket letters (Ticket A/B/C/D/G) and their numeric siblings.
{ id: 'ticket-letter', re: /\bticket\s+[A-Z]\b/, why: 'F-10 Ticket A/B/... letter code' },
{ id: 'ticket-num', re: /\bticket\s*#\d+/i, why: 'F-10 Ticket #N number' },
// task #N / #NNN in comments (JS uses # only for private, so # in a comment
// is almost always an issue ticket).
{ id: 'task-num', re: /\btask\s*#\d+/i, why: 'F-10 task #NNN' },
{ id: 'bare-hash-num', re: /(?<![\w"'`])#\d{2,4}\b/, why: 'F-10 bare "#NNN" (issue/ticket)' },
// Round/shot/wave markers ("round-6 shot 11", "Round-visual N1", "round-8").
{ id: 'round-shot', re: /\bround[- ]?\d+\b|\bround[- ]visual\s*[A-Z]?\d+/i, why: 'F-10 round-N/shot marker' },
{ id: 'shot-num', re: /\bshot\s*\d+\b/i, why: 'F-10 shot N marker' },
// "rec 22-bis" / "rec §X.Y" / "recommendation NN".
{ id: 'rec-num', re: /\brec\s*\d+[- ]?bis\b|\brec\s*§\s*[\d.]+|\brecommendation\s*\d+/i, why: 'F-10 recommendation N/rec-N-bis' },
// "team-lead §X.Y" / "team lead §X" — internal dispatch coord.
{ id: 'team-lead-ref', re: /team[- ]lead\s*(?:§|dispatch|verbatim|正面|指令|拍板)/i, why: 'F-11 team-lead §X.Y' },
// 老板 / boss references + 实拍 / 实测 delta markers.
{ id: 'boss-delta', re: /老板\s*(?:实测|实拍|指令)|boss[- ]delta|205-Δ\d+/i, why: 'F-10 老板实测 delta / 实拍 marker' },
// dated PROCESS prefix ("2026-07-17 老板实测", "2026-07-18 e2e audit"): a
// date + narrative word close together at the START of a comment.
{ id: 'date-prefix', re: /^\s*(?:\/\/|\*)?\s*(?:20\d{2}-\d{2}-\d{2})\s*(?:老板|team|fresh|round|shot|delta|e2e|review|audit|Ticket|task|rec|冲刺|walkthrough)/im, why: 'F-13 dated process-metadata prefix' },
// density-spec / study §X.Y coord (internal doc anchors).
{ id: 'internal-doc-ref', re: /(density[- ]spec|langsmith[- ](?:study|tracing)|trace[- ]parity(?:[- ]batch)?|pi[- ]agent[- ]ui[- ]study|trace[- ]viz)\s*§?\s*[\d.a-z-]+/i, why: 'F-10 internal doc §X.Y ref' },
// "F-1 / F-2 / F-3 / F-4" e2e-audit finding letters bare in comments.
{ id: 'finding-letter', re: /\bF-\d{1,2}\b/, why: 'F-10 finding letter (F-N)' },
// packages/*/src/*.ts:33-52 upstream path coordinates (F-20).
{ id: 'upstream-path', re: /packages\/[\w-]+\/[\w-]+\/src\/[\w./-]+\.ts:\d+/, why: 'F-20 upstream packages/*/src path coord' },
// "wire audit port 9224" / port-based process artifact.
{ id: 'port-audit', re: /port\s*\d{4,5}\s*wire\s*audit|wire\s*audit\s*port\s*\d{4,5}/i, why: 'F-13 port NNNN wire audit marker' },
// "phase 2 of rec X" / "phase N (pi §X.Y)".
{ id: 'phase-of-rec', re: /\bphase\s*\d+\s*\((?:pi|rec|task)/i, why: 'F-13 phase N (pi/rec/task) subordination' },
// e2e audit / walkthrough dated batch.
{ id: 'e2e-audit', re: /\be2e[- ]audit\b|\bwalkthrough[- ](?:audit|shot|batch)\b/i, why: 'F-10 e2e-audit marker' },
// Clickability audit D3 / hygiene batch task 3 style compound refs.
{ id: 'audit-batch', re: /clickability[- ]audit\s*[A-Z]?\d*|hygiene\s*batch\s*task\s*\d+|trace[- ]parity\s*batch\s*task\s*\d+/i, why: 'F-10 audit batch/task ref' },
];
// -----------------------------------------------------------------------------
// File discovery
// -----------------------------------------------------------------------------
function listJsFiles() {
const results = [];
for (const dir of SCAN_DIRS) {
const abs = path.join(ROOT, dir);
if (!fs.existsSync(abs)) continue;
walk(abs, results);
}
return results;
}
function walk(dir, out) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, ent.name);
if (ent.isDirectory()) {
walk(p, out);
continue;
}
if (!ent.isFile()) continue;
if (!/\.(js|mjs|cjs)$/.test(ent.name)) continue;
if (EXCLUDE_BASENAMES.has(ent.name)) continue;
out.push(p);
}
}
// -----------------------------------------------------------------------------
// Comment extraction. We stream the file char-by-char to know when we're
// inside //, /* */, or a string. Every comment span is emitted with its
// starting line number so downstream can report file:line accurately.
// -----------------------------------------------------------------------------
function extractComments(src) {
const spans = []; // { line, text, kind: 'line'|'block' }
const N = src.length;
let i = 0;
let line = 1;
let inStr = null; // "'", '"', '`'
while (i < N) {
const c = src[i];
const next = src[i + 1];
if (inStr) {
if (c === '\\') { i += 2; if (src[i - 1] === '\n') line++; continue; }
if (c === inStr) { inStr = null; i++; continue; }
if (c === '\n') line++;
i++;
continue;
}
if (c === "'" || c === '"' || c === '`') {
inStr = c;
i++;
continue;
}
if (c === '/' && next === '/') {
const startLine = line;
let j = i + 2;
while (j < N && src[j] !== '\n') j++;
const text = src.slice(i + 2, j);
spans.push({ line: startLine, text, kind: 'line' });
i = j;
continue;
}
if (c === '/' && next === '*') {
const startLine = line;
let j = i + 2;
const startIdx = j;
while (j < N && !(src[j] === '*' && src[j + 1] === '/')) {
if (src[j] === '\n') line++;
j++;
}
const raw = src.slice(startIdx, j);
// Emit one span per LINE in the block so line numbers are meaningful.
const lines = raw.split('\n');
let ln = startLine;
for (const l of lines) {
spans.push({ line: ln, text: l, kind: 'block' });
ln++;
}
i = j + 2;
continue;
}
if (c === '\n') line++;
i++;
}
return spans;
}
// -----------------------------------------------------------------------------
// Main scan
// -----------------------------------------------------------------------------
function scanFile(abs) {
const rel = path.relative(ROOT, abs);
const src = fs.readFileSync(abs, 'utf8');
const spans = extractComments(src);
const hits = [];
for (const span of spans) {
const t = span.text;
if (!t.trim()) continue;
for (const f of FAMILIES) {
const m = f.re.exec(t);
if (m) {
hits.push({
family: f.id,
why: f.why,
path: rel,
line: span.line,
match: m[0].trim(),
context: t.trim().slice(0, 200),
});
}
}
}
return hits;
}
function main() {
const args = process.argv.slice(2);
const mode = args.includes('--json') ? 'json'
: args.includes('--stats-file') ? 'stats-file'
: args.includes('--stats') ? 'stats'
: 'tsv';
const files = listJsFiles();
const hits = [];
for (const f of files) {
for (const h of scanFile(f)) hits.push(h);
}
if (mode === 'tsv') {
for (const h of hits) {
// pipe-separated so awk/cut/sort work; strip pipes/newlines from context
const ctx = h.context.replace(/\|/g, '¦').replace(/[\r\n]+/g, ' ');
process.stdout.write(`${h.family}|${h.path}|${h.line}|${h.match}|${ctx}\n`);
}
} else if (mode === 'json') {
for (const h of hits) process.stdout.write(JSON.stringify(h) + '\n');
} else if (mode === 'stats') {
const by = new Map();
for (const h of hits) by.set(h.family, (by.get(h.family) || 0) + 1);
const total = hits.length;
const rows = [...by.entries()].sort((a, b) => b[1] - a[1]);
for (const [k, v] of rows) process.stdout.write(`${v.toString().padStart(4)} ${k}\n`);
process.stdout.write(`${'-'.repeat(6)}\n${total.toString().padStart(4)} TOTAL hits\n`);
process.stdout.write(`${files.length.toString().padStart(4)} files scanned\n`);
} else if (mode === 'stats-file') {
const by = new Map(); // path -> Map(family -> count)
for (const h of hits) {
if (!by.has(h.path)) by.set(h.path, new Map());
const inner = by.get(h.path);
inner.set(h.family, (inner.get(h.family) || 0) + 1);
}
const rows = [...by.entries()].sort((a, b) => {
const at = [...a[1].values()].reduce((x, y) => x + y, 0);
const bt = [...b[1].values()].reduce((x, y) => x + y, 0);
return bt - at;
});
for (const [p, inner] of rows) {
const total = [...inner.values()].reduce((x, y) => x + y, 0);
process.stdout.write(`${total.toString().padStart(4)} ${p}\n`);
const fams = [...inner.entries()].sort((a, b) => b[1] - a[1]);
for (const [f, c] of fams) process.stdout.write(` ${c.toString().padStart(3)} ${f}\n`);
}
}
}
if (require.main === module) main();
module.exports = { extractComments, FAMILIES, scanFile, listJsFiles };

View File

@@ -0,0 +1,148 @@
// tools/shoot-turn-flow-glyph.js — CDP driver to seed a multi-loop turn
// via __dshQaPlayFixture and screenshot the turn footer with the glyph.
'use strict'
const http = require('http')
const fs = require('fs')
// Node 22 ships a built-in WebSocket; no ws package needed.
const HOST = '127.0.0.1'
const PORT = process.env.CDP_PORT || 9227
const FIXTURE = process.argv[2] || '2.6-turn-flow-glyph-multiloop.json'
const OUT = process.argv[3] || '/tmp/turn-flow-glyph-multiloop.png'
function get(url) {
return new Promise((resolve, reject) => {
http.get(url, (r) => {
let b = ''
r.on('data', (c) => (b += c))
r.on('end', () => resolve(b))
}).on('error', reject)
})
}
;(async () => {
const targets = JSON.parse(await get(`http://${HOST}:${PORT}/json/list`))
const page = targets.find((t) => t.type === 'page' && t.url.includes('index.html'))
if (!page) throw new Error('no page target')
const ws = new WebSocket(page.webSocketDebuggerUrl)
let msgId = 1
const pending = new Map()
ws.addEventListener('message', (ev) => {
const j = JSON.parse(typeof ev.data === 'string' ? ev.data : Buffer.from(ev.data).toString())
if (j.id && pending.has(j.id)) {
const { resolve, reject } = pending.get(j.id)
pending.delete(j.id)
if (j.error) reject(new Error(JSON.stringify(j.error)))
else resolve(j.result)
}
})
const send = (method, params) => new Promise((resolve, reject) => {
const id = msgId++
pending.set(id, { resolve, reject })
ws.send(JSON.stringify({ id, method, params }))
})
await new Promise((r) => ws.addEventListener('open', r, { once: true }))
await send('Page.enable', {})
await send('Runtime.enable', {})
const evalJS = (expr) => send('Runtime.evaluate', {
expression: expr, awaitPromise: true, returnByValue: true,
}).then((r) => r.result ? r.result.value : null)
const evalJSSync = (expr) => send('Runtime.evaluate', {
expression: expr, returnByValue: true,
}).then((r) => r.result ? r.result.value : null)
// Dismiss onboarding BEFORE playing the fixture so the click-through
// does not race the QA seam. Sync eval — no awaitPromise chain.
await evalJSSync(`(() => {
try {
const skipBtn = Array.from(document.querySelectorAll('button')).find(b =>
/skip and use defaults/i.test((b.textContent || '').trim())
);
if (skipBtn) skipBtn.click();
} catch (_) {}
try {
document.querySelectorAll('.onboarding-scrim, .app-dialog').forEach(n => {
try { if (typeof n.close === 'function') n.close() } catch (_) {}
try { if (n && n.parentNode) n.parentNode.removeChild(n) } catch (_) {}
});
} catch (_) {}
return 'dismissed';
})()`)
await new Promise((r) => setTimeout(r, 200))
// Seed the fixture (creates a new session, plays events through
// onSessionEvent, resolves the id).
const played = await evalJS(`window.__dshQaPlayFixture(${JSON.stringify(FIXTURE)}).then(r => JSON.stringify(r))`)
console.log('play result:', played)
// Small wait for DOM.
await new Promise((r) => setTimeout(r, 800))
// Force chat pane on + close devtools panel again (fixture play may
// have re-triggered another surface). Sync eval — no awaitPromise.
await evalJSSync(`(() => {
try {
const chatTab = document.querySelector('.tab-btn[data-tab="chat"]');
if (chatTab) chatTab.click();
} catch (_) {}
try {
const dt = document.querySelector('.devtools-panel');
if (dt && !dt.hidden) {
const close = dt.querySelector('.dt-close, .devtools-close, [data-close]');
if (close) close.click();
dt.hidden = true;
}
} catch (_) {}
return 'cleaned';
})()`)
await new Promise((r) => setTimeout(r, 800))
// Confirm the glyph is in the DOM.
const found = await evalJS(`(() => {
const g = document.querySelectorAll('.turn-flow-glyph');
if (!g.length) return { count: 0 };
const first = g[0];
return {
count: g.length,
width: first.getAttribute('width'),
dots: first.querySelectorAll('circle').length,
kinds: Array.from(first.querySelectorAll('circle')).map(c => c.getAttribute('class')),
};
})()`)
console.log('glyph diagnostics:', JSON.stringify(found))
// Scroll the LAST assistant-turn (the one we just played) into view and
// ensure the trace drawer is closed so the footer row (with the glyph)
// is the visual anchor.
await evalJS(`(() => {
const rows = document.querySelectorAll('.assistant-turn');
if (rows.length) {
const last = rows[rows.length - 1];
const footer = last.querySelector('.turn-footer');
if (footer) footer.scrollIntoView({ block: 'center' });
last.querySelectorAll('.turn-trace-drawer[open]').forEach(d => { d.open = false });
}
return true;
})()`)
await new Promise((r) => setTimeout(r, 300))
// Grab a tight clip around the glyph's footer row so the reader sees
// the visual craft (baseline alignment, hover-target size, palette).
const clip = await evalJS(`(() => {
const g = document.querySelector('.turn-flow-glyph');
if (!g) return null;
const footer = g.closest('.turn-footer');
const r = (footer || g).getBoundingClientRect();
// 24px padding around so the shot has breathing room without leaking neighbors.
return { x: Math.max(0, r.left - 24), y: Math.max(0, r.top - 24), width: Math.min(r.width + 48, 900), height: Math.max(r.height + 48, 80), scale: 2 };
})()`)
const shotParams = { format: 'png', captureBeyondViewport: false }
if (clip && clip.width > 0 && clip.height > 0) shotParams.clip = clip
const shot = await send('Page.captureScreenshot', shotParams)
fs.writeFileSync(OUT, Buffer.from(shot.data, 'base64'))
console.log('wrote', OUT, 'clip=', JSON.stringify(clip))
ws.close()
})().catch((e) => { console.error(e); process.exit(1) })