done-done-axi 0.6.2 → 0.6.4

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
@@ -155,6 +155,7 @@ Another fleet works the same way: add the include text to whatever your workers
155
155
 
156
156
  - [Vision](VISION.md): the three-tool family, the rules they share, and where done-done goes next.
157
157
  - [Worked example: a CLI project](docs/example-cli.md): set up, one journey, check and receipt on a command-line tool.
158
+ - [Worked example: create and maintain a verify skill](docs/example-create-maintain.md): init writes the skill, a source change makes it drift, maintain fixes it, then a real browser journey is judged.
158
159
  - [Full reference](docs/reference.md): every command and option, the Checks block rules, each file check, and both config files.
159
160
  - [Report a problem or ask a question](https://github.com/umeranjum17/done-done-axi/issues)
160
161
 
package/dist/agents.js CHANGED
@@ -22,6 +22,32 @@ function errorLine(text) {
22
22
  }
23
23
  // A provider's rate limit (HTTP 429, a usage limit with its reset time). The CLI may retry it for many minutes, so the call stops at the first line that says so.
24
24
  const RATE_LIMIT = /\b429\b|rate[ -]?limit|usage limit|too many requests/i;
25
+ // A JSON event line (pi --mode json) that mentions a limit: the readable text inside it, not the event. The first string
26
+ // that says so, and the provider's own message when that string is `429: {"message":"..."}`; any other line as it is.
27
+ const limitText = (line) => {
28
+ const found = (v) => {
29
+ if (typeof v === 'string')
30
+ return RATE_LIMIT.test(v) ? v : undefined;
31
+ if (v && typeof v === 'object')
32
+ for (const x of Object.values(v)) {
33
+ const f = found(x);
34
+ if (f)
35
+ return f;
36
+ }
37
+ };
38
+ let text;
39
+ try {
40
+ text = found(JSON.parse(line));
41
+ }
42
+ catch { }
43
+ const body = text?.match(/^\d+: (\{.*\})$/s)?.[1];
44
+ try {
45
+ return (body && JSON.parse(body).message) || text || line;
46
+ }
47
+ catch {
48
+ return text ?? line;
49
+ }
50
+ };
25
51
  const isRejectedRateLimit = (l) => {
26
52
  try {
27
53
  const e = JSON.parse(l);
@@ -388,6 +414,8 @@ function start(bin, argv, c, cwd, input, env, idleMs) {
388
414
  }
389
415
  else {
390
416
  line = stripVTControlCharacters(chunk).split(/\r?\n/).find((l) => RATE_LIMIT.test(l) && (!stdoutLine || /reset|retry/i.test(l)));
417
+ if (line)
418
+ line = limitText(line);
391
419
  }
392
420
  if (!line || error)
393
421
  return;
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/maintain.js CHANGED
@@ -76,6 +76,9 @@ const localTarget = (t) => (/^[a-z][\w+.-]*:/i.test(t) ? '' : t.split('#')[0]);
76
76
  // of the tracked files', so host:port text is never read as a citation.
77
77
  // ponytail: a file name after a / may open with one bracket or paren group (pages/[id].tsx); [[...rest]].tsx is not a citation, add when a repo cites one.
78
78
  const CITE = /(?<![\w/.-])((?:(?:[\w.-]+|\([\w.-]+\)|\[[\w.-]+\])\/)*(?:[\w-][\w.-]*|(?<=\/)(?:\[[\w.-]+\]|\([\w.-]+\))[\w.-]*)\.[A-Za-z]\w*):(\d+)(?:-(\d+))?\b/g;
79
+ // A file name in running text, with or without a line: a repo path or a bare name with an extension. Only the names that
80
+ // citedFiles finds among the tracked files count, so ordinary words and URLs are ignored.
81
+ const NAMED = /(?<![\w/.:-])(?:[\w.-]+\/)*[\w-][\w.-]*\.[A-Za-z]\w*(?![\w/-])/g;
79
82
  // Whether the file at `p` really lies under the repo: a symlinked folder on the way to it is followed, so it counts by where it points.
80
83
  const realInside = (repo, p) => realpathSync(p).startsWith(`${realpathSync(repo)}${sep}`);
81
84
  // Why repo-relative `file` has no line `line` (through `end`), or '' when it has.
@@ -265,8 +268,11 @@ async function pass(run, dir, cfg, h, prefix = '') {
265
268
  const left = new Set();
266
269
  let total = 0;
267
270
  let capped = false;
268
- for (const m of cites(text)) {
269
- for (const p of citedFiles(repo, tracked, m[1])) {
271
+ // Files the feature file cites with a line, then files it only names (`public/index.html`, init writes no line numbers).
272
+ // The skill's own files are never source.
273
+ const names = [...cites(text).map((m) => m[1]), ...(text.match(NAMED) ?? [])];
274
+ for (const name of names) {
275
+ for (const p of citedFiles(repo, tracked, name).filter((p) => !p.startsWith(`${rel(skill)}/`))) {
270
276
  if (seen.has(p) || left.has(p))
271
277
  continue;
272
278
  if (capped) {
@@ -322,21 +328,32 @@ async function pass(run, dir, cfg, h, prefix = '') {
322
328
  add({ kind: 'coverage', file: rel(file), what: 'the feature entry is not a readable file, so no reader covers it' });
323
329
  return undefined;
324
330
  });
325
- return text === undefined ? [] : [{ name, text }];
331
+ return text === undefined ? [] : [{ name, text, file }];
326
332
  });
333
+ // SKILL.md states the launch and readiness check, which drift like a feature does (a renamed health route): it is read too,
334
+ // but only when it names tracked source (a skill that names none has nothing to compare), and after the features.
335
+ const main = join(skill, 'SKILL.md');
336
+ const mainText = agentBlock || regular.length >= MAX_CALLS - 1 ? undefined : tryRead(main, undefined, () => (lstatSync(main).isFile() ? readFileSync(main, 'utf8') : undefined));
337
+ const skillRel = `${rel(skill)}/`;
338
+ if (mainText && [...cites(mainText).map((m) => m[1]), ...(mainText.match(NAMED) ?? [])].some((n) => citedFiles(repo, tracked, n).some((p) => !p.startsWith(skillRel))))
339
+ regular.push({ name: 'SKILL.md', text: mainText, file: main });
327
340
  const readers = [];
328
- for (const { name, text } of regular) {
329
- const file = join(featDir, name);
341
+ for (const { name, text, file } of regular) {
330
342
  if (text.length > FEATURE_CHARS)
331
343
  add({ kind: 'truncated', file: rel(file), what: `the reader saw only the first ${FEATURE_CHARS} characters of this feature file` });
344
+ const source = toolless ? citedSource(rel(file), text.slice(0, FEATURE_CHARS)) : '';
345
+ // A reader with no tools and no source can only call the feature right: that is not a clean pass.
346
+ if (toolless && !source)
347
+ add({ kind: 'coverage', file: rel(file), what: 'names no tracked source file, so the reader had no source to compare it with; name the files it comes from (path or path:line)' });
332
348
  const prompt = `You are a read-only source reader for done-done maintain. Do not edit, create or delete any file, and do not run the app or any command.
333
349
  ${toolless ? 'You have no tools: the source that the feature file cites is below, each line after its line number, and it is all you can read.' : 'The repository is your working directory.'} Below is one feature file from the verification skill at ${cfg.skill} (app ${cfg.app}).
334
350
  ${toolless ? 'Use the cited source, not the skill, and explain' : "Read the product's source code, not the skill, and explain"} how this user-facing feature works today.
335
351
  - drift: each statement in the feature file that the source contradicts or no longer supports, each {claim: the feature file's words, why: what the source does instead, file, line: the source location that shows it}. Empty when the file is right.
352
+ Statements about done-done itself (its instance block, DONE_DONE_* variables, the evidence folder, maintain) are not the product's behavior: never report them as drift.
336
353
  - recipe: one short recipe to verify the feature live, the way a user reaches it.
337
354
 
338
355
  Feature file ${rel(file)}:
339
- ${text.slice(0, FEATURE_CHARS)}${toolless ? citedSource(rel(file), text.slice(0, FEATURE_CHARS)) : ''}`;
356
+ ${text.slice(0, FEATURE_CHARS)}${source}`;
340
357
  const a = await askAgent({ ...agent, prompt, schema: READER_SCHEMA, cwd: repo, tools: toolless ? 'none' : 'read', record: record(`reader-${name.replace(/\.md$/, '')}`) });
341
358
  if (!a.ok)
342
359
  add({ kind: 'coverage', file: rel(file), what: `the reader did not answer: ${a.error}` });
@@ -0,0 +1,77 @@
1
+ # Worked example: create and maintain a verify skill
2
+
3
+ done-done on a tiny public sample, **Pocket Notes**: a zero-dependency Node web app where you type a note, press a button and see it in a list. It walks the whole loop with a real coding agent and a real judge: `init` writes a verify skill, a source change makes the skill drift, `maintain` finds and fixes it, a real browser journey captures the screen, and the judge passes it. Nothing is stubbed and no image is copied in.
4
+
5
+ You need Node 22.18+, git, Chrome or Chromium, and a coding agent and judge you are signed in to (any pair `done-done init --help` lists). Replace the `--agent`/`--model` and `--judge`/`--judge-model` values with yours; a free or cheap model is fine. A throwaway `DONE_DONE_HOME` keeps this off your own config and evidence.
6
+
7
+ **1. Install the built package and make the sample.**
8
+
9
+ ```sh
10
+ d=$(mktemp -d) && cd "$d"
11
+ npm pack done-done-axi && npm install -g --prefix "$d/prefix" ./done-done-axi-*.tgz # or: npm install -g done-done-axi
12
+ export PATH="$d/prefix/bin:$PATH" DONE_DONE_HOME="$d/home"
13
+ cp -r "$d/prefix/lib/node_modules/done-done-axi/docs/examples/pocket-notes" . && cd pocket-notes
14
+ git init -q -b main && npm install && git add -A && git commit -qm "Pocket Notes"
15
+ ```
16
+
17
+ The sample is in the package under `docs/examples/pocket-notes/` (also in this repo): `server.mjs` (an HTTP server: `GET`/`POST /api/notes`, `GET /health`, the page at `/`, port from `PORT`, data folder from `NOTES_DATA`), `public/index.html` (a heading, a `Write a note` field, an `Add note` button and the list) and the journey script of step 5. Any small web app works the same way. Add a `.gitignore` with `node_modules` and `notes.json` before the commit.
18
+
19
+ **2. `init` has your agent write the skill.**
20
+
21
+ ```sh
22
+ done-done init --agent <agent> --model <model> --judge <judge> --judge-model <model>
23
+ ```
24
+
25
+ It writes `.done-done.yaml` and `.agents/skills/done-done-verify-pocket-notes/` (`SKILL.md` plus one file per feature, one marked the core journey). Read it: the launch should use a temp `NOTES_DATA` and its own port, and each feature should name its source (`public/index.html`). Commit it. `done-done doctor` should pass.
26
+
27
+ **3. Change the source; the skill is now stale.** Rename the button to `Save note`, the placeholder to `Type a note` and the health route to `/healthz`, and commit:
28
+
29
+ ```sh
30
+ sed -i 's#/health#/healthz#' server.mjs
31
+ sed -i 's#Write a note#Type a note#; s#>Add note<#>Save note<#' public/index.html
32
+ git commit -qam "Rename Add to Save, health to /healthz"
33
+ ```
34
+
35
+ **4. `maintain` finds the drift, then fixes the skill.**
36
+
37
+ ```sh
38
+ done-done maintain --foreground # result: drift, with each finding
39
+ printf 'auto_fix:\n maintain: 2\n' >> .done-done.yaml && git commit -qam "Allow maintain fixes"
40
+ done-done maintain --foreground # result: changed, a commit on done-done/maintain-<run-id>
41
+ git merge --ff-only done-done/maintain-<run-id>
42
+ done-done maintain --foreground # result: clean
43
+ ```
44
+
45
+ The first run reports that the feature file says `Write a note` and `Add note` and `SKILL.md` says `/health`, each with the source line. The fix commit touches only the skill folder; your branch is untouched until you merge it. (Before 0.6.3 a tool-less reader was given no source when a feature file named it without a line number, so this step ended `clean` without reading anything.)
46
+
47
+ **5. A real journey.** `init --journey` records it (the command drives the product; a test run would be refused):
48
+
49
+ ```yaml
50
+ instance:
51
+ command: PORT=$DONE_DONE_PORT NOTES_DATA=$TMPDIR node server.mjs
52
+ ready: { url: http://127.0.0.1:$DONE_DONE_PORT/healthz }
53
+ journeys:
54
+ add-note:
55
+ command: node scripts/prove-core.mjs
56
+ core: true
57
+ ```
58
+
59
+ `scripts/prove-core.mjs` (already in the sample; `npm install` fetched `playwright-core`) opens `$DONE_DONE_BASE_URL` in headless Chrome at 390x844 with `playwright-core` (`executablePath` from `DONE_DONE_CHROME`, else `/usr/bin/chromium`), fills `Type a note` with `milk`, clicks `Save note`, waits for `milk` in the list and saves `$DONE_DONE_EVIDENCE_ROOT/note-listed.png`. It also writes `steps.json` beside it (target version, start state, the user's steps, the end result): `check` refuses a capture whose run has no step record that covers the claim ([Step record](reference.md#step-record)).
60
+
61
+ The repo's own `journeys/create-maintain.mjs` runs this whole walkthrough unattended (`done-done run create-maintain`).
62
+
63
+ **6. Run, check, receipt.** Commit first, then:
64
+
65
+ ```sh
66
+ done-done run add-note
67
+ done-done check --outcome "a user can type a note, press Save note, and see it in the list" <the png the run printed>
68
+ done-done receipt --outcome "<same claim>" <same png>
69
+ ```
70
+
71
+ The judge looks at the screenshot (`result: pass`, exit 0). Open the picture yourself too.
72
+
73
+ What it shows:
74
+
75
+ - Keep each outcome to what one capture shows; a claim with several parts comes back `inconclusive`.
76
+ - The agent's skill is a draft: read it, and let `maintain` keep it true as the source moves.
77
+ - A model free-tier or weekly limit ends a call with the provider's own message and reset time (exit 2), never a result.
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "pocket-notes",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "A tiny notes web app: add a note and see it in the list.",
7
+ "scripts": {
8
+ "start": "node server.mjs",
9
+ "test": "node --test"
10
+ },
11
+ "devDependencies": {
12
+ "playwright-core": "^1.64.1"
13
+ }
14
+ }
@@ -0,0 +1,45 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Pocket Notes</title>
7
+ <style>
8
+ body { font: 16px system-ui, sans-serif; margin: 0; background: #f6f5f1; color: #222; }
9
+ main { max-width: 420px; margin: 0 auto; padding: 24px 16px; }
10
+ h1 { font-size: 22px; margin: 0 0 16px; }
11
+ form { display: flex; gap: 8px; }
12
+ input { flex: 1; padding: 10px; font: inherit; border: 1px solid #bbb; border-radius: 6px; }
13
+ button { padding: 10px 14px; font: inherit; border: 0; border-radius: 6px; background: #1d6b4f; color: #fff; }
14
+ ul { list-style: none; padding: 0; margin: 16px 0 0; }
15
+ li { background: #fff; border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <main>
20
+ <h1>Pocket Notes</h1>
21
+ <form id="add"><input id="text" placeholder="Write a note" autocomplete="off"><button type="submit">Add note</button></form>
22
+ <ul id="notes"></ul>
23
+ </main>
24
+ <script>
25
+ const list = document.getElementById('notes');
26
+ async function refresh() {
27
+ const notes = await (await fetch('/api/notes')).json();
28
+ list.innerHTML = '';
29
+ for (const n of notes) {
30
+ const li = document.createElement('li');
31
+ li.textContent = n.text;
32
+ list.append(li);
33
+ }
34
+ }
35
+ document.getElementById('add').addEventListener('submit', async (e) => {
36
+ e.preventDefault();
37
+ const input = document.getElementById('text');
38
+ await fetch('/api/notes', { method: 'POST', body: JSON.stringify({ text: input.value }) });
39
+ input.value = '';
40
+ refresh();
41
+ });
42
+ refresh();
43
+ </script>
44
+ </body>
45
+ </html>
@@ -0,0 +1,21 @@
1
+ // Drives the real Pocket Notes page the way a user does and saves what it sees under $DONE_DONE_EVIDENCE_ROOT.
2
+ import { writeFileSync } from 'node:fs';
3
+ import { chromium } from 'playwright-core';
4
+
5
+ const base = process.env.DONE_DONE_BASE_URL;
6
+ const out = process.env.DONE_DONE_EVIDENCE_ROOT;
7
+ const browser = await chromium.launch({ executablePath: process.env.DONE_DONE_CHROME || '/usr/bin/chromium', args: ['--no-sandbox'] });
8
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
9
+ await page.goto(base);
10
+ await page.getByPlaceholder('Type a note').fill('milk');
11
+ await page.getByRole('button', { name: 'Save note' }).click();
12
+ await page.getByText('milk', { exact: true }).waitFor();
13
+ await page.screenshot({ path: `${out}/note-listed.png` });
14
+ await browser.close();
15
+ // The step record done-done reads beside the capture: what a user did and what they see at the end.
16
+ writeFileSync(`${out}/steps.json`, JSON.stringify({
17
+ target: { version: '1.0.0' },
18
+ start: 'the Pocket Notes page with an empty list',
19
+ steps: ['type milk in the Type a note field', 'press Save note'],
20
+ result: 'the note milk is listed below the form',
21
+ }));
@@ -0,0 +1,30 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ const port = Number(process.env.PORT || 4100);
6
+ const dataFile = join(process.env.NOTES_DATA || '.', 'notes.json');
7
+ const page = readFileSync(new URL('./public/index.html', import.meta.url));
8
+
9
+ const load = () => (existsSync(dataFile) ? JSON.parse(readFileSync(dataFile, 'utf8')) : []);
10
+ const save = (notes) => writeFileSync(dataFile, JSON.stringify(notes));
11
+
12
+ createServer((req, res) => {
13
+ if (req.url === '/health') return res.end('ok');
14
+ if (req.url === '/api/notes' && req.method === 'GET') return res.setHeader('content-type', 'application/json'), res.end(JSON.stringify(load()));
15
+ if (req.url === '/api/notes' && req.method === 'POST') {
16
+ let body = '';
17
+ req.on('data', (c) => (body += c));
18
+ req.on('end', () => {
19
+ const { text } = JSON.parse(body || '{}');
20
+ if (!text || !text.trim()) return res.writeHead(400).end('text is required');
21
+ const notes = load();
22
+ notes.push({ id: notes.length + 1, text: text.trim() });
23
+ save(notes);
24
+ res.writeHead(201).end(JSON.stringify(notes.at(-1)));
25
+ });
26
+ return;
27
+ }
28
+ res.setHeader('content-type', 'text/html');
29
+ res.end(page);
30
+ }).listen(port, () => console.log(`pocket-notes on http://127.0.0.1:${port}`));
@@ -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.
@@ -47,7 +47,7 @@ A `run -- <command>` whose command matches no recorded journey still runs, but i
47
47
 
48
48
  At any point, `done-done doctor` checks the setup and changes nothing except the `--probe` call record. Each item reports `ok`, `warning` or `missing` with the exact fix command; an agent you have not set yet is `missing`, with the command that saves your choice, and so is a config whose journeys only run code checks, one that names no core journey, or an app that ships an installable artifact with no upgrade journey. `done-done doctor --probe` also asks the agent one question to prove it answers; on a new machine that probe fails until the agent is signed in (for pi, run `pi` and then `/login`). The quick start above never calls the coding agent, so `run` and `check` work before it is signed in; `check` does call the judge, so set one first. The items are listed in the [reference](reference.md#check-the-setup).
49
49
 
50
- `done-done maintain` keeps the verify skill true: it checks the skill's links and feature index, checks that every `file:line` it cites exists, and has your coding agent read the source for each feature, read-only, to report drift. It ends `clean`, `drift` with the findings to fix, or `blocked`. It never runs without that reader: an agent that is not set asks, and one that cannot start or cannot read without tools stops it before the run (exit 2). With `auto_fix: { maintain: <n> }` (as in no-mistakes; 0, the default, reports only), your judge first sorts each drift finding (a skill out of date, or a product that may be wrong, which stops and asks you), then your agent fixes the skill folder only, in a done-done worktree, and maintain runs again, up to n times. A clean end is `changed`: one commit on a `done-done/maintain-<run-id>` branch, your branch untouched ([reference](reference.md#keep-the-verify-skill-true)).
50
+ `done-done maintain` keeps the verify skill true: it checks the skill's links and feature index, checks that every `file:line` it cites exists, and has your coding agent read the source for each feature, read-only, to report drift. It reads each feature file's named source files (with or without a line) and `SKILL.md`'s launch claims; a reader with nothing to read ends it `blocked`, never `clean`. It ends `clean`, `drift` with the findings to fix, or `blocked`. It never runs without that reader: an agent that is not set asks, and one that cannot start or cannot read without tools stops it before the run (exit 2). With `auto_fix: { maintain: <n> }` (as in no-mistakes; 0, the default, reports only), your judge first sorts each drift finding (a skill out of date, or a product that may be wrong, which stops and asks you), then your agent fixes the skill folder only, in a done-done worktree, and maintain runs again, up to n times. A clean end is `changed`: one commit on a `done-done/maintain-<run-id>` branch, your branch untouched ([reference](reference.md#keep-the-verify-skill-true)).
51
51
 
52
52
  **3. Run it, then check it**
53
53
 
package/docs/reference.md CHANGED
@@ -80,7 +80,7 @@ done-done doctor --probe
80
80
 
81
81
  #### Agents
82
82
 
83
- done-done starts the coding agent you choose, and never picks one. `agent:` names exactly one (`claude`, `codex`, `opencode`, `pi`, `gemini` or `acp:<target>`); there is no auto-detection and no fallback list, so an agent that is not installed or cannot start stops the command with the reason. While no agent is set, a command that starts it asks in a terminal (a numbered list of agents, then of models) and saves the answer to the global config; anywhere else (an agent calling done-done) it exits 2 with `no agent is set: ask the user which agent to use (claude, codex, opencode, pi, gemini, acp:<target>); never pick one for them` and the command that saves the answer: `done-done init --agent <agent> --model <model>`. `doctor` reports an unset agent as missing with that same fix. The model is `agent_config.<agent>.model` (global config only), else the agent's cheap default (`claude-haiku-5-5` for claude, `opencode-go/muse-spark-1.3-contributor` for opencode, `flash-lite` for gemini; claude and opencode also default to `effort: low`). That table is the only place done-done names a default model, and `init` writes it into the global config. `agent_config.<agent>.effort` (`minimal`, `low`, `medium`, `high`, `xhigh` or `max`, or `''` for the CLI's own) goes to the CLI's own flag as written: `--effort` (claude), `-c model_reasoning_effort="<level>"` (codex), `--variant` (opencode) and `--thinking` (pi); gemini and `acp:<target>` have none, so an effort for them is a config error. `agent_args_override.<agent>` adds extra CLI flags after done-done's own (do not repeat a flag done-done passes), `agent_path_override.<agent>` starts that binary instead of the one on PATH, `acpx_path` names the acpx bridge, and `agent_timeout` (default `30m`) bounds each agent call: a verify drive, a maintain reader, the judge and the doctor probe. A streaming agent (pi, opencode, codex, `acp:<target>`) that prints nothing, on stdout or stderr, for 5 minutes is stopped early (30 seconds for the doctor probe); the no-answer reason then quotes its stderr and the call record path, so a hung agent costs minutes, not the whole timeout. A rate limit the CLI prints (HTTP 429, a usage limit) ends any agent call at that line, with the limit and its reset time as the reason; the one exception is a claude judge call with images, which stops only at claude's own rejected rate-limit event, so its retries run. claude and gemini print one answer at the end, so only `agent_timeout` bounds them. These defaults are the cost guard for those agents: done-done never defaults to a flagship model for them. codex, pi and `acp:<target>` have no public cheap alias, so with no `agent_config.<agent>.model` (for `acp:<target>`, `agent_config.acp:<target>.model`) they run their own configured model, which can be a flagship; that setting caps their cost. `agent: acp:<target>` (for example `acp:gemini`) starts any Agent Client Protocol agent through [acpx](https://github.com/openclaw/acpx), which you install yourself (`npm install -g acpx`). doctor reports an `acp:` agent missing, with that install step, when acpx is not on PATH or does not start.
83
+ done-done starts the coding agent you choose, and never picks one. `agent:` names exactly one (`claude`, `codex`, `opencode`, `pi`, `gemini` or `acp:<target>`); there is no auto-detection and no fallback list, so an agent that is not installed or cannot start stops the command with the reason. While no agent is set, a command that starts it asks in a terminal (a numbered list of agents, then of models) and saves the answer to the global config; anywhere else (an agent calling done-done) it exits 2 with `no agent is set: ask the user which agent to use (claude, codex, opencode, pi, gemini, acp:<target>); never pick one for them` and the command that saves the answer: `done-done init --agent <agent> --model <model>`. `doctor` reports an unset agent as missing with that same fix. The model is `agent_config.<agent>.model` (global config only), else the agent's cheap default (`claude-haiku-5-5` for claude, `opencode-go/muse-spark-1.3-contributor` for opencode, `flash-lite` for gemini; claude and opencode also default to `effort: low`). That table is the only place done-done names a default model, and `init` writes it into the global config. `agent_config.<agent>.effort` (`minimal`, `low`, `medium`, `high`, `xhigh` or `max`, or `''` for the CLI's own) goes to the CLI's own flag as written: `--effort` (claude), `-c model_reasoning_effort="<level>"` (codex), `--variant` (opencode) and `--thinking` (pi); gemini and `acp:<target>` have none, so an effort for them is a config error. `agent_args_override.<agent>` adds extra CLI flags after done-done's own (do not repeat a flag done-done passes), `agent_path_override.<agent>` starts that binary instead of the one on PATH, `acpx_path` names the acpx bridge, and `agent_timeout` (default `30m`) bounds each agent call: a verify drive, a maintain reader, the judge and the doctor probe. A streaming agent (pi, opencode, codex, `acp:<target>`) that prints nothing, on stdout or stderr, for 5 minutes is stopped early (30 seconds for the doctor probe); the no-answer reason then quotes its stderr and the call record path, so a hung agent costs minutes, not the whole timeout. A rate limit the CLI prints (HTTP 429, a usage limit) ends any agent call at that line, with the limit and its reset time as the reason (for pi's JSON events, the provider's own message, not the event); the one exception is a claude judge call with images, which stops only at claude's own rejected rate-limit event, so its retries run. claude and gemini print one answer at the end, so only `agent_timeout` bounds them. These defaults are the cost guard for those agents: done-done never defaults to a flagship model for them. codex, pi and `acp:<target>` have no public cheap alias, so with no `agent_config.<agent>.model` (for `acp:<target>`, `agent_config.acp:<target>.model`) they run their own configured model, which can be a flagship; that setting caps their cost. `agent: acp:<target>` (for example `acp:gemini`) starts any Agent Client Protocol agent through [acpx](https://github.com/openclaw/acpx), which you install yourself (`npm install -g acpx`). doctor reports an `acp:` agent missing, with that install step, when acpx is not on PATH or does not start.
84
84
 
85
85
  Each call is a fresh, single-shot session that must answer one JSON object matching a schema. The prompt goes on stdin (claude, codex, pi, gemini, acp) or into a 0600 prompt file (opencode), never in argv. done-done can start claude (`claude -p --output-format json --json-schema <schema> --model <model> --no-session-persistence`, all tools off with `--tools ''`; tool-less calls also pass `--setting-sources ''` and run from a scratch folder, so no project settings or hooks load; read-only calls allow only `--tools Read,Grep,Glob` and pass `--setting-sources ''` and `--strict-mcp-config`, so no user settings or MCP servers load), opencode (`opencode run "Follow the attached instructions." --format json -m <provider/model> -f <prompt file>`, with the schema in the 0600 prompt file and every tool denied through `OPENCODE_PERMISSION` except on an edit step ([Drive by agent](#drive-by-agent), Scope)), pi (`pi -p --mode json --no-session --no-tools`, tools off for every read or probe call, since pi has no read-only mode that refuses outside reads, and `--model <model>` only when one is set) and gemini (`gemini -p "Follow the instructions on stdin." -o json -m <model> --approval-mode plan|auto_edit`, where `plan` is read-only for the probe and read steps and `auto_edit` is used for edit steps; `--skip-trust` is added only to the tool-less probe in its scratch folder, so repo calls keep Gemini's workspace trust gate) and codex (`codex exec --json --ephemeral [-m <model>]`, the answer being the last agent message of its JSONL events; an edit step runs in codex's `--sandbox workspace-write` with the run's evidence and scratch folders added by `--add-dir` and network on, so the drive can launch the app and talk to it, while it keeps your own codex config). codex cannot be a tool-less call: probed live on codex-cli 0.159.2 with your config.toml honored, its MCP server read a canary file outside the folder and `--disable multi_agent` left its multi-agent tools on, so done-done never starts codex tool-less: the judge exits with the reason, starting nothing; `doctor --probe` shows a warning with that reason and asks codex nothing; a codex maintain run stops before any run with exit 2 and the reason, as a gemini maintain run does. `acp:<target>` runs `acpx --cwd <dir> --format json --json-strict --deny-all --non-interactive-permissions deny [--model <model>] <target> exec --file -`, with the prompt and schema on stdin; every tool is denied. pi has no permission gate for edit steps, so doctor adds a `tool_gate` warning row whenever pi is the agent: every pi tool runs without approval. codex's sandbox covers its shell too, so doctor adds a `tool_gate` warning row whenever codex is the agent: a drive's recipe tool can write only where the drive may (see [Drive by agent](#drive-by-agent), Scope).
86
86
 
@@ -238,6 +238,7 @@ journeys:
238
238
  - `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
239
  - `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
240
  - `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.
241
+ - `.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
242
 
242
243
  ### Base-ref before
243
244
 
@@ -536,7 +537,7 @@ done-done maintain # --no-wait, --wait (the default), or --foreground (fo
536
537
 
537
538
  - `locate`: the verify skill (`skill`, default `.agents/skills/done-done-verify-<app>`) must have a `SKILL.md`; with none, the run ends `blocked` and points at `done-done init`.
538
539
  - `hygiene` (plain code): every local markdown link in the skill must resolve (`dead-link`); `features/README.md` must exist (`index`), list each `features/*.md` file (`unlisted`) and list it only once (`duplicate`).
539
- - `source-wave`: one fresh reader per feature file, at the configured agent (`agent`, `agent_config`). Every reader except claude's gets no tools (pi `--no-tools`, opencode and acp with every tool denied); codex and gemini cannot read tool-less at all, so `maintain` never runs without a reader: an agent that cannot read tool-less, or that done-done cannot start, stops it before any run with exit 2 and the reason (use claude, opencode, pi or acp). Where readers run, plain code puts the feature file and the text of each tracked file it cites into the prompt, each file capped at 20,000 characters (a cut file is marked `[truncated after 20000 characters]` in the prompt, with no finding of its own) and all of a feature's cited text at 60,000 characters, with one `truncated` notice naming the feature and the cited files left out when any are dropped. Only claude readers get read-only tools in the repo (`--tools Read,Grep,Glob` with `--setting-sources ''` and `--strict-mcp-config`, so no user settings or MCP servers load): a live probe showed opencode reads through an in-repo symlink to an outside target, while claude refuses it. Each reader explains the feature from source and returns its drift (each statement the source contradicts, with the source file:line) and one live recipe. Readers run one after another; a run makes at most 30 recorded agent calls, each bounded by `agent_timeout`; a tool-less call that does not answer valid JSON is tried up to three times in all within its record, so a run starts at most 90 agent processes. A feature file longer than 20,000 characters is read only up to that cap, and a `truncated` notice names it; the notice is listed but does not make the result `drift`. A symlink on the skill path (the skill folder, its features folder, the index or a feature file) is a blocking `coverage` finding and is never read, so a committed link cannot send a file outside the repo to the agent; a skill reached through a symlinked ancestor is refused at `locate`. Readers cannot read outside the repo: claude runs without `--add-dir` or a permission bypass, so reads outside its working directory are refused.
540
+ - `source-wave`: one fresh reader per feature file, at the configured agent (`agent`, `agent_config`). Every reader except claude's gets no tools (pi `--no-tools`, opencode and acp with every tool denied); codex and gemini cannot read tool-less at all, so `maintain` never runs without a reader: an agent that cannot read tool-less, or that done-done cannot start, stops it before any run with exit 2 and the reason (use claude, opencode, pi or acp). Where readers run, plain code puts the feature file and the text of each tracked file it cites into the prompt, each file capped at 20,000 characters (a cut file is marked `[truncated after 20000 characters]` in the prompt, with no finding of its own) and all of a feature's cited text at 60,000 characters, with one `truncated` notice naming the feature and the cited files left out when any are dropped. Only claude readers get read-only tools in the repo (`--tools Read,Grep,Glob` with `--setting-sources ''` and `--strict-mcp-config`, so no user settings or MCP servers load): a live probe showed opencode reads through an in-repo symlink to an outside target, while claude refuses it. A feature file names its source by path with or without a line (`public/index.html`, `src/app.js:12`; `init` writes names without lines), and each named tracked file is sent. A tool-less reader whose feature file names no tracked file is `coverage` (the run ends `blocked`): with no source it could only call the feature right, so a `clean` would mean nothing was read. `SKILL.md` is read the same way, after the features, when it names tracked source: its launch and readiness claims (a health route, a port variable) drift like a feature's. Statements about done-done itself (the `instance` block, `DONE_DONE_*` variables) are never drift. Each reader explains the feature from source and returns its drift (each statement the source contradicts, with the source file:line) and one live recipe. Readers run one after another; a run makes at most 30 recorded agent calls, each bounded by `agent_timeout`; a tool-less call that does not answer valid JSON is tried up to three times in all within its record, so a run starts at most 90 agent processes. A feature file longer than 20,000 characters is read only up to that cap, and a `truncated` notice names it; the notice is listed but does not make the result `drift`. A symlink on the skill path (the skill folder, its features folder, the index or a feature file) is a blocking `coverage` finding and is never read, so a committed link cannot send a file outside the repo to the agent; a skill reached through a symlinked ancestor is refused at `locate`. Readers cannot read outside the repo: claude runs without `--add-dir` or a permission bypass, so reads outside its working directory are refused.
540
541
  - `reconcile`: the tool-less call names at most 500 recently changed paths, most recent first, and says how many older ones it left out (those get no `unmapped` check). Plain code checks each `path:line` the skill cites (`citation`) and each file:line a reader cites. A bare file name (`app.js:50`) is checked against the tracked files whose path ends with it, and the line must exist in one of them; no tracked match is a finding. A repo path (`src/app.js:50`) must be a tracked file too, so an untracked or ignored file is a citation finding in the skill and an unverified drift in a reader's answer. A bare name is a citation only when its extension is one that a tracked file has, so host:port text such as `api.example.com:8443` is ignored. A reader's drift cites files the same way, and its drift becomes a `drift` finding only when its citation exists, and unverified citations are listed under `unverified` in the run's status (and in the step log), with a count and the first few. An answer item in an unexpected shape, or a list field that is not a list, is listed there too and never trusted. One tool-less call merges the recipes into as few app states as it can and names files changed in the last 30 days (`git log`, outside the skill folder) that no feature covers; each one that is in that list becomes an `unmapped` finding. maintain gives no result without its readers: with no agent set it asks, as in [Agents](#agents). Gemini cannot be a reader yet: its no-tools mode is read-only plan mode, not tool-free, so `maintain` with gemini stops with exit 2 before any run.
541
542
 
542
543
  The result is `clean` (no findings but notices, exit 0), `drift` (findings to fix by hand, or with `auto_fix`, exit 1), or `blocked` (no skill, a skill path that is a symlink or cannot be read, a reader that did not answer, more feature files than the call cap, or a repo that changed; exit 3). A blocked run still lists the findings from the plain checks it finished. `status` lists the findings (`--full` for all of them), each with an id, kind, file, line and what is wrong. Each agent call is recorded in `agents/<n>-<name>.json` in the run folder, without the prompt text; the readers' answers are in `logs/source-wave.log` and the merged recipes in `logs/reconcile.log`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "done-done-axi",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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",