done-done-axi 0.4.1 → 0.4.2

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/README.md CHANGED
@@ -162,6 +162,7 @@ Run `done-done` with no arguments to see the current config and the next step.
162
162
  - 👀 **Shows the outcome** (always): `check`, `receipt` and `verify` start done-done's own judge, a fresh, tools-off agent on your own plan or key (Claude Code, opencode, pi or Gemini CLI, or your own OpenAI API key), to say whether each capture is the right screen for the claim (and each text capture, such as a log, JSON or CLI output, the right output), and a wrong one fails. There is no way to turn it off: done-done gives no result without it. The checks above never pass on their own, and a check that cites only files the judge cannot read (a video) stops before the judge, with exit 2. You choose the judge route (`done-done init --judge <agent> --judge-model <model>`, or pick it from the list done-done shows in a terminal; `done-done check --judge <route>` overrides it for one call); for a Pi judge with no explicit model, done-done defaults to `openai-codex/gpt-6-luna` unless `agent_config.pi.model` is set. Any agent and model you choose runs, your coding agent's own too. A judge that fails or times out stops the command with its cause (exit 2; a `verify` run stops with exit 2 and its cause in the run record), and no receipt is written.
163
163
  - 🎨 **Looks right**: `done-done design <screens...> [--mock <screen>=<mock>]` has the same judge look at each captured screen, alone or next to its approved mock, and fails it on stretched or squashed images, clipped or squashed text, overlap, elements off-screen, blank or black strips and broken layout, naming each defect and where it is ([how](docs/reference.md#design-check)).
164
164
  - 📐 **Fits the screen**: `done-done look <url>` opens a web page in headless Chrome at a 390x844 phone viewport, captures it, and fails the journey that calls it on squashed text, clipped text, elements cut off by an edge or a page wider than the screen, naming each element, with no model ([how](docs/reference.md#look-check)).
165
+ - 🧮 **Honest, cheap claim gate**: `done-done lint <PR URL | report file> <evidence folder>` is a no-model gate on a worker's `## Checks` block, on whenever you call it. It prints `PASS` or `FAIL: <reason>` and exits 1 on a fail: the four lines present, proof naming a command or path, a real repro on a fix PR, evidence files that exist, are non-empty, are newer than the evidence folder and differ from each other, at least one never used by another PR (hashes kept in `~/.done-done/lint.hashes`, every run logged to `lint.log` there), and under 1,000 added non-generated lines ([how](docs/reference.md#lint-check)). `evidence: not proven: <cause>` passes.
165
166
  - 🧩 **Page is clean** (with a `dom` block): each captured screen also needs its page-check record. A page that overflows the viewport, logs a console error, has a failed request or shows squashed, clipped or cut-off UI fails, unless an allow list names it ([how](docs/reference.md#dom-assertions)).
166
167
  - 🔒 **Safe in CI**: `--trusted-ref origin/main` reads the rules from your main branch, so a pull request can't loosen them.
167
168
 
package/dist/cli.js CHANGED
@@ -18,6 +18,7 @@ import { cellEnv, cellName, cellSuffix, hasMatrix, matrixManifest, splitMatrix }
18
18
  import { design, KINDS } from './design.js';
19
19
  import { checkMedia } from './media.js';
20
20
  import { domIssues, domRecord, isDomRecord, lookIssues, SQUASHED_TEXT } from './dom.js';
21
+ import { lint } from './lint.js';
21
22
  import { look } from './look.js';
22
23
  import { scanSecrets } from './secrets.js';
23
24
  const PASS = 0, FAIL = 1, USAGE = 2, INCONCLUSIVE = 3, WAITING = 4;
@@ -197,6 +198,17 @@ const COMMANDS = {
197
198
  overflow, squashed text, text clipped by its container and elements cut off by the left, right or top edge, writes the
198
199
  screenshot and its .dom.json record, and exits 1 naming each element, kind and text, 0 when there is none. A hit always
199
200
  fails: never wrap it in || true. Needs Chrome or Chromium (DONE_DONE_CHROME names the binary)`],
201
+ },
202
+ lint: {
203
+ usage: [['done-done lint <PR URL | report file> <evidence folder>', 'merge gate on a worker\'s claim: prints PASS or FAIL: <reason>; the rules need no judge, model or config']],
204
+ flags: [],
205
+ more: [`lint: a deterministic gate that is on whenever you call it. The newest "## Checks" block of the PR body (gh pr view) or report file needs
206
+ outcome:, repro:, proof: and evidence: lines. proof names a command or path, and the repo's verify-<app> skill, or says "no verify skill"
207
+ when the repo (the current folder) has none; repro is not n/a on a fix PR. evidence names files in the evidence folder (or absolute):
208
+ each exists, is non-empty and is newer than the folder itself; no two are byte-identical; at least one has a hash no other PR used
209
+ (hash store lint.hashes in ~/.done-done, or $DONE_DONE_HOME). "evidence: not proven: <cause>" passes. A PR with 1,000 or more added
210
+ non-generated lines fails (lockfiles, vendor/, dist/, LICENSE-type files, deletions and moved lines do not count); a bot PR passes.
211
+ Every run is appended to lint.log next to the store. Exit 0 PASS, 1 FAIL, 2 usage error. Needs gh for a PR URL`],
200
212
  },
201
213
  status: {
202
214
  usage: [['done-done status [--run <run-id>]', "show a run's steps, result, findings and next step"]],
@@ -1886,9 +1898,23 @@ function showRun(dir, cfg, command, gone = false) {
1886
1898
  emit(result);
1887
1899
  return code;
1888
1900
  }
1901
+ // A progress line on stderr every 60s (DONE_DONE_HEARTBEAT_MS overrides) naming the running step, so a caller that kills silent commands does not.
1902
+ function heartbeat(dir) {
1903
+ const t = setInterval(() => {
1904
+ const run = loadRun(dir);
1905
+ process.stderr.write(`done-done: run ${run.run_id} still ${run.status}, step ${run.steps.find((s) => s.status === 'running')?.name ?? 'between steps'}\n`);
1906
+ }, Number(process.env.DONE_DONE_HEARTBEAT_MS) || 60_000);
1907
+ return () => clearInterval(t);
1908
+ }
1889
1909
  async function waitRun(dir, pid) {
1890
- while (['pending', 'running'].includes(loadRun(dir).status) && alive(pid))
1891
- await new Promise((r) => setTimeout(r, 200));
1910
+ const stop = heartbeat(dir);
1911
+ try {
1912
+ while (['pending', 'running'].includes(loadRun(dir).status) && alive(pid))
1913
+ await new Promise((r) => setTimeout(r, 200));
1914
+ }
1915
+ finally {
1916
+ stop();
1917
+ }
1892
1918
  }
1893
1919
  // The judge a command will start: the user's agent and judge, asked for when unset.
1894
1920
  async function preflightJudge(cfg, needsAgent = true) {
@@ -1972,7 +1998,13 @@ async function cmdVerify(argv) {
1972
1998
  // runner goes on when this command or its shell ends; --wait (the default) holds this command until the run ends.
1973
1999
  async function launch(dir, cfg, command, how) {
1974
2000
  if (how.foreground) {
1975
- await runSteps(dir);
2001
+ const stop = heartbeat(dir);
2002
+ try {
2003
+ await runSteps(dir);
2004
+ }
2005
+ finally {
2006
+ stop();
2007
+ }
1976
2008
  return showRun(dir, cfg, command);
1977
2009
  }
1978
2010
  const fd = openSync(join(dir, 'logs', 'runner.log'), 'a');
@@ -3446,6 +3478,14 @@ async function cmdLook(argv) {
3446
3478
  });
3447
3479
  return hits.length ? FAIL : PASS;
3448
3480
  }
3481
+ function cmdLint(argv) {
3482
+ const { positionals } = parseArgs({ args: argv, allowPositionals: true });
3483
+ if (positionals.length !== 2)
3484
+ throw new UsageError('lint needs a PR URL or report file, then the evidence folder', 'Re-run as: done-done lint <PR URL | report file> <evidence folder>');
3485
+ const v = lint(positionals[0], positionals[1], home());
3486
+ console.log(v.ok ? 'PASS' : `FAIL: ${v.why}`);
3487
+ return v.ok ? PASS : FAIL;
3488
+ }
3449
3489
  async function cmdDesign(argv) {
3450
3490
  const { values, positionals } = parseArgs({ args: argv, options: { mock: { type: 'string', multiple: true } }, allowPositionals: true });
3451
3491
  const mocks = new Map();
@@ -3569,6 +3609,8 @@ async function main(argv) {
3569
3609
  return await cmdDesign(rest);
3570
3610
  if (cmd === 'look')
3571
3611
  return await cmdLook(rest);
3612
+ if (cmd === 'lint')
3613
+ return cmdLint(rest);
3572
3614
  if (cmd === 'status')
3573
3615
  return cmdStatus(rest);
3574
3616
  if (cmd === 'attach')
package/dist/lint.js ADDED
@@ -0,0 +1,125 @@
1
+ // `done-done lint`: the deterministic merge gate on a worker's claim. It reads the newest "## Checks" block of a PR body
2
+ // or report file and fails an unproven, reused or oversized claim. No judge, no model, no repo config.
3
+ import { spawnSync } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
6
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
7
+ // Third-party, legal text and committed build output are not reviewable code.
8
+ const GENERATED = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|go\.sum|[^/]*\.lock)$|(^|\/)vendor\/|(^|\/)dist\/|(^|\/)(LICENSE|LICENSES|NOTICE|COPYING)[^/]*$/;
9
+ export const SIZE_LIMIT = 1000;
10
+ const FIX_TITLE = /^(fix|bugfix|hotfix)\b/i;
11
+ const NOT_APPLICABLE = /^n\/?a\b/i;
12
+ const gh = (...args) => {
13
+ const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 60_000, maxBuffer: 1 << 28 });
14
+ return { status: r.error ? 1 : r.status, stdout: r.stdout ?? '', stderr: r.error?.message ?? r.stderr ?? '' };
15
+ };
16
+ // Added minus deleted non-generated lines of a unified diff: a line moved between files cancels out.
17
+ function netLines(diff) {
18
+ const net = new Map();
19
+ let generated = false;
20
+ for (const l of diff.split('\n')) {
21
+ if (l.startsWith('+++ ')) {
22
+ generated = GENERATED.test(l.startsWith('+++ b/') ? l.slice(6) : l.slice(4));
23
+ continue;
24
+ }
25
+ if (l.startsWith('--- ') || generated || !l.slice(1).trim())
26
+ continue;
27
+ if (l[0] === '+' || l[0] === '-')
28
+ net.set(l.slice(1).trim(), (net.get(l.slice(1).trim()) ?? 0) + (l[0] === '+' ? 1 : -1));
29
+ }
30
+ let total = 0;
31
+ for (const n of net.values())
32
+ if (n > 0)
33
+ total += n;
34
+ return total;
35
+ }
36
+ const sha256 = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
37
+ // 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.
38
+ export function lint(src, ev, dir, run = gh) {
39
+ const log = (v) => {
40
+ mkdirSync(dir, { recursive: true });
41
+ appendFileSync(join(dir, 'lint.log'), `${new Date().toISOString()}\t${src}\t${v.head}\t${v.ok ? 'pass' : 'fail'}\t${v.why}\n`);
42
+ return v;
43
+ };
44
+ let body = '', title = '', head = '-', task = '';
45
+ if (existsSync(src) && statSync(src).isFile()) {
46
+ body = readFileSync(src, 'utf8');
47
+ task = basename(dirname(resolve(src)));
48
+ }
49
+ else {
50
+ const r = run('pr', 'view', src, '--json', 'author,body,title,files,headRefOid,headRefName');
51
+ if (r.status)
52
+ return log({ ok: false, why: `gh pr view failed: ${r.stderr.trim().split('\n').pop()?.slice(0, 160) || '?'}`, head });
53
+ const pr = JSON.parse(r.stdout);
54
+ ({ body, title } = { body: pr.body ?? '', title: pr.title ?? '' });
55
+ head = pr.headRefOid ?? '-';
56
+ task = String(pr.headRefName ?? '').split('/').pop();
57
+ const author = pr.author?.login ?? '';
58
+ if (/^(app\/)?(dependabot|renovate)(\[bot\])?$/.test(author))
59
+ return log({ ok: true, why: 'bot', head });
60
+ let size = (pr.files ?? []).reduce((n, f) => n + (GENERATED.test(f.path ?? '') ? 0 : f.additions ?? 0), 0);
61
+ if (size >= SIZE_LIMIT) {
62
+ const d = run('pr', 'diff', src);
63
+ if (!d.status)
64
+ size = netLines(d.stdout);
65
+ }
66
+ if (size >= SIZE_LIMIT)
67
+ return log({ ok: false, why: `${size} added non-generated lines (moved lines excluded): split it (limit ${SIZE_LIMIT})`, head });
68
+ }
69
+ const fail = (why) => log({ ok: false, why, head });
70
+ const block = [...body.matchAll(/^##\s*Checks\s*$([\s\S]*?)(?=^##\s|(?![\s\S]))/gm)].pop(); // newest: workers append, never replace
71
+ if (!block)
72
+ return fail('no "## Checks" block');
73
+ const f = {};
74
+ for (const m of block[1].replace(/```\w*/g, '').matchAll(/^\s*(outcome|repro|proof|evidence)\s*:\s*(.*)$/gm))
75
+ f[m[1]] = m[2].trim();
76
+ const missing = ['outcome', 'repro', 'proof', 'evidence'].filter((k) => !f[k]);
77
+ if (missing.length)
78
+ return fail(`missing or empty Checks line: ${missing.join(', ')}`);
79
+ if (!/[/`]|\.\w{2,4}\b|\b(npm|pnpm|yarn|npx|bash|sh|python3?|node|cargo|go|gradle|make|adb|xcrun|done-done)\b/.test(f.proof))
80
+ return fail('proof: names no command or path');
81
+ const skills = join(process.cwd(), '.agents', 'skills');
82
+ const hasVerify = existsSync(skills) && readdirSync(skills).some((d) => d.startsWith('verify-'));
83
+ if (!f.proof.includes('verify-') && (hasVerify || !/no verify skill/i.test(f.proof)))
84
+ return fail(`proof: does not name the verify-<app> skill run${hasVerify ? '' : ' (or "no verify skill")'}`);
85
+ if (NOT_APPLICABLE.test(f.repro) && FIX_TITLE.test(title))
86
+ return fail('repro: is n/a on a fix PR');
87
+ if (/^not proven\s*:\s*\S/i.test(f.evidence))
88
+ return log({ ok: true, why: `not-proven: ${f.evidence.slice(0, 120)}`, head });
89
+ const names = f.evidence.split(/[,\s]+/).map((n) => n.replace(/^[`"']+|[`"']+$/g, '')).filter((n) => /\.\w{1,10}$/.test(n));
90
+ if (!names.length)
91
+ return fail('evidence: names no files (or write "not proven: <cause>")');
92
+ const store = join(dir, 'lint.hashes');
93
+ const seen = new Map();
94
+ if (existsSync(store))
95
+ for (const l of readFileSync(store, 'utf8').split('\n')) {
96
+ const p = l.split(' ');
97
+ if (p.length === 2)
98
+ seen.set(p[0], p[1]);
99
+ }
100
+ // The evidence folder is born when the task starts, so a file older than it was captured for something else.
101
+ const born = existsSync(ev) ? statSync(ev).birthtimeMs : 0;
102
+ const mine = [];
103
+ let fresh = false;
104
+ for (const n of names) {
105
+ const p = isAbsolute(n) ? n : join(ev, n);
106
+ if (!existsSync(p) || !statSync(p).isFile() || statSync(p).size === 0)
107
+ return fail(`evidence file missing or empty: ${p}`);
108
+ if (born && statSync(p).mtimeMs < born)
109
+ return fail(`evidence file older than the task start: ${p}`);
110
+ const h = sha256(p);
111
+ mine.push(h);
112
+ const owner = seen.get(h); // the same task's earlier lint is not reuse
113
+ if (owner === undefined || owner === src || owner === task)
114
+ fresh = true;
115
+ }
116
+ if (!fresh)
117
+ return fail('every evidence file was already used by another PR');
118
+ if (new Set(mine).size < mine.length)
119
+ return fail('two evidence files are byte-identical: capture each claimed state separately, or write "not proven: <cause>"');
120
+ mkdirSync(dir, { recursive: true });
121
+ for (const h of mine)
122
+ if (!seen.has(h))
123
+ appendFileSync(store, `${h} ${task || src}\n`);
124
+ return log({ ok: true, why: `${names.length} evidence files`, head });
125
+ }
package/docs/reference.md CHANGED
@@ -221,6 +221,17 @@ journeys:
221
221
  | a hit of any kind | 1 |
222
222
  | bad flags, no Chrome, or the page did not load | 2 |
223
223
 
224
+ ### Lint check
225
+
226
+ `done-done lint <PR URL | report file> <evidence folder>` is a deterministic gate on a worker's claim: no judge, no model, no config. It prints `PASS` or `FAIL: <reason>` and exits 0, 1, or 2 on a usage error. A PR URL is read with `gh pr view`; a file is read as the report.
227
+
228
+ - The newest `## Checks` block needs `outcome:`, `repro:`, `proof:` and `evidence:`.
229
+ - `proof:` names a command or path, and the repo's `verify-<app>` skill; it may say `no verify skill` only when `.agents/skills/` in the current folder holds none.
230
+ - `repro:` is not `n/a` when the PR title starts with `fix`, `bugfix` or `hotfix`.
231
+ - `evidence: not proven: <cause>` passes.
232
+ - Otherwise each named file (relative to the evidence folder, or absolute) exists, is non-empty and is newer than the evidence folder itself; no two are byte-identical; and at least one has a hash no other PR used. Hashes are kept in `lint.hashes` and each run is appended to `lint.log`, both in `~/.done-done` (`$DONE_DONE_HOME`). A later lint of the same PR is not reuse.
233
+ - A PR with 1,000 or more added non-generated lines fails. Lockfiles, `vendor/`, `dist/` and `LICENSE`-type files do not count, and neither do deletions or lines moved between files. A dependabot or renovate PR passes.
234
+
224
235
  ### Base-ref before
225
236
 
226
237
  A before/after change is judged as a pair. With `base_ref` set to a branch, tag or commit (for example `base_ref: origin/main`), `verify` drives the base ref too, after the head's drive and only when it passed:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "done-done-axi",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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",
@@ -46,6 +46,11 @@ journeys:
46
46
  command: done-done look http://127.0.0.1:3000/inbox --out "$DONE_DONE_EVIDENCE_ROOT/inbox.png"
47
47
  ```
48
48
 
49
+ To gate a worker's claim cheaply, `done-done lint <PR URL | report file> <evidence folder>` reads the newest `## Checks` block
50
+ with no judge or model and prints `PASS` or `FAIL: <reason>` (exit 1 on a fail): missing lines, evidence that is absent,
51
+ empty, stale, duplicated or already used by another PR, a n/a repro on a fix, or a PR of 1,000+ added lines all fail.
52
+ `evidence: not proven: <cause>` passes. Fix the cause it names and re-run; never edit the evidence to get past it.
53
+
49
54
  done-done starts a coding agent the user chose. When it says no agent is set, ask the user which agent
50
55
  (and model) to use and save their answer with the command it prints; never choose one for them.
51
56