wei-wuwei 0.2.1 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ One section per published version, newest first. `wuwei update` prints the
4
4
  sections between the installed version and the new one before it asks;
5
5
  `wuwei check` fails when package.json's version has no section here.
6
6
 
7
+ ## 0.4.0
8
+
9
+ - feat: review turns — claims-scan, batched lens checks, review-collect breakdown (#68)
10
+
11
+ ## 0.3.0
12
+
13
+ - wuwei verify on mobile: Maestro on a simulator + wuwei's mock server (#62)
14
+
7
15
  ## 0.2.1
8
16
 
9
17
  - `wuwei review-collect`: finds the latest Claude Code session that
package/README.md CHANGED
@@ -72,8 +72,9 @@ node bin/wuwei.js digest-check <f> # a review digest's arithmetic before it is
72
72
  node bin/wuwei.js plan-check [stream] # plan-phases step 11's checkable half, before sign-off
73
73
  node bin/wuwei.js worktree <lens> <sha> # a throwaway worktree no other lens shares (--prune)
74
74
  node bin/wuwei.js mutate <f>:<n> --from … --to … --run … --in <wt> # one mutation: landed, baseline, red count, restored
75
- node bin/wuwei.js sites <pattern> [--symbol <name>] # a set derived from the tree, graph and grep both
76
- node bin/wuwei.js verify [page] [--phase NN] # every data-bearing page × 500/403/404/empty/pending/failed refetch; --phase: ACCEPTANCE's ## Verify
75
+ node bin/wuwei.js sites <pattern>… [--symbol <name>] # a set derived from the tree, graph and grep both; several names in one call
76
+ node bin/wuwei.js claims-scan <doc>… # a document's paths, names, file:lines and commands checked against the tree in one call
77
+ node bin/wuwei.js verify [page] [--phase NN] # every data-bearing page × 500/403/404/empty/pending/failed refetch; --phase: ACCEPTANCE's ## Verify; mobile: Maestro + mock server
77
78
  node bin/wuwei.js uninstall # removes exactly what install wrote, nothing else
78
79
  ```
79
80
 
@@ -198,7 +199,7 @@ wuwei/
198
199
  ├── agents/ # canonical subagent definitions
199
200
  │ └── _template/
200
201
  ├── templates/ # PR / review templates, installed per-repo
201
- ├── bin/wuwei.js # CLI: build · check · list · init · install · update · status · review-prep · review-collect · claim · digest-check · plan-check · worktree · mutate · sites · verify · uninstall
202
+ ├── bin/wuwei.js # CLI: build · check · list · init · install · update · status · review-prep · review-collect · claim · digest-check · plan-check · worktree · mutate · sites · claims-scan · verify · uninstall
202
203
  │ # the per-tool emitters live here too, not in adapters/
203
204
  ├── adapters/ # notes only — see bin/wuwei.js for the actual emitters
204
205
  ├── docs/
@@ -56,6 +56,10 @@ Per-phase tidiness already has two owners — `wuwei-execute-phase`'s simplify
56
56
  step and `review:defects` — and a smell list is the long tail wuwei declines to
57
57
  chase. If one phase's diff shows it, it is not yours.
58
58
 
59
+ **Turns are the cost**: every tool call re-reads your whole context. Put
60
+ independent checks in one Bash call (`a; b; c`), and several names in one
61
+ `wuwei sites a b c`.
62
+
59
63
  ## Out of scope
60
64
 
61
65
  - **Changing anything.** No Edit or Write; `Bash` reads (`git diff` of the
@@ -67,6 +67,10 @@ agent's judgment can act on or block on. This section is input to the *next*
67
67
  plan, not a gate on this one — `## Sign-off`'s decision stands until someone
68
68
  with sign-off authority revisits it.
69
69
 
70
+ **Turns are the cost**: every tool call re-reads your whole context. Put
71
+ independent checks in one Bash call (`a; b; c`), and several names in one
72
+ `wuwei sites a b c`.
73
+
70
74
  ## Out of scope
71
75
 
72
76
  - **Changing anything.** No Edit or Write; `Bash` reads (`git diff`, `git
@@ -65,6 +65,10 @@ fully covered.**
65
65
  **No recorded run is a broken link**, not a pass — the same rule the phase
66
66
  pipeline gates on, and **you do not close the gap by running it yourself**.
67
67
 
68
+ **Turns are the cost**: every tool call re-reads your whole context. Put
69
+ independent checks in one Bash call (`a; b; c`), and several names in one
70
+ `wuwei sites a b c`.
71
+
68
72
  ## Out of scope
69
73
 
70
74
  - **Changing anything, and that includes running the suite.** No Edit or
@@ -22,6 +22,10 @@ targets: [claude-code]
22
22
  coverage。你沒有派工工具:一個視角寫完它的發現,才開下一個,不混著看。審查對象是 stream 的 `SPEC.md` 與 `phases/*.md`,時點在驗收
23
23
  契約簽收**之前**:你放行的東西,user 才簽字承擔。
24
24
 
25
+ **Turns are the cost**: every tool call re-reads your whole context. Put
26
+ independent checks in one Bash call (`a; b; c`), and several names in one
27
+ `wuwei sites a b c`.
28
+
25
29
  ## Out of scope
26
30
 
27
31
  - **改檔。** 你只有 Read / Grep / Glob,`Bash` 只能查(`git fetch`、
@@ -28,6 +28,10 @@ Not "is it well written" and not "is it complete" — those belong to
28
28
  assertion about: the code, the config, the runbook, the ticket, the number's
29
29
  source. **A number with no source is a finding.**
30
30
 
31
+ **Start with `wuwei claims-scan <doc>… --sha <sha>`**: every path, name,
32
+ `file:line` and command in the document, checked in one call. By hand: what
33
+ it lists for judgment, and every assertion no code span carries.
34
+
31
35
  **A count carries its derivation** — the set you started from and how you
32
36
  narrowed it; a bare count is `unsupported` even when right.
33
37
 
@@ -136,6 +140,10 @@ with its derivation), **not from the names the document
136
140
  handed you** — those are the claim, not the evidence — and report the
137
141
  difference.
138
142
 
143
+ **Turns are the cost**: every tool call re-reads your whole context. Put
144
+ independent checks in one Bash call (`a; b; c`), and several names in one
145
+ `wuwei sites a b c`.
146
+
139
147
  ## Out of scope
140
148
 
141
149
  - **Changing the dispatched tree.** No Edit or Write; `Bash` reads it
@@ -126,6 +126,10 @@ agent exists to avoid:
126
126
  The last one is the hardest to self-check: a downgrade leaves no trace, and
127
127
  the downgraded finding ships.
128
128
 
129
+ **Turns are the cost**: every tool call re-reads your whole context. Put
130
+ independent checks in one Bash call (`a; b; c`), and several names in one
131
+ `wuwei sites a b c`.
132
+
129
133
  ## Out of scope
130
134
 
131
135
  - **Changing the dispatched tree.** No Edit or Write; `Bash` reads and runs
@@ -133,6 +133,10 @@ searched or report `PLAUSIBLE`.
133
133
  change produces, not the base** — the change itself can add the first hit (a
134
134
  spec, a fixture, a guard test). The sentence names the ref it holds for.
135
135
 
136
+ **Turns are the cost**: every tool call re-reads your whole context. Put
137
+ independent checks in one Bash call (`a; b; c`), and several names in one
138
+ `wuwei sites a b c`.
139
+
136
140
  ## Out of scope
137
141
 
138
142
  - **Changing the dispatched tree.** No Edit or Write; `Bash` reads it. A
@@ -122,6 +122,10 @@ A phase that changes schema and says nothing about lock duration has not been
122
122
  reviewed for this; "the constraint is a strict superset so existing rows all
123
123
  pass" answers compatibility, not cost.
124
124
 
125
+ **Turns are the cost**: every tool call re-reads your whole context. Put
126
+ independent checks in one Bash call (`a; b; c`), and several names in one
127
+ `wuwei sites a b c`.
128
+
125
129
  ## Out of scope
126
130
 
127
131
  - **Changing the dispatched tree.** No Edit or Write; `Bash` reads it
@@ -129,6 +129,10 @@ is not one. Quote the clause.
129
129
  **A zero-hit claim is checked against the tree the change produces, not the
130
130
  base** — the change itself can add the first hit. Name the ref it holds for.
131
131
 
132
+ **Turns are the cost**: every tool call re-reads your whole context. Put
133
+ independent checks in one Bash call (`a; b; c`), and several names in one
134
+ `wuwei sites a b c`.
135
+
132
136
  ## Out of scope
133
137
 
134
138
  - **Changing anything.** No Edit or Write; `Bash` reads (`grep`, `git log`,
@@ -61,6 +61,9 @@ So: **for every claim a document makes about what is or is not required, find
61
61
  the procedure section that would have to change with it.** A new section
62
62
  asserting a new fact, with the old procedure untouched, is the pattern.
63
63
 
64
+ **Turns are the cost**: every tool call re-reads your whole context. Put
65
+ alternatives in one pattern (`a|b|c`) rather than one search each.
66
+
64
67
  ## Out of scope
65
68
 
66
69
  - **Changing anything.** No Edit or Write, and no Bash — everything here is
@@ -34,6 +34,10 @@ targets: [claude-code]
34
34
  只取本席轄區的條目,bundle 答不了就直接讀樹——它是 reviewed sha 的快取,
35
35
  不是權威,引用的座標一律是 repo 的座標。
36
36
 
37
+ **Turns are the cost**: every tool call re-reads your whole context. Put
38
+ independent checks in one Bash call (`a; b; c`), and several names in one
39
+ `wuwei sites a b c`.
40
+
37
41
  ## Out of scope
38
42
 
39
43
  - **改派工給你的那棵樹。** 沒有 Edit / Write。發現以報告回傳,補洞是實作的事。
package/bin/wuwei.js CHANGED
@@ -29,11 +29,14 @@
29
29
  * wuwei worktree <lens> [<sha>] a throwaway worktree no other lens shares; --prune
30
30
  * wuwei mutate <f>:<n> --from --to --run one mutation in a wuwei worktree: landed?
31
31
  * baseline vs mutated, failing tests, restored, recorded
32
- * wuwei sites <pattern> [--symbol <name>] a set from the tree, with its derivation;
32
+ * wuwei sites <pattern>… [--symbol <name>] a set from the tree, with its derivation;
33
33
  * graph and grep both for a symbol
34
- * wuwei verify [<page>…] [--phase NN] every data-bearing page through its states
35
- * (Playwright, fixtures from .wuwei/verify.json): a matrix of
36
- * shots; --phase runs the pages ACCEPTANCE.md binds to it
34
+ * wuwei claims-scan <doc>… [--sha <rev>] a document's paths, names, file:lines and
35
+ * commands checked against the tree in one pass
36
+ * wuwei verify [<page>…] [--phase NN] [--endpoint <text>] [--states 500,404] every data-bearing page through its states
37
+ * (Playwright on web, Maestro + a mock server on mobile;
38
+ * fixtures from .wuwei/verify.json): a matrix of shots;
39
+ * --phase runs the pages ACCEPTANCE.md binds to it
37
40
  * wuwei plan-check [<stream>] wuwei-plan-phases step 11's checkable half
38
41
  * wuwei uninstall [<path>] remove exactly what install wrote
39
42
  * wuwei -v | --version print the installed version
@@ -48,6 +51,7 @@ import { fileURLToPath } from 'node:url'
48
51
  import { createInterface } from 'node:readline/promises'
49
52
  import { spawnSync, spawn } from 'node:child_process'
50
53
  import { createRequire } from 'node:module'
54
+ import { createServer } from 'node:http'
51
55
  import { createHash } from 'node:crypto'
52
56
 
53
57
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
@@ -1555,10 +1559,15 @@ function cmdSites(argv) {
1555
1559
  const dd = argv.indexOf('--')
1556
1560
  const pathspec = dd === -1 ? [] : argv.slice(dd + 1)
1557
1561
  const args = dd === -1 ? argv : argv.slice(0, dd)
1558
- const pattern = args.find((a, i) => !a.startsWith('--') && !['--sha', '--symbol'].includes(args[i - 1]))
1562
+ const patterns = args.filter((a, i) => !a.startsWith('--') && !['--sha', '--symbol'].includes(args[i - 1]))
1559
1563
  const sha = flag('--sha') ?? 'HEAD', symbol = flag('--symbol'), regex = args.includes('--regex')
1560
- if (!pattern && !symbol) { console.error("usage: wuwei sites <pattern> [--regex] [--sha <rev>] [--symbol <name>] [-- <pathspec>…]"); process.exit(1) }
1561
- const term = pattern ?? symbol
1564
+ if (!patterns.length && !symbol) { console.error("usage: wuwei sites <pattern>… [--regex] [--sha <rev>] [--symbol <name>] [-- <pathspec>…]"); process.exit(1) }
1565
+ // several names in one call: every call re-reads the lens's whole context, so one beats many
1566
+ if (patterns.length > 1 && !symbol) {
1567
+ for (const p of patterns) { console.log(`── ${p}`); cmdSites([p, ...(regex ? ['--regex'] : []), '--sha', sha, ...(pathspec.length ? ['--', ...pathspec] : [])]); console.log() }
1568
+ return
1569
+ }
1570
+ const term = patterns[0] ?? symbol
1562
1571
  const grepArgs = ['grep', '-n', regex ? '-E' : '-F', '-e', term, sha, '--', ...pathspec]
1563
1572
  const g = quiet('git', grepArgs)
1564
1573
  if (g.status > 1) { console.error(`✗ git grep failed: ${firstLine(g.stderr)}`); process.exit(1) }
@@ -1603,6 +1612,116 @@ function cmdSites(argv) {
1603
1612
  }
1604
1613
  }
1605
1614
 
1615
+ // ── claims-scan: a document's checkable assertions, all in one call ─────
1616
+ // review:claims checked each path, name and line with its own grep: 74–117
1617
+ // turns a document, every one re-reading the lens's whole context — 59M of a
1618
+ // 196M-token review (reward-system, `wuwei review-collect`). This pulls them
1619
+ // out and checks them against the tree in one pass; the lens judges only what
1620
+ // it lists as needing judgment
1621
+ function cmdClaimsScan(argv) {
1622
+ const flag = name => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1] }
1623
+ const sha = flag('--sha') ?? 'HEAD', all = argv.includes('--all')
1624
+ const docs = argv.filter((a, i) => !a.startsWith('--') && argv[i - 1] !== '--sha')
1625
+ if (!docs.length) { console.error('usage: wuwei claims-scan <doc>… [--sha <rev>] [--all]'); process.exit(1) }
1626
+ const at = quiet('git', ['rev-parse', '--short', sha]).stdout?.trim()
1627
+ if (!at) { console.error(`✗ ${sha} is not a commit here`); process.exit(1) }
1628
+ const tree = quiet('git', ['ls-tree', '-r', '--name-only', sha]).stdout.split('\n').filter(Boolean)
1629
+ const files = new Set(tree), dirs = new Set()
1630
+ for (const f of tree) { const p = f.split('/'); for (let i = 1; i < p.length; i++) dirs.add(p.slice(0, i).join('/')) }
1631
+ const lengths = new Map()
1632
+ const lineCount = f => { if (!lengths.has(f)) lengths.set(f, (quiet('git', ['show', `${sha}:${f}`]).stdout ?? '').split('\n').length - 1); return lengths.get(f) }
1633
+ let scripts = {}
1634
+ try { scripts = JSON.parse(quiet('git', ['show', `${sha}:package.json`]).stdout).scripts ?? {} } catch {}
1635
+
1636
+ const norm = p => p.replace(/^\.\//, '').replace(/\/$/, '').split('/').reduce((a, s) => s === '..' ? a.slice(0, -1) : s === '.' ? a : [...a, s], []).join('/')
1637
+ // a path as written, relative to the repo or the document; else wherever a file of that name is
1638
+ const resolvePath = (p, docDir) => {
1639
+ for (const c of [norm(p), norm(join(docDir, p))]) if (files.has(c) || dirs.has(c)) return { ok: c }
1640
+ const tail = '/' + norm(p), hits = tree.filter(f => f.endsWith(tail))
1641
+ return hits.length === 1 ? { moved: hits[0] } : hits.length ? { ambiguous: hits.slice(0, 3) } : { missing: true }
1642
+ }
1643
+ const SKIP = new Set(['null', 'true', 'false', 'undefined', 'none', 'yes', 'no', 'and', 'or', 'not', 'the', 'todo', 'fixme'])
1644
+ const COUNT = /\b(\d[\d,]*)\s+(files?|tests?|rows?|items?|endpoints?|keys?|lines?|columns?|tables?|routes?|pages?|steps?|commits?|calls?|call sites?|sites?|places?|locations?|entries|icons?|images?|states?|cases?|checks?|findings?|members?|fields?|migrations?|phases?)\b/gi
1645
+
1646
+ for (const doc of docs) {
1647
+ const text = existsSync(doc) ? readFileSync(doc, 'utf8') : quiet('git', ['show', `${sha}:${doc}`]).stdout
1648
+ if (text == null || text === '') { console.log(`✗ ${doc}: not found here or at ${at}`); continue }
1649
+ const docDir = dirname(norm(doc)), rows = [], ids = new Map(), counts = []
1650
+ let fenced = false
1651
+ text.split('\n').forEach((ln, i) => {
1652
+ if (/^\s*(```|~~~)/.test(ln)) { fenced = !fenced; return }
1653
+ if (fenced) return
1654
+ const where = `${doc}:${i + 1}`
1655
+ for (const m of ln.matchAll(/`([^`\n]+)`/g)) rows.push({ where, raw: m[1].trim() })
1656
+ for (const m of ln.matchAll(/\]\(([^)\s]+)\)/g)) if (!/^(https?:|mailto:|#)/.test(m[1])) rows.push({ where, raw: m[1].split('#')[0], link: true })
1657
+ for (const m of ln.replace(/`[^`]*`/g, '').matchAll(COUNT)) counts.push({ where, raw: m[0] })
1658
+ })
1659
+ const out = []
1660
+ for (const r of rows) {
1661
+ const s = r.raw
1662
+ const fl = s.match(/^([\w./@-]+\.[A-Za-z0-9]+):(\d+)(?:[-–](\d+))?$/)
1663
+ if (fl) {
1664
+ const p = resolvePath(fl[1], docDir), f = p.ok ?? p.moved
1665
+ if (!f) { out.push({ ...r, kind: 'file:line', result: p.ambiguous ? `ambiguous: ${p.ambiguous.join(', ')}` : 'file missing' }); continue }
1666
+ const last = Number(fl[3] ?? fl[2]), n = lineCount(f)
1667
+ out.push({ ...r, kind: 'file:line', result: last > n ? `past the end — ${f} has ${n} lines` : p.moved ? `moved: ${f}:${fl[2]}${fl[3] ? `-${fl[3]}` : ''}` : 'ok' })
1668
+ continue
1669
+ }
1670
+ if (/^(npm|pnpm|yarn|bun) (run )?[\w:.-]+/.test(s)) {
1671
+ const name = s.match(/^(?:npm|pnpm|yarn|bun) (?:run )?([\w:.-]+)/)[1]
1672
+ const builtin = ['install', 'ci', 'test', 'i', 'exec', 'add', 'publish', 'pack', 'view', 'link'].includes(name) && !/ run /.test(s)
1673
+ out.push({ ...r, kind: 'command', result: builtin || scripts[name] ? (builtin ? 'command — not run' : 'script exists — not run') : `no package.json script "${name}"` })
1674
+ continue
1675
+ }
1676
+ if (/^(npx|node|wuwei|ww|git|make|python3?|bash|sh|curl|gh|docker|kubectl|gcloud|bunx|deno|cargo|go)\s/.test(s)) {
1677
+ const path = s.match(/^(?:node|python3?|bash|sh)\s+(\S+\.\w+)/)?.[1]
1678
+ const p = path && resolvePath(path, docDir)
1679
+ out.push({ ...r, kind: 'command', result: p && !p.ok ? (p.moved ? `script moved: ${p.moved}` : 'script missing') : 'command — not run' })
1680
+ continue
1681
+ }
1682
+ // not this repo's to answer: a placeholder, a machine path, a version, a slash command
1683
+ if (/[<>{}]|\bNN\b|^[~$]|^\/[^/]*$|^\/(Users|home|tmp|etc|opt|var)\/|^v?\d+(\.[\dx*]+)+$|^\.\w+$/.test(s)) continue
1684
+ if (!/\s/.test(s) && !/^[a-z]+:\/\//.test(s) && (r.link || s.includes('/') || /\.[A-Za-z][A-Za-z0-9]{0,5}$/.test(s)) && !/^[\d.]+$/.test(s)) {
1685
+ // a path is this repo's when its first directory is; a bare name may be anyone's file
1686
+ const first = norm(s).split('/')[0], ours = s.includes('/') && (dirs.has(first) || dirs.has(norm(join(docDir, first))))
1687
+ if (s.includes('*')) {
1688
+ const re = globRe(norm(s)), n = tree.filter(f => re.test(f)).length
1689
+ out.push({ ...r, kind: 'glob', result: n ? `ok (${n} file${n === 1 ? '' : 's'})` : ours ? 'matches nothing' : 'not in this repo' })
1690
+ } else {
1691
+ const p = resolvePath(s, docDir)
1692
+ out.push({ ...r, kind: 'path', result: p.ok ? 'ok' : !s.includes('/') && (p.moved || p.ambiguous) ? `ok (${p.moved ?? p.ambiguous.join(', ')})`
1693
+ : p.moved ? `moved: ${p.moved}` : p.ambiguous ? `ambiguous: ${p.ambiguous.join(', ')}` : !ours ? 'not in this repo' : 'missing' })
1694
+ }
1695
+ continue
1696
+ }
1697
+ const id = s.replace(/\(\)$/, '')
1698
+ if (/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/.test(id) && id.length >= 3 && !SKIP.has(id.toLowerCase())) {
1699
+ if (!ids.has(id)) ids.set(id, [])
1700
+ ids.get(id).push(r)
1701
+ }
1702
+ }
1703
+ // every identifier in one git grep, the document itself excluded
1704
+ if (ids.size) {
1705
+ const g = quiet('git', ['grep', '-o', '-h', '-w', '-F', ...[...ids.keys()].flatMap(id => ['-e', id]), sha, '--', '.', `:(exclude)${norm(doc)}`])
1706
+ const seen = new Map()
1707
+ for (const m of (g.stdout ?? '').split('\n')) if (m) seen.set(m.replace(`${sha}:`, ''), (seen.get(m.replace(`${sha}:`, '')) ?? 0) + 1)
1708
+ for (const [id, rs] of ids) for (const r of rs) out.push({ ...r, kind: 'name', result: seen.get(id) ? `ok (${seen.get(id)} in the tree)` : 'not in this repo' })
1709
+ }
1710
+ const elsewhere = out.filter(o => o.result === 'not in this repo')
1711
+ const bad = out.filter(o => !o.result.startsWith('ok') && !/not run/.test(o.result) && o.result !== 'not in this repo')
1712
+ const judge = [...out.filter(o => /not run/.test(o.result)), ...counts.map(c => ({ ...c, kind: 'count', result: 're-derive' }))]
1713
+ console.log(`claims-scan ${doc} @ ${at}: ${out.length} checked · ${bad.length} wrong · ${elsewhere.length} not in this repo · ${judge.length} for judgment`)
1714
+ if (bad.length) { console.log('\nwrong at the sha:\n| where | claim | kind | at the sha |\n|---|---|---|---|'); for (const o of bad) console.log(`| ${o.where} | \`${o.raw}\` | ${o.kind} | ${o.result} |`) }
1715
+ const byClaim = new Map()
1716
+ for (const o of elsewhere) byClaim.set(o.raw, [...(byClaim.get(o.raw) ?? []), o.where.split(':').pop()])
1717
+ if (byClaim.size) console.log(`\nnot in this repo at the sha — another repo's, a tool's config, removed, or wrong: ${[...byClaim].map(([c, ls]) => `\`${c}\` (${ls.join(',')})`).join(' · ')}`)
1718
+ if (judge.length) { console.log('\nfor judgment — counts to re-derive, commands not run:'); for (const o of judge) console.log(` ${o.where} ${o.kind === 'count' ? o.raw : `\`${o.raw}\``} — ${o.result}`) }
1719
+ if (all) { console.log('\nchecked ok:'); for (const o of out.filter(o => o.result.startsWith('ok'))) console.log(` ${o.where} \`${o.raw}\` ${o.result}`) }
1720
+ console.log('')
1721
+ }
1722
+ console.log('what it cannot see: a claim in prose with no code span, a behaviour, a number with no unit — those stay the lens\'s')
1723
+ }
1724
+
1606
1725
  // ── verify: hands and eyes — every data-bearing page through its states ──
1607
1726
  // The record: UI state bugs found one round at a time (#268 R1 → R2 → R3:
1608
1727
  // a refetch failure that replaces loaded rows, a 403 shown as a generic
@@ -1615,6 +1734,9 @@ const VERIFY_STATES = ['500', '403', '404', 'empty', 'pending', 'refetch-500', '
1615
1734
  const BUSY = '[aria-busy="true"],[role="progressbar"],[data-slot*="skeleton"],[data-loading="true"],.skeleton,[data-skeleton],[class*="skeleton"],.animate-pulse,.animate-spin'
1616
1735
  const SAYS_ERROR = /error|failed|could not|couldn't|unable|try again|retry|錯誤|失敗|無法/i
1617
1736
  const TICKS = /\b\d+\s*(s|sec|secs|seconds?|min|mins|minutes?|h|hours?)\s+ago\b|\d+\s*(秒|分鐘|小時)前|just now|剛剛/i
1737
+ const SAYS_NOT_FOUND = /not found|unknown|doesn't exist|does not exist|no longer|找不到|不存在/i
1738
+ // a request never answered, the loader still up: the app has no timeout
1739
+ const STUCK = cfg => `still loading ${(cfg.pendingStuckMs ?? 30000) / 1000}s on, the request never answered — no timeout, the wait never ends`
1618
1740
  const SAYS_FORBIDDEN = /permission|forbidden|not allowed|denied|access|authori[sz]|權限|無權/i
1619
1741
 
1620
1742
  function loadPlaywright(cwd) {
@@ -1624,7 +1746,7 @@ function loadPlaywright(cwd) {
1624
1746
  return null
1625
1747
  }
1626
1748
 
1627
- const globRe = g => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*') + '$')
1749
+ const globRe = g => new RegExp('^' + g.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*') + '$')
1628
1750
 
1629
1751
  function fixtureFor(fixtures, method, url) {
1630
1752
  const u = new URL(url)
@@ -1678,13 +1800,28 @@ async function cmdVerify(argv) {
1678
1800
  "pages": [ { "name": "items", "path": "/items", "ready": "text=Items" } ],
1679
1801
  "refetch": "focus", "refetchAfterMs": 600000, "refetchWaitMs": 30000,
1680
1802
  "busy": "[data-slot=skeleton]", // optional: your loader, beyond the usual ones
1681
- "errorWaitMs": 60000 // simulated time client retries may hold the loader
1803
+ "errorWaitMs": 60000, // simulated time client retries may hold the loader
1804
+ "pendingStuckMs": 30000, // a loader still up this long on an unanswered request is a hang; 0 skips
1805
+ "driftPct": 1, "transientMs": 4000 // live numbers within 1% are the same line; watch a failure 4s for a toast
1806
+ }
1807
+ a mobile app (Maestro on a simulator; the app built against the mock server):
1808
+ {
1809
+ "mobile": { "appId": "com.example.app", "port": 48780, // iOS → http://localhost:48780, Android emulator → http://10.0.2.2:48780
1810
+ "login": ".maestro/login.yaml", // optional: run after each launch
1811
+ "device": "<udid>", "maestro": "<path>" }, // optional: which simulator; maestro if not on PATH or in ~/.maestro/bin
1812
+ "fixtures": { … as above … },
1813
+ "pages": [ { "name": "items", "link": "myapp://items", "ready": "Items" } ], // or "flow": ".maestro/open-items.yaml"
1814
+ "refetchAfterMs": 0, // real time in the background: build with staleTime 0, or wait it out
1815
+ "busy": "skeleton|spinner" // optional: a regex over ids and labels
1682
1816
  }`)
1683
1817
  process.exit(1)
1684
1818
  }
1685
1819
  const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'))
1686
1820
  const flag = name => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1] }
1687
- const only = argv.filter((a, i) => !a.startsWith('--') && !['--phase', '--stream'].includes(argv[i - 1]))
1821
+ const only = argv.filter((a, i) => !a.startsWith('--') && !['--phase', '--stream', '--endpoint', '--states'].includes(argv[i - 1]))
1822
+ // a narrow run: endpoints whose key contains any --endpoint text, states from --states
1823
+ const endpointFilter = argv.flatMap((a, i) => a === '--endpoint' ? [argv[i + 1]] : [])
1824
+ const stateFilter = flag('--states')?.split(',').map(s => s.trim()).filter(Boolean) ?? null
1688
1825
  // --phase NN: the pages ACCEPTANCE.md binds to that phase — the contract, not memory
1689
1826
  if (flag('--phase')) {
1690
1827
  const sdir = join(dest, '.wuwei', 'streams')
@@ -1699,7 +1836,151 @@ async function cmdVerify(argv) {
1699
1836
  only.push(...want)
1700
1837
  }
1701
1838
  const missing = only.filter(n => !(cfg.pages ?? []).some(pg => pg.name === n))
1702
- if (missing.length) { console.error(`✗ not in .wuwei/verify.json: ${missing.join(', ')} — add each page (path, ready) and the fixtures it calls, from the API's schema`); process.exit(1) }
1839
+ if (missing.length) { console.error(`✗ not in .wuwei/verify.json: ${missing.join(', ')} — add each page (path or link, ready) and the fixtures it calls, from the API's schema`); process.exit(1) }
1840
+ const sha = quiet('git', ['rev-parse', '--verify', '--quiet', 'HEAD']).stdout?.trim().slice(0, 12) || 'nogit'
1841
+ const outDir = join(dest, '.wuwei-cache', 'verify', sha)
1842
+ const driver = cfg.mobile ? await verifyMobile(cfg, dest, outDir) : await verifyWeb(cfg, dest)
1843
+ const report = []
1844
+ const lines = t => new Set(t.split('\n').map(l => l.trim()).filter(l => l.length > 2))
1845
+ // a live number (an on-chain TVL) moves between any two opens: a line whose
1846
+ // only change is its numbers, each within driftPct (1%), is the same line —
1847
+ // $33,000 → $8,000 is not drift, it is the failure
1848
+ const NUM = /\d[\d,]*(?:\.\d+)?/g
1849
+ const nums = l => (l.match(NUM) ?? []).map(n => Number(n.replace(/,/g, '')))
1850
+ // and a relative time ("updated 2 min ago") is the clock, not the content: it is
1851
+ // taken out of a line, never the whole line with it
1852
+ const unticked = l => l.replace(new RegExp(TICKS.source, 'gi'), '').trim()
1853
+ const drifted = (a, b) => (a = unticked(a), b = unticked(b), a.replace(NUM, '#') === b.replace(NUM, '#')) && nums(a).every((x, i) => {
1854
+ const y = nums(b)[i]; return x === y || Math.abs(x - y) <= Math.max(Math.abs(x), Math.abs(y)) * (cfg.driftPct ?? 1) / 100 })
1855
+ const diff = (before, after) => {
1856
+ const said = [...after].filter(l => !before.has(l)), gone = []
1857
+ for (const l of [...before].filter(l => !after.has(l) && unticked(l))) {
1858
+ const i = said.findIndex(s => drifted(l, s)); if (i === -1) gone.push(l); else said.splice(i, 1)
1859
+ }
1860
+ return { said, gone }
1861
+ }
1862
+ const writeMatrix = ({ page, rows, endpoints, dir }, stopped) => {
1863
+ const md = [`# verify ${page.name} @ ${sha}`, '', `page: \`${page.path ?? page.link ?? page.flow}\` · endpoints: ${endpoints.length} · states: ${VERIFY_STATES.join(', ')}`,
1864
+ ...(stopped ? ['', `**stopped:** ${stopped}`] : []), '',
1865
+ '| endpoint | state | flags | on screen, beyond happy | shot |', '|---|---|---|---|---|',
1866
+ ...rows.map(r => `| ${r.endpoint} | ${r.state} | ${r.flags.length ? r.flags.join('; ') : '—'} | ${(r.note ?? (r.said ?? r.text).split('\n').find(l => l.trim()) ?? '—').slice(0, 60).replace(/\|/g, '\\|')} | ${r.shot} |`)]
1867
+ writeFileSync(join(dir, 'matrix.md'), md.join('\n') + '\n')
1868
+ }
1869
+ // stopped by hand: what ran so far is still a matrix
1870
+ let current = null
1871
+ process.once('SIGINT', () => {
1872
+ if (current) { writeMatrix(current, 'interrupted'); console.log(`\n✗ ${current.page.name}: interrupted after ${current.rows.length} state(s) — ${join(current.dir, 'matrix.md')}`) }
1873
+ process.exit(130)
1874
+ })
1875
+
1876
+ for (const page of (cfg.pages ?? []).filter(pg => !only.length || only.includes(pg.name))) {
1877
+ const dir = join(outDir, page.name)
1878
+ mkdirSync(dir, { recursive: true })
1879
+ const happy = await driver.visit(page, { happy: true })
1880
+ await happy.shot(join(dir, 'happy.png'))
1881
+ writeFileSync(join(dir, 'happy.txt'), happy.text)
1882
+ await happy.close()
1883
+ // a page that never opened makes every state below a picture of the wrong screen
1884
+ const abort = happy.flowError ? `the flow failed — ${happy.flowError}`
1885
+ : page.ready && !happy.ready ? `happy never showed "${page.ready}" — check the link or flow, login and fixtures (happy.png)` : null
1886
+ if (abort) {
1887
+ writeMatrix({ page, rows: [{ endpoint: '—', state: 'happy', flags: ['flow failed'], note: abort.slice(0, 60), shot: 'happy.png', text: '' }], endpoints: [], dir }, abort)
1888
+ report.push({ page: page.name, rows: [], dir, abort }); continue
1889
+ }
1890
+ for (const vp of driver.viewports ?? []) {
1891
+ const narrow = await driver.visit(page, { viewport: vp })
1892
+ await narrow.shot(join(dir, `happy-${vp.width}.png`))
1893
+ await narrow.close()
1894
+ }
1895
+ const endpoints = [...happy.seen.keys()]
1896
+ const happyLines = lines(happy.text)
1897
+ const rows = [{ endpoint: '—', state: 'happy', flags: [...[...happy.unmocked].map(u => `unmocked: ${u}`), ...happy.errors.map(e => `page error: ${e}`)], shot: 'happy.png', text: happy.text }]
1898
+ const served = new Map(happy.seen)
1899
+ current = { page, rows, endpoints, dir }
1900
+ // which endpoints the trigger refetches is the app's query config, not the
1901
+ // failing endpoint's: after one trigger, only those it re-requested run refetch states
1902
+ let triggered = false
1903
+ const refetchable = new Set(), invisible = new Set()
1904
+ // a filter that is a whole key means that key alone; otherwise any key containing it
1905
+ const picked = k => !endpointFilter.length || endpointFilter.some(f => endpoints.includes(f) ? k === f : k.includes(f))
1906
+ pageLoop: for (const key of endpoints.filter(picked)) {
1907
+ for (const state of VERIFY_STATES.filter(s => !stateFilter || stateFilter.includes(s))) {
1908
+ if (state === 'empty' && !emptied(served.get(key)?.json)) continue
1909
+ const refetch = state.match(/^refetch-(\d+)$/)
1910
+ if (refetch && triggered && !refetchable.has(key)) { rows.push({ endpoint: key, state, flags: [], note: `skipped — the trigger re-requested ${refetchable.size ? [...refetchable].join(', ') : 'nothing'}`, shot: '—', text: '' }); continue }
1911
+ const v = refetch ? await driver.visit(page, { refetchFail: { key, status: Number(refetch[1]) } }) : await driver.visit(page, { override: { key, state } })
1912
+ const slug = `${key.replace(/[^\w]+/g, '_').replace(/^_|_$/g, '')}--${state}`
1913
+ await v.shot(join(dir, `${slug}.png`))
1914
+ writeFileSync(join(dir, `${slug}.txt`), v.text)
1915
+ await v.close()
1916
+ if (v.flowError) {
1917
+ rows.push({ endpoint: key, state, flags: ['flow failed'], note: v.flowError.slice(0, 60), shot: `${slug}.png`, text: '' })
1918
+ const abort = `${key} · ${state}: the flow failed — ${v.flowError}`
1919
+ writeMatrix(current, abort); report.push({ page: page.name, rows, dir, abort, written: true }); break pageLoop
1920
+ }
1921
+ if (refetch) { triggered = true; for (const k of v.afterKeys ?? []) refetchable.add(k) }
1922
+ const vLines = lines(v.text)
1923
+ // judged on what the state added to the screen: a nav item saying "Access" is not an answer.
1924
+ // A toast gone by the time the screen settles still said it: glimpsed lines count
1925
+ const d = diff(happyLines, vLines)
1926
+ const known = [...happyLines, ...vLines]
1927
+ const glimpsed = [...(v.glimpsed ?? [])].filter(l => l.length > 2 && unticked(l) && !known.some(k => k === l || drifted(k, l)))
1928
+ // what the screen ended on decides loading; what it said along the way counts as said
1929
+ const said = [...d.said, ...glimpsed].join('\n'), gone = d.gone
1930
+ const loading = v.busy || /loading|載入/i.test(d.said.join('\n'))
1931
+ const flags = v.errors.map(e => `page error: ${e}`)
1932
+ // the screen as happy had it: this page shows nothing of this endpoint — not a missing error
1933
+ const same = !said && !gone.length
1934
+ // shows nothing of it when it fails outright, so a failed refetch of it shows nothing either
1935
+ if (same && refetch && invisible.has(key)) { rows.push({ endpoint: key, state, flags, note: 'no visible effect — this page shows nothing of it', shot: `${slug}.png`, text: v.text, said }); continue }
1936
+ if (same && !refetch) invisible.add(key)
1937
+ if (same && !refetch) { rows.push({ endpoint: key, state, flags: [...flags, ...(v.stuck ? [STUCK(cfg)] : [])], note: 'no visible effect — this page shows nothing of it', shot: `${slug}.png`, text: v.text, said }); continue }
1938
+ if (/^5/.test(state) && loading) flags.push(`still loading ${(cfg.errorWaitMs ?? driver.errorWaitMs) / 1000}s on — client retries outlast the wait; raise "errorWaitMs"`)
1939
+ else if (/^5/.test(state) && !SAYS_ERROR.test(said)) flags.push('no error shown')
1940
+ if (state === '403' && !SAYS_FORBIDDEN.test(said)) flags.push('403 not told as a permission problem')
1941
+ if (state === '404' && v.retry) flags.push('Retry offered on a 404 — it can never succeed')
1942
+ // a 404 that changed the screen — lines gone, or a control greyed out — and says nothing
1943
+ const changed = [...gone, ...d.said.filter(l => l.startsWith('[disabled]'))]
1944
+ if (state === '404' && changed.length && !SAYS_ERROR.test(said) && !SAYS_NOT_FOUND.test(said)) flags.push(`404: ${changed.length} change(s) on screen and nothing says why (${changed.slice(0, 2).join(' / ').slice(0, 60)})`)
1945
+ if (state === '403' && v.retry) flags.push('Retry offered on a 403 — it can never succeed')
1946
+ if (state === 'pending' && !v.busy && !/loading|載入/i.test(v.text)) flags.push('no loading indicator')
1947
+ if (state === 'pending' && v.stuck) flags.push(STUCK(cfg))
1948
+ // a refetch that never happened says nothing about how a failed one looks;
1949
+ // what it did say is the lines it added — a relative time ticking is not an error
1950
+ const told = SAYS_ERROR.test(said) || (state === 'refetch-403' && SAYS_FORBIDDEN.test(said))
1951
+ if (refetch && !v.refetched) flags.push(driver.notRefetched)
1952
+ else if (refetch && !told) flags.push('silent: the failed refetch shows nothing')
1953
+ else if (refetch && gone.length) flags.push(`refetch failure replaced loaded content: ${gone.length} line(s) gone (${gone.slice(0, 2).join(' / ').slice(0, 60)})`)
1954
+ if (state === 'refetch-403' && v.refetched && told && !SAYS_FORBIDDEN.test(said)) flags.push('403 on refetch not told as a permission problem')
1955
+ rows.push({ endpoint: key, state, flags, shot: `${slug}.png`, text: v.text, said })
1956
+ }
1957
+ }
1958
+ current = null
1959
+ if (report.at(-1)?.page === page.name) continue
1960
+ // a failure that reads exactly like no data: the user is told there is nothing, not that it broke
1961
+ for (const e of rows.filter(r => r.state === 'empty' && r.said)) for (const r of rows)
1962
+ if (r.endpoint === e.endpoint && /^\d+$/.test(r.state) && r.said === e.said) r.flags.push(`${r.state} shown as the empty state ("${e.said.split('\n')[0].slice(0, 40)}")`)
1963
+ writeMatrix({ page, rows, endpoints, dir })
1964
+ report.push({ page: page.name, rows, dir })
1965
+ }
1966
+ await driver.done()
1967
+
1968
+ for (const { page, rows, dir, abort } of report) {
1969
+ if (abort) { console.log(`✗ ${page}: stopped after ${rows.length} state(s) — ${abort}${rows.length ? ` · what ran: ${join(dir, 'matrix.md')}` : ''}`); continue }
1970
+ const flagged = rows.filter(r => r.flags.length), quiet = rows.filter(r => r.note && !r.flags.length)
1971
+ console.log(`${page}: ${rows.length} state(s), ${flagged.length} flagged — ${join(dir, 'matrix.md')}`)
1972
+ for (const r of flagged) console.log(` ${r.endpoint} · ${r.state}: ${r.flags.join('; ')}`)
1973
+ const noEffect = [...new Set(quiet.filter(r => r.note.startsWith('no visible')).map(r => r.endpoint))]
1974
+ if (noEffect.length) console.log(` no visible effect on this page: ${noEffect.join(', ')}`)
1975
+ const skipped = quiet.filter(r => r.note.startsWith('skipped'))
1976
+ if (skipped.length) console.log(` ${skipped.length} refetch state(s) skipped — ${skipped[0].note.replace(/^skipped — /, '')}`)
1977
+ }
1978
+ console.log('\nflags are leads, not findings: open the shot, and confirm against the spec what the page should say')
1979
+ if (report.some(r => r.abort)) process.exit(1)
1980
+ }
1981
+
1982
+ // the web half: Playwright routes the page's own requests to the fixtures
1983
+ async function verifyWeb(cfg, dest) {
1703
1984
  const pw = loadPlaywright(dest)
1704
1985
  if (!pw) { console.error('✗ Playwright not found in this project or globally — npm i -D playwright'); process.exit(1) }
1705
1986
  let server = null
@@ -1708,12 +1989,9 @@ async function cmdVerify(argv) {
1708
1989
  if (!(await waitForUrl(cfg.url, cfg.startTimeoutMs ?? 120000))) { console.error(`✗ ${cfg.url} did not come up after \`${cfg.start}\``); try { process.kill(-server.pid) } catch {} ; process.exit(1) }
1709
1990
  } else if (!(await waitForUrl(cfg.url, 5000))) { console.error(`✗ ${cfg.url} is not reachable — start the app, or set "start"`); process.exit(1) }
1710
1991
 
1711
- const sha = quiet('git', ['rev-parse', 'HEAD']).stdout?.trim().slice(0, 12) || 'nogit'
1712
- const outDir = join(dest, '.wuwei-cache', 'verify', sha)
1713
1992
  const browser = await pw.chromium.launch()
1714
1993
  const apiRes = [].concat(cfg.api ?? ['**/api/**']).map(globRe)
1715
1994
  const isApi = url => apiRes.some(re => re.test(url) || re.test(new URL(url).pathname))
1716
- const report = []
1717
1995
 
1718
1996
  // one visit: routes every API call to its fixture, except the endpoint under test
1719
1997
  const visit = async (page, { override, refetchFail, viewport } = {}) => {
@@ -1726,19 +2004,21 @@ async function cmdVerify(argv) {
1726
2004
  }, cfg.auth)
1727
2005
  const p = await ctx.newPage()
1728
2006
  const seen = new Map(), unmocked = new Set(), errors = []
1729
- let failing = false, refetched = 0
2007
+ let failing = false, refetched = 0, hits = 0
2008
+ const afterKeys = new Set(), glimpsed = new Set()
1730
2009
  p.on('pageerror', e => errors.push(String(e.message ?? e).split('\n')[0]))
1731
2010
  await p.route('**/*', async route => {
1732
2011
  const req = route.request(), url = req.url()
1733
2012
  if (!isApi(url)) return route.continue()
1734
2013
  const fx = fixtureFor(cfg.fixtures, req.method(), url)
1735
2014
  if (!fx) { unmocked.add(`${req.method()} ${new URL(url).pathname}`); return route.fulfill({ status: 501, body: 'unmocked by wuwei verify' }) }
2015
+ if (failing) afterKeys.add(fx.key)
1736
2016
  seen.set(fx.key, fx)
1737
2017
  const hit = override && fx.key === override.key
1738
2018
  if ((hit && override.state === 'pending')) return // never answered: the loading state
1739
- if (hit && /^\d+$/.test(override.state)) return route.fulfill({ status: Number(override.state), contentType: 'application/json', body: JSON.stringify({ message: `wuwei verify ${override.state}` }) })
2019
+ if (hit && /^\d+$/.test(override.state)) return hits++, route.fulfill({ status: Number(override.state), contentType: 'application/json', body: JSON.stringify({ message: `wuwei verify ${override.state}` }) })
1740
2020
  if (hit && override.state === 'empty') return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(emptied(fx.json)) })
