done-done-axi 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -128,6 +128,8 @@ const FLAGS = {
128
128
  '--run <run-id>': 'the run to show (default: the newest run of this checkout)',
129
129
  '--probe': 'ask the configured agent one JSON-schema question and require a valid answer (one call at the agent\'s model: its cheap default where it has one, else its own; set agent_config.<agent>.model to choose)',
130
130
  '--mock <screen>=<mock>': 'judge this screen next to its approved mock (repeat per screen; the screen is judged even when not listed)',
131
+ '--mock <file>': 'with look: the approved design of this page (PNG or JPEG); the judge the user set compares the capture with it, as design --mock does, and a defect fails look',
132
+ '--act <selector>': 'with look: click the element this CSS selector names, then require the page to change what a user can read (its text, elements or address) within 5s; repeat to click several in order; look measures and captures the page they leave',
131
133
  '--step <name>': 'the step log to show (verify: resolve, drive, check, judge, gate, receipt, report; maintain: locate, hygiene, source-wave, reconcile, triage, fix, report)',
132
134
  '--feature <name>': 'with no journey, the author agent drives this feature of the verify skill (features/<name>.md); default: the only feature, else the agent picks the one the outcome is about',
133
135
  '--action <approve|skip|fix>': 'at a spend gate, approve: spend and run the journey; skip: end the run with nothing spent. At the judge gate only fix or skip: fix ends it fail, then the product or the verify skill is fixed (done-done maintain checks it against the source); skip ends it fail without the fix',
@@ -220,12 +222,14 @@ const COMMANDS = {
220
222
  passed, 1 a screen failed, 3 none failed but one is unsure`],
221
223
  },
222
224
  look: {
223
- usage: [['done-done look <url> [--out <file.png>]', 'open a page in headless Chrome at a phone viewport, capture it with its page-check record, and fail on any squashed, clipped or cut-off UI']],
224
- flags: ['--out <file.png>'],
225
- more: [`look: one deterministic step a journey calls as its command or inside its script; no judge, no model. It measures horizontal
225
+ usage: [['done-done look <url> [--act <selector>]... [--mock <file>] [--out <file.png>]', 'open a page in headless Chrome at a phone viewport, click each action, capture it with its page-check record, and fail on any squashed, clipped or cut-off UI, an action that changes nothing, or a mismatch with the design']],
226
+ flags: ['--act <selector>', '--mock <file>', '--out <file.png>'],
227
+ more: [`look: one step a journey calls as its command or inside its script; with no --mock, no judge and no model. It measures horizontal
226
228
  overflow, squashed text, text clipped by its container and elements cut off by the left, right or top edge, writes the
227
- screenshot and its .dom.json record, and exits 1 naming each element, kind and text, 0 when there is none. A hit always
228
- fails: never wrap it in || true. Needs Chrome or Chromium (DONE_DONE_CHROME names the binary)`],
229
+ screenshot and its .dom.json record, and exits 1 naming each element, kind and text, 0 when there is none. Each --act is
230
+ clicked and must change the page. With a look block in .done-done.yaml (the approved reference: tokens, max_control) it
231
+ also fails on a control drawn as the browser's own and on one taller than max_control. A hit always fails: never wrap it
232
+ in || true. Exit 3 when the only doubt is an unsure judge. Needs Chrome or Chromium (DONE_DONE_CHROME names the binary)`],
229
233
  },
230
234
  gate: {
231
235
  usage: [['done-done gate [<head>] [--dir <folder>] [--pushed <time>]', 'no-model check for a hosted runner: PASS or FAIL: <reason> for a receipt committed under .done-done-receipts/']],
@@ -764,6 +768,7 @@ async function runCheck(argv, run) {
764
768
  const captures = [];
765
769
  // The run behind each cited capture, for the real-product rule and the receipt.
766
770
  const runs = new Map();
771
+ const warnings = [];
767
772
  // base_ref: a capture in <run id>/before/ of a run that drove its base ref pairs with <run id>/<the same name>.
768
773
  const pairOf = (inRoot) => {
769
774
  const [id, dir, ...rest] = inRoot?.split(sep) ?? [];
@@ -809,7 +814,7 @@ async function runCheck(argv, run) {
809
814
  const inRoot = under(cfg.evidence.root, path);
810
815
  const pair = pairOf(inRoot);
811
816
  if (cfg.dom && !pair && ['png', 'jpeg', 'webp', 'gif', 'mp4', 'webm'].includes(v.type))
812
- for (const why of domIssues(cfg.dom, path))
817
+ for (const why of domIssues(cfg.dom, path, cfg.look))
813
818
  findings.push({ level: 'fail', why: `${name}: ${why}` });
814
819
  // A base capture may equal its head capture byte for byte: the pair's judge, not this check, says the screen did not change.
815
820
  if (seen.has(hash) && !record && !pair)
@@ -839,7 +844,7 @@ async function runCheck(argv, run) {
839
844
  continue;
840
845
  }
841
846
  const rec = (atNow.length ? atNow : claims).reduce((a, b) => (b.started_at > a.started_at ? b : a));
842
- runs.set(rec.run_id, { run_id: rec.run_id, journey: rec.journey, command: rec.command, ...(rec.feature && { feature: rec.feature }), ...(rec.tool && { tool: rec.tool }), ...(rec.steps && { steps: rec.steps }), ...(rec.steps_error && { steps_error: rec.steps_error }) });
847
+ runs.set(rec.run_id, { run_id: rec.run_id, commit: rec.commit, journey: rec.journey, command: rec.command, ...(rec.feature && { feature: rec.feature }), ...(rec.tool && { tool: rec.tool }), ...(rec.steps && { steps: rec.steps }), ...(rec.steps_error && { steps_error: rec.steps_error }) });
843
848
  // Only a capture tied to a run record can reach the judge, so a stray cited file is never sent.
844
849
  const c = { name, bytes, sha256: hash, media: v };
845
850
  if (v.status === 'ok' && !record && pair)
@@ -849,7 +854,11 @@ async function runCheck(argv, run) {
849
854
  if (rec.base)
850
855
  heads.push([c, inRoot, rec]);
851
856
  }
852
- const stale = staleness(repo, cfg, rec.commit, now.commit);
857
+ const kept = [];
858
+ const stale = staleness(repo, cfg, rec.commit, now.commit, rec, kept);
859
+ for (const w of kept)
860
+ if (!warnings.includes(w))
861
+ warnings.push(w);
853
862
  if (stale)
854
863
  findings.push({ level: 'fail', why: `${name}: ${stale} (run ${rec.run_id})` });
855
864
  if (rec.dirty && !cfg.evidence.allow_dirty)
@@ -873,7 +882,6 @@ async function runCheck(argv, run) {
873
882
  if (codeRuns)
874
883
  findings.push({ level: 'fail', why: codeRuns });
875
884
  // The user steps behind the captures: a cited real run needs a step record covering the claim. Runs from before step records warn.
876
- const warnings = [];
877
885
  if (claim && runs.size && !codeRuns) {
878
886
  const s = stepsFault([...runs.values()], claim.outcome);
879
887
  if (s.fault)
@@ -1079,7 +1087,7 @@ function recordReceipt(repo, cfg, r) {
1079
1087
  return { code: PASS, result: { ...result, ...(r.judge && { judge: r.judge }), help: [`Verify this receipt (merge gate): done-done receipt --verify ${r.head.commit}`] } };
1080
1088
  }
1081
1089
  // Why evidence from `from` no longer proves `to`, or '' if it still does.
1082
- function staleness(repo, cfg, from, to) {
1090
+ function staleness(repo, cfg, from, to, run, warnings = []) {
1083
1091
  if (from === to)
1084
1092
  return '';
1085
1093
  let changed;
@@ -1092,6 +1100,19 @@ function staleness(repo, cfg, from, to) {
1092
1100
  const outside = changed.filter((p) => !cfg.evidence.ignore_paths.some((g) => matchesGlob(p, g)));
1093
1101
  if (changed.length && !outside.length)
1094
1102
  return '';
1103
+ // A head change that touches no journey or feature the run covered leaves its proof valid; both commits are reported.
1104
+ const needs = outside.filter((p) => !p.startsWith(`${GATE_DIR}/`));
1105
+ if (run && (run.journey || run.feature) && needs.length) {
1106
+ const m = mapDiff(repo, cfg, needs, git(repo, 'ls-files', '-z').split('\0').filter(Boolean));
1107
+ if (!m.journeys.some((j) => j.name === run.journey) && !m.features.some((f) => f.name === run.feature)) {
1108
+ warnings.push(`run ${run.run_id} is for commit ${from.slice(0, 12)} and HEAD is ${to.slice(0, 12)}: its ${run.journey ? `journey ${run.journey}` : `feature ${run.feature}`} is untouched by the ${needs.length} changed file${needs.length === 1 ? '' : 's'} (done-done affected)`);
1109
+ return '';
1110
+ }
1111
+ }
1112
+ if (run && changed.length && !needs.length) {
1113
+ warnings.push(`run ${run.run_id} is for commit ${from.slice(0, 12)} and HEAD is ${to.slice(0, 12)}: only gate receipts changed`);
1114
+ return '';
1115
+ }
1095
1116
  return `evidence is for commit ${from.slice(0, 12)}, HEAD is ${to.slice(0, 12)}${outside.length ? ` (changed: ${outside.slice(0, 3).join(', ')}${outside.length > 3 ? ', ...' : ''})` : ''}`;
1096
1117
  }
1097
1118
  // Quote an argument only when a shell would split it.
@@ -3812,9 +3833,9 @@ async function cmdDoctor(argv) {
3812
3833
  emit(result);
3813
3834
  return missingRequired.length ? USAGE : PASS;
3814
3835
  }
3815
- // The design check: every named screen, and each --mock screen, judged for visual defects.
3836
+ // The look step: one page measured, with its actions clicked, against the project's look reference and the --mock design.
3816
3837
  async function cmdLook(argv) {
3817
- const { values, positionals } = parseArgs({ args: argv, options: { out: { type: 'string' } }, allowPositionals: true });
3838
+ const { values, positionals } = parseArgs({ args: argv, options: { out: { type: 'string' }, mock: { type: 'string' }, act: { type: 'string', multiple: true } }, allowPositionals: true });
3818
3839
  if (positionals.length !== 1)
3819
3840
  throw new UsageError('look needs one page URL', 'Re-run with the page to check: done-done look <url>');
3820
3841
  // A path to a page on disk opens as its file:// URL.
@@ -3822,23 +3843,36 @@ async function cmdLook(argv) {
3822
3843
  const capture = resolve(values.out ?? join(process.env.DONE_DONE_EVIDENCE_ROOT || '.', 'look.png'));
3823
3844
  if (!capture.endsWith('.png'))
3824
3845
  throw new UsageError(`--out ${values.out} must be a .png file`);
3846
+ const cfg = loadConfig(repoOrCwd());
3847
+ // The mock and the judge are checked before Chrome starts, so a bad one costs no capture.
3848
+ const mock = values.mock === undefined ? undefined : loadScreen(values.mock);
3849
+ const choice = mock && (await preflightJudge(cfg, false));
3825
3850
  mkdirSync(dirname(capture), { recursive: true });
3826
- const record = await look(url, capture, 390, 844);
3827
- const hits = lookIssues(record);
3851
+ const record = await look(url, capture, 390, 844, values.act ?? []);
3852
+ const hits = lookIssues(record, cfg.look);
3853
+ const [judged] = mock && choice ? await design(cfg, choice, [{ ...loadScreen(capture), mock }], join(home(), 'agents')) : [];
3854
+ for (const d of judged?.defects ?? [])
3855
+ hits.push(`${url} differs from the approved design ${values.mock}: ${d.kind}: ${d.where}`);
3856
+ const code = hits.length ? FAIL : judged?.verdict === 'unsure' ? INCONCLUSIVE : PASS;
3828
3857
  const shown = cap(hits);
3858
+ const again = `done-done look ${[positionals[0], ...(values.act ?? []).flatMap((a) => ['--act', a]), ...(values.mock ? ['--mock', values.mock] : []), ...(values.out ? ['--out', values.out] : [])].map(quote).join(' ')}`;
3829
3859
  emit({
3830
3860
  command: 'look',
3831
3861
  // look measures the layout; it gives no verdict on the outcome, which only the judged check does.
3832
- result: hits.length ? 'fail' : 'clean',
3862
+ result: code === FAIL ? 'fail' : code === INCONCLUSIVE ? 'inconclusive' : 'clean',
3833
3863
  url,
3834
3864
  viewport: '390x844',
3835
3865
  capture,
3836
3866
  record: domRecord(capture),
3867
+ ...(cfg.look && { reference: { ...cfg.look, ...(values.mock && { mock: values.mock }) } }),
3868
+ ...(judged && { judge: { route: choice.route, model: choice.model, verdict: judged.verdict, ...(judged.reason && { reason: judged.reason }), ...(judged.cached && { cached: true }) } }),
3837
3869
  hits: shown.items,
3838
3870
  ...(shown.omitted && { hits_omitted: shown.omitted }),
3839
- help: hits.length ? [`Fix each element named above so it fits the 390 px wide screen, then re-run: done-done look ${quote(positionals[0])}${values.out ? ` --out ${quote(values.out)}` : ''}`] : ['No squashed, clipped or cut-off UI on this page; look at the capture to check the rest'],
3871
+ help: code === FAIL ? [`Fix each element or action named above so it fits the 390 px wide screen, the design and its reference, then re-run: ${again}`]
3872
+ : code === INCONCLUSIVE ? [`The judge is unsure the page matches ${values.mock}: look at both yourself, then re-run: ${again}`]
3873
+ : ['No squashed, clipped or cut-off UI on this page; look at the capture to check the rest'],
3840
3874
  });
3841
- return hits.length ? FAIL : PASS;
3875
+ return code;
3842
3876
  }
3843
3877
  // The folder of a branch where a receipt copied from the evidence root is committed, for `gate`.
3844
3878
  const GATE_DIR = '.done-done-receipts';
@@ -3916,6 +3950,25 @@ function cmdLint(argv) {
3916
3950
  console.log(!v.ok ? `FAIL: ${v.why}` : v.warn ? `WARN: ${v.warn}` : 'PASS');
3917
3951
  return v.ok ? PASS : FAIL;
3918
3952
  }
3953
+ // A screen or mock file for the design judge.
3954
+ function loadScreen(name) {
3955
+ let bytes;
3956
+ try {
3957
+ bytes = readFileSync(name);
3958
+ }
3959
+ catch (e) {
3960
+ throw new UsageError(`${name}: cannot read (${e.code ?? e.message})`, 'Pass screen and mock files that exist');
3961
+ }
3962
+ return { name, bytes, sha256: sha256(bytes), media: checkMedia(name, bytes) };
3963
+ }
3964
+ function repoOrCwd() {
3965
+ try {
3966
+ return repoRoot();
3967
+ }
3968
+ catch {
3969
+ return process.cwd();
3970
+ }
3971
+ }
3919
3972
  async function cmdDesign(argv) {
3920
3973
  const { values, positionals } = parseArgs({ args: argv, options: { mock: { type: 'string', multiple: true } }, allowPositionals: true });
3921
3974
  const mocks = new Map();
@@ -3928,25 +3981,8 @@ async function cmdDesign(argv) {
3928
3981
  const names = [...new Set([...positionals, ...[...mocks.keys()].filter((p) => !positionals.some((n) => resolve(n) === p))])];
3929
3982
  if (!names.length)
3930
3983
  throw new UsageError('design needs at least one screen file', 'Re-run with the screens to check: done-done design <screens...>');
3931
- const load = (name) => {
3932
- let bytes;
3933
- try {
3934
- bytes = readFileSync(name);
3935
- }
3936
- catch (e) {
3937
- throw new UsageError(`${name}: cannot read (${e.code ?? e.message})`, 'Pass screen and mock files that exist');
3938
- }
3939
- return { name, bytes, sha256: sha256(bytes), media: checkMedia(name, bytes) };
3940
- };
3941
- const screens = names.map((n) => ({ ...load(n), ...(mocks.has(resolve(n)) && { mock: load(mocks.get(resolve(n))) }) }));
3942
- let repo;
3943
- try {
3944
- repo = repoRoot();
3945
- }
3946
- catch {
3947
- repo = process.cwd();
3948
- }
3949
- const cfg = loadConfig(repo);
3984
+ const screens = names.map((n) => ({ ...loadScreen(n), ...(mocks.has(resolve(n)) && { mock: loadScreen(mocks.get(resolve(n))) }) }));
3985
+ const cfg = loadConfig(repoOrCwd());
3950
3986
  const choice = await preflightJudge(cfg, false);
3951
3987
  const judged = await design(cfg, choice, screens, join(home(), 'agents'));
3952
3988
  const count = (v) => judged.filter((j) => j.verdict === v).length;
@@ -3992,6 +4028,13 @@ function defaultBranch(repo) {
3992
4028
  }
3993
4029
  }
3994
4030
  const isAncestor = (repo, a, b) => spawnSync('git', ['merge-base', '--is-ancestor', a, b], { cwd: repo, stdio: 'ignore' }).status === 0;
4031
+ // The journeys and features `changed` needs (src/affected.ts), from the verify skill at the working tree.
4032
+ function mapDiff(repo, cfg, changed, tracked, noBase) {
4033
+ const featDir = join(repo, cfg.skill, 'features');
4034
+ const features = Object.fromEntries(skillFeatures(repo, cfg).map((n) => [n, repoText(join(featDir, `${n}.md`)) ?? '']));
4035
+ const coreLine = featureMap(repo, cfg).split('\n').find((l) => /^\s*[-*] /.test(l) && l.includes(MARK.core));
4036
+ return mapChange({ changed, tracked, journeys: cfg.journeys, skill: cfg.skill, features, coreFeature: coreLine?.match(/\]\(([^)#]+)\.md\)/)?.[1], noBase });
4037
+ }
3995
4038
  // Which journeys the change needs (src/affected.ts) and whether HEAD's proof covers them. The change is read from `baseArg`,
3996
4039
  // else from the newest receipt before HEAD (the commits since the last proof), else from the merge base with the default
3997
4040
  // branch; with none, the mapping is uncertain and the core journey is needed.
@@ -4025,10 +4068,7 @@ function affectedOf(repo, cfg, baseArg) {
4025
4068
  // Untracked files are no part of the change until added, as everywhere else in done-done.
4026
4069
  const uncommitted = list('diff', '--name-only', '--no-renames', '-z', 'HEAD').filter(counts);
4027
4070
  const changed = [...new Set([...(base ? list('diff', '--name-only', '--no-renames', '-z', base.commit, 'HEAD').filter(counts) : []), ...uncommitted])];
4028
- const featDir = join(repo, cfg.skill, 'features');
4029
- const features = Object.fromEntries(skillFeatures(repo, cfg).map((n) => [n, repoText(join(featDir, `${n}.md`)) ?? '']));
4030
- const coreLine = featureMap(repo, cfg).split('\n').find((l) => /^\s*[-*] /.test(l) && l.includes(MARK.core));
4031
- const m = mapChange({ changed, tracked, journeys: cfg.journeys, skill: cfg.skill, features, coreFeature: coreLine?.match(/\]\(([^)#]+)\.md\)/)?.[1], noBase: base ? undefined : 'no receipt before HEAD and no default branch to diff against' });
4071
+ const m = mapDiff(repo, cfg, changed, tracked, base ? undefined : 'no receipt before HEAD and no default branch to diff against');
4032
4072
  const needed = [...m.journeys.map((j) => j.name), ...m.features.map((f) => `feature ${f.name}`)];
4033
4073
  // Proof: a valid receipt for HEAD whose runs include every journey and feature the change needs, with nothing uncommitted.
4034
4074
  const verified = verifyReceipt(repo, cfg, 'HEAD');
package/dist/config.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { basename, isAbsolute, join, resolve } from 'node:path';
3
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import { parse } from 'yaml';
5
5
  import { checkMatrix } from './matrix.js';
6
- import { parseDom } from './dom.js';
6
+ import { parseDom, parseLook } from './dom.js';
7
7
  export class UsageError extends Error {
8
8
  help;
9
9
  // The exact next command, when the error has one.
@@ -66,6 +66,7 @@ const KEYS = {
66
66
  instance: { scope: 'repo', type: 'instance' },
67
67
  matrix: { scope: 'repo', type: 'matrix' },
68
68
  dom: { scope: 'repo', type: 'dom' },
69
+ look: { scope: 'repo', type: 'look' },
69
70
  base_ref: { scope: 'repo', type: 'string' },
70
71
  'evidence.root': { scope: 'repo', type: 'string' },
71
72
  'evidence.allow_dirty': { scope: 'repo', type: 'boolean' },
@@ -219,6 +220,14 @@ function apply(target, text, file, scope) {
219
220
  target.dom = d;
220
221
  continue;
221
222
  }
223
+ if (key === 'look') {
224
+ const l = parseLook(value, dirname(file));
225
+ if (typeof l === 'string')
226
+ throw new UsageError(`${file}: look ${l}`);
227
+ target.from[key] = scope;
228
+ target.look = l;
229
+ continue;
230
+ }
222
231
  if (!ok)
223
232
  throw new UsageError(`${file}: ${key} must be ${want[spec.type] ?? `a ${spec.type}`}${key === 'matrix' ? `: ${checkMatrix(value)}` : ''}`);
224
233
  if (key === 'agent_config')
@@ -279,6 +288,7 @@ function defaultConfig(repo) {
279
288
  instance: null,
280
289
  matrix: null,
281
290
  dom: null,
291
+ look: null,
282
292
  base_ref: '',
283
293
  evidence: { local_root: join(home(), 'evidence'), root: '', allow_dirty: false, ignore_paths: [] },
284
294
  secrets: { patterns: {}, allow: [] },
package/dist/dom.js CHANGED
@@ -5,10 +5,12 @@
5
5
  // "console_errors": ["<message>", ...], "failed_requests": [{"url": "<request url>", "status": <HTTP status, 0 when none>}],
6
6
  // "squashed_text": [{"element": "<element>", "text": "<its text, ~40 chars>", "chars": <width>, "lines": <n>, "words": <n>}],
7
7
  // "clipped_text": [{"element": "<element>", "text": "<its text>", "by": "<the container that cuts it>"}],
8
- // "cut_off": [{"element": "<element>", "text": "<its text>", "edges": ["left" | "right" | "top", ...]}]}
9
- // squashed_text, clipped_text and cut_off are optional, so a record written before them stays valid.
8
+ // "cut_off": [{"element": "<element>", "text": "<its text>", "edges": ["left" | "right" | "top", ...]}],
9
+ // "controls": [{"element": "<element>", "text": "<its label>", "native": <true when drawn as the browser's own control>, "height": <px, absent for a textarea or list box>}],
10
+ // "actions": [{"selector": "<what was clicked>", "text": "<its label>", "failed": "<why it did not complete, absent when it did>"}]}
11
+ // squashed_text, clipped_text, cut_off, controls and actions are optional, so a record written before them stays valid.
10
12
  import { readFileSync } from 'node:fs';
11
- import { basename } from 'node:path';
13
+ import { basename, resolve } from 'node:path';
12
14
  // The measurement the recipe passes to its browser tool's eval, so no agent invents one: every visible element with its
13
15
  // own non-empty text is squashed when its box is under 8 characters wide (box width / the average glyph width of its
14
16
  // font) while its text is wider than the box, or when its text sits on more line boxes than it has words.
@@ -67,6 +69,39 @@ export const LOOK = `() => {
67
69
  }
68
70
  return { overflow: document.documentElement.scrollWidth > vw, overflowing: [...document.querySelectorAll('body *')].filter((e) => e.getBoundingClientRect().right > vw + 1).slice(0, 10).map(name), ...out };
69
71
  }`.replace(/\s*\n\s*/g, ' ');
72
+ // Every visible control with its label and height, and whether it is drawn as the browser's own: its background and
73
+ // border are what the browser gives the same control (same tag, input type and colour scheme) on a page with no styles.
74
+ // A checkbox, radio, range, colour or file input is not measured, and a textarea or list box has no height to hold.
75
+ export const CONTROLS = `() => {
76
+ const name = (e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : e.classList[0] ? '.' + e.classList[0] : '');
77
+ const f = document.createElement('iframe'); f.style.cssText = 'position:fixed;width:0;height:0;border:0'; document.documentElement.append(f);
78
+ const d = f.contentDocument; d.open(); d.write('<!doctype html><body>'); d.close();
79
+ const keys = ['backgroundColor', 'borderTopStyle', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius']; const out = [];
80
+ for (const e of document.querySelectorAll('button, select, textarea, input, [role=button]')) {
81
+ const s = getComputedStyle(e); const r = e.getBoundingClientRect();
82
+ if (!e.getClientRects().length || s.visibility !== 'visible' || r.width < 1 || r.height < 1 || (e.tagName === 'INPUT' && ['checkbox', 'radio', 'range', 'color', 'file'].includes(e.type))) continue;
83
+ let native = false;
84
+ if (['BUTTON', 'SELECT', 'TEXTAREA', 'INPUT'].includes(e.tagName)) { const t = d.createElement(e.tagName); if (e.tagName === 'INPUT') t.type = e.type; t.style.colorScheme = s.colorScheme; d.body.append(t); const u = f.contentWindow.getComputedStyle(t); native = keys.every((k) => s[k] === u[k]); }
85
+ const tall = e.tagName === 'TEXTAREA' || (e.tagName === 'SELECT' && (e.multiple || e.size > 1));
86
+ out.push({ element: name(e), text: (e.getAttribute('aria-label') || e.innerText || e.value || e.placeholder || '').replace(/\\s+/g, ' ').trim().slice(0, 40), native, ...(!tall && { height: Math.round(r.height) }) });
87
+ }
88
+ f.remove(); return out;
89
+ }`.replace(/\s*\n\s*/g, ' ');
90
+ // What a user can read of the page: its address, how many elements it has and its visible text. An action completes when
91
+ // this changes: a card moves, a reply shows as saved, a toast says so.
92
+ const STATE = "(() => location.href + '\\n' + document.querySelectorAll('body *').length + '\\n' + document.body.innerText)()";
93
+ // Where to click the action `sel` names, with its label and the page state before it, or why it cannot be clicked.
94
+ export const AIM = `(sel) => {
95
+ const e = document.querySelector(sel); if (!e) return { text: '', failed: 'is not on the page' };
96
+ e.scrollIntoView({ block: 'center' }); const r = e.getBoundingClientRect(); const x = r.left + r.width / 2; const y = r.top + r.height / 2;
97
+ const text = (e.getAttribute('aria-label') || e.innerText || e.value || '').replace(/\\s+/g, ' ').trim().slice(0, 40);
98
+ if (e.disabled || e.getAttribute('aria-disabled') === 'true') return { text, failed: 'is disabled' };
99
+ const hit = document.elementFromPoint(x, y);
100
+ if (!hit || (hit !== e && !e.contains(hit))) return { text, failed: 'is covered by ' + (hit ? hit.tagName.toLowerCase() + (hit.id ? '#' + hit.id : hit.classList[0] ? '.' + hit.classList[0] : '') : 'nothing that takes a click') };
101
+ return { text, x, y, before: ${STATE} };
102
+ }`.replace(/\s*\n\s*/g, ' ');
103
+ // Whether the page state differs from `before` within `ms`, checked each 50 ms.
104
+ export const CHANGED = `async (before, ms) => { for (let t = 0; t <= ms; t += 50) { if (${STATE} !== before) return true; await new Promise((r) => setTimeout(r, 50)); } return false; }`;
70
105
  const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
71
106
  const strings = (a) => Array.isArray(a) && a.every((s) => typeof s === 'string');
72
107
  // An optional record list: absent, or every item has an element and text and passes `more`.
@@ -100,8 +135,41 @@ export function parseDom(v) {
100
135
  return 'fail_on_overflow must be true or false';
101
136
  return { allow_console: v.allow_console ?? [], allow_requests: v.allow_requests ?? [], fail_on_overflow: v.fail_on_overflow ?? true };
102
137
  }
138
+ // The look block read from config in `repo`, or the reason it is not one. max_control is a number of px, or the name of
139
+ // a custom property in the tokens file (--ctl-h: 2.75rem), read as px with a rem or em of 16 px.
140
+ export function parseLook(v, repo) {
141
+ if (v == null)
142
+ v = {};
143
+ if (!isMap(v))
144
+ return 'a mapping with optional tokens and max_control';
145
+ for (const k of Object.keys(v))
146
+ if (!['tokens', 'max_control'].includes(k))
147
+ return `unknown key ${k} (tokens, max_control)`;
148
+ const { tokens, max_control: max } = v;
149
+ if (tokens !== undefined && (typeof tokens !== 'string' || !tokens))
150
+ return 'tokens must be the path of the design tokens file';
151
+ if (typeof max === 'number')
152
+ return max > 0 ? { ...(tokens && { tokens }), max_control: max } : 'max_control must be more than 0 px';
153
+ if (max === undefined)
154
+ return tokens ? { tokens } : {};
155
+ if (typeof max !== 'string' || !/^--[\w-]+$/.test(max))
156
+ return 'max_control must be a number of px or the name of a token such as --ctl-h';
157
+ if (!tokens)
158
+ return `max_control ${max} names a token, so tokens must name the file that sets it`;
159
+ let text;
160
+ try {
161
+ text = readFileSync(resolve(repo, tokens), 'utf8');
162
+ }
163
+ catch (e) {
164
+ return `tokens ${tokens} does not read: ${e.code ?? e.message}`;
165
+ }
166
+ const m = new RegExp(`(?<![\\w-])${max}\\s*:\\s*([\\d.]+)(px|rem|em)\\s*[;}]`).exec(text);
167
+ if (!m)
168
+ return `max_control ${max} is not set in ${tokens} as a px, rem or em size`;
169
+ return { tokens, max_control: Number(m[1]) * (m[2] === 'px' ? 1 : 16) };
170
+ }
103
171
  // Every non-allowed issue the capture's page-check record shows; a missing or malformed record is an issue too.
104
- export function domIssues(dom, capture) {
172
+ export function domIssues(dom, capture, ref) {
105
173
  const path = domRecord(capture);
106
174
  let r;
107
175
  try {
@@ -114,22 +182,28 @@ export function domIssues(dom, capture) {
114
182
  if (!isMap(r) || typeof r.url !== 'string' || typeof r.overflow !== 'boolean' || !strings(r.overflowing ?? []) || !strings(r.console_errors)
115
183
  || !Array.isArray(r.failed_requests) || !r.failed_requests.every((q) => isMap(q) && typeof q.url === 'string' && typeof q.status === 'number')
116
184
  || !list(r.squashed_text, (q) => [q.chars, q.lines, q.words].every((n) => typeof n === 'number'))
117
- || !list(r.clipped_text, (q) => typeof q.by === 'string') || !list(r.cut_off, (q) => strings(q.edges)))
118
- return [`page-check record ${basename(path)} is not {url, overflow, overflowing, console_errors, failed_requests: [{url, status}], squashed_text: [{element, text, chars, lines, words}], clipped_text: [{element, text, by}], cut_off: [{element, text, edges}]}`];
185
+ || !list(r.clipped_text, (q) => typeof q.by === 'string') || !list(r.cut_off, (q) => strings(q.edges))
186
+ || !list(r.controls, (q) => typeof q.native === 'boolean' && ['number', 'undefined'].includes(typeof q.height))
187
+ || !(r.actions === undefined || (Array.isArray(r.actions) && r.actions.every((q) => isMap(q) && typeof q.selector === 'string' && typeof q.text === 'string' && ['string', 'undefined'].includes(typeof q.failed)))))
188
+ return [`page-check record ${basename(path)} is not {url, overflow, overflowing, console_errors, failed_requests: [{url, status}], squashed_text: [{element, text, chars, lines, words}], clipped_text: [{element, text, by}], cut_off: [{element, text, edges}], controls: [{element, text, native, height}], actions: [{selector, text, failed}]}`];
119
189
  const allowed = (list, s) => list.some((re) => new RegExp(re).test(s));
120
190
  return [
121
- ...lookIssues({ ...r, overflow: dom.fail_on_overflow && r.overflow }),
191
+ ...lookIssues({ ...r, overflow: dom.fail_on_overflow && r.overflow }, ref),
122
192
  ...r.console_errors.filter((m) => !allowed(dom.allow_console, m)).map((m) => `${r.url} console error: ${m}`),
123
193
  ...r.failed_requests.filter((q) => (q.status === 0 || q.status >= 400) && !allowed(dom.allow_requests, q.url)).map((q) => `${r.url} request failed (${q.status || 'no response'}): ${q.url}`),
124
194
  ];
125
195
  }
126
- // The look defects a page-check record shows: horizontal overflow, squashed, clipped and cut-off text or elements.
127
- export function lookIssues(r) {
196
+ // The look defects a page-check record shows: horizontal overflow, squashed, clipped and cut-off text or elements, and an
197
+ // action that did not complete; against a reference, also a control drawn as the browser's own or taller than max_control.
198
+ export function lookIssues(r, ref) {
128
199
  const quoted = (t) => (t ? ` "${t}"` : '');
129
200
  return [
130
201
  ...(r.overflow ? [`${r.url} overflows the viewport horizontally${r.overflowing?.length ? ` (${r.overflowing.join(', ')})` : ''}`] : []),
131
202
  ...(r.squashed_text ?? []).map((q) => `${r.url} squashed text: ${q.element} "${q.text}" is ${q.chars} characters wide (lines ${q.lines}, words ${q.words})`),
132
203
  ...(r.clipped_text ?? []).map((q) => `${r.url} clipped text: ${q.element}${quoted(q.text)} is cut by ${q.by}`),
133
204
  ...(r.cut_off ?? []).map((q) => `${r.url} cut off: ${q.element}${quoted(q.text)} runs past the ${q.edges.join(' and ')} edge of the viewport`),
205
+ ...(ref ? r.controls ?? [] : []).filter((q) => q.native).map((q) => `${r.url} unstyled control: ${q.element}${quoted(q.text)} is drawn as the browser's own control, not with the design's style`),
206
+ ...(ref?.max_control ? r.controls ?? [] : []).filter((q) => q.height > ref.max_control + 0.5).map((q) => `${r.url} oversized control: ${q.element}${quoted(q.text)} is ${q.height} px tall, over the ${ref.max_control} px the design allows`),
207
+ ...(r.actions ?? []).filter((q) => q.failed).map((q) => `${r.url} action did not complete: ${q.selector}${quoted(q.text)} ${q.failed}`),
134
208
  ];
135
209
  }
package/dist/lint.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // `done-done lint`: the deterministic merge gate on a worker's claim. It reads the newest "## Checks" block of a PR body
2
2
  // or report file and fails an unproven, reused or oversized claim. No judge, no model; the repo config only switches on
3
- // the two optional size rules (checks.upstream_catch_up, checks.moved_from) and extra generated paths (checks.generated_paths).
3
+ // the two optional size rules (checks.upstream_catch_up, checks.moved_from) and extra generated paths (checks.generated_paths);
4
+ // files the target repo marks generated in .gitattributes are not counted either.
4
5
  import { spawnSync } from 'node:child_process';
5
6
  import { createHash } from 'node:crypto';
6
7
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
@@ -8,6 +9,26 @@ import { basename, dirname, isAbsolute, join, matchesGlob, resolve } from 'node:
8
9
  // Third-party, legal text and committed build output are not reviewable code.
9
10
  const GENERATED = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|go\.sum|[^/]*\.lock)$|(^|\/)vendor\/|(^|\/)dist\/|(^|\/)(LICENSE|LICENSES|NOTICE|COPYING)[^/]*$/;
10
11
  const generatedBy = (globs = []) => (p) => GENERATED.test(p) || globs.some((g) => matchesGlob(p, g));
12
+ // A target repo's own .gitattributes marks generated files: linguist-generated, linguist-generated=true or -diff. The last matching line wins;
13
+ // linguist-generated=false and an unset attribute mean not generated. A pattern with no slash matches the basename, like git.
14
+ function attrGenerated(attrs) {
15
+ const rules = attrs.split('\n').map((l) => l.trim().split(/\s+/)).filter((w) => w[0] && !w[0].startsWith('#') && w.length > 1);
16
+ return (p) => {
17
+ let gen = false;
18
+ for (const [pat, ...as] of rules) {
19
+ const glob = pat.replace(/^\//, '');
20
+ if (!matchesGlob(glob.includes('/') ? p : basename(p), glob.endsWith('/') ? `${glob}**` : glob))
21
+ continue;
22
+ for (const a of as) {
23
+ if (/^(linguist-generated(=true)?|-diff)$/.test(a))
24
+ gen = true;
25
+ else if (/^(linguist-generated=false|-linguist-generated|!linguist-generated)$/.test(a))
26
+ gen = false;
27
+ }
28
+ }
29
+ return gen;
30
+ };
31
+ }
11
32
  export const SIZE_LIMIT = 1000;
12
33
  const FIX_TITLE = /^(fix|bugfix|hotfix)\b/i;
13
34
  const NOT_APPLICABLE = /^n\/?a\b/i;
@@ -70,7 +91,8 @@ function movedLines(run, nwo, body, paths, isGenerated) {
70
91
  const sha256 = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
71
92
  // src: a PR URL or a report file; ev: the folder the evidence names are relative to; dir: where the hash store and log live.
72
93
  export function lint(src, ev, dir, rules = {}, run = gh) {
73
- const isGenerated = generatedBy(rules.generated_paths);
94
+ const byConfig = generatedBy(rules.generated_paths);
95
+ let isGenerated = byConfig;
74
96
  let warn = ''; // set for a PR that touches src/ with no journeys/ file; it only softens a PASS
75
97
  const log = (v) => {
76
98
  if (v.ok && warn)
@@ -95,6 +117,12 @@ export function lint(src, ev, dir, rules = {}, run = gh) {
95
117
  const author = pr.author?.login ?? '';
96
118
  if (/^(app\/)?(dependabot|renovate)(\[bot\])?$/.test(author))
97
119
  return log({ ok: true, why: 'bot', head });
120
+ const nwo = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\//.exec(src)?.[1];
121
+ const attrs = nwo && [head, pr.baseRefOid].map((ref) => ref && run('api', '-H', 'Accept: application/vnd.github.raw', `repos/${nwo}/contents/.gitattributes?ref=${ref}`)).find((a) => a && a.status === 0);
122
+ if (attrs) {
123
+ const byAttr = attrGenerated(attrs.stdout);
124
+ isGenerated = (p) => byConfig(p) || byAttr(p);
125
+ }
98
126
  const paths = (pr.files ?? []).map((f) => f.path ?? '');
99
127
  if (paths.some((p) => p.startsWith('src/')) && !paths.some((p) => p.startsWith('journeys/')))
100
128
  warn = 'the diff changes src/ and no journeys/ file: run the journey, commit it, then open the PR';
@@ -103,7 +131,6 @@ export function lint(src, ev, dir, rules = {}, run = gh) {
103
131
  const d = run('pr', 'diff', src);
104
132
  if (!d.status) {
105
133
  let net = netLines(d.stdout, isGenerated);
106
- const nwo = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\//.exec(src)?.[1];
107
134
  if (nwo && rules.upstream_catch_up && total(net) >= SIZE_LIMIT)
108
135
  net = without(net, upstreamDelta(run, nwo, head, pr.baseRefOid ?? '', isGenerated));
109
136
  if (nwo && rules.moved_from && total(net) >= SIZE_LIMIT)
package/dist/look.js CHANGED
@@ -3,14 +3,17 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { UsageError } from './config.js';
6
- import { domRecord, LOOK, SQUASHED_TEXT } from './dom.js';
6
+ import { AIM, CHANGED, CONTROLS, domRecord, LOOK, SQUASHED_TEXT } from './dom.js';
7
7
  import { findBin } from './agents.js';
8
8
  // done-done look: opens one page in headless Chrome at a phone viewport over the DevTools protocol (Node's own
9
- // WebSocket, no browser library), writes the screenshot and its page-check record next to it, and returns the record.
9
+ // WebSocket, no browser library), clicks each action in `acts` in order (a CSS selector each) and waits for it to complete,
10
+ // then writes the screenshot and its page-check record next to it, and returns the record.
10
11
  // Chrome runs without its sandbox so it starts in containers: point look at your own app, not untrusted sites.
11
12
  const BROWSERS = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'];
12
13
  const WAIT = 30_000;
13
- export async function look(url, capture, width, height) {
14
+ // How long an action has to change what a user can read of the page.
15
+ const ACT = 5_000;
16
+ export async function look(url, capture, width, height, acts = []) {
14
17
  const bin = process.env.DONE_DONE_CHROME ? findBin(process.env.DONE_DONE_CHROME) : BROWSERS.map(findBin).find(Boolean);
15
18
  if (!bin)
16
19
  throw new UsageError(`look needs Chrome or Chromium: ${process.env.DONE_DONE_CHROME ? `DONE_DONE_CHROME=${process.env.DONE_DONE_CHROME} is not an executable` : `none of ${BROWSERS.slice(0, 4).join(', ')} is on PATH`}`, 'Install Chrome or Chromium, or set DONE_DONE_CHROME to its binary');
@@ -93,8 +96,23 @@ export async function look(url, capture, width, height) {
93
96
  throw new Error(`${url} did not load: ${nav.errorText}`);
94
97
  await loaded;
95
98
  // Web fonts change text widths, so measure once they are in and two frames have painted.
96
- await evaluate('document.fonts.ready.then(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))))');
97
- const record = { url, ...(await evaluate(`(${LOOK})()`)), console_errors: consoleErrors, failed_requests: failed, squashed_text: await evaluate(`(${SQUASHED_TEXT})()`) };
99
+ const settle = () => evaluate('document.fonts.ready.then(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))))');
100
+ await settle();
101
+ // Each action is a real click at its centre, so a control under a toast or overlay fails as covered.
102
+ const actions = [];
103
+ for (const selector of acts) {
104
+ const at = await evaluate(`(${AIM})(${JSON.stringify(selector)})`);
105
+ if (!at.failed) {
106
+ for (const type of ['mousePressed', 'mouseReleased'])
107
+ await send('Input.dispatchMouseEvent', { type, x: at.x, y: at.y, button: 'left', clickCount: 1 });
108
+ if (!(await evaluate(`(${CHANGED})(${JSON.stringify(at.before)}, ${ACT})`)))
109
+ at.failed = `changed nothing a user can read on the page (its text, elements or address) within ${ACT / 1000}s`;
110
+ }
111
+ actions.push({ selector, text: at.text, ...(at.failed && { failed: at.failed }) });
112
+ }
113
+ if (acts.length)
114
+ await evaluate('scrollTo(0, 0)').then(settle);
115
+ const record = { url, ...(await evaluate(`(${LOOK})()`)), console_errors: consoleErrors, failed_requests: failed, squashed_text: await evaluate(`(${SQUASHED_TEXT})()`), controls: await evaluate(`(${CONTROLS})()`), ...(acts.length && { actions }) };
98
116
  const shot = await send('Page.captureScreenshot', { format: 'png' });
99
117
  writeFileSync(capture, Buffer.from(shot.data, 'base64'));
100
118
  writeFileSync(domRecord(capture), `${JSON.stringify(record, null, 2)}\n`);
@@ -0,0 +1,151 @@
1
+ ------------------------------ MODULE PaneAttach ------------------------------
2
+ (* Two Herdr panes, one shared tree refresh and one attach, as muxr v0.2.9 runs *)
3
+ (* them on @byokit/herdr 0.16.1. Pane A closes; pane B survives and is the pane *)
4
+ (* the user opens. Each action names the code it stands for; see README.md. *)
5
+ EXTENDS FiniteSets
6
+
7
+ CONSTANTS
8
+ Matched, \* reply-id shapes socket.js counts as a refusal of its own watch
9
+ DrainsQueue \* runBootstrap starts the queued follow-up (FALSE: broken variant)
10
+
11
+ Panes == {"A", "B"}
12
+ Callers == {"R", "U"} \* R: any other refresh caller; U: the user's attach to B
13
+ \* Herdr answers a watch on a closed pane with id "<id>:sub:0:probe" (muxr PR 1006).
14
+ RefusalShape == "subProbe"
15
+
16
+ VARIABLES
17
+ alive, \* Herdr: the pane exists
18
+ closedEvt, \* A's pane_closed event is on its way to the kit's event socket
19
+ booting, \* kit.js `booting`
20
+ queued, \* kit.js `queued`
21
+ phase, \* runBootstrap: "idle", "reading" (snapshot in flight), "acks"
22
+ waiters, \* kit.js `waiters`
23
+ nextWaiters, \* kit.js `nextWaiters`
24
+ watch, \* per-pane status watch socket
25
+ ready, \* that watch's `ready` promise
26
+ refusal, \* Herdr has refused this pane's watch (the pane is gone for good)
27
+ caller \* each refresh() caller
28
+
29
+ vars == <<alive, closedEvt, booting, queued, phase, waiters, nextWaiters,
30
+ watch, ready, refusal, caller>>
31
+
32
+ TypeOK ==
33
+ /\ alive \in [Panes -> BOOLEAN]
34
+ /\ closedEvt \in BOOLEAN /\ booting \in BOOLEAN /\ queued \in BOOLEAN
35
+ /\ phase \in {"idle", "reading", "acks"}
36
+ /\ waiters \subseteq Callers /\ nextWaiters \subseteq Callers
37
+ /\ watch \in [Panes -> {"none", "opening", "retry", "open", "stopped"}]
38
+ /\ ready \in [Panes -> {"none", "pending", "ok", "refused"}]
39
+ /\ refusal \in [Panes -> BOOLEAN]
40
+ /\ caller \in [Callers -> {"idle", "waiting", "done"}]
41
+
42
+ \* The kit has started: its first bootstrap landed and both watches acked.
43
+ Init ==
44
+ /\ alive = [p \in Panes |-> TRUE]
45
+ /\ closedEvt = FALSE /\ booting = FALSE /\ queued = FALSE /\ phase = "idle"
46
+ /\ waiters = {} /\ nextWaiters = {}
47
+ /\ watch = [p \in Panes |-> "open"]
48
+ /\ ready = [p \in Panes |-> "ok"]
49
+ /\ refusal = [p \in Panes |-> FALSE]
50
+ /\ caller = [c \in Callers |-> "idle"]
51
+
52
+ \* runBootstrap -> bootstrap sends `session.snapshot` (kit.js:523-530, 363-371).
53
+ StartBoot == booting' = TRUE /\ phase' = "reading"
54
+
55
+ \* kit.refresh() (kit.js:694-706). U reaches it through open -> resolvePane ->
56
+ \* refreshSnapshot -> client.kit.refresh() (herdrSessionSource.ts:2606, 1878-1897, 1578-1583).
57
+ Refresh(c) ==
58
+ /\ caller[c] = "idle"
59
+ /\ caller' = [caller EXCEPT ![c] = "waiting"]
60
+ /\ IF booting
61
+ THEN /\ queued' = TRUE /\ nextWaiters' = nextWaiters \cup {c}
62
+ /\ UNCHANGED <<waiters, booting, phase>>
63
+ ELSE /\ waiters' = waiters \cup {c} /\ StartBoot
64
+ /\ UNCHANGED <<queued, nextWaiters>>
65
+ /\ UNCHANGED <<alive, closedEvt, watch, ready, refusal>>
66
+
67
+ \* Pane A closes. Herdr closes an acked watch's socket, which re-dials after 2 s
68
+ \* (socket.js:115-121); a watch still opening is answered by HerdrAnswer.
69
+ CloseA ==
70
+ /\ alive["A"]
71
+ /\ alive' = [alive EXCEPT !["A"] = FALSE]
72
+ /\ closedEvt' = TRUE
73
+ /\ watch' = IF watch["A"] = "open" THEN [watch EXCEPT !["A"] = "retry"] ELSE watch
74
+ /\ UNCHANGED <<booting, queued, phase, waiters, nextWaiters, ready, refusal, caller>>
75
+
76
+ \* A structural event queues a bootstrap, or a follow-up while one runs (kit.js:582-597, 562-568).
77
+ ClosedEventLands ==
78
+ /\ closedEvt
79
+ /\ closedEvt' = FALSE
80
+ /\ IF booting
81
+ THEN queued' = TRUE /\ UNCHANGED <<booting, phase>>
82
+ ELSE StartBoot /\ UNCHANGED queued
83
+ /\ UNCHANGED <<alive, waiters, nextWaiters, watch, ready, refusal, caller>>
84
+
85
+ \* The snapshot lands: stop the old status watches, open one per agent pane in it
86
+ \* (kit.js:409-414, 440-447; openStatusWatch kit.js:496-514).
87
+ SnapshotLands ==
88
+ /\ phase = "reading"
89
+ /\ phase' = "acks"
90
+ /\ watch' = [p \in Panes |-> IF alive[p] THEN "opening"
91
+ ELSE IF watch[p] = "none" THEN "none" ELSE "stopped"]
92
+ /\ ready' = [p \in Panes |-> IF alive[p] THEN "pending" ELSE "none"]
93
+ /\ UNCHANGED <<alive, closedEvt, booting, queued, waiters, nextWaiters, refusal, caller>>
94
+
95
+ \* Herdr answers one `events.subscribe` (socket.js:94-112). A live pane acks. A closed
96
+ \* pane is refused, then Herdr closes the socket: a matched refusal settles `ready` false
97
+ \* and stops; an unmatched one is ignored and the close handler re-dials (socket.js:115-121).
98
+ HerdrAnswer(p) ==
99
+ /\ watch[p] = "opening"
100
+ /\ IF alive[p]
101
+ THEN /\ watch' = [watch EXCEPT ![p] = "open"]
102
+ /\ ready' = IF ready[p] = "pending" THEN [ready EXCEPT ![p] = "ok"] ELSE ready
103
+ /\ UNCHANGED refusal
104
+ ELSE /\ refusal' = [refusal EXCEPT ![p] = TRUE]
105
+ /\ IF RefusalShape \in Matched
106
+ THEN /\ watch' = [watch EXCEPT ![p] = "stopped"]
107
+ /\ ready' = IF ready[p] = "pending" THEN [ready EXCEPT ![p] = "refused"] ELSE ready
108
+ ELSE /\ watch' = [watch EXCEPT ![p] = "retry"] /\ UNCHANGED ready
109
+ /\ UNCHANGED <<alive, closedEvt, booting, queued, phase, waiters, nextWaiters, caller>>
110
+
111
+ \* later(open, backoff) dials a fresh watch with a new id (socket.js:88-92, 120-124).
112
+ Retry(p) ==
113
+ /\ watch[p] = "retry"
114
+ /\ watch' = [watch EXCEPT ![p] = "opening"]
115
+ /\ UNCHANGED <<alive, closedEvt, booting, queued, phase, waiters, nextWaiters, ready, refusal, caller>>
116
+
117
+ \* Every watch this bootstrap opened has settled (Promise.allSettled, kit.js:446-447), so
118
+ \* runBootstrap's statusWatchReady() returns: answer the waiters and start the queued
119
+ \* follow-up for nextWaiters (kit.js:531-557). An answered attach finds B in the tree.
120
+ AcksSettle ==
121
+ /\ phase = "acks"
122
+ /\ \A p \in Panes : ready[p] # "pending"
123
+ /\ caller' = [c \in Callers |-> IF c \in waiters THEN "done" ELSE caller[c]]
124
+ /\ IF queued /\ DrainsQueue
125
+ THEN /\ queued' = FALSE /\ waiters' = nextWaiters /\ nextWaiters' = {}
126
+ /\ StartBoot
127
+ ELSE /\ waiters' = {} /\ booting' = FALSE /\ phase' = "idle"
128
+ /\ UNCHANGED <<queued, nextWaiters>>
129
+ /\ UNCHANGED <<alive, closedEvt, watch, ready, refusal>>
130
+
131
+ KitStep == ClosedEventLands \/ SnapshotLands \/ AcksSettle
132
+ \/ \E p \in Panes : HerdrAnswer(p) \/ Retry(p)
133
+
134
+ Next == KitStep \/ CloseA \/ \E c \in Callers : Refresh(c)
135
+
136
+ \* Herdr answers every request and the kit's timers fire: weak fairness on the kit and
137
+ \* Herdr's replies. Pane closes and user actions get none; they may never happen.
138
+ Spec == Init /\ [][Next]_vars
139
+ /\ WF_vars(ClosedEventLands) /\ WF_vars(SnapshotLands) /\ WF_vars(AcksSettle)
140
+ /\ \A p \in Panes : WF_vars(HerdrAnswer(p)) /\ WF_vars(Retry(p))
141
+
142
+ \* Property 1: after a final refusal, its watch settles (stops for good).
143
+ WatchSettles == \A p \in Panes : refusal[p] ~> (watch[p] = "stopped")
144
+
145
+ \* Property 2: an attach to the surviving pane B eventually answers. This model has no
146
+ \* failure path (no outage, no snapshot error), so an answer here is a success.
147
+ AttachAnswers == (caller["U"] = "waiting") ~> (caller["U"] = "done")
148
+
149
+ \* Witness: violated exactly when some run opens B, so the properties are not vacuous.
150
+ NeverAttached == caller["U"] # "done"
151
+ =============================================================================
@@ -0,0 +1,109 @@
1
+ # Pane-attach race: a statechart pilot
2
+
3
+ A small statechart and an executable model of one real bug: in muxr v0.2.9, a pane that closes before its status watch opens leaves the shared Herdr tree read waiting forever, so opening a pane that is still alive hangs. A pinned model checker finds that path on the old code, and the path is written below as the steps a user takes.
4
+
5
+ **This is supporting evidence only. It is not proof that muxr works or fails.** Real-product replay: **not checked**. No Herdr lifecycle command was run and muxr was not touched. The journey entry that a real replay would follow is [pane-attach-race.md](pane-attach-race.md). It is written in the format of muxr's project skill and is waiting for an isolated Herdr lab.
6
+
7
+ Files: [PaneAttach.tla](PaneAttach.tla) (model and properties), [check.mjs](check.mjs) (repeatable check), [pane-attach-race.md](pane-attach-race.md) (project-skill journey entry).
8
+
9
+ ## The code it models
10
+
11
+ | Model action | Real code (muxr tag `v0.2.9`, `@byokit/herdr` 0.16.1 as published on npm) |
12
+ |---|---|
13
+ | `Refresh(U)`: the user opens pane B | `open` → `resolvePane` → `refreshSnapshot` → `client.kit.refresh()` (`apps/host/src/agent/infrastructure/herdrSessionSource.ts:2606, 1878-1897, 1578-1583`) |
14
+ | `Refresh(c)` joins a running read or starts one | `HerdrKit.refresh()` (`dist/kit.js:694-706`) |
15
+ | `StartBoot` / `SnapshotLands` | `runBootstrap` → `bootstrap` sends `session.snapshot`, stops the old status watches and opens one per agent pane (`dist/kit.js:523-530, 363-371, 409-414, 440-447`; `openStatusWatch` `dist/kit.js:496-514`) |
16
+ | `CloseA`, then `ClosedEventLands` | a structural event queues a bootstrap, or a follow-up while one runs (`dist/kit.js:582-597, 562-568`); Herdr closing an acked watch's socket re-dials it (`dist/socket.js:115-121`) |
17
+ | `HerdrAnswer(p)` | the watch's frame handler. Only an error whose id is `''` or exactly the watch's id counts as a refusal (`dist/socket.js:94-112`). Herdr refuses a watch on a closed pane with id `<id>:sub:0:probe` (the record in muxr PR 1006) |
18
+ | `Retry(p)` | `later(open, backoff)` dials a new watch with a new id (`dist/socket.js:88-92, 120-124`) |
19
+ | `AcksSettle` | `Promise.allSettled` over the watches' `ready` promises; `runBootstrap` then answers its waiters and starts the queued follow-up (`dist/kit.js:446-447, 531-557`) |
20
+
21
+ Source identity: muxr `v0.2.9` is commit `3b8321f897f984ee1bf186177bf89ebfd812de2d`, whose `apps/host/package.json` pins `@byokit/herdr` `0.16.1`. The kit tarball `herdr-0.16.1.tgz` has sha256 `407fc6fce982fd61e5f1a77f60b0516c51ef6bf6963998f8ac80330d6bc2f2c9` (gitHead `39be7f88b7b13b7a9f32fbc1a1d74567016198b0`). Its `dist/socket.js` has sha256 `525d439da77312880dc170f9bf8f186bb0a295fb3d7210372ab19eda44700a90` and its `dist/kit.js` has sha256 `e4e6eb848e3043e97f4c95c485390a963879df67feb7f57a3f188828960f8597`.
22
+
23
+ ## Statechart
24
+
25
+ One tree read in the kit:
26
+
27
+ ```mermaid
28
+ stateDiagram-v2
29
+ [*] --> Idle
30
+ Idle --> Reading: refresh() or a pane event
31
+ Reading --> Reading: refresh() while reading, queued as a follow-up
32
+ Reading --> AwaitingWatches: snapshot lands, one watch opened per agent pane
33
+ AwaitingWatches --> Idle: every watch settled, waiters answered
34
+ AwaitingWatches --> Reading: every watch settled, queued follow-up starts
35
+ ```
36
+
37
+ One pane's status watch:
38
+
39
+ ```mermaid
40
+ stateDiagram-v2
41
+ [*] --> Opening
42
+ Opening --> Open: Herdr accepts (pane alive), ready = true
43
+ Opening --> Stopped: refusal with an empty id or the watch's own id, ready = false
44
+ Opening --> Redial: refusal with id ...sub:0:probe, ignored in 0.16.1
45
+ Open --> Redial: Herdr closes the socket (pane closed)
46
+ Redial --> Opening: re-dial with a new id
47
+ Open --> Stopped: the next snapshot stops old watches
48
+ ```
49
+
50
+ The bad loop is `Opening → Redial → Opening`, entered while the tree read waits in `AwaitingWatches`. The watch's `ready` never settles, so the tree read never leaves `AwaitingWatches`. Every `refresh()` waits behind it, and so does every pane open.
51
+
52
+ ## Properties
53
+
54
+ 1. **After a final refusal, its watch settles** (`WatchSettles`): once Herdr refuses a pane's watch, which happens only after the pane has closed for good, that watch eventually stops.
55
+ 2. **Opening a surviving pane eventually answers** (`AttachAnswers`): once the user opens pane B, the open eventually finishes. This model has no outage or snapshot error, so the only answer is success. Explicit failure paths are outside its scope.
56
+
57
+ `NeverAttached` is a witness. It is violated exactly when some run opens B, which shows the properties are not passing trivially.
58
+
59
+ ## Results
60
+
61
+ Run on 22 October 2026 with `node docs/examples/statechart-pilot/check.mjs <tla2tools.jar> <out-dir>`, which exited 0:
62
+
63
+ | Run | Kit behaviour | Checks | Expected | Got | Distinct states |
64
+ |---|---|---|---|---|---|
65
+ | `old-watch` | 0.16.1 matcher (`''` or `<id>`) | `WatchSettles` | violated | violated | 460 |
66
+ | `old-attach` | 0.16.1 matcher | `AttachAnswers` | violated | violated | 460 |
67
+ | `old-witness` | 0.16.1 matcher | `NeverAttached` | violated (B can open) | violated | 97 |
68
+ | `fixed` | also matches `<id>:` ids (the kit fix named in PR 1006) | both | hold | hold | 399 |
69
+ | `fixed-witness` | corrected matcher | `NeverAttached` | violated (B can open) | violated | 93 |
70
+ | `broken-attach` | corrected matcher, but `runBootstrap` never starts the queued follow-up | `AttachAnswers` | violated | violated | 435 |
71
+ | `broken-watch` | the same broken variant | `WatchSettles` | holds | holds | 435 |
72
+
73
+ The broken variant is caught by the attach property, which was not written for it. The watch property still holds for that variant, so the two properties check different things.
74
+
75
+ ### The counterexample, as user steps (`old-attach`)
76
+
77
+ 1. Umer opens agent B on the phone. The host asks the kit to re-read the Herdr tree.
78
+ 2. Something else re-reads the tree at the same moment, such as another open. It queues behind the first read.
79
+ 3. The tree comes back listing agents A and B. The kit opens a status watch for each and waits for both.
80
+ 4. Agent A's pane closes before Herdr has accepted A's watch.
81
+ 5. Herdr accepts B's watch.
82
+ 6. Herdr refuses A's watch with id `<id>:sub:0:probe` and closes the socket. The kit does not see that id as a refusal, logs nothing, and dials A's watch again.
83
+ 7. Each new dial gets the same refusal, forever.
84
+
85
+ What Umer sees: B stays on "connecting" even though B is alive. Every later open queues behind the same tree read. Only stopping the kit (a host restart) rejects the waiting opens. `old-watch` reaches the same loop one read later: the first tree read lands, and the queued follow-up read is the one that hangs.
86
+
87
+ ## Model report
88
+
89
+ - Checker: TLC2 Version 2.19 of 08 August 2024 (rev: 5a47802), from [tla2tools.jar v1.7.4](https://github.com/tlaplus/tlaplus/releases/tag/v1.7.4). Its sha256 is `936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88` and its sha1 `bee4a54f3ee3d4afc347c3240ec2d9e93b075104` matches the release notes. `check.mjs` refuses any other jar. It ran on OpenJDK 21.0.2 with `-workers 1 -deadlock`: v1.7.4's liveness check is only sound with one worker (tlaplus issue 971), and finished runs are allowed to stop.
90
+ - Model `PaneAttach.tla` sha256: `47caab3c3d5eb0fe34841ae841e93cafb51873b67e861fdd1d1e63aee3e7e739`.
91
+ - Properties (sha256 of each definition's text): `WatchSettles` `7b79e33aa012dad92414f12b13df535f87fb3ee4facee03b6b07d1695cf373a0`, `AttachAnswers` `40e9a75d27e47f467113ddc84428528769d192a80c73fa112891eb5e8b0da678`, `NeverAttached` `ab58eddaa46974dcbc9273bc1698e06255a1e04c74f44c5f22e501e1b71f122c`.
92
+ - Source commit: muxr `3b8321f897f984ee1bf186177bf89ebfd812de2d` (`v0.2.9`) with `@byokit/herdr` 0.16.1 (identity above).
93
+ - Bounds: two panes (A may close, B never closes), one other refresh caller and one open of B, each made once. Every open and dial of a watch is modelled, with no retry cap, because the watch loop is finite in state. A full search finds every reachable state; no depth limit is used.
94
+ - Assumptions:
95
+ - Herdr answers every request and the kit's timers fire. This is weak fairness on the kit and Herdr steps. The refusal is a delivered answer, not a lost packet, so a network that never answers is not mistaken for this bug.
96
+ - Closing a pane and the user's actions get no fairness; they may never happen.
97
+ - A closed pane never comes back.
98
+ - A snapshot shows the panes alive when it lands. A pane closing between Herdr's read and the reply landing is the same race as closing just after it lands.
99
+ - The `<id>:sub:0:probe` refusal shape comes from the record in muxr PR 1006, not from a Herdr run here.
100
+ - Not modelled: Herdr outages, snapshot errors, the event socket reconnecting, `kit.stop()`, and the 20-second refresh bound and kit restart that PR 1006 adds to the host. Whether that host recovery repairs the property is a separate check and is not claimed here.
101
+
102
+ ## Rerun it
103
+
104
+ ```bash
105
+ curl -fLo tla2tools.jar https://github.com/tlaplus/tlaplus/releases/download/v1.7.4/tla2tools.jar
106
+ node docs/examples/statechart-pilot/check.mjs tla2tools.jar <out-dir>
107
+ ```
108
+
109
+ Each run's TLC transcript, counterexamples included, and `model-report.json` with every hash land in `<out-dir>`. The command exits 1 when any run differs from the expected result above. When the kit or `herdrSessionSource.ts` changes, re-read the cited lines, update the model to match the code, and rerun. If the code no longer matches a row in the code-map table, the model is stale and its results do not apply.
@@ -0,0 +1,72 @@
1
+ // Model check for the pane-attach race pilot: runs the pinned TLC on PaneAttach.tla for the old kit, the corrected
2
+ // kit, a deliberately broken variant and two reachability witnesses, and fails unless each gives its expected result.
3
+ // Usage: node docs/examples/statechart-pilot/check.mjs <tla2tools.jar> <out-dir>. Needs java on PATH; writes one TLC
4
+ // transcript per run and model-report.json into <out-dir>. This is supporting evidence, never real-product proof.
5
+ import { spawnSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ // tla2tools.jar from https://github.com/tlaplus/tlaplus/releases/tag/v1.7.4 (sha1 bee4a54f… as its release notes list).
13
+ const TLC_SHA256 = '936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88';
14
+ const OLD = '{"exact", "empty"}'; // socket.js:95: frame.id === '' || frame.id === id
15
+ const FIXED = '{"exact", "empty", "subProbe"}'; // the kit fix PR 1006 names: match "<id>:" ids too
16
+ const RUNS = [
17
+ { name: 'old-watch', matched: OLD, drains: 'TRUE', check: 'PROPERTY WatchSettles', expect: 'violated' },
18
+ { name: 'old-attach', matched: OLD, drains: 'TRUE', check: 'PROPERTY AttachAnswers', expect: 'violated' },
19
+ { name: 'old-witness', matched: OLD, drains: 'TRUE', check: 'INVARIANT NeverAttached', expect: 'violated' },
20
+ { name: 'fixed', matched: FIXED, drains: 'TRUE', check: 'PROPERTY WatchSettles AttachAnswers', expect: 'holds' },
21
+ { name: 'fixed-witness', matched: FIXED, drains: 'TRUE', check: 'INVARIANT NeverAttached', expect: 'violated' },
22
+ // Broken variant: the corrected kit, but runBootstrap never starts the queued follow-up (kit.js:550-555).
23
+ { name: 'broken-attach', matched: FIXED, drains: 'FALSE', check: 'PROPERTY AttachAnswers', expect: 'violated' },
24
+ { name: 'broken-watch', matched: FIXED, drains: 'FALSE', check: 'PROPERTY WatchSettles', expect: 'holds' },
25
+ ];
26
+
27
+ const [jar, outArg] = process.argv.slice(2);
28
+ if (!jar || !outArg) {
29
+ console.error('usage: node docs/examples/statechart-pilot/check.mjs <tla2tools.jar> <out-dir>');
30
+ process.exit(2);
31
+ }
32
+ const sha256 = (data) => createHash('sha256').update(data).digest('hex');
33
+ const jarHash = sha256(readFileSync(jar));
34
+ if (jarHash !== TLC_SHA256) {
35
+ console.error(`check: ${jar} has sha256 ${jarHash}, not the pinned TLC v1.7.4 ${TLC_SHA256}`);
36
+ process.exit(2);
37
+ }
38
+ const here = dirname(fileURLToPath(import.meta.url));
39
+ const model = readFileSync(join(here, 'PaneAttach.tla'), 'utf8');
40
+ const definition = (name) => model.match(new RegExp(`^${name} ==.*(?:\\n[ \\t].*)*`, 'm'))[0];
41
+ const out = resolve(outArg);
42
+ mkdirSync(out, { recursive: true });
43
+ const work = mkdtempSync(join(tmpdir(), 'dd-paneattach-'));
44
+ const report = {
45
+ tool: '', java: spawnSync('java', ['-version'], { encoding: 'utf8' }).stderr.split('\n')[0], tla2tools_sha256: jarHash,
46
+ model_sha256: sha256(model),
47
+ properties: Object.fromEntries(['WatchSettles', 'AttachAnswers', 'NeverAttached'].map((p) => [p, sha256(definition(p))])),
48
+ runs: [],
49
+ };
50
+ let failed = false;
51
+ try {
52
+ copyFileSync(join(here, 'PaneAttach.tla'), join(work, 'PaneAttach.tla'));
53
+ for (const run of RUNS) {
54
+ const cfg = `SPECIFICATION Spec\nCONSTANTS\n Matched = ${run.matched}\n DrainsQueue = ${run.drains}\nINVARIANT TypeOK\n${run.check}\n`;
55
+ writeFileSync(join(work, `${run.name}.cfg`), cfg);
56
+ // One worker: TLC 1.7.4 liveness checking is only sound single-threaded (tlaplus issue 971).
57
+ const r = spawnSync('java', ['-cp', resolve(jar), 'tlc2.TLC', '-workers', '1', '-deadlock', '-cleanup',
58
+ '-metadir', join(work, `${run.name}.states`), '-config', `${run.name}.cfg`, 'PaneAttach.tla'], { cwd: work, encoding: 'utf8' });
59
+ const text = r.stdout + r.stderr;
60
+ writeFileSync(join(out, `${run.name}.txt`), `${cfg}\n${text}`);
61
+ report.tool ||= text.match(/TLC2 Version [^\n]*/)?.[0] ?? '';
62
+ const got = /No error has been found/.test(text) ? 'holds' : /(Temporal properties were violated|Invariant \w+ is violated)/.test(text) ? 'violated' : 'error';
63
+ const states = text.match(/(\d+) distinct states found/)?.[1];
64
+ report.runs.push({ ...run, cfg_sha256: sha256(cfg), got, distinct_states: Number(states), exit: r.status });
65
+ console.log(`${got === run.expect ? 'ok ' : 'FAIL'} ${run.name}: expected ${run.expect}, got ${got} (${states} distinct states)`);
66
+ if (got !== run.expect) failed = true;
67
+ }
68
+ } finally {
69
+ rmSync(work, { recursive: true, force: true });
70
+ }
71
+ writeFileSync(join(out, 'model-report.json'), `${JSON.stringify(report, null, 2)}\n`);
72
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,37 @@
1
+ # A pane that closes before its watch opens never stops other panes opening
2
+
3
+ A journey entry in the format of muxr's project skill (`.agents/skills/done-done-verify-pockit/features/`). It turns the model checker's counterexample in [README.md](README.md) into a journey on the real product. The entry lives here until muxr's verification owner replays it in an isolated Herdr lab and moves it into muxr's skill.
4
+
5
+ **Real-product replay: not checked.** The model results are supporting evidence only. They never count as proof that this journey passes or fails.
6
+
7
+ ## Sub-features
8
+
9
+ - While a tree read waits on the status watches, an agent pane closes before Herdr accepts its watch. Opening a different, live agent still finishes: the terminal opens, or the phone shows an explicit error. It never stays on "connecting".
10
+ - Later opens after that moment answer at normal speed, without a host restart.
11
+
12
+ ## How to get to it (user POV)
13
+
14
+ - On a busy computer, agents come and go. Open any live agent on the phone right as another agent finishes. The terminal opens.
15
+
16
+ ## Driving it with the private stack
17
+
18
+ Needs a separately authorized, isolated Herdr lab session. Never drive the captain's live Herdr.
19
+
20
+ 1. Start a lab host against a named lab Herdr session with two agent panes, A and B, and pair a lab phone client (Umer).
21
+ 2. Hold A's `pane.agent_status_changed` subscribe at a socket proxy so the kit's tree read waits for A's watch, then close pane A. This is step 4 of the counterexample.
22
+ 3. Release the subscribe. Herdr answers `{"id":"<id>:sub:0:probe","error":{"code":"pane_not_found"}}` and closes the socket.
23
+ 4. Open B from the phone, type a short line, and read it back in the terminal.
24
+ 5. Open B a second time.
25
+
26
+ Pass: steps 4 and 5 each open the terminal and show the typed line, or show an explicit failure on the phone, within 60 seconds. Fail: either open is still on "connecting" after 60 seconds.
27
+
28
+ PR 1006's `scripts/diagnostics/application/checkHerdrVanishedWatch.mjs` already holds a subscribe at a proxy, according to that PR's record. It checks the host client's tree read, not the phone, so it covers steps 1-3 only.
29
+
30
+ ## Evidence
31
+
32
+ - A phone screenshot after step 4 and after step 5.
33
+ - The host journal for the run, plus a proxy log showing A's refusal frame and its id.
34
+ - `steps.json` naming the muxr version, the starting state (two agents, A held), the steps above and what Umer sees at the end.
35
+ - Kept separately as supporting evidence: the model report from `node docs/examples/statechart-pilot/check.mjs` in done-done, at the muxr and kit commits that were driven.
36
+
37
+ On muxr v0.2.9 with `@byokit/herdr` 0.16.1, the model predicts step 4 hangs. With the kit's refusal-id fix, the model predicts both opens answer. Neither prediction has been checked on the real product.
package/docs/reference.md CHANGED
@@ -177,18 +177,23 @@ done-done never drives the browser: the recipe or journey writes one page-check
177
177
  {"url": "http://127.0.0.1:4100/", "overflow": true, "overflowing": ["div#wide"], "console_errors": ["boom"], "failed_requests": [{"url": "http://127.0.0.1:4100/x.js", "status": 404}],
178
178
  "squashed_text": [{"element": "h3.card-title", "text": "Approved", "chars": 1.6, "lines": 8, "words": 1}],
179
179
  "clipped_text": [{"element": "h3.card-title", "text": "Answered: the release notes are approved", "by": "its own box"}],
180
- "cut_off": [{"element": "div.clock", "text": "12:30", "edges": ["top"]}]}
180
+ "cut_off": [{"element": "div.clock", "text": "12:30", "edges": ["top"]}],
181
+ "controls": [{"element": "button#request", "text": "Request changes", "native": true, "height": 21}],
182
+ "actions": [{"selector": "#request", "text": "Request changes", "failed": "changed nothing a user can read on the page (its text, elements or address) within 5s"}]}
181
183
  ```
182
184
 
185
+ `controls` and `actions` are optional too; [`done-done look`](#look-check) writes `controls` on every page and `actions` when it clicks one. With a [`look` block](#look-check), the check fails on each control in `controls` drawn as the browser's own, and on one taller than `max_control`; it fails on each `actions` item with a `failed` reason with or without one. A recipe that drives its own browser runs done-done's `CONTROLS` measurement (exported from `dom.js` next to `LOOK`) for `controls`, and `AIM` and `CHANGED` around each click for `actions`.
186
+
183
187
  With your browser tool, for example chrome-devtools-axi: `overflow` and `overflowing` from `eval` (document `scrollWidth` beyond `clientWidth`, and the elements whose right edge passes it), `console_errors` from `console --type error`, `failed_requests` (status 400 or more, or 0 for no response) from `network`, `squashed_text` from `eval` of done-done's measurement function, run in each frame of the page (an embedded frame too). It lists every visible HTML element with its own text (SVG text has no layout box and is skipped; widths are untransformed layout pixels, so a scaled ancestor does not shrink them) whose box is under 8 characters wide (its width over the average glyph width of its font) while its text is wider than the box, or whose text sits on more lines (distinct line boxes) than it has words: a title wrapped one letter per line in a narrow column. `squashed_text`, `clipped_text` and `cut_off` are optional, so a record written without them stays valid; [`done-done look`](#look-check) writes all of them, and the check fails on each `clipped_text` and `cut_off` item as it does on `squashed_text`. The agent drive prompt carries the recipe, and a verify skill should carry it too. Verify's check and `done-done check` fail naming the capture and each issue not on an allow list, and fail when a capture has no record or a record is not this shape. No `dom` block keeps today's behaviour.
184
188
 
185
189
  ### Look check
186
190
 
187
- `done-done look` is the look step a journey calls on a web page. It opens the page in headless Chrome at a 390x844 phone viewport (a mobile device at 1x scale), measures the look defects in the DOM, writes the screenshot with its page-check record next to it, and exits 1 on any hit, naming the element, the kind and its text. It is a measurement step, not a verdict: with no hit it ends `result: clean` (exit 0), never `pass`, and its capture is judged when a `check` or `verify` cites it. No model runs here, so the same page gives the same answer. A worker's claim that a phone UI works needs this 390 px capture of the real page, plus the page's scroll-container count, as proof; a mock or another width is not proof.
191
+ `done-done look` is the look step a journey calls on a web page. It opens the page in headless Chrome at a 390x844 phone viewport (a mobile device at 1x scale), clicks each `--act` and checks it completes, measures the look defects in the DOM, writes the screenshot with its page-check record next to it, and exits 1 on any hit, naming the element, the kind and its text. It is a measurement step, not a verdict: with no hit it ends `result: clean` (exit 0), never `pass`, and its capture is judged when a `check` or `verify` cites it. Without `--mock` no model runs here, so the same page gives the same answer. A worker's claim that a phone UI works needs this 390 px capture of the real page, plus the page's scroll-container count, as proof; a mock or another width is not proof.
188
192
 
189
193
  ```sh
190
194
  done-done look http://127.0.0.1:3000/inbox --out "$DONE_DONE_EVIDENCE_ROOT/inbox.png"
191
195
  done-done look ./public/inbox.html # a path on disk opens as its file:// URL
196
+ done-done look "$DONE_DONE_BASE_URL/#decide" --act '#item-1 .approve' --mock design/decide.png --out "$DONE_DONE_EVIDENCE_ROOT/decide.png"
192
197
  ```
193
198
 
194
199
  A journey in `.done-done.yaml` that calls it (one command per screen, `&&` between them, so the first hit stops the journey):
@@ -211,17 +216,31 @@ journeys:
211
216
  | squashed text | done-done's [squashed-text measurement](#dom-assertions): a text block under 8 characters wide while its text does not fit, or on more lines than it has words |
212
217
  | clipped text | part of an element's text shows and part runs past a container that hides its overflow (`overflow: hidden` or `clip`), with no `text-overflow: ellipsis` (sideways) or line clamp (downwards): a card title cut to one line |
213
218
  | cut off | part of an element shows and part lies past the left, right or top edge of the viewport: a status-bar clock clipped at the top. The page is at its top, so content below the bottom edge is fine |
219
+ | action did not complete | an `--act <selector>` names nothing, a disabled control, or one covered where its centre is (a toast over a button), or its click changes nothing a user can read within 5 seconds: the page's visible text, its element count and its address all stay the same. A card that moves, a reply that shows as saved or a toast that says so is a change; a button that is only there is not |
220
+ | unstyled control | with a `look` block: a button, select, textarea or input drawn as the browser's own, its background and border the ones the browser gives the same control (same tag, input type and colour scheme) on a page with no styles. Checkbox, radio, range, colour and file inputs are not measured |
221
+ | oversized control | with `look.max_control`: a button, select, input or `role=button` taller than that many px. A textarea or list box is not measured |
222
+ | differs from the approved design | with `--mock <file>`: the [design check](#design-check)'s judge (the one the user set) names a defect on the capture next to that approved mock, the same request as `design --mock`, cached the same way |
214
223
 
215
224
  Something entirely outside its container or the viewport is hidden on purpose (a closed drawer, a visually hidden label) and is not a hit; a cut element's children are not listed again. Content in a scrolling container (`overflow: auto` or `scroll`, such as a carousel) scrolls into view and is not clipped.
216
- - **What it writes.** `--out <file.png>` (default `look.png` in `$DONE_DONE_EVIDENCE_ROOT` inside a journey, else in the current folder) gets the viewport screenshot, exactly the viewport size in pixels, and `<file>.dom.json` next to it the page-check record of [DOM assertions](#dom-assertions), with the page's console errors and failed requests too. look itself fails only on the four kinds above; with a `dom` block, verify's check also reads the record and applies its allow lists to console errors and failed requests.
225
+ - **The approved reference.** A `look` block in `.done-done.yaml` is the project's approved design reference; with none, look behaves as before:
226
+
227
+ ```yaml
228
+ look:
229
+ tokens: web/tokens.css # the approved design tokens file, relative to the repo
230
+ max_control: --ctl-h # the tallest a control may be: px (44), or a token in tokens set in px, rem or em (a rem or em is 16 px)
231
+ ```
232
+
233
+ The block alone turns on the unstyled-control check; `max_control` adds the size check. A token that is not set in the file, a tokens file that does not read or an unknown key is a config error (exit 2). The per-page reference is its mock: pass `--mock <file>` with the approved design of that screen. The `--act` clicks are real mouse clicks at each control's centre, in order, and the page they leave is the one measured and captured, so a journey drives the page to the state to check with them.
234
+ - **What it writes.** `--out <file.png>` (default `look.png` in `$DONE_DONE_EVIDENCE_ROOT` inside a journey, else in the current folder) gets the viewport screenshot, exactly the viewport size in pixels, and `<file>.dom.json` next to it the page-check record of [DOM assertions](#dom-assertions), with the page's console errors and failed requests too. look itself fails only on the kinds above; with a `dom` block, verify's check also reads the record and applies its allow lists to console errors and failed requests.
217
235
  - **Chrome.** look starts its own headless Chrome or Chromium (`google-chrome`, `google-chrome-stable`, `chromium` or `chromium-browser` on PATH, or `DONE_DONE_CHROME` naming the binary) with a throwaway profile, and removes both when it ends. It runs Chrome without its sandbox so it starts in containers, so point it at your own app, not untrusted sites. No Chrome is a usage error (exit 2) that says how to install or point to one; a page that does not load (its network error, or no load event within 30 seconds) is exit 2 with the cause.
218
- - **Limits.** Only the main frame is measured, not an embedded frame; a transform or `clip-path` that hides part of a box is not seen; nothing is clicked or scrolled first, so drive the page to the state to check (a URL per state) before you call look. Look at the capture too: a defect the DOM cannot see (a stretched image, a colour) is the [design check](#design-check)'s.
236
+ - **Limits.** Only the main frame is measured, not an embedded frame; a transform or `clip-path` that hides part of a box is not seen; nothing is scrolled first, and only `--act` clicks; drive any other state (typing, a URL per state) before you call look. Look at the capture too: a defect the DOM cannot see (a stretched image, a colour) is the [design check](#design-check)'s.
219
237
 
220
238
  | Result | Exit |
221
239
  |---|---|
222
240
  | no hit | 0 |
223
241
  | a hit of any kind | 1 |
224
- | bad flags, no Chrome, or the page did not load | 2 |
242
+ | bad flags, a bad `look` block, no Chrome, the page did not load, or `--mock` with no judge set | 2 |
243
+ | no hit, but the `--mock` judge is unsure | 3 |
225
244
 
226
245
  ### Lint check
227
246
 
@@ -238,6 +257,7 @@ journeys:
238
257
  - `checks.upstream_catch_up: true`: in a fork, lines the parent repo already shipped are not authored here, so lint subtracts the changes between the PR's base and the merge base with the parent's default branch. A PR that merges in no new upstream commit loses nothing.
239
258
  - `checks.moved_from: true`: a line in the PR body `moved-from: <owner>/<repo>@<ref> <dst-prefix>=<src-prefix>` (more `dst=src` pairs may follow) cancels added lines that match, byte for byte after trimming, the file at the same path under `<src-prefix>` in that repo at `<ref>`. The source repo must have the PR's owner; authored lines still count.
240
259
  - `checks.generated_paths: ["gen/**", "*.snap"]`: globs for the repo's own generated files, counted as generated beside the built-in ones above, so they stay out of the size count. Default none, so results do not change. Like the two rules above it applies to a PR URL, never a report file.
260
+ - `.gitattributes` in the target repo: a file it marks `linguist-generated`, `linguist-generated=true` or `-diff` is counted as generated too, with no config. `linguist-generated=false` and an unset attribute are not; the last matching line wins. `lint` reads the file at the PR head (else its base) and, like the rules above, only for a PR URL.
241
261
 
242
262
  ### Base-ref before
243
263
 
@@ -348,7 +368,7 @@ evidence: <paths of the evidence files, or "not proven: <cause>">
348
368
  |---|---|
349
369
  | Not empty | The file must contain bytes. |
350
370
  | Valid media | The magic bytes match the extension. PNG and JPEG are complete (end markers present) and at least 64×64. PNG is not a blank frame. WebP and GIF are not truncated. MP4/MOV and WebM/MKV decode at least two video packets (`ffprobe`). Text and log files hold text; JSON parses. |
351
- | Fresh | The file is in a run record whose commit is HEAD. The one exception: if the only commits since the run touch `ignore_paths`, the evidence still counts. An empty commit does not count as touching only `ignore_paths`. |
371
+ | Fresh | The file is in a run record whose commit is HEAD. Two exceptions: if the only commits since the run touch `ignore_paths`, the evidence still counts; and if the changed files touch no journey or feature the run covered (the same mapping as `done-done affected`, model-free), the proof holds and the result warns with both commits. A change that does touch the run's journey is stale. A receipt keeps each run's `commit` beside its own `head`. An empty commit does not count as touching only `ignore_paths`. |
352
372
  | Clean | The run was not dirty, unless `evidence.allow_dirty: true`. The run command also exited 0. |
353
373
  | Not reused | Fails when the files are byte-identical within one claim. Also fails when every file was already used to prove a different claim in this repo. A claim is named by `--claim-id <id>`, by default the current branch (with `--from <PR URL>`, the PR's branch; on a detached HEAD, the commit), so rewording the outcome is not reuse. One branch is one claim: several claims on one branch each need their own `--claim-id`, or they share one reuse scope. The ledger is `<root>/.done-done/ledger` in the evidence root, so each repo keeps its own (pin `app`, as `init` does, so two repos with the same directory name do not share one); `DONE_DONE_HOME` moves it only with the default evidence root. |
354
374
 
@@ -652,6 +672,7 @@ An `agent_config` entry merges with the defaults field by field, so `claude: { m
652
672
  | `instance` | none; the app under test started per run, see [Run-instance harness](#run-instance-harness) |
653
673
  | `matrix` | none, so each journey runs once; `theme`, `width`, `form` and `skip` set the [capture matrix](#capture-matrix) |
654
674
  | `dom` | none; page-check records per capture, see [DOM assertions](#dom-assertions) |
675
+ | `look` | none; the look step's approved design reference (`tokens`, `max_control`), see [Look check](#look-check) |
655
676
  | `inputs` | `[]`; repo-relative paths or globs the drive must leave alone, see [Input guard](#input-guard) |
656
677
  | `base_ref` | none; a ref `verify` also drives, pairing its captures with the head's, see [Base-ref before](#base-ref-before) |
657
678
  | `evidence.root` | `<local_root>/<app>`, outside the repo. A relative path is under the repo; an absolute path is used as-is |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "done-done-axi",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Proof that the real product works before a task is called done: commit-bound evidence runs and evidence checks.",
6
6
  "type": "module",
@@ -50,7 +50,11 @@ its findings and captures, and end it with `done-done respond --action fix` only
50
50
  A journey that `spends` runs only after the user approves spending: `done-done run <journey>` refuses it, so ask the user and use `verify`.
51
51
 
52
52
  For a web page, `done-done look <url>` is the look step a journey calls: it captures the page at a 390x844 phone viewport
53
- with its page-check record and exits 1 on squashed, clipped or cut-off UI, naming each element. A hit fails the journey;
53
+ with its page-check record and exits 1 on squashed, clipped or cut-off UI, naming each element. `--act <selector>` clicks a
54
+ control and fails when the page does not change (a card moves, a reply saves, a toast shows): a button that is only there
55
+ is not proof it works. A `look` block in `.done-done.yaml` (`tokens`, `max_control`) is the approved reference: look
56
+ then fails on a control drawn as the browser's own or taller than `max_control`, and `--mock <file>` has the judge compare
57
+ the page with its approved mock. A hit fails the journey;
54
58
  never wrap it in `|| true` or log the exit and go on. A claim that a phone UI works needs a 390 px capture of the real
55
59
  page and the page's scroll-container count as proof: a mock, a different width or a claim with no count is not proof. A journey in `.done-done.yaml` that calls it:
56
60