1741
- if (refetchFail && fx.key === refetchFail.key && failing) return refetched++, route.fulfill({ status: refetchFail.status, contentType: 'application/json', body: JSON.stringify({ message: `wuwei verify refetch ${refetchFail.status}` }) })
2021
+ if (refetchFail && fx.key === refetchFail.key && failing) return refetched++, hits++, route.fulfill({ status: refetchFail.status, contentType: 'application/json', body: JSON.stringify({ message: `wuwei verify refetch ${refetchFail.status}` }) })
1742
2022
  return route.fulfill({ status: fx.status, contentType: 'application/json', body: JSON.stringify(fx.json) })
1743
2023
  })
1744
2024
  // the page's clock is ours: a fresh cache never refetches on focus (React Query's
@@ -1747,20 +2027,32 @@ async function cmdVerify(argv) {
1747
2027
  await p.clock.install()
1748
2028
  const busyNow = () => p.evaluate(sel => Boolean(document.querySelector(sel)) || /\bloading\b|載入中/i.test(document.body.innerText),
1749
2029
  [BUSY, cfg.busy].filter(Boolean).join(',')).catch(() => false)
1750
- const settle = async (ms, until) => {
2030
+ // a query with no loader retrying a 5xx is not done either: quiet = 8 simulated
2031
+ // seconds with no new hit on the failing endpoint (React Query backs off 1s, 2s, 4s)
2032
+ let lastHits = -1, quiet = 0
2033
+ const retrying = () => { if (hits !== lastHits) { lastHits = hits; quiet = 0 } else quiet++; return quiet < 8 }
2034
+ // what the screen said along the way: a toast gone before it settles still said it
2035
+ const glimpse = async () => { for (const l of (await p.locator('body').innerText().catch(() => '')).split('\n')) if (l.trim().length > 2) glimpsed.add(l.replace(/[ \t]+/g, ' ').trim()) }
2036
+ const settle = async (ms, until, min = 0) => {
1751
2037
  for (let t = 0; t < ms; t += 1000) {
1752
- if (until && !(await until())) return
2038
+ if (t >= min && until && !(await until())) return
1753
2039
  await p.clock.runFor(1000)
1754
2040
  // real time for the fetch a timer just fired: networkidle, once reached, never waits again
1755
2041
  await p.waitForTimeout(100)
2042
+ await glimpse()
1756
2043
  }
1757
2044
  }
1758
2045
  await p.goto(new URL(page.path, cfg.url).href, { waitUntil: 'domcontentloaded' })
1759
- if (override?.state === 'pending') await p.waitForTimeout(cfg.pendingWaitMs ?? 1500)
1760
- else {
1761
- if (page.ready) await p.locator(page.ready).first().waitFor({ timeout: 15000 }).catch(() => {})
2046
+ let ready = true, early = null, stuck = false
2047
+ if (override?.state === 'pending') {
2048
+ await p.waitForTimeout(cfg.pendingWaitMs ?? 1500)
2049
+ early = await busyNow()
2050
+ // the loader is right at first; still up long after, with the request never answered, is a hang
2051
+ if (cfg.pendingStuckMs !== 0) { await settle(cfg.pendingStuckMs ?? 30000); stuck = await busyNow() }
2052
+ } else {
2053
+ if (page.ready) ready = await p.locator(page.ready).first().waitFor({ timeout: 15000 }).then(() => true, () => false)
1762
2054
  await p.waitForLoadState('networkidle').catch(() => {})
1763
- if (override) await settle(cfg.errorWaitMs ?? 60000, busyNow)
2055
+ if (override) await settle(cfg.errorWaitMs ?? 60000, async () => (await busyNow()) || (/^5/.test(override.state) && retrying()), cfg.transientMs ?? 4000)
1764
2056
  }
1765
2057
  if (refetchFail) {
1766
2058
  failing = true
@@ -1770,80 +2062,178 @@ async function cmdVerify(argv) {
1770
2062
  else await p.locator(cfg.refetch).first().click().catch(() => {})
1771
2063
  await settle(cfg.refetchWaitMs ?? 30000)
1772
2064
  }
1773
- const text = (await p.locator('body').innerText().catch(() => '')).replace(/[ \t]+/g, ' ').trim()
2065
+ // a control that stopped accepting input changed the screen, whatever its text says
2066
+ const disabled = await p.evaluate(() => [...document.querySelectorAll('button:disabled,input:disabled,select:disabled,[aria-disabled="true"]')]
2067
+ .map(e => `[disabled] ${(e.innerText || e.value || e.getAttribute('aria-label') || e.name || e.tagName).trim()}`)).catch(() => [])
2068
+ const text = [(await p.locator('body').innerText().catch(() => '')).replace(/[ \t]+/g, ' ').trim(), ...disabled].join('\n')
1774
2069
  const busy = await p.evaluate(sel => Boolean(document.querySelector(sel)), [BUSY, cfg.busy].filter(Boolean).join(','))
1775
2070
  const retry = await p.getByRole('button', { name: /retry|try again|重試/i }).count()
1776
- return { p, ctx, text, busy, retry, seen, unmocked, errors, refetched }
2071
+ return { text, busy: early ?? busy, retry, seen, unmocked, errors, refetched, afterKeys, glimpsed, ready, stuck,
2072
+ shot: path => p.screenshot({ path, fullPage: true }), close: () => ctx.close() }
1777
2073
  }
1778
- const lines = t => new Set(t.split('\n').map(l => l.trim()).filter(l => l.length > 2))
1779
-
1780
- for (const page of (cfg.pages ?? []).filter(pg => !only.length || only.includes(pg.name))) {
1781
- const dir = join(outDir, page.name)
1782
- mkdirSync(dir, { recursive: true })
1783
- const happy = await visit(page)
1784
- await happy.p.screenshot({ path: join(dir, 'happy.png'), fullPage: true })
1785
- writeFileSync(join(dir, 'happy.txt'), happy.text)
1786
- for (const vp of cfg.viewports ?? [{ width: 390, height: 844 }]) {
1787
- const narrow = await visit(page, { viewport: vp })
1788
- await narrow.p.screenshot({ path: join(dir, `happy-${vp.width}.png`), fullPage: true })
1789
- await narrow.ctx.close()
2074
+ return {
2075
+ visit, viewports: cfg.viewports ?? [{ width: 390, height: 844 }], errorWaitMs: 60000,
2076
+ notRefetched: `not refetched by "${cfg.refetch ?? 'focus'}" ${Math.round((cfg.refetchAfterMs ?? 600000) / 60000)} min on — set "refetch" to what refetches it, or a longer "refetchAfterMs"`,
2077
+ done: async () => { await browser.close(); if (server) try { process.kill(-server.pid) } catch {} },
2078
+ }
2079
+ }
2080
+
2081
+ // the mobile half: Maestro drives the app on a simulator, and the app is built
2082
+ // against wuwei's mock server — a native app has no page.route to take over.
2083
+ // No clock either: the background wait is real, so a test build with staleTime 0
2084
+ // (or "refetchAfterMs" ≥ staleTime) is what lets a refetch happen at all
2085
+ async function verifyMobile(cfg, dest, outDir) {
2086
+ const m = cfg.mobile
2087
+ if (!m.appId) { console.error('✗ verify.json: mobile.appId is required'); process.exit(1) }
2088
+ const unreachable = (cfg.pages ?? []).filter(pg => !pg.link && !pg.flow).map(pg => pg.name)
2089
+ if (unreachable.length) { console.error(`✗ verify.json: ${unreachable.join(', ')} — a mobile page needs "link" (a deep link) or "flow" (a Maestro flow that opens it)`); process.exit(1) }
2090
+ // the installer puts it in ~/.maestro/bin, which a non-interactive shell's PATH often lacks
2091
+ const MAESTRO = m.maestro ?? (!quiet('maestro', ['--version']).error ? 'maestro'
2092
+ : existsSync(join(homedir(), '.maestro', 'bin', 'maestro')) ? join(homedir(), '.maestro', 'bin', 'maestro') : null)
2093
+ if (!MAESTRO) { console.error('✗ maestro not found on PATH or in ~/.maestro/bin — curl -Ls "https://get.maestro.mobile.dev" | bash'); process.exit(1) }
2094
+ // one directory per run: a second run at the same sha must not overwrite the flows an error names
2095
+ const flowsDir = join(outDir, 'flows', new Date().toISOString().replace(/[:.]/g, '-'))
2096
+ mkdirSync(flowsDir, { recursive: true })
2097
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
2098
+ const device = m.device ? ['--device', m.device] : []
2099
+
2100
+ // one scenario at a time: what the next visit's requests get
2101
+ let sc = null
2102
+ const held = []
2103
+ const server = createServer((req, res) => {
2104
+ const reply = (status, json) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(json)) }
2105
+ const fx = fixtureFor(cfg.fixtures, req.method, `http://wuwei.local${req.url}`)
2106
+ if (!sc) return reply(503, { message: 'wuwei verify: no scenario' })
2107
+ if (!fx) { sc.unmocked.add(`${req.method} ${req.url.split('?')[0]}`); return reply(501, { message: 'unmocked by wuwei verify' }) }
2108
+ sc.seen.set(fx.key, fx)
2109
+ if (sc.failing) sc.afterKeys.add(fx.key)
2110
+ const { override, refetchFail } = sc
2111
+ const hit = override && fx.key === override.key
2112
+ if (hit && override.state === 'pending') return held.push(res) // never answered: the loading state
2113
+ if (hit && /^\d+$/.test(override.state)) return sc.hits++, sc.lastHit = Date.now(), reply(Number(override.state), { message: `wuwei verify ${override.state}` })
2114
+ if (hit && override.state === 'empty') return reply(200, emptied(fx.json))
2115
+ if (refetchFail && fx.key === refetchFail.key && sc.failing) return sc.refetched++, sc.lastHit = Date.now(), reply(refetchFail.status, { message: `wuwei verify refetch ${refetchFail.status}` })
2116
+ return reply(fx.status, fx.json)
2117
+ })
2118
+ await new Promise((ok, fail) => server.once('error', fail).listen(m.port ?? 48780, '127.0.0.1', ok))
2119
+ .catch(e => { console.error(`✗ mock server on port ${m.port ?? 48780}: ${e.message}`); process.exit(1) })
2120
+
2121
+ // never spawnSync here: a blocked event loop is a mock server that answers nothing
2122
+ const maestro = args => new Promise(done => {
2123
+ const c = spawn(MAESTRO, [...device, ...args], { stdio: ['ignore', 'pipe', 'pipe'] })
2124
+ let out = '', err = ''
2125
+ c.stdout.on('data', d => { out += d })
2126
+ c.stderr.on('data', d => { err += d })
2127
+ const timer = setTimeout(() => c.kill(), 180000)
2128
+ c.on('close', status => { clearTimeout(timer); done({ status, stdout: out, stderr: err }) })
2129
+ c.on('error', e => { clearTimeout(timer); done({ status: -1, stdout: out, stderr: String(e.message) }) })
2130
+ })
2131
+ // a flow that failed is never a silent pass: its file and the error lines, with
2132
+ // typed text (a login's password) kept out of the report
2133
+ let n = 0
2134
+ const run = async steps => {
2135
+ const file = join(flowsDir, `${String(++n).padStart(3, '0')}.yaml`)
2136
+ writeFileSync(file, `appId: ${m.appId}\n---\n${steps.join('\n')}\n`)
2137
+ const r = await maestro(['test', file])
2138
+ if (r.status === 0) return null
2139
+ // Maestro often prints only a summary; its FAILED line is in the newest ~/.maestro/tests/*/maestro.log
2140
+ const logs = join(homedir(), '.maestro', 'tests')
2141
+ const latest = existsSync(logs) ? readdirSync(logs).map(d => join(logs, d, 'maestro.log')).filter(existsSync).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0] : null
2142
+ const why = `${r.stderr}\n${r.stdout}\n${latest ? readFileSync(latest, 'utf8').split('\n').slice(-200).join('\n') : ''}`.split('\n')
2143
+ .filter(l => /error|fail|unrecognized|exception|not found/i.test(l) && !/input ?text/i.test(l))
2144
+ return `${file.slice(dest.length + 1)} exited ${r.status}: ${(why.slice(0, 2).join(' / ') || 'no error line').trim().slice(0, 200)}`
2145
+ }
2146
+ const abs = f => resolve(dest, f)
2147
+ const BUSY_RE = new RegExp(['progress|spinner|skeleton|activity.?indicator|loading', m.busy ?? cfg.busy].filter(Boolean).join('|'), 'i')
2148
+ // the screen as text: every node's text (or label) in tree order
2149
+ // the status bar — clock, battery, signal — changes between any two shots
2150
+ const CHROME = /^(\d{1,2}:\d{2}(\s?[AP]M)?|\d{1,3}\s?%|(battery|wi-?fi|signal|cellular|no sim|no signal|no service|searching|lte|5g|4g)\b.*)$/i
2151
+ const capture = async () => {
2152
+ const r = await maestro(['hierarchy'])
2153
+ let tree = null
2154
+ // log lines come before the JSON and after it: the tree is from the first line
2155
+ // that opens an object to the last that closes one
2156
+ try { const o = r.stdout; tree = JSON.parse(o.slice(o.search(/^\{/m), o.lastIndexOf('\n}') + 2)) } catch {}
2157
+ const ids = []
2158
+ // a node's own text, else its label — but a label over children that have text
2159
+ // is their concatenation (an RN accessibility group), so the children speak
2160
+ // and a label whose text the children do not carry (iOS Text nodes often have
2161
+ // none of their own) is kept: the group's label is then the only place the value is
2162
+ const walk = node => {
2163
+ const a = node?.attributes ?? {}
2164
+ ids.push(a['resource-id'] ?? '', a.accessibilityText ?? '', a.class ?? '')
2165
+ const kids = (node?.children ?? []).flatMap(walk)
2166
+ const label = (a.accessibilityText || a.value || '').trim()
2167
+ let rest = label
2168
+ for (const k of kids) rest = rest.replace(k, '')
2169
+ const own = (a.text || (/[\p{L}\p{N}]{2,}/u.test(rest) ? label : '') || '').trim()
2170
+ // a control that stopped accepting input changed the screen, whatever its text says
2171
+ const off = a.enabled === 'false' && (a.clickable === 'true' || a.text || label) ? [`[disabled] ${a.text || label || a['resource-id']}`] : []
2172
+ return [...(own && !CHROME.test(own) ? [own] : []), ...kids, ...off]
1790
2173
  }
1791
- const endpoints = [...happy.seen.keys()]
1792
- const happyLines = lines(happy.text)
1793
- const rows = [{ endpoint: '—', state: 'happy', flags: [...[...happy.unmocked].map(u => `unmocked: ${u}`), ...happy.errors.map(e => `page error: ${e}`)], shot: 'happy.png', text: happy.text }]
1794
- await happy.ctx.close()
1795
- const served = new Map(happy.seen)
1796
- for (const key of endpoints) {
1797
- for (const state of VERIFY_STATES) {
1798
- if (state === 'empty' && !emptied(served.get(key)?.json)) continue
1799
- const refetch = state.match(/^refetch-(\d+)$/)
1800
- const v = refetch ? await visit(page, { refetchFail: { key, status: Number(refetch[1]) } }) : await visit(page, { override: { key, state } })
1801
- const slug = `${key.replace(/[^\w]+/g, '_').replace(/^_|_$/g, '')}--${state}`
1802
- await v.p.screenshot({ path: join(dir, `${slug}.png`), fullPage: true })
1803
- writeFileSync(join(dir, `${slug}.txt`), v.text)
1804
- await v.ctx.close()
1805
- const vLines = lines(v.text)
1806
- // judged on what the state added to the screen: a nav item saying "Access" is not an answer
1807
- const said = [...vLines].filter(l => !happyLines.has(l)).join('\n')
1808
- const loading = v.busy || /loading|載入/i.test(said)
1809
- const flags = v.errors.map(e => `page error: ${e}`)
1810
- if (/^5/.test(state) && loading) flags.push(`still loading ${(cfg.errorWaitMs ?? 60000) / 1000}s on — client retries outlast the wait; raise "errorWaitMs"`)
1811
- else if (/^5/.test(state) && !SAYS_ERROR.test(said)) flags.push('no error shown')
1812
- if (state === '403' && !SAYS_FORBIDDEN.test(said)) flags.push('403 not told as a permission problem')
1813
- if (state === '404' && v.retry) flags.push('Retry offered on a 404 — it can never succeed')
1814
- if (state === '403' && v.retry) flags.push('Retry offered on a 403 — it can never succeed')
1815
- if (state === 'pending' && !v.busy && !/loading|載入/i.test(v.text)) flags.push('no loading indicator')
1816
- // a refetch that never happened says nothing about how a failed one looks;
1817
- // what it did say is the lines it added — a relative time ticking is not an error
1818
- const gone = [...happyLines].filter(l => !vLines.has(l) && !TICKS.test(l))
1819
- const told = SAYS_ERROR.test(said) || (state === 'refetch-403' && SAYS_FORBIDDEN.test(said))
1820
- if (refetch && !v.refetched) flags.push(`not refetched by "${cfg.refetch ?? 'focus'}" ${Math.round((cfg.refetchAfterMs ?? 600000) / 60000)} min on — set "refetch" to what refetches it, or a longer "refetchAfterMs"`)
1821
- else if (refetch && !told) flags.push('silent: the failed refetch shows nothing')
1822
- else if (refetch && gone.length) flags.push(`refetch failure replaced loaded content: ${gone.length} line(s) gone (${gone.slice(0, 2).join(' / ').slice(0, 60)})`)
1823
- if (state === 'refetch-403' && v.refetched && told && !SAYS_FORBIDDEN.test(said)) flags.push('403 on refetch not told as a permission problem')
1824
- rows.push({ endpoint: key, state, flags, shot: `${slug}.png`, text: v.text, said })
1825
- }
2174
+ const texts = walk(tree).filter((t, i, all) => all[i - 1] !== t)
2175
+ const text = texts.join('\n')
2176
+ return { text, busy: ids.some(i => i && BUSY_RE.test(i)) || /\bloading\b|載入中/i.test(text), retry: texts.filter(t => /^(retry|try again|重試)$/i.test(t)).length, ok: Boolean(tree) }
2177
+ }
2178
+ // every capture along the way is kept: a toast gone before the screen settles still said it
2179
+ let glimpsed = new Set()
2180
+ const settle = async (ms, until, min = 0) => {
2181
+ const t0 = Date.now()
2182
+ let c = await capture()
2183
+ for (const l of c.text.split('\n')) glimpsed.add(l)
2184
+ while (Date.now() - t0 < ms && (Date.now() - t0 < min || until(c))) {
2185
+ await sleep(1500); c = await capture()
2186
+ for (const l of c.text.split('\n')) glimpsed.add(l)
1826
2187
  }
1827
- // a failure that reads exactly like no data: the user is told there is nothing, not that it broke
1828
- for (const e of rows.filter(r => r.state === 'empty' && r.said)) for (const r of rows)
1829
- if (r.endpoint === e.endpoint && /^\d+$/.test(r.state) && r.said === e.said) r.flags.push(`${r.state} shown as the empty state ("${e.said.split('\n')[0].slice(0, 40)}")`)
1830
- const md = [`# verify ${page.name} @ ${sha}`, '', `page: \`${page.path}\` · endpoints: ${endpoints.length} · states: ${VERIFY_STATES.join(', ')}`, '',
1831
- '| endpoint | state | flags | on screen, beyond happy | shot |', '|---|---|---|---|---|',
1832
- ...rows.map(r => `| ${r.endpoint} | ${r.state} | ${r.flags.length ? r.flags.join('; ') : '—'} | ${((r.said ?? r.text).split('\n').find(l => l.trim()) ?? '—').slice(0, 60).replace(/\|/g, '\\|')} | ${r.shot} |`)]
1833
- writeFileSync(join(dir, 'matrix.md'), md.join('\n') + '\n')
1834
- report.push({ page: page.name, rows, dir })
2188
+ return c
1835
2189
  }
1836
- await browser.close()
1837
- if (server) try { process.kill(-server.pid) } catch {}
1838
2190
 
1839
- for (const { page, rows, dir } of report) {
1840
- const flagged = rows.filter(r => r.flags.length)
1841
- console.log(`${page}: ${rows.length} state(s), ${flagged.length} flagged — ${join(dir, 'matrix.md')}`)
1842
- for (const r of flagged) console.log(` ${r.endpoint} · ${r.state}: ${r.flags.join('; ')}`)
2191
+ const open = page => [
2192
+ `- launchApp:\n stopApp: true\n clearState: ${m.clearState === true}`,
2193
+ ...(m.login ? [`- runFlow: ${abs(m.login)}`] : []),
2194
+ ...(page.link ? [`- openLink: ${page.link}`] : page.flow ? [`- runFlow: ${abs(page.flow)}`] : []),
2195
+ ]
2196
+ const visit = async (page, { override, refetchFail, happy } = {}) => {
2197
+ sc = { override, refetchFail, failing: false, refetched: 0, afterKeys: new Set(), hits: 0, lastHit: 0, seen: new Map(), unmocked: new Set() }
2198
+ glimpsed = new Set()
2199
+ // a query with no loader retrying a 5xx is not done: 6s with no new hit is
2200
+ const retrying = () => Date.now() - sc.lastHit < 6000
2201
+ const done = (c, extra = {}) => ({ ...c, seen: sc.seen, unmocked: sc.unmocked, errors: [], refetched: sc.refetched, afterKeys: sc.afterKeys, glimpsed, ...extra,
2202
+ shot: async path => { await run([`- takeScreenshot: ${path.replace(/\.png$/, '')}`]) },
2203
+ // the held request is let go only after the shot, or the shot is of what came next
2204
+ close: async () => { for (const res of held.splice(0)) res.destroy() } })
2205
+ // no extendedWaitUntil: older Maestro rejects its `optional`, and a failed wait
2206
+ // reads like a failed launch. `ready` is polled from the hierarchy instead
2207
+ const opened = await run(open(page))
2208
+ if (opened) return done({ text: '', busy: false, retry: 0, ok: false }, { flowError: opened })
2209
+ let c, ready = true, stuck = false
2210
+ if (happy && page.ready) {
2211
+ c = await settle(15000, x => !x.text.includes(page.ready))
2212
+ ready = c.text.includes(page.ready)
2213
+ if (ready) c = await settle(15000, x => x.busy)
2214
+ } else if (override?.state === 'pending') {
2215
+ await sleep(cfg.pendingWaitMs ?? 3000); c = await capture()
2216
+ // the loader is right at first; still up long after, with the request never answered, is a hang
2217
+ if (c.busy && cfg.pendingStuckMs !== 0) { await sleep(cfg.pendingStuckMs ?? 30000); stuck = (await capture()).busy }
2218
+ } else c = await settle(override ? cfg.errorWaitMs ?? 30000 : 15000, x => x.busy || (/^5/.test(override?.state) && retrying()), override ? cfg.transientMs ?? 4000 : 0)
2219
+ if (refetchFail) {
2220
+ sc.failing = true
2221
+ const bg = await run(['- pressKey: Home'])
2222
+ await sleep(cfg.refetchAfterMs ?? 0)
2223
+ // back to the foreground: React Query hears it only through focusManager wired to AppState
2224
+ const fg = bg ?? await run(['- launchApp:\n stopApp: false'])
2225
+ if (bg || fg) return done(c, { flowError: bg ?? fg })
2226
+ c = await settle(cfg.refetchWaitMs ?? 30000, x => !sc.refetched || x.busy || retrying())
2227
+ }
2228
+ if (!c.ok) return done(c, { flowError: `no hierarchy from maestro — its output is not JSON (${MAESTRO} hierarchy)` })
2229
+ return done(c, { ready, stuck })
2230
+ }
2231
+ return {
2232
+ visit, errorWaitMs: 30000,
2233
+ notRefetched: `not refetched on returning to the foreground ${Math.round((cfg.refetchAfterMs ?? 0) / 1000)}s on — React Query on RN refetches then only with focusManager wired to AppState, and only a stale query (staleTime; "refetchAfterMs" past it)`,
2234
+ done: async () => { for (const res of held.splice(0)) res.destroy(); await new Promise(r => server.close(r)) },
1843
2235
  }
1844
- console.log('\nflags are leads, not findings: open the shot, and confirm against the spec what the page should say')
1845
2236
  }
1846
-
1847
2237
  // ── review-collect: the lenses' own record, from Claude Code's transcripts ──
1848
2238
  // Claude Code keeps every subagent's transcript under ~/.claude/projects/<cwd,
1849
2239
  // non-alphanumerics as '-'>/<session>/subagents/agent-<id>.jsonl, its type in
@@ -1853,8 +2243,20 @@ async function cmdVerify(argv) {
1853
2243
  function transcriptStats(file) {
1854
2244
  const usage = new Map(), uses = new Map(), reads = new Map()
1855
2245
  const t = { turns: 0, built: 0, reread: 0, input: 0, output: 0, first: null, last: null, report: '' }
2246
+ // what a Bash call was for, by its first command: the 'other' that dominated a real review needed names
2247
+ const bashKind = cmd => {
2248
+ const c = cmd.replace(/^\s*(cd [^;&]+(&&|;)\s*)+/, '').trim()
2249
+ return /\bcodegraph\b/.test(c) ? 'Bash:codegraph'
2250
+ : /^(wuwei|ww|node \S*wuwei\.js)\b/.test(c) ? 'Bash:wuwei'
2251
+ : /^(git (-C \S+ )?grep|grep|rg|ag)\b/.test(c) ? 'Bash:grep'
2252
+ : /^git (-C \S+ )?(show|diff|log|blame|cat-file|ls-tree|rev-parse|merge-base)\b/.test(c) ? 'Bash:git'
2253
+ : /^(sed|cat|head|tail|awk|nl|wc|less|jq)\b/.test(c) ? 'Bash:read'
2254
+ : /^(ls|find|fd|tree|stat|git (-C \S+ )?ls-files)\b/.test(c) ? 'Bash:find'
2255
+ : /^(npm|npx|pnpm|yarn|bun|node|vitest|jest|pytest|tsc|biome|make|cargo|go|python3?)\b/.test(c) ? 'Bash:run'
2256
+ : 'Bash:other'
2257
+ }
1856
2258
  const kind = (name, inp = {}) => name === 'Read' ? (/\.wuwei-cache/.test(inp.file_path ?? '') ? 'Read:bundle' : 'Read:tree')
1857
- : name === 'Bash' ? (/codegraph/.test(inp.command ?? '') ? 'Bash:codegraph' : /\b(git )?grep\b|\brg\b/.test(inp.command ?? '') ? 'Bash:grep' : 'Bash:other') : name
2259
+ : name === 'Bash' ? bashKind(inp.command ?? '') : name
1858
2260
  for (const line of readFileSync(file, 'utf8').split('\n')) {
1859
2261
  let d; try { d = JSON.parse(line) } catch { continue }
1860
2262
  if (d.timestamp) { t.first ??= d.timestamp; t.last = d.timestamp }
@@ -1911,21 +2313,21 @@ function cmdReviewCollect(argv) {
1911
2313
  const name = n === 1 ? base : `${base}-${n}`
1912
2314
  writeFileSync(join(out, `${name}.jsonl`), readFileSync(a.jsonl))
1913
2315
  if (a.s.report) writeFileSync(join(out, `${name}.md`), a.s.report.trimEnd() + '\n')
1914
- rows.push({ name, ...a.s })
2316
+ rows.push({ name, what: (a.meta.description ?? '').slice(0, 40), ...a.s })
1915
2317
  }
1916
2318
  const k = x => x >= 1e6 ? `${(x / 1e6).toFixed(1)}M` : x >= 1e3 ? `${Math.round(x / 1e3)}k` : String(x)
1917
2319
  const total = rows.reduce((t, r) => ({ built: t.built + r.built, reread: t.reread + r.reread }), { built: 0, reread: 0 })
1918
2320
  console.log(`session ${session.id} · ${rows.length} subagent(s) → ${out}\n`)
1919
2321
  // output tokens are left out: the transcript records a streaming chunk's count, not the message's
1920
- console.log('| agent | turns | context built | re-read (cached) | where the reading went |\n|---|---|---|---|---|')
2322
+ console.log('| agent | dispatched as | turns | context built | re-read (cached) | where the reading went |\n|---|---|---|---|---|---|')
1921
2323
  for (const r of [...rows].sort((a, b) => (b.built + b.reread / 10) - (a.built + a.reread / 10)))
1922
- console.log(`| ${r.name} | ${r.turns} | ${k(r.built)} | ${k(r.reread)} | ${r.reads.slice(0, 4).map(([kk, v]) => `${kk} ${v.n}× ≈${k(v.tokens)}`).join(' · ') || '—'} |`)
1923
- console.log(`| **total** | | ${k(total.built)} | ${k(total.reread)} | |`)
2324
+ console.log(`| ${r.name} | ${r.what.replace(/\|/g, '/') || '—'} | ${r.turns} | ${k(r.built)} | ${k(r.reread)} | ${r.reads.slice(0, 4).map(([kk, v]) => `${kk} ${v.n}× ≈${k(v.tokens)}`).join(' · ') || '—'} |`)
2325
+ console.log(`| **total** | | ${rows.reduce((t, r) => t + r.turns, 0)} | ${k(total.built)} | ${k(total.reread)} | |`)
1924
2326
  const byKind = new Map()
1925
2327
  for (const r of rows) for (const [kk, v] of r.reads) byKind.set(kk, (byKind.get(kk) ?? 0) + v.tokens)
1926
2328
  const top = [...byKind].sort((a, b) => b[1] - a[1]).slice(0, 3)
1927
2329
  if (top.length) console.log(`\nmost read, across the lenses: ${top.map(([kk, v]) => `${kk} ≈${k(v)}`).join(' · ')}`)
1928
- console.log('re-read is billed at about a tenth of context built; a lens with many turns pays its context again on each')
2330
+ console.log('re-read is billed at about a tenth of context built, but every turn pays the whole context again: turns are the cost')
1929
2331
  }
1930
2332
 
1931
2333
  // ── digest-check: wuwei-review §7's arithmetic, before publishing ─────
@@ -2911,9 +3313,10 @@ switch (cmd) {
2911
3313
  case 'worktree': cmdWorktree(rest); break
2912
3314
  case 'mutate': cmdMutate(rest); break
2913
3315
  case 'sites': cmdSites(rest); break
3316
+ case 'claims-scan': cmdClaimsScan(rest); break
2914
3317
  case 'plan-check': cmdPlanCheck(rest); break
2915
3318
  case 'uninstall': await cmdUninstall(arg); break
2916
3319
  default:
2917
- console.error(`unknown command '${cmd}'\nusage: wuwei [build|check|list|init [path]|install [path]|update|status [path]|review-prep [base] [--pr n] [--path p]|review-collect|verify [page] [--phase NN]|claim [stream] [phase]|digest-check <digest.md>|worktree <lens> [sha]|mutate <file>:<line>|sites <pattern>|plan-check [stream]|uninstall [path]|--version] [--dry]`)
3320
+ console.error(`unknown command '${cmd}'\nusage: wuwei [build|check|list|init [path]|install [path]|update|status [path]|review-prep [base] [--pr n] [--path p]|review-collect|verify [page] [--phase NN]|claim [stream] [phase]|digest-check <digest.md>|worktree <lens> [sha]|mutate <file>:<line>|sites <pattern>…|claims-scan <doc>…|plan-check [stream]|uninstall [path]|--version] [--dry]`)
2918
3321
  process.exit(1)
2919
3322
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wei-wuwei",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "為無為,則無不治 — a source-of-truth skills & agents framework that compiles to Claude Code and Codex (Cursor and Gemini CLI planned). Write once, run in every agent, govern without forcing.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -173,7 +173,8 @@ tags: [planning, workflow]
173
173
  red blocks that phase)
174
174
  or manual UAT (steps + expected result). **A repo with no e2e harness is
175
175
  not a reason for UAT**: the first phase with a bound item adds one
176
- (Playwright, which `wuwei verify` runs on too) as its first task.
176
+ (Playwright on web, Maestro on mobile — what `wuwei verify` runs on) as
177
+ its first task.
177
178
  **`## Verify`** lists each page that shows fetched data — `- <page>:
178
179
  <route> · phase: NN`, the phase that builds it; its error, 403, empty,
179
180
  loading and failed-refetch states are `wuwei verify`'s, not UAT's. No
@@ -30,6 +30,7 @@ changed in one is changed in the other.
30
30
 
31
31
  | Lens | Rule |
32
32
  |---|---|
33
+ | claims | **start with `wuwei claims-scan <doc>… --sha <sha>`**: every path, name, `file:line` and command checked in one call; by hand only what it lists for judgment, and assertions no code span carries |
33
34
  | claims | **a document that instructs is reviewed for what it invites**: enumerate the positions a safety claim covers, subtract the ones its mechanism protects, report the rest. Confirming it for the cases that exist proves nothing about the ones the document teaches someone to write. **A position the syntax permits is reachable — the reader is the path**, so "no such case exists yet" does not make it PLAUSIBLE. This governs *claims in documents*, not sinks in code: `audit:security`'s reachability rule is unchanged, and the two lenses reaching opposite severities on one subject is a real disagreement for a human, not a contradiction to resolve by collation |
34
35
  | claims | **a checkable gate (an acceptance item, a `grep -c … >= 1`, a `test -f`) is a claim about what will satisfy it** — ask what *else* in the tree satisfies it, not only whether the intended thing does; **a gate reading a measurement is checked for its emitter in the tree the change produces** (a deleted emitter reads as a fake zero); **a gate names its enforcer** or is a registered gap, and **N copies of a claim by one author are one claim**; **every gate is re-run after the fixes land**, since a refactor the panel asked for is how a gate stops matching. Three ways it is false and **the dangerous ones pass**: satisfied by the wrong thing (a grep meant to prove a method exists, whose one hit is a comment about another class — green, certifying work nobody did), never satisfiable (greps a since-renamed name), or its emitter deleted by the change (a fake zero). Run it against the tree as it is now and say which it currently is — **and running its syntax is not running it**, since a fragment that parses can still be case-blind or satisfied by a superstring; a fix that would satisfy the gate on arrival is itself a finding, not a reason to stay quiet |
35
36
  | claims | **a claim about history ("never", "since it landed", "every version") is answered by `git log -S` / `git log -- <path>`, not by the tree**; and **a claim to be complete is verified by re-deriving the set, never by checking off the set it lists** — "all four call sites", "every place this is mapped", "the only consumer". Ticking each listed item passes while a fifth one exists, so derive the set from the tree instead: grep the type, the decorator, the shape — **not the names the document handed you**, which are the claim under test — then subtract. This is `defects`' exclusivity rule aimed at prose, and it bites hardest on a plan, where the executor works the list rather than the tree |
package/wuwei.config.json CHANGED
@@ -39,7 +39,7 @@
39
39
  "agents/wuwei-audit-contract/agent.md": 700,
40
40
  "agents/wuwei-audit-coverage/agent.md": 800,
41
41
  "agents/wuwei-pre-implementation-doubt/agent.md": 600,
42
- "agents/wuwei-review-claims/agent.md": 1600,
42
+ "agents/wuwei-review-claims/agent.md": 1700,
43
43
  "agents/wuwei-review-code/agent.md": 2000,
44
44
  "agents/wuwei-review-defects/agent.md": 1500,
45
45
  "agents/wuwei-review-design/agent.md": 1600,