wei-wuwei 0.0.0-stage → 0.2.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -2
  4. package/agents/_template/agent.md +28 -0
  5. package/agents/wuwei-audit-composition/agent.md +85 -0
  6. package/agents/wuwei-audit-contract/agent.md +97 -0
  7. package/agents/wuwei-audit-coverage/agent.md +93 -0
  8. package/agents/wuwei-implementer/agent.md +34 -0
  9. package/agents/wuwei-pre-implementation-doubt/agent.md +158 -0
  10. package/agents/wuwei-review-claims/agent.md +187 -0
  11. package/agents/wuwei-review-code/agent.md +219 -0
  12. package/agents/wuwei-review-defects/agent.md +180 -0
  13. package/agents/wuwei-review-design/agent.md +177 -0
  14. package/agents/wuwei-review-drift/agent.md +184 -0
  15. package/agents/wuwei-review-structure/agent.md +86 -0
  16. package/agents/wuwei-security-auditor/agent.md +229 -0
  17. package/bin/wuwei.js +2832 -0
  18. package/package.json +44 -4
  19. package/skills/_template/skill.md +34 -0
  20. package/skills/wuwei-brainstorm/skill.md +69 -0
  21. package/skills/wuwei-commit-hygiene/skill.md +38 -0
  22. package/skills/wuwei-debug-root-cause/skill.md +48 -0
  23. package/skills/wuwei-execute-phase/skill.md +624 -0
  24. package/skills/wuwei-grill/skill.md +67 -0
  25. package/skills/wuwei-legible-output/skill.md +27 -0
  26. package/skills/wuwei-map-codebase/skill.md +171 -0
  27. package/skills/wuwei-plan-phases/skill.md +326 -0
  28. package/skills/wuwei-review/references/lens-rules.md +41 -0
  29. package/skills/wuwei-review/references/prep.md +35 -0
  30. package/skills/wuwei-review/references/scopes.md +56 -0
  31. package/skills/wuwei-review/references/stream.md +94 -0
  32. package/skills/wuwei-review/skill.md +495 -0
  33. package/skills/wuwei-tdd/skill.md +47 -0
  34. package/templates/code-review.md +137 -0
  35. package/templates/pr.md +35 -0
  36. package/wuwei.config.json +51 -0
package/bin/wuwei.js ADDED
@@ -0,0 +1,2832 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wuwei — build canonical skills/agents into per-tool formats.
4
+ *
5
+ * Usage:
6
+ * wuwei build [--dry] emit all enabled targets (repo-local artifacts)
7
+ * wuwei check validate frontmatter
8
+ * wuwei list show discovered sources
9
+ * wuwei init [<path>] bootstrap a project (repo-scope install into path or cwd);
10
+ * also detects existing GSD (.planning/) state and offers
11
+ * a one-shot verbatim import into .wuwei/, and flags any
12
+ * installed superpowers skills that overlap wuwei's own
13
+ * wuwei install [--dry] user scope: install into ~/.claude
14
+ * wuwei install <path> repo scope: templates + .wuwei skeleton into a project
15
+ * wuwei update [--dry] is a newer wuwei out? then diff it against what
16
+ * install wrote, and apply on confirmation
17
+ * wuwei status [<path>] read-only: streams/phases/claims from .wuwei,
18
+ * cross-checked against STATE.md, drift flagged
19
+ * wuwei review-prep [<base>] [--pr <n>] [--path <p>] [--no-scan]
20
+ * wuwei-review §1's deterministic half: dispatcher
21
+ * checks + scan bundle in .wuwei-cache/review/<sha>/
22
+ * wuwei claim [<stream>] [<phase>] [--take-over] [--by <id>] [--dry]
23
+ * wuwei-execute-phase step 1: list what is eligible,
24
+ * or claim one — cut, claim, push
25
+ * wuwei digest-check <file> wuwei-review §7's arithmetic before publishing: count,
26
+ * rows, credits, file:line, Action names, redaction
27
+ * wuwei worktree <lens> [<sha>] a throwaway worktree no other lens shares; --prune
28
+ * wuwei mutate <f>:<n> --from --to --run one mutation in a wuwei worktree: landed?
29
+ * baseline vs mutated, failing tests, restored, recorded
30
+ * wuwei sites <pattern> [--symbol <name>] a set from the tree, with its derivation;
31
+ * graph and grep both for a symbol
32
+ * wuwei verify [<page>…] [--phase NN] every data-bearing page through its states
33
+ * (Playwright, fixtures from .wuwei/verify.json): a matrix of
34
+ * shots; --phase runs the pages ACCEPTANCE.md binds to it
35
+ * wuwei plan-check [<stream>] wuwei-plan-phases step 11's checkable half
36
+ * wuwei uninstall [<path>] remove exactly what install wrote
37
+ * wuwei -v | --version print the installed version
38
+ */
39
+ import {
40
+ readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync, rmSync, statSync,
41
+ chmodSync,
42
+ } from 'node:fs'
43
+ import { join, dirname, resolve } from 'node:path'
44
+ import { homedir, tmpdir } from 'node:os'
45
+ import { fileURLToPath } from 'node:url'
46
+ import { createInterface } from 'node:readline/promises'
47
+ import { spawnSync, spawn } from 'node:child_process'
48
+ import { createRequire } from 'node:module'
49
+ import { createHash } from 'node:crypto'
50
+
51
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
52
+ const CONFIG = JSON.parse(readFileSync(join(ROOT, 'wuwei.config.json'), 'utf8'))
53
+ const PKG = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
54
+
55
+ // the version is stamped so an installed artifact says which version wrote it;
56
+ // isOurs matches the 'GENERATED BY wuwei' prefix, so older stamps still count
57
+ const GENERATED_HEADER =
58
+ '<!-- GENERATED BY wuwei v%VER% — do not edit. Source: %SRC%. Run `npm run build`. -->'
59
+ const stamp = src => CONFIG.options.stampGeneratedHeader
60
+ ? GENERATED_HEADER.replace('%VER%', PKG.version).replace('%SRC%', src) + '\n\n' : ''
61
+ const BLOCK_BEGIN = '<!-- wuwei:begin — generated rules, do not edit inside. `wuwei uninstall` removes this block. -->'
62
+ const BLOCK_END = '<!-- wuwei:end -->'
63
+ // Codex's default read limit for AGENTS.md (project_doc_max_bytes, 32 KiB)
64
+ const CODEX_DOC_MAX_BYTES = 32 * 1024
65
+
66
+ // ── tiny frontmatter parser (no deps) ────────────────────────────────
67
+ // CRLF first: with git's core.autocrlf=true (the Windows default) every source
68
+ // file lands here with \r line endings, and `^([A-Za-z0-9_-]+):\s*(.*)$` can't
69
+ // match a line ending in \r — `.` never matches it and `$` won't step over it.
70
+ // Every field then reads as missing and `check` fails on files that are fine.
71
+ function parseFrontmatter(input) {
72
+ const raw = input.replace(/\r\n?/g, '\n')
73
+ if (!raw.startsWith('---')) return { data: {}, body: raw }
74
+ const end = raw.indexOf('\n---', 3)
75
+ if (end === -1) return { data: {}, body: raw }
76
+ const head = raw.slice(4, end)
77
+ const body = raw.slice(end + 4).replace(/^\n/, '')
78
+ const data = {}
79
+ let key = null
80
+ for (const line of head.split('\n')) {
81
+ if (/^\s+/.test(line) && key) { data[key] += ' ' + line.trim(); continue }
82
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
83
+ if (!m) continue
84
+ key = m[1]
85
+ let v = m[2].trim()
86
+ if (v === '>-' || v === '>' || v === '|') { data[key] = ''; continue }
87
+ if (v.startsWith('[') && v.endsWith(']')) {
88
+ v = v.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean)
89
+ }
90
+ data[key] = v
91
+ }
92
+ for (const k of Object.keys(data))
93
+ if (typeof data[k] === 'string') data[k] = data[k].trim()
94
+ return { data, body }
95
+ }
96
+
97
+ // ── discovery ────────────────────────────────────────────────────────
98
+ // two roots per kind: the shipped one (sources.skills / sources.agents) and
99
+ // local/<kind>/ — definitions that tune this repo itself (wuwei-retro).
100
+ // local/ is outside package.json `files`, so publish never carries it, and
101
+ // install skips it (isLocal) so no other project sees it either
102
+ function discover(kind) {
103
+ const file = kind === 'agents' ? 'agent.md' : 'skill.md'
104
+ const roots = [
105
+ { dir: join(ROOT, CONFIG.sources[kind]), local: false },
106
+ ...(CONFIG.sources.local ? [{ dir: join(ROOT, CONFIG.sources.local, kind), local: true }] : []),
107
+ ]
108
+ return roots.flatMap(({ dir, local }) => {
109
+ if (!existsSync(dir)) return []
110
+ return readdirSync(dir, { withFileTypes: true })
111
+ .filter(d => d.isDirectory() && !d.name.startsWith('_'))
112
+ .map(d => join(dir, d.name, file))
113
+ .filter(existsSync)
114
+ .map(p => {
115
+ const { data, body } = parseFrontmatter(readFileSync(p, 'utf8'))
116
+ return { path: p, rel: p.slice(ROOT.length + 1), kind, data, body, local, refs: discoverRefs(dirname(p)) }
117
+ })
118
+ })
119
+ }
120
+
121
+ // a skill's references/*.md load on demand: the skill body names them, and
122
+ // the model reads one only when that branch of the procedure runs
123
+ function discoverRefs(dir) {
124
+ const refDir = join(dir, 'references')
125
+ if (!existsSync(refDir)) return []
126
+ return readdirSync(refDir).filter(f => f.endsWith('.md')).sort()
127
+ .map(f => ({ name: f, rel: join(refDir, f).slice(ROOT.length + 1), content: readFileSync(join(refDir, f), 'utf8') }))
128
+ }
129
+
130
+ // written beside SKILL.md
131
+ const refArtifacts = (it, skillDir) => (it.refs ?? [])
132
+ .map(r => ({ file: join(skillDir, 'references', r.name), content: stamp(r.rel) + r.content }))
133
+
134
+ // ── install record: what install wrote, by hash — how update tells an edit you
135
+ // made from a change upstream (GSD's file manifest). ~/.wuwei is ours: the record,
136
+ // and local-patches/ where update copies an edited file before overwriting it
137
+ const wuweiHome = () => join(homedir(), '.wuwei')
138
+ const manifestPath = () => join(wuweiHome(), 'manifest.json')
139
+ const sha256 = s => createHash('sha256').update(s).digest('hex')
140
+ const recorded = new Map() // file, or file#rules for a managed block → hash of what this run wrote
141
+ function readManifest() {
142
+ try { return JSON.parse(readFileSync(manifestPath(), 'utf8')) } catch { return null }
143
+ }
144
+
145
+ // write what an emitter computed. A skill's references/ dir is regenerated
146
+ // whole, so a reference removed from the source does not linger in the target
147
+ function writeArtifacts(artifacts, dry) {
148
+ if (dry) return artifacts.map(a => a.file)
149
+ for (const a of artifacts.filter(a => a.file.endsWith('SKILL.md'))) {
150
+ const refDir = join(dirname(a.file), 'references')
151
+ if (existsSync(refDir) && isOurs(a.file)) rmSync(refDir, { recursive: true })
152
+ }
153
+ for (const a of artifacts) { mkdirSync(dirname(a.file), { recursive: true }); writeFileSync(a.file, a.content); recorded.set(a.file, sha256(a.content)) }
154
+ return artifacts.map(a => a.file)
155
+ }
156
+
157
+ function discoverTemplates() {
158
+ const dir = join(ROOT, CONFIG.sources.templates ?? 'templates')
159
+ if (!existsSync(dir)) return []
160
+ return readdirSync(dir)
161
+ .filter(f => f.endsWith('.md'))
162
+ .map(f => ({ name: f, path: join(dir, f), content: readFileSync(join(dir, f), 'utf8') }))
163
+ }
164
+
165
+ // ── validation ───────────────────────────────────────────────────────
166
+ const REQUIRED = ['name', 'description', 'version']
167
+ const MODES = ['always', 'triggered']
168
+ const ALWAYS_MAX_LINES = 12
169
+ // claude-code subagent fields we pass through; values are checked because a
170
+ // typo would otherwise ship an artifact the target silently ignores
171
+ const MODEL_ALIASES = ['sonnet', 'opus', 'haiku', 'fable', 'inherit']
172
+ const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
173
+
174
+ function validate(items) {
175
+ const errs = []
176
+ const seen = new Set()
177
+ for (const it of items) {
178
+ for (const f of REQUIRED)
179
+ if (!it.data[f]) errs.push(`${it.rel}: missing frontmatter field '${f}'`)
180
+ if (it.data.name) {
181
+ if (seen.has(it.data.name)) errs.push(`${it.rel}: duplicate name '${it.data.name}'`)
182
+ seen.add(it.data.name)
183
+ if (!/^[a-z0-9-]+$/.test(it.data.name))
184
+ errs.push(`${it.rel}: name must be kebab-case`)
185
+ }
186
+ if (it.data.description && it.data.description.length > 400)
187
+ errs.push(`${it.rel}: description too long (${it.data.description.length} > 400)`)
188
+ if (it.data.mode && !MODES.includes(it.data.mode))
189
+ errs.push(`${it.rel}: mode must be one of ${MODES.join('|')}`)
190
+ // on a skill, Claude Code applies model/effort to the turn the skill runs
191
+ // in (and only that turn — the session's resume on the next prompt)
192
+ if (it.data.model && !MODEL_ALIASES.includes(it.data.model)
193
+ && !/^claude-[a-z0-9.-]+$/.test(it.data.model))
194
+ errs.push(`${it.rel}: model must be ${MODEL_ALIASES.join('|')} or a full id (claude-…)`)
195
+ if (it.data.effort && !EFFORTS.includes(it.data.effort))
196
+ errs.push(`${it.rel}: effort must be one of ${EFFORTS.join('|')}`)
197
+ // location decides what stays in this repo — a field would be a second
198
+ // source of truth that can disagree with the directory
199
+ if (it.data.scope)
200
+ errs.push(`${it.rel}: 'scope' is not a field — put repo-only definitions under local/${it.kind}/`)
201
+ for (const name of [].concat(it.data['inline-agents'] ?? [])) {
202
+ if (it.kind !== 'skills') errs.push(`${it.rel}: inline-agents applies to skills only`)
203
+ else if (!items.some(a => a.kind === 'agents' && a.data.name === name))
204
+ errs.push(`${it.rel}: inline-agents names '${name}', which is not an agent`)
205
+ }
206
+ if (it.data.mode === 'always') {
207
+ const lines = it.body.split('\n').filter(l => l.trim()).length
208
+ if (lines > ALWAYS_MAX_LINES)
209
+ errs.push(`${it.rel}: mode:always body too long (${lines} > ${ALWAYS_MAX_LINES} non-empty lines) — a rule rides in every request, keep it terse`)
210
+ }
211
+ }
212
+ return errs
213
+ }
214
+
215
+ // ── shared helpers ───────────────────────────────────────────────────
216
+ const targeted = (it, tool) => !it.data.targets || it.data.targets.includes(tool)
217
+ const isRule = it => it.data.mode === 'always'
218
+ const isLocal = it => it.local === true
219
+ const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
220
+
221
+ function rulesBlock(rules) {
222
+ const parts = [BLOCK_BEGIN]
223
+ for (const it of rules) parts.push('', it.body.trim())
224
+ parts.push('', BLOCK_END)
225
+ return parts.join('\n')
226
+ }
227
+
228
+ function mergeManagedBlock(file, block, dry) {
229
+ const existing = existsSync(file) ? readFileSync(file, 'utf8') : ''
230
+ const re = new RegExp(`${escapeRe(BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(BLOCK_END)}`)
231
+ const next = re.test(existing)
232
+ ? existing.replace(re, block)
233
+ : (existing ? existing.replace(/\n*$/, '\n\n') + block + '\n' : block + '\n')
234
+ if (!dry) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, next); recorded.set(`${file}#rules`, sha256(block)) }
235
+ }
236
+
237
+ function stripManagedBlock(file, dry = false) {
238
+ if (!existsSync(file)) return false
239
+ const existing = readFileSync(file, 'utf8')
240
+ const re = new RegExp(`\\n*${escapeRe(BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n*`)
241
+ if (!re.test(existing)) return false
242
+ if (!dry) {
243
+ const next = existing.replace(re, '\n').trim()
244
+ if (next) writeFileSync(file, next + '\n')
245
+ else rmSync(file)
246
+ }
247
+ return true
248
+ }
249
+
250
+ // only ever remove a path we stamped ourselves — never a same-named user file
251
+ const isOurs = probe => existsSync(probe) && readFileSync(probe, 'utf8').includes('GENERATED BY wuwei')
252
+
253
+ // a rename or removal leaves the old generated dir/file behind forever
254
+ // unless we sweep for stamped paths that no longer match a current definition
255
+ function sweepStale(base, currentSkillNames, currentAgentNames, dry) {
256
+ const removed = []
257
+ const skillsDir = join(base, 'skills')
258
+ if (existsSync(skillsDir)) {
259
+ for (const d of readdirSync(skillsDir, { withFileTypes: true })) {
260
+ if (!d.isDirectory() || currentSkillNames.has(d.name)) continue
261
+ const dir = join(skillsDir, d.name)
262
+ if (isOurs(join(dir, 'SKILL.md'))) { if (!dry) rmSync(dir, { recursive: true }); removed.push(dir) }
263
+ }
264
+ }
265
+ const agentsDir = join(base, 'agents')
266
+ if (existsSync(agentsDir)) {
267
+ for (const f of readdirSync(agentsDir, { withFileTypes: true })) {
268
+ if (!f.isFile() || !f.name.endsWith('.md')) continue
269
+ const name = f.name.slice(0, -3)
270
+ if (currentAgentNames.has(name)) continue
271
+ const file = join(agentsDir, f.name)
272
+ if (isOurs(file)) { if (!dry) rmSync(file); removed.push(file) }
273
+ }
274
+ }
275
+ return removed
276
+ }
277
+
278
+ // ── claude-code emit (build: repo scope · install: user scope) ───────
279
+ // what install writes, without writing it — `wuwei update` diffs against this,
280
+ // so its preview cannot drift from what install produces. Targeted + non-rule
281
+ // is also the set the stale sweep keeps: a skill that flips to mode:always or
282
+ // drops claude-code would otherwise count as "current" and never sweep
283
+ function claudeArtifacts(items, base) {
284
+ return items.filter(i => targeted(i, 'claude-code') && !isRule(i)).flatMap(it => {
285
+ const fm = ['---', `name: ${it.data.name}`, `description: ${it.data.description}`]
286
+ if (it.data['allowed-tools']?.length)
287
+ fm.push(`allowed-tools: [${[].concat(it.data['allowed-tools']).join(', ')}]`)
288
+ if (it.data.tools?.length)
289
+ fm.push(`tools: [${[].concat(it.data.tools).join(', ')}]`)
290
+ if (it.data.model) fm.push(`model: ${it.data.model}`)
291
+ if (it.data.effort) fm.push(`effort: ${it.data.effort}`)
292
+ fm.push('---', '')
293
+ // skills: one dir per skill (SKILL.md); agents: flat .md — Claude Code subagent format
294
+ const file = it.kind === 'agents'
295
+ ? join(base, 'agents', `${it.data.name}.md`)
296
+ : join(base, 'skills', it.data.name, 'SKILL.md')
297
+ const main = { file, content: fm.join('\n') + stamp(it.rel) + it.body }
298
+ return it.kind === 'skills' ? [main, ...refArtifacts(it, dirname(file))] : [main]
299
+ })
300
+ }
301
+
302
+ const namesUnder = (artifacts, base, kind) => new Set(artifacts
303
+ .map(a => a.file.slice(base.length + 1).split(/[\\/]/))
304
+ .filter(p => p[0] === kind).map(p => kind === 'agents' ? p[1].replace(/\.md$/, '') : p[1]))
305
+
306
+ function emitClaude(items, base, rulesFile, dry) {
307
+ const artifacts = claudeArtifacts(items, base)
308
+ const removed = sweepStale(base, namesUnder(artifacts, base, 'skills'), namesUnder(artifacts, base, 'agents'), dry)
309
+ const written = writeArtifacts(artifacts, dry)
310
+ const rules = items.filter(i => targeted(i, 'claude-code') && isRule(i))
311
+ if (rules.length) {
312
+ mergeManagedBlock(rulesFile, rulesBlock(rules), dry)
313
+ written.push(rulesFile)
314
+ } else if (stripManagedBlock(rulesFile, dry)) {
315
+ // last rule flipped away from mode:always (or was removed) — the block
316
+ // has no source anymore, so it goes stale exactly like an orphaned skill dir
317
+ removed.push(rulesFile)
318
+ }
319
+ // tag removals distinctly so callers can print them without counting them as writes
320
+ return [...removed.map(r => `${r} [stale, removed]`), ...written]
321
+ }
322
+
323
+ // a line tagged this way by emitClaude is a stale-sweep removal, not a write —
324
+ // cmdBuild/cmdInstall print it but exclude it from their write counts
325
+ const REMOVED_TAG = ' [stale, removed]'
326
+ const isRemovalLine = line => line.endsWith(REMOVED_TAG)
327
+
328
+ // ── adapters (build: repo-local artifacts) ───────────────────────────
329
+ const adapters = {
330
+ 'claude-code': (items, outDir, dry) =>
331
+ emitClaude(items, join(ROOT, outDir), join(ROOT, 'CLAUDE.md'), dry).map(line => {
332
+ const removal = isRemovalLine(line)
333
+ const path = removal ? line.slice(0, -REMOVED_TAG.length) : line
334
+ return path.slice(ROOT.length + 1) + (removal ? REMOVED_TAG : '')
335
+ }),
336
+
337
+ // Codex: AGENTS.md carries only what must ride every request (always-on
338
+ // rules); each skill goes to .agents/skills/<name>/SKILL.md, which Codex
339
+ // discovers natively and loads on demand (name + description up front,
340
+ // body only when used) — the same lazy load claude-code gets. Dumping every
341
+ // body into AGENTS.md put 121 KB against Codex's default 32 KiB read limit
342
+ // (project_doc_max_bytes), so everything past the cut never reached it.
343
+ codex: (items, outFile, dry) => {
344
+ const picked = items.filter(it => it.kind === 'skills' && targeted(it, 'codex'))
345
+ if (!picked.length) return []
346
+ const rules = picked.filter(isRule)
347
+ const lazy = picked.filter(it => !isRule(it))
348
+ const { removed, written } = emitCodexSkills(lazy, join(ROOT, dirname(outFile), '.agents'), dry, items)
349
+ const parts = []
350
+ if (CONFIG.options.stampGeneratedHeader)
351
+ parts.push(stamp('skills/ (mode: always)').trimEnd(), '')
352
+ parts.push('# AGENTS.md', '', 'Always-on rules for this repository, generated by wuwei.',
353
+ 'Skills live in `.agents/skills/` and load when a task matches their description.', '')
354
+ for (const it of rules) parts.push(it.body.trim(), '')
355
+ const out = parts.join('\n')
356
+ if (Buffer.byteLength(out) > CODEX_DOC_MAX_BYTES)
357
+ console.warn(`! ${outFile} is ${Buffer.byteLength(out)} bytes — Codex reads ${CODEX_DOC_MAX_BYTES} by default (project_doc_max_bytes); the rest never reaches it`)
358
+ if (!dry) {
359
+ mkdirSync(dirname(join(ROOT, outFile)), { recursive: true })
360
+ writeFileSync(join(ROOT, outFile), out)
361
+ }
362
+ return [...removed.map(r => `${r} [stale, removed]`), outFile, ...written.map(f => f.slice(ROOT.length + 1))]
363
+ },
364
+ }
365
+
366
+ // each skill to <base>/skills/<name>/SKILL.md (+ references/), stale stamped dirs swept.
367
+ // Codex has no subagents, so an agent a skill runs (`inline-agents:`) goes in as
368
+ // references/<agent>.md for the skill to walk inline — one source, the agent file
369
+ function codexArtifacts(skills, base, agents = []) {
370
+ return skills.flatMap(it => {
371
+ const fm = ['---', `name: ${it.data.name}`, `description: ${it.data.description}`, '---', '']
372
+ const file = join(base, 'skills', it.data.name, 'SKILL.md')
373
+ const inline = [].concat(it.data['inline-agents'] ?? []).map(name => {
374
+ const ag = agents.find(a => a.kind === 'agents' && a.data.name === name)
375
+ return { file: join(dirname(file), 'references', `${name}.md`), content: stamp(ag.rel) + ag.body }
376
+ })
377
+ return [{ file, content: fm.join('\n') + stamp(it.rel) + it.body }, ...refArtifacts(it, dirname(file)), ...inline]
378
+ })
379
+ }
380
+
381
+ function emitCodexSkills(skills, base, dry, agents = []) {
382
+ const removed = sweepStale(base, new Set(skills.map(it => it.data.name)), new Set(), dry)
383
+ return { removed, written: writeArtifacts(codexArtifacts(skills, base, agents), dry) }
384
+ }
385
+
386
+ // Codex at user scope (codex-rs ext/skills/src/host_roots.rs, codex-home/src/instructions):
387
+ // skills from ~/.agents/skills ($CODEX_HOME/skills is the deprecated location),
388
+ // rules from $CODEX_HOME/AGENTS.md unless a non-empty AGENTS.override.md
389
+ // replaces it. CODEX_HOME defaults to ~/.codex.
390
+ const codexHome = () => process.env.CODEX_HOME || join(homedir(), '.codex')
391
+ const codexUserSkillsBase = () => join(homedir(), '.agents')
392
+
393
+ // install writes Codex's user scope only where Codex has run: creating
394
+ // ~/.codex for someone who never installed it would be a side effect
395
+ function installCodexUser(items, dry) {
396
+ const home = codexHome()
397
+ if (!existsSync(home)) {
398
+ console.log(`codex: ${home} not found — skipped (run codex once, then install again)`)
399
+ return
400
+ }
401
+ const picked = items.filter(it => it.kind === 'skills' && targeted(it, 'codex'))
402
+ const { removed, written } = emitCodexSkills(picked.filter(it => !isRule(it)), codexUserSkillsBase(), dry, items)
403
+ const rulesFile = join(home, 'AGENTS.md')
404
+ const rules = picked.filter(isRule)
405
+ if (rules.length) { mergeManagedBlock(rulesFile, rulesBlock(rules), dry); written.push(rulesFile) }
406
+ else if (stripManagedBlock(rulesFile, dry)) removed.push(rulesFile)
407
+ console.log(`${dry ? '[dry] ' : ''}codex user scope → ${codexUserSkillsBase()}, ${rulesFile}`)
408
+ for (const r of removed) console.log(` ${r}${REMOVED_TAG}`)
409
+ for (const w of written) console.log(` ${w}`)
410
+ const override = join(home, 'AGENTS.override.md')
411
+ if (rules.length && existsSync(override) && readFileSync(override, 'utf8').trim())
412
+ console.warn(`! ${override} is non-empty — Codex reads it instead of AGENTS.md, so the rules block is not loaded`)
413
+ }
414
+
415
+ // ── STATE.md skeleton (repo-scope install) ───────────────────────────
416
+ const STATE_HEADER_LINE = '| stream | mode | tracker | phases done/total | blocked |'
417
+ const STATE_SKELETON = `# wuwei state
418
+
419
+ <!-- index of streams — maintained by wuwei-plan-phases / wuwei-execute-phase, human-editable -->
420
+ <!-- this file is a projection of streams/; \`wuwei status\` renders it cross-checked against the directories -->
421
+
422
+ ${STATE_HEADER_LINE}
423
+ |--------|------|---------|-------------------|---------|
424
+ `
425
+
426
+ // ── commands ─────────────────────────────────────────────────────────
427
+ function loadValidated() {
428
+ const items = [...discover('skills'), ...discover('agents')]
429
+ const errs = validate(items)
430
+ if (errs.length) {
431
+ console.error('✗ validation failed:\n' + errs.map(e => ' ' + e).join('\n'))
432
+ process.exit(1)
433
+ }
434
+ return items
435
+ }
436
+
437
+ function cmdList() {
438
+ for (const kind of ['skills', 'agents']) {
439
+ const items = discover(kind)
440
+ console.log(`\n${kind} (${items.length})`)
441
+ for (const it of items)
442
+ console.log(` ${(isRule(it) ? it.data.name + ' [always]' : isLocal(it) ? it.data.name + ' [local]' : it.data.name).padEnd(28)} ${it.rel}`)
443
+ }
444
+ const tpl = discoverTemplates()
445
+ console.log(`\ntemplates (${tpl.length})`)
446
+ for (const t of tpl) console.log(` ${t.name}`)
447
+ console.log()
448
+ }
449
+
450
+ // ── lint: what retros kept checking by hand ──────────────────────────
451
+ // check-only (CI), never part of build/install: a shipped package must not
452
+ // refuse to install over its own prose
453
+ const COUNT_WORDS = { two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12 }
454
+ const wordCount = text => text.split(/\s+/).filter(Boolean).length
455
+
456
+ // "Eight shapes need evidence …:" followed by a list or a table — the number
457
+ // is a claim about the list, and the list is what a retro edits
458
+ function countClaims(rel, text) {
459
+ const errs = []
460
+ const lines = text.split('\n')
461
+ const isItem = l => /^\s*([-*]|\d+\.)\s/.test(l ?? '')
462
+ lines.forEach((line, i) => {
463
+ const m = line.match(/\b(two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\s+(?!of\b|more\b|times\b|or\b|and\b)([A-Za-z-]+)/i)
464
+ if (!m) return
465
+ // the sentence may wrap: it has to end in ':' at a line end within three lines
466
+ let end = -1
467
+ for (let j = i; j < Math.min(i + 3, lines.length); j++) {
468
+ const tail = j === i ? line.slice(m.index) : lines[j]
469
+ if (/\.\s/.test(tail) || /\.$/.test(tail.trim())) break
470
+ if (/:\s*$/.test(tail)) { end = j; break }
471
+ }
472
+ if (end === -1) return
473
+ let j = end + 1
474
+ while (j < lines.length && !lines[j].trim()) j++
475
+ let items = 0
476
+ if (isItem(lines[j])) {
477
+ const indent = lines[j].match(/^\s*/)[0].length
478
+ for (; j < lines.length; j++) {
479
+ const l = lines[j]
480
+ if (!l.trim()) { if (isItem(lines[j + 1]) && lines[j + 1].match(/^\s*/)[0].length >= indent) continue; break }
481
+ const own = isItem(l) && l.match(/^\s*/)[0].length === indent
482
+ if (own) items++
483
+ else if (l.match(/^\s*/)[0].length <= indent && !isItem(l)) break
484
+ }
485
+ } else if (/^\s*\|/.test(lines[j] ?? '')) {
486
+ // a table's rows are what the number counts only when its first header
487
+ // names them ("Five columns" over | Column | …) — "two axes" over a
488
+ // table of targets is a claim about its columns, not its rows
489
+ const stem = w => w.toLowerCase().replace(/(es|s)$/, '')
490
+ const head = lines[j].split('|')[1]?.trim() ?? ''
491
+ if (stem(head) !== stem(m[2])) return
492
+ for (; j < lines.length && /^\s*\|/.test(lines[j]); j++) if (!/^\s*\|[\s:|-]+\|\s*$/.test(lines[j])) items++
493
+ items-- // the header row
494
+ } else return
495
+ const said = COUNT_WORDS[m[1].toLowerCase()]
496
+ if (items !== said)
497
+ errs.push(`${rel}:${i + 1}: says "${m[1]} ${m[2]}" but the list below has ${items} — fix the number or the list`)
498
+ })
499
+ return errs
500
+ }
501
+
502
+ function lint(items) {
503
+ const errs = []
504
+ const byName = new Map(items.map(it => [it.data.name, it]))
505
+ const sectionsOf = it => new Set([...it.body.matchAll(/^##+\s+(\d+)\./gm)].map(m => m[1]))
506
+ const refsOf = it => new Set([...(it.refs ?? []).map(r => r.name), ...[].concat(it.data['inline-agents'] ?? []).map(n => `${n}.md`)])
507
+ const budgets = CONFIG.wordBudgets ?? {}
508
+ for (const it of items) {
509
+ const texts = [[it.rel, it.body], ...(it.refs ?? []).map(r => [r.rel, r.content])]
510
+ for (const [rel, text] of texts) {
511
+ errs.push(...countClaims(rel, text))
512
+ const scan = (line, i) => {
513
+ const at = i < 0 ? `${rel} (description)` : `${rel}:${i + 1}`
514
+ for (const m of line.matchAll(/`(wuwei-[a-z0-9-]+)`/g))
515
+ if (!byName.has(m[1])) errs.push(`${at}: \`${m[1]}\` is not a skill or agent — renamed or removed?`)
516
+ for (const m of line.matchAll(/`(wuwei-[a-z0-9-]+)`(?:'s)?\s+§(\d+)/g))
517
+ if (byName.has(m[1]) && !sectionsOf(byName.get(m[1])).has(m[2]))
518
+ errs.push(`${at}: \`${m[1]}\` §${m[2]} — it has no "## ${m[2]}." section`)
519
+ for (const m of line.matchAll(/references\/([A-Za-z0-9_.-]+\.md)/g)) {
520
+ const named = [...line.matchAll(/`(wuwei-[a-z0-9-]+)`/g)].map(x => byName.get(x[1])).filter(Boolean)
521
+ if (![it, ...named].some(x => refsOf(x).has(m[1])))
522
+ errs.push(`${at}: references/${m[1]} is not in ${[it, ...named].map(x => x.data.name).join(' or ')}`)
523
+ }
524
+ }
525
+ text.split('\n').forEach(scan)
526
+ // the description decides when it triggers: a stale name there misroutes
527
+ if (rel === it.rel) scan(it.data.description ?? '', -1)
528
+ // a budget is a ratchet: growing past it is a decision, recorded as one
529
+ const budget = budgets[rel]
530
+ if (budget && wordCount(text) > budget)
531
+ errs.push(`${rel}: ${wordCount(text)} words > budget ${budget} — cut, or raise wordBudgets in wuwei.config.json with a docs/decisions.md row`)
532
+ }
533
+ }
534
+ for (const rel of Object.keys(budgets))
535
+ if (!items.some(it => it.rel === rel || (it.refs ?? []).some(r => r.rel === rel)))
536
+ errs.push(`wuwei.config.json: wordBudgets names ${rel}, which is not a definition or reference`)
537
+ // modelRoles names the tier; the agent file is what dispatch actually reads
538
+ const impl = CONFIG.modelRoles?.implementation
539
+ if (impl) {
540
+ const ag = items.find(it => it.kind === 'agents' && it.data.name === impl.agent)
541
+ if (!ag) errs.push(`wuwei.config.json: modelRoles.implementation.agent ${impl.agent} is not an agent`)
542
+ else for (const f of ['model', 'effort']) if (ag.data[f] !== impl[f])
543
+ errs.push(`${ag.rel}: ${f} ${ag.data[f] ?? '(unset)'} ≠ modelRoles.implementation.${f} ${impl[f]} — the roles tier is what this file pins`)
544
+ }
545
+ // the skill that runs implementation runs its own turn at the same tier
546
+ const sk = impl?.skill && items.find(it => it.kind === 'skills' && it.data.name === impl.skill)
547
+ if (impl?.skill && !sk) errs.push(`wuwei.config.json: modelRoles.implementation.skill ${impl.skill} is not a skill`)
548
+ else if (sk) for (const f of ['model', 'effort']) if (sk.data[f] !== impl[f])
549
+ errs.push(`${sk.rel}: ${f} ${sk.data[f] ?? '(unset)'} ≠ modelRoles.implementation.${f} ${impl[f]} — execute runs at the implementer's tier`)
550
+ for (const l of CONFIG.modelRoles?.review?.lenses ?? [])
551
+ if (!items.some(it => it.kind === 'agents' && it.data.name === l)) errs.push(`wuwei.config.json: modelRoles.review.lenses names ${l}, which is not an agent`)
552
+ // update prints this file's sections — a version without one updates in silence
553
+ const changelog = existsSync(join(ROOT, 'CHANGELOG.md')) ? readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8') : ''
554
+ if (!new RegExp(`^## v?${escapeRe(PKG.version)}\\b`, 'm').test(changelog))
555
+ errs.push(`CHANGELOG.md has no "## ${PKG.version}" section — what \`wuwei update\` shows before it asks`)
556
+ return errs
557
+ }
558
+
559
+ function cmdCheck() {
560
+ const items = loadValidated()
561
+ const errs = lint(items)
562
+ if (errs.length) {
563
+ console.error('✗ lint failed:\n' + errs.map(e => ' ' + e).join('\n'))
564
+ process.exit(1)
565
+ }
566
+ console.log(`✓ ${items.length} definitions valid, lint clean`)
567
+ }
568
+
569
+ function cmdBuild(dry) {
570
+ const items = loadValidated()
571
+ let total = 0
572
+ for (const t of CONFIG.targets) {
573
+ if (!t.enabled) continue
574
+ const fn = adapters[t.adapter]
575
+ if (!fn) { console.warn(`! no adapter '${t.adapter}', skipping`); continue }
576
+ const lines = fn(items, t.out, dry)
577
+ const count = lines.filter(l => !isRemovalLine(l)).length
578
+ total += count
579
+ console.log(`${dry ? '[dry] ' : ''}${t.adapter} → ${count} file(s)`)
580
+ for (const l of lines) console.log(` ${l}`)
581
+ }
582
+ console.log(`\n${dry ? '[dry] would write' : 'wrote'} ${total} file(s) from ${items.length} definition(s)`)
583
+ }
584
+
585
+ async function cmdInstall(target, dry) {
586
+ if (!target) {
587
+ // user scope: disciplines follow the person — local/ definitions do not
588
+ const items = loadValidated().filter(it => !isLocal(it))
589
+ const base = join(homedir(), '.claude')
590
+ const written = emitClaude(items, base, join(base, 'CLAUDE.md'), dry)
591
+ console.log(`${dry ? '[dry] ' : ''}user scope → ${base}`)
592
+ for (const w of written) console.log(` ${w}`)
593
+ installCodexUser(items, dry)
594
+ if (!dry) {
595
+ mkdirSync(wuweiHome(), { recursive: true })
596
+ const files = Object.fromEntries([...recorded].filter(([f]) => f.startsWith(homedir())))
597
+ writeFileSync(manifestPath(), JSON.stringify({ version: PKG.version, files }, null, 1) + '\n')
598
+ }
599
+ // offer to register the global bin, unless it's already linked. isGloballyLinked
600
+ // asks npm directly rather than guessing from how this invocation started —
601
+ // `npm link`/symlinked checkouts would otherwise mis-detect either direction.
602
+ // Interactive: ask, act only on yes — same explicit-confirmation pattern as
603
+ // the GSD import, never silently touch global npm state. Non-interactive:
604
+ // still say so (this is information, not a nag), just don't block on an
605
+ // answer that will never come.
606
+ if (!dry && !isGloballyLinked()) {
607
+ // from a clone, install that checkout; from the package (npx), install it
608
+ // by name — `install -g .` inside npx's cache would link a dir npm may prune
609
+ const fromClone = existsSync(join(ROOT, '.git'))
610
+ const spec = fromClone ? '.' : PKG.name
611
+ const command = fromClone ? `(cd ${ROOT} && npm install -g .)` : `npm install -g ${PKG.name}`
612
+ // the skills call `wuwei review-prep`, `wuwei claim`, `wuwei verify`… — without the
613
+ // command those steps fail, so say so rather than leave it as a convenience
614
+ const manualHint = `the skills call \`wuwei …\` and those steps fail without it; run this yourself anytime:\n ${command}`
615
+ if (!isInteractive()) {
616
+ console.log(`\n\`wuwei\`/\`ww\` aren't global commands yet — ${manualHint}`)
617
+ } else if (await promptYesNo(`\n\`wuwei\`/\`ww\` aren't global commands yet. Run \`npm install -g ${spec}\` now so they are?`)) {
618
+ const result = runNpm(['install', '-g', spec], { cwd: ROOT, stdio: 'inherit' })
619
+ console.log(result.status === 0
620
+ ? 'done — run `ww -v` to confirm it prints a version.'
621
+ : `\nthat failed${result.error ? '' : ` (exit ${result.status})`} — ${manualHint}`)
622
+ } else {
623
+ console.log(`skipped — ${manualHint}`)
624
+ }
625
+ }
626
+ return
627
+ }
628
+ // repo scope: templates + state skeleton follow the project
629
+ const dest = resolve(target)
630
+ if (!existsSync(dest) || !statSync(dest).isDirectory()) {
631
+ console.error(`✗ not a directory: ${dest}`)
632
+ process.exit(1)
633
+ }
634
+ const written = []
635
+ for (const t of discoverTemplates()) {
636
+ const file = t.name === 'pr.md'
637
+ ? join(dest, '.github', 'PULL_REQUEST_TEMPLATE.md')
638
+ : join(dest, '.wuwei', 'templates', t.name)
639
+ if (!dry) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, t.content) }
640
+ written.push(file)
641
+ }
642
+ const state = join(dest, '.wuwei', 'STATE.md')
643
+ if (!existsSync(state)) {
644
+ if (!dry) { mkdirSync(dirname(state), { recursive: true }); writeFileSync(state, STATE_SKELETON) }
645
+ written.push(state)
646
+ }
647
+ console.log(`${dry ? '[dry] ' : ''}repo scope → ${dest}`)
648
+ for (const w of written) console.log(` ${w}`)
649
+ }
650
+
651
+ // both stdin and stdout must be a real terminal — otherwise a prompt's text
652
+ // vanishes into a redirected file/pipe and we'd block forever on an answer
653
+ // nobody can see or give
654
+ const isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY)
655
+
656
+ // npm needs shell:true on Windows to resolve npm.cmd; a missing npm binary
657
+ // (result.error, e.g. ENOENT) is surfaced distinctly from a clean non-zero exit
658
+ function runCmd(bin, args, opts = {}) {
659
+ const result = spawnSync(bin, args, { shell: process.platform === 'win32', ...opts })
660
+ if (result.error) console.error(` ${bin} not runnable: ${result.error.message}`)
661
+ return result
662
+ }
663
+ const runNpm = (args, opts = {}) => runCmd('npm', args, opts)
664
+ const runNpx = (args, opts = {}) => runCmd('npx', args, opts)
665
+ const runGit = (args, opts = {}) => runCmd('git', args, opts)
666
+
667
+ // true only if THIS package is registered in npm's global node_modules —
668
+ // never assume; ask npm directly, since global bin locations vary by system
669
+ function isGloballyLinked() {
670
+ const result = runNpm(['root', '-g'], { encoding: 'utf8' })
671
+ const globalRoot = result.stdout?.trim()
672
+ return result.status === 0 && !!globalRoot && existsSync(join(globalRoot, PKG.name))
673
+ }
674
+
675
+ // true if user-scope install already ran — same isOurs stamp-check pattern as
676
+ // uninstall, so we never nag about something already done (mirrors
677
+ // isGloballyLinked/isCodegraphIndexed's own detect-before-offer discipline)
678
+ function isSkillsInstalled() {
679
+ const base = join(homedir(), '.claude', 'skills')
680
+ return discover('skills').filter(it => !isLocal(it))
681
+ .some(it => isOurs(join(base, it.data.name, 'SKILL.md')))
682
+ }
683
+
684
+ async function cmdUninstall(target) {
685
+ if (!target) {
686
+ const items = [...discover('skills'), ...discover('agents')]
687
+ const base = join(homedir(), '.claude')
688
+ const removed = []
689
+ for (const it of items) {
690
+ const path = it.kind === 'agents'
691
+ ? join(base, 'agents', `${it.data.name}.md`)
692
+ : join(base, 'skills', it.data.name)
693
+ const probe = it.kind === 'agents' ? path : join(path, 'SKILL.md')
694
+ // only remove what we generated — never a user's own file sharing a name
695
+ if (isOurs(probe)) {
696
+ rmSync(path, { recursive: true })
697
+ removed.push(path)
698
+ }
699
+ }
700
+ if (stripManagedBlock(join(base, 'CLAUDE.md')))
701
+ removed.push(join(base, 'CLAUDE.md') + ' (managed block)')
702
+ for (const it of items.filter(it => it.kind === 'skills')) {
703
+ const dir = join(codexUserSkillsBase(), 'skills', it.data.name)
704
+ if (isOurs(join(dir, 'SKILL.md'))) { rmSync(dir, { recursive: true }); removed.push(dir) }
705
+ }
706
+ if (stripManagedBlock(join(codexHome(), 'AGENTS.md')))
707
+ removed.push(join(codexHome(), 'AGENTS.md') + ' (managed block)')
708
+ if (removeCodexProfile()) removed.push(`${codexConfigPath()} ([profiles.${CODEX_PROFILE}])`)
709
+ if (existsSync(manifestPath())) { rmSync(manifestPath()); removed.push(manifestPath()) }
710
+ console.log(`user scope ← ${base}`)
711
+ for (const r of removed) console.log(` removed ${r}`)
712
+ if (!removed.length) console.log(' nothing to remove')
713
+
714
+ // mirror install's offer to link: if it's registered globally, offer to
715
+ // unlink it too — same explicit-confirmation pattern, same non-interactive fallback
716
+ if (isGloballyLinked()) {
717
+ const manualHint = `remove it yourself anytime:\n npm uninstall -g ${PKG.name}`
718
+ if (!isInteractive()) {
719
+ console.log(`\n\`wuwei\`/\`ww\` are still registered globally — ${manualHint}`)
720
+ } else if (await promptYesNo(`\nAlso remove the global \`wuwei\`/\`ww\` commands (npm uninstall -g ${PKG.name})?`)) {
721
+ const result = runNpm(['uninstall', '-g', PKG.name], { stdio: 'inherit' })
722
+ console.log(result.status === 0
723
+ ? 'done — `wuwei`/`ww` are no longer global commands.'
724
+ : `\nthat failed${result.error ? '' : ` (exit ${result.status})`} — ${manualHint}`)
725
+ } else {
726
+ console.log(`kept — ${manualHint}`)
727
+ }
728
+ }
729
+ await offerCodegraphRemoval('user')
730
+ await offerScannerRemoval()
731
+ return
732
+ }
733
+ const dest = resolve(target)
734
+ const removed = []
735
+ for (const t of discoverTemplates()) {
736
+ const file = t.name === 'pr.md'
737
+ ? join(dest, '.github', 'PULL_REQUEST_TEMPLATE.md')
738
+ : join(dest, '.wuwei', 'templates', t.name)
739
+ if (existsSync(file) && readFileSync(file, 'utf8').includes('wuwei')) {
740
+ rmSync(file)
741
+ removed.push(file)
742
+ }
743
+ }
744
+ console.log(`repo scope ← ${dest}`)
745
+ for (const r of removed) console.log(` removed ${r}`)
746
+ console.log(' kept .wuwei/STATE.md and streams/ — state is user data, delete manually if intended')
747
+ await offerCodegraphRemoval('repo', dest)
748
+ }
749
+
750
+ // ── status: read-only projection of .wuwei state, cross-checked ──────
751
+ // STATE.md is an index maintained by skills and humans; streams/ is what
752
+ // actually exists. The two drift. This renders the index against the
753
+ // directories and flags every mismatch it can prove — and nothing it can't:
754
+ // "done" is defined by e2e evidence, which is skill territory, so the
755
+ // done/total column is reported as recorded, never recomputed here.
756
+ function readStateRows(stateFile) {
757
+ if (!existsSync(stateFile)) return null
758
+ const rows = []
759
+ for (const line of readFileSync(stateFile, 'utf8').split('\n')) {
760
+ const t = line.trim()
761
+ if (!t.startsWith('|') || t === STATE_HEADER_LINE || isTableSeparatorLine(t)) continue
762
+ const cells = t.split('|').map(c => c.trim())
763
+ // split('|') on "| a | b |" yields ['', a, b, ''] — cells[1] is the first column
764
+ if (cells.length >= 6 && cells[1])
765
+ rows.push({ stream: cells[1], mode: cells[2], tracker: cells[3], done: cells[4], blocked: cells[5] })
766
+ }
767
+ return rows
768
+ }
769
+
770
+ function cmdStatus(target) {
771
+ const dest = resolve(target ?? process.cwd())
772
+ const wuweiDir = join(dest, '.wuwei')
773
+ if (!existsSync(wuweiDir)) {
774
+ console.log(`no .wuwei/ under ${dest} — not a wuwei project yet; \`wuwei init\` bootstraps one`)
775
+ return
776
+ }
777
+ const rows = readStateRows(join(wuweiDir, 'STATE.md')) ?? []
778
+ const streamsDir = join(wuweiDir, 'streams')
779
+ const dirs = existsSync(streamsDir)
780
+ ? readdirSync(streamsDir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name)
781
+ : []
782
+ const warnings = []
783
+ const tracked = codegraphTrackedFiles(dest)
784
+ if (tracked.length)
785
+ warnings.push(`.codegraph/ is tracked by git (${tracked.length} file(s)) — every phase branch` +
786
+ ' rewrites the same index and every merge conflicts on binary.' +
787
+ '\n git rm -r --cached .codegraph && echo .codegraph/ >> .gitignore')
788
+ const names = [...new Set([...rows.map(r => r.stream), ...dirs])].sort()
789
+
790
+ if (!names.length) {
791
+ console.log(`${wuweiDir}\n no streams yet — the wuwei-plan-phases skill opens the first one`)
792
+ // repo-wide warnings do not depend on a stream existing — printing them
793
+ // only on the has-streams path would hide them for exactly the projects
794
+ // still being set up, which is when they are cheapest to act on
795
+ for (const w of warnings) console.log(` ! ${w}`)
796
+ return
797
+ }
798
+
799
+ const table = []
800
+ for (const name of names) {
801
+ const row = rows.find(r => r.stream === name)
802
+ const dir = dirs.includes(name) ? join(streamsDir, name) : null
803
+ if (!dir) {
804
+ warnings.push(`${name}: listed in STATE.md but streams/${name}/ does not exist`)
805
+ table.push({ name, mode: row.mode, tracker: row.tracker, done: row.done, blocked: row.blocked, phases: '—', claims: '' })
806
+ continue
807
+ }
808
+ if (!row) warnings.push(`${name}: streams/${name}/ exists but has no STATE.md row`)
809
+
810
+ const spec = join(dir, 'SPEC.md')
811
+ if (!existsSync(spec)) warnings.push(`${name}: no SPEC.md`)
812
+ else if (!/^## Sign-off/m.test(readFileSync(spec, 'utf8')))
813
+ warnings.push(`${name}: SPEC.md has no \`## Sign-off\` — nothing should execute before sign-off`)
814
+ if (!existsSync(join(dir, 'ACCEPTANCE.md'))) warnings.push(`${name}: no ACCEPTANCE.md`)
815
+
816
+ const phasesDir = join(dir, 'phases')
817
+ const phaseFiles = existsSync(phasesDir)
818
+ ? readdirSync(phasesDir).filter(f => f.endsWith('.md')).sort()
819
+ : []
820
+ const claims = []
821
+ let untracked = 0
822
+ for (const f of phaseFiles) {
823
+ const { data } = parseFrontmatter(readFileSync(join(phasesDir, f), 'utf8'))
824
+ if (data['claimed-by'])
825
+ claims.push(`${f.replace(/\.md$/, '')} → ${data['claimed-by']}${data['claimed-at'] ? ` (${data['claimed-at']})` : ''}`)
826
+ if (!data['tracker-issue']) untracked++
827
+ }
828
+ // a linked stream whose phases carry no issue key gives execute-phase
829
+ // nothing to transition, and it fails silently — worth saying locally,
830
+ // which we can do without calling the tracker at all
831
+ const linked = row?.tracker && !['none', '-', '—', ''].includes(row.tracker)
832
+ if (linked && phaseFiles.length && untracked === phaseFiles.length)
833
+ warnings.push(`${name}: tracker '${row.tracker}' but no phase carries \`tracker-issue\` — transitions have nothing to act on`)
834
+ // the recorded total should match what's on disk; the done half is evidence-defined
835
+ const total = row?.done?.match(/^\s*\d+\s*\/\s*(\d+)\s*$/)?.[1]
836
+ if (total !== undefined && Number(total) !== phaseFiles.length)
837
+ warnings.push(`${name}: STATE.md says ${row.done} phases but streams/${name}/phases/ has ${phaseFiles.length} file(s)`)
838
+
839
+ table.push({
840
+ name, mode: row?.mode ?? '—', tracker: row?.tracker ?? '—',
841
+ done: row?.done ?? '—', blocked: row?.blocked ?? '—',
842
+ phases: String(phaseFiles.length), claims: claims.join('; '),
843
+ })
844
+ }
845
+
846
+ console.log(`${wuweiDir} — ${names.length} stream(s), ${warnings.length} warning(s)\n`)
847
+ const cols = ['stream', 'mode', 'tracker', 'done/total', 'files', 'blocked']
848
+ const data = table.map(t => [t.name, t.mode, t.tracker, t.done, t.phases, t.blocked])
849
+ const w = cols.map((c, i) => Math.max(c.length, ...data.map(r => (r[i] ?? '').length)))
850
+ console.log((' ' + cols.map((c, i) => c.padEnd(w[i])).join(' ')).trimEnd())
851
+ for (const r of data)
852
+ console.log((' ' + r.map((c, i) => (c ?? '').padEnd(w[i])).join(' ')).trimEnd())
853
+ for (const t of table) if (t.claims) console.log(` ↳ ${t.name} claims: ${t.claims}`)
854
+ if (warnings.length) {
855
+ console.log()
856
+ for (const wmsg of warnings) console.log(` ! ${wmsg}`)
857
+ }
858
+ }
859
+
860
+ // ── update ───────────────────────────────────────────────────────────
861
+ // `install` with its eyes open: is a newer wuwei out, and what would applying
862
+ // it change? Nothing is written without a yes.
863
+
864
+ // the stamp is metadata, not content: compared without it, a stamp-format
865
+ // change does not read as every artifact having changed
866
+ const STAMP_PREFIX = '<!-- GENERATED BY wuwei'
867
+ const withoutStamp = text => text.split('\n').filter(l => !l.startsWith(STAMP_PREFIX)).join('\n').trim()
868
+ const stampedVersion = text => text.match(/GENERATED BY wuwei v(\S+)/)?.[1] ?? null
869
+
870
+ // numeric x.y.z; a prerelease sorts below its release
871
+ function newerThan(a, b) {
872
+ const parse = v => { const [core, pre] = v.split('-'); return [...core.split('.').map(Number), pre ? 0 : 1] }
873
+ const [x, y] = [parse(a), parse(b)]
874
+ for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return x[i] > y[i]
875
+ return false
876
+ }
877
+
878
+ function currentManagedBlock(file) {
879
+ if (!existsSync(file)) return null
880
+ const re = new RegExp(`${escapeRe(BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(BLOCK_END)}`)
881
+ return readFileSync(file, 'utf8').match(re)?.[0] ?? null
882
+ }
883
+
884
+ // a clone is updated through git, the package through npm. A newer copy that
885
+ // takes over runs the diff itself and this process exits with it
886
+ async function updateSource(dry) {
887
+ const fromClone = existsSync(join(ROOT, '.git'))
888
+ console.log(`source: ${ROOT} (v${PKG.version}, ${fromClone ? 'git clone' : 'npm package'})`)
889
+ // after a pull or an upgrade the code in memory is the old one — the new
890
+ // copy runs the diff, so the preview is what the new version would write
891
+ const handOff = (bin, args) => {
892
+ const r = runCmd(bin, [...args, 'update', '--no-fetch', ...(dry ? ['--dry'] : [])], { stdio: 'inherit' })
893
+ process.exit(r.status ?? 1)
894
+ }
895
+ if (fromClone) {
896
+ const git = (args, opts) => runGit(['-C', ROOT, ...args], { encoding: 'utf8', ...opts })
897
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']).stdout?.trim()
898
+ const upstream = git(['rev-parse', '--abbrev-ref', '@{upstream}']).stdout?.trim()
899
+ if (!upstream) { console.log(` ${branch} has no upstream — can't tell whether a newer version exists`); return }
900
+ git(['fetch', '--quiet'], { timeout: 30000 })
901
+ const behind = Number(git(['rev-list', '--count', `HEAD..${upstream}`]).stdout?.trim() || 0)
902
+ if (!behind) { console.log(` up to date with ${upstream}`); return }
903
+ console.log(` ${behind} commit(s) behind ${upstream}`)
904
+ const hint = `get them yourself anytime:\n (cd ${ROOT} && git pull --ff-only)`
905
+ if (dry) console.log(` [dry] not pulling — ${hint}`)
906
+ else if (git(['status', '--porcelain']).stdout?.trim()) console.log(' uncommitted changes here, not touching them — commit or stash, then re-run')
907
+ else if (!isInteractive()) console.log(` ${hint}`)
908
+ else if (await promptYesNo(`Pull those ${behind} commit(s) first?`)) {
909
+ if (git(['pull', '--ff-only'], { stdio: 'inherit', encoding: undefined }).status !== 0) {
910
+ console.error('\n✗ pull failed — resolve it yourself, then re-run `wuwei update`')
911
+ process.exit(1)
912
+ }
913
+ handOff(process.execPath, [join(ROOT, 'bin', 'wuwei.js')])
914
+ }
915
+ console.log(' diffing against this checkout')
916
+ return
917
+ }
918
+ const view = runNpm(['view', PKG.name, 'version'], { encoding: 'utf8', timeout: 30000 })
919
+ const latest = view.status === 0 ? view.stdout.trim() : null
920
+ if (!latest) {
921
+ console.log(` ${/E404/.test(view.stderr ?? '') ? `${PKG.name} is not on npm` : `can't reach npm for ${PKG.name}`} — diffing against this copy`)
922
+ return
923
+ }
924
+ if (newerThan(PKG.version, latest)) { console.log(` ahead of npm (v${latest}) — a dev install; update never downgrades it`); return }
925
+ if (!newerThan(latest, PKG.version)) { console.log(` up to date with npm (v${latest})`); return }
926
+ console.log(` v${latest} is on npm`)
927
+ const global = isGloballyLinked()
928
+ const command = global ? `npm install -g ${PKG.name}@${latest}` : `npx ${PKG.name}@${latest} update`
929
+ if (dry) console.log(` [dry] not upgrading — ${command}`)
930
+ else if (!isInteractive()) console.log(` upgrade yourself anytime:\n ${command}`)
931
+ else if (await promptYesNo(global ? `Upgrade the global install to v${latest} first?` : `Run v${latest} for this update?`)) {
932
+ if (!global) handOff('npx', ['--yes', `${PKG.name}@${latest}`])
933
+ if (runNpm(['install', '-g', `${PKG.name}@${latest}`], { stdio: 'inherit' }).status !== 0) {
934
+ console.error(`\n✗ upgrade failed — run \`${command}\` yourself, then \`wuwei update\``)
935
+ process.exit(1)
936
+ }
937
+ const root = runNpm(['root', '-g'], { encoding: 'utf8' }).stdout.trim()
938
+ handOff(process.execPath, [join(root, PKG.name, 'bin', 'wuwei.js')])
939
+ }
940
+ console.log(` diffing against v${PKG.version}`)
941
+ }
942
+
943
+ // install's targets — the same artifact lists install writes — against disk
944
+ function updateRows() {
945
+ const items = loadValidated().filter(it => !isLocal(it))
946
+ const rows = []
947
+ let unchanged = 0
948
+ const stamps = new Set()
949
+ const manifest = readManifest()
950
+ // differs from the new version: an upstream change — unless it also differs from
951
+ // what install wrote, which means you edited it. No record → can't tell
952
+ const differs = (key, disk) => manifest?.files?.[key] && sha256(disk) !== manifest.files[key]
953
+ ? ['edited', 'edited since install — backed up, then overwritten'] : ['changed', '']
954
+ const compare = artifacts => {
955
+ for (const a of artifacts) {
956
+ const label = a.file.slice(homedir().length + 1)
957
+ if (!existsSync(a.file)) { rows.push(['new', label, '']); continue }
958
+ const disk = readFileSync(a.file, 'utf8')
959
+ if (!isOurs(a.file)) rows.push(['conflict', label, 'not wuwei-generated — applying overwrites it'])
960
+ else if (withoutStamp(disk) !== withoutStamp(a.content)) { const [state, note] = differs(a.file, disk); rows.push([state, label, note]) }
961
+ else unchanged++
962
+ const v = stampedVersion(disk); if (v) stamps.add(v)
963
+ }
964
+ // a stamped skill's references/ is regenerated whole: anything not produced again goes
965
+ const produced = new Set(artifacts.map(a => a.file))
966
+ for (const a of artifacts.filter(a => a.file.endsWith('SKILL.md') && isOurs(a.file))) {
967
+ const refDir = join(dirname(a.file), 'references')
968
+ if (!existsSync(refDir)) continue
969
+ for (const f of readdirSync(refDir)) if (!produced.has(join(refDir, f)))
970
+ rows.push(['stale', join(refDir, f).slice(homedir().length + 1), 'no longer in source — removed'])
971
+ }
972
+ }
973
+ const compareBlock = (file, rules) => {
974
+ const want = rules.length ? rulesBlock(rules) : null
975
+ const have = currentManagedBlock(file)
976
+ const label = `${file.slice(homedir().length + 1)} (rules block)`
977
+ if (want === have) { if (want) unchanged++; return }
978
+ const [state, note] = !have ? ['new', ''] : !want ? ['stale', 'no rules left in source — stripped'] : differs(`${file}#rules`, have)
979
+ rows.push([state, label, note])
980
+ }
981
+ const staleDirs = (base, skills, agents) => {
982
+ for (const p of sweepStale(base, skills, agents, true))
983
+ rows.push(['stale', p.slice(homedir().length + 1), 'no longer in source — removed'])
984
+ }
985
+
986
+ const claudeBase = join(homedir(), '.claude')
987
+ const claude = claudeArtifacts(items, claudeBase)
988
+ compare(claude)
989
+ staleDirs(claudeBase, namesUnder(claude, claudeBase, 'skills'), namesUnder(claude, claudeBase, 'agents'))
990
+ compareBlock(join(claudeBase, 'CLAUDE.md'), items.filter(i => targeted(i, 'claude-code') && isRule(i)))
991
+
992
+ if (existsSync(codexHome())) {
993
+ const picked = items.filter(it => it.kind === 'skills' && targeted(it, 'codex'))
994
+ const lazy = picked.filter(it => !isRule(it))
995
+ const codex = codexArtifacts(lazy, codexUserSkillsBase(), items)
996
+ compare(codex)
997
+ staleDirs(codexUserSkillsBase(), new Set(lazy.map(it => it.data.name)), new Set())
998
+ compareBlock(join(codexHome(), 'AGENTS.md'), picked.filter(isRule))
999
+ }
1000
+ return { rows, unchanged, stamps: [...stamps], manifest }
1001
+ }
1002
+
1003
+ // CHANGELOG.md's sections after `since`, up to this copy — one "## <version>" each
1004
+ function changelogSince(since) {
1005
+ const file = join(ROOT, 'CHANGELOG.md')
1006
+ if (!existsSync(file)) return null
1007
+ const picked = readFileSync(file, 'utf8').split(/^(?=## )/m).filter(sec => {
1008
+ const v = sec.match(/^## v?(\d+\.\d+\.\d+\S*)/)?.[1]
1009
+ return v && newerThan(v, since) && !newerThan(v, PKG.version)
1010
+ })
1011
+ return picked.length ? picked.join('').trim() : null
1012
+ }
1013
+
1014
+ // ── update check: GSD's SessionStart hook + statusline, without either — the
1015
+ // skills call `wuwei` all day, so any command is where a newer version gets
1016
+ // mentioned (stderr, at most hourly). The npm query runs once a day in a
1017
+ // detached child, never on a clone (git is its channel) or in CI
1018
+ const updateCachePath = () => join(homedir(), '.cache', 'wuwei', 'update-check.json')
1019
+ function noticeUpdate(cmd) {
1020
+ if (['update', '__update-check', '-v', '--version', 'version'].includes(cmd) || process.env.CI || process.env.WUWEI_NO_UPDATE_CHECK || existsSync(join(ROOT, '.git'))) return
1021
+ let cache = null
1022
+ try { cache = JSON.parse(readFileSync(updateCachePath(), 'utf8')) } catch {}
1023
+ if (cache?.latest && newerThan(cache.latest, PKG.version) && Date.now() - (cache.noticed ?? 0) > 3600000) {
1024
+ console.error(`⬆ wuwei v${cache.latest} is out (this is v${PKG.version}) — wuwei update`)
1025
+ try { writeFileSync(updateCachePath(), JSON.stringify({ ...cache, noticed: Date.now() })) } catch {}
1026
+ }
1027
+ if (cache && Date.now() - cache.checked < 86400000) return
1028
+ try { spawn(process.execPath, [fileURLToPath(import.meta.url), '__update-check'], { detached: true, stdio: 'ignore' }).unref() } catch {}
1029
+ }
1030
+ function cmdUpdateCheck() {
1031
+ const view = runNpm(['view', PKG.name, 'version'], { encoding: 'utf8', timeout: 30000 })
1032
+ mkdirSync(dirname(updateCachePath()), { recursive: true })
1033
+ writeFileSync(updateCachePath(), JSON.stringify({ checked: Date.now(), latest: view.status === 0 ? view.stdout.trim() : null }))
1034
+ }
1035
+
1036
+ async function cmdUpdate(dry, noFetch) {
1037
+ if (!noFetch) await updateSource(dry)
1038
+ const { rows, unchanged, stamps, manifest } = updateRows()
1039
+ console.log(`\ninstalled: ${stamps.length ? stamps.map(v => `v${v}`).join(', ') : 'unstamped or not installed'} · this copy: v${PKG.version}`)
1040
+ // what changed between the installed version and this one, before what it would write
1041
+ const since = stamps.filter(v => newerThan(PKG.version, v)).sort((a, b) => newerThan(a, b) ? 1 : -1)[0]
1042
+ if (since) {
1043
+ const notes = changelogSince(since)
1044
+ if (notes) console.log(`\nwhat's new since v${since}:\n\n${notes}`)
1045
+ }
1046
+ if (!existsSync(codexHome())) console.log(`codex: ${codexHome()} not found — not compared`)
1047
+ if (!rows.length) {
1048
+ console.log(`\n✓ already up to date — ${unchanged} artifact(s) match exactly`)
1049
+ return
1050
+ }
1051
+ const width = Math.max(...rows.map(r => r[1].length))
1052
+ console.log()
1053
+ for (const [state, label, note] of rows)
1054
+ console.log(` ${state.padEnd(9)} ${note ? label.padEnd(width) + ' ' + note : label}`)
1055
+ if (unchanged) console.log(` ${''.padEnd(9)} …and ${unchanged} unchanged`)
1056
+ // what applying would overwrite that is yours: edits since install, files wuwei
1057
+ // didn't write, and — with no install record to tell — every changed file
1058
+ const keep = rows.filter(([s]) => s === 'edited' || s === 'conflict' || (s === 'changed' && !manifest))
1059
+ if (!manifest && rows.some(([s]) => s === 'changed'))
1060
+ console.log(`\nno install record (${manifestPath()}) — installed before wuwei kept one, so an edit you made can't be told from an upstream change; applying backs up every changed file first`)
1061
+ const hint = keep.length
1062
+ ? `${keep.length} file(s) of yours would be overwritten — run \`wuwei update\` in a terminal, which backs them up first (\`install\` does not)`
1063
+ : `apply it yourself anytime:\n ${existsSync(join(ROOT, '.git')) ? `node ${join(ROOT, 'bin', 'wuwei.js')}` : `npx ${PKG.name}@${PKG.version}`} install`
1064
+ if (dry) console.log(`\n[dry] nothing written — ${hint}`)
1065
+ else if (!isInteractive()) console.log(`\nnon-interactive, nothing written — ${hint}`)
1066
+ else if (await promptYesNo(`\nApply these ${rows.length} change(s)?${keep.length ? ` ${keep.length} file(s) of yours are backed up first.` : ''}`)) {
1067
+ if (keep.length) {
1068
+ const dir = join(wuweiHome(), 'local-patches', new Date().toISOString().replace(/[:.]/g, '-'))
1069
+ for (const [, label] of keep) {
1070
+ const rel = label.replace(/ \(rules block\)$/, ''), from = join(homedir(), rel)
1071
+ if (!existsSync(from)) continue
1072
+ mkdirSync(dirname(join(dir, rel)), { recursive: true })
1073
+ writeFileSync(join(dir, rel), readFileSync(from))
1074
+ }
1075
+ console.log(`\nbacked up ${keep.length} file(s) → ${dir}`)
1076
+ }
1077
+ console.log()
1078
+ await cmdInstall(undefined, false)
1079
+ rmSync(updateCachePath(), { force: true })
1080
+ console.log('\nrestart Claude Code and Codex sessions — a running session keeps the skills it loaded')
1081
+ }
1082
+ else console.log(`skipped — ${hint}`)
1083
+ console.log('\nrepo scope (.wuwei/templates/, the PR template) updates per project: `wuwei init <path>`')
1084
+ }
1085
+
1086
+ // ── claim: wuwei-execute-phase step 1's mechanical half ──────────────
1087
+ // picks what is eligible, writes the claim, cuts the phase branch from the
1088
+ // stream branch and pushes it. Judgment stays with the skill: syncing the
1089
+ // stream branch and re-running its green e2e, whether a stale claim is taken
1090
+ // over, and the tracker
1091
+ const phaseNo = s => String(s).match(/(?:^|\/)(\d+)(?:-|\.md|$)/)?.[1] ?? null
1092
+
1093
+ // three labelled fields under SPEC.md ## Sign-off (plan-phases step 8)
1094
+ function signOffField(spec, label) {
1095
+ const section = spec.split(/^## Sign-off\s*$/m)[1]?.split(/^## /m)[0] ?? ''
1096
+ const line = section.split('\n').find(l => new RegExp(label, 'i').test(l))
1097
+ if (!line) return null
1098
+ return line.match(/`([^`]+)`/)?.[1] ?? line.split(/[:|]/).map(x => x.trim()).filter(Boolean).at(-1) ?? null
1099
+ }
1100
+
1101
+ function setFrontmatter(text, fields) {
1102
+ const end = text.startsWith('---') ? text.indexOf('\n---', 3) : -1
1103
+ if (end === -1) return `---\n${Object.entries(fields).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n\n${text}`
1104
+ let head = text.slice(0, end)
1105
+ for (const [k, v] of Object.entries(fields)) {
1106
+ const re = new RegExp(`^${k}:.*$`, 'm')
1107
+ head = re.test(head) ? head.replace(re, `${k}: ${v}`) : `${head}\n${k}: ${v}`
1108
+ }
1109
+ return head + text.slice(end)
1110
+ }
1111
+
1112
+ function claimPhases(dest, stream) {
1113
+ const git = (args, opts) => quiet('git', ['-C', dest, ...args], opts)
1114
+ const dir = join(dest, '.wuwei', 'streams', stream)
1115
+ const spec = existsSync(join(dir, 'SPEC.md')) ? readFileSync(join(dir, 'SPEC.md'), 'utf8') : ''
1116
+ const streamBranch = signOffField(spec, 'stream branch')
1117
+ const integration = signOffField(spec, 'integration branch')
1118
+ if (!streamBranch) return { error: `${stream}: no stream branch under SPEC.md ## Sign-off — planning owes it (wuwei-plan-phases step 8)` }
1119
+ // phase branches sit beside the stream branch, never under it: git cannot
1120
+ // hold `x` and `x/01-…` at once, since a ref name is also a path
1121
+ if (!/\/base$/.test(streamBranch))
1122
+ return { error: `${stream}: stream branch '${streamBranch}' cannot have phase branches beside it — name it '${streamBranch}/base' (wuwei-plan-phases step 8): git refuses '${streamBranch}/NN-…' while '${streamBranch}' exists` }
1123
+ const prefix = streamBranch.replace(/\/base$/, '')
1124
+ git(['fetch', '--quiet', 'origin'], { timeout: 60000 })
1125
+ const remote = `origin/${streamBranch}`
1126
+ if (!ok(git(['rev-parse', '--verify', '--quiet', remote])))
1127
+ return { error: `${remote} does not exist — the stream branch was recorded but never pushed` }
1128
+ const behind = integration
1129
+ ? Number(git(['rev-list', '--count', `${remote}..origin/${integration}`]).stdout?.trim() || NaN) : NaN
1130
+ // the phase files as the stream branch has them — that is what every session reads
1131
+ const files = git(['ls-tree', '--name-only', `${remote}`, `.wuwei/streams/${stream}/phases/`]).stdout
1132
+ ?.split('\n').filter(f => f.endsWith('.md')).sort() ?? []
1133
+ const phases = files.map(f => {
1134
+ const name = f.split('/').pop().replace(/\.md$/, '')
1135
+ const { data } = parseFrontmatter(git(['show', `${remote}:${f}`]).stdout ?? '')
1136
+ const branch = `${prefix}/${name}`
1137
+ const onRemote = ok(git(['rev-parse', '--verify', '--quiet', `origin/${branch}`]))
1138
+ // merged — three traces, strongest first: the pushed branch is inside the
1139
+ // stream branch; or the branch is gone and the claim commit (written only
1140
+ // on a phase branch) reached the stream branch, which a squash merge leaves
1141
+ // too; or a merge commit names the branch
1142
+ const how = onRemote
1143
+ ? (git(['merge-base', '--is-ancestor', `origin/${branch}`, remote]).status === 0 ? 'branch inside the stream branch' : null)
1144
+ : data['claimed-by'] ? 'its claim reached the stream branch'
1145
+ : git(['log', '--merges', '--oneline', '-1', '--fixed-strings', `--grep=${branch}`, remote]).stdout.trim() ? 'a merge commit names it'
1146
+ : null
1147
+ const merged = Boolean(how)
1148
+ // a claim lives on the pushed phase branch: read it there first
1149
+ const claimed = onRemote ? parseFrontmatter(git(['show', `origin/${branch}:${f}`]).stdout ?? '').data : data
1150
+ return {
1151
+ name, no: phaseNo(name), file: f, branch, onRemote, merged, how,
1152
+ dependsOn: [].concat(data['depends-on'] ?? []).map(phaseNo).filter(Boolean),
1153
+ claimedBy: claimed['claimed-by'] || null, claimedAt: claimed['claimed-at'] || null,
1154
+ review: data.review ?? 'auto', tracker: data['tracker-issue'] ?? null,
1155
+ }
1156
+ })
1157
+ for (const p of phases) {
1158
+ const missing = p.dependsOn.filter(d => !phases.some(q => q.no === d))
1159
+ const open = p.dependsOn.filter(d => phases.some(q => q.no === d && !q.merged))
1160
+ p.state = p.merged ? `merged (${p.how})`
1161
+ : missing.length ? `blocked — depends-on ${missing.join(', ')} is not a phase`
1162
+ : open.length ? `waiting on ${open.join(', ')}`
1163
+ : p.claimedBy || p.onRemote ? `claimed by ${p.claimedBy ?? '?'}${p.claimedAt ? ` at ${p.claimedAt}` : ''}`
1164
+ : 'ready'
1165
+ }
1166
+ return { streamBranch, integration, remote, behind, phases }
1167
+ }
1168
+
1169
+ // SPEC ## Sign-off's model policy: `inherit` (default, also when the line is
1170
+ // absent) or `roles`, for the review tier. Implementation is not on the menu:
1171
+ // it is always wuwei-implementer at modelRoles.implementation's tier
1172
+ function modelPolicy(spec) {
1173
+ const raw = (signOffField(spec ?? '', 'model policy') ?? 'inherit').trim()
1174
+ const v = (raw.match(/\breview\s*[=:]\s*(\w+)/i)?.[1] ?? raw).toLowerCase()
1175
+ const error = /\bimplementation\b/i.test(raw)
1176
+ ? `model policy '${raw}': implementation is fixed (wuwei-implementer, ${CONFIG.modelRoles?.implementation?.model} / ${CONFIG.modelRoles?.implementation?.effort}) — the policy is inherit or roles, for review`
1177
+ : !['inherit', 'roles'].includes(v) ? `model policy '${raw}': inherit or roles` : null
1178
+ // Codex, when it runs execute: `codex: <model> / <effort>` — the session it is started as
1179
+ const cx = (spec ?? '').split(/^## Sign-off\s*$/m)[1]?.split(/^## /m)[0]?.split('\n').find(l => /^[-*|\s]*codex\s*[:|]/i.test(l))
1180
+ const cv = cx ? (cx.match(/`([^`]+)`/)?.[1] ?? cx.split(/[:|]/).map(x => x.trim()).filter(Boolean).at(-1)) : null
1181
+ const cm = cv?.match(/^(\S+)\s*\/\s*(\S+)$/)
1182
+ const codex = cm ? { model: cm[1], effort: cm[2] } : null
1183
+ const codexError = cx && !cm ? `codex '${cv}': write it as <model> / <effort>`
1184
+ : codex && !CODEX_EFFORTS.includes(codex.effort) ? `codex effort '${codex.effort}': one of ${CODEX_EFFORTS.join('|')}` : null
1185
+ return { raw, tiers: { review: v }, error: error ?? codexError, codex }
1186
+ }
1187
+
1188
+ function codexLine(want) {
1189
+ const { profile } = codexProfile()
1190
+ if (!want) return profile
1191
+ ? `codex start it with \`codex --profile ${CODEX_PROFILE}\` (${profile.model} / ${profile.effort}) — none recorded under ## Sign-off`
1192
+ : 'codex the model Codex was started with — no `codex:` under ## Sign-off'
1193
+ return profile?.model === want.model && profile?.effort === want.effort
1194
+ ? `codex start it with \`codex --profile ${CODEX_PROFILE}\` (${want.model} / ${want.effort})`
1195
+ : `codex start it with \`${codexLaunch(want.model, want.effort)}\`${profile ? ` — profile ${CODEX_PROFILE} is ${profile.model} / ${profile.effort}, not what ## Sign-off says` : ''}`
1196
+ }
1197
+
1198
+ // what step 2 and step 5 dispatch, from the policy and wuwei.config.json's
1199
+ // modelRoles — printed, so the tier is read off the screen, not remembered
1200
+ function dispatchLines(policy) {
1201
+ const roles = CONFIG.modelRoles ?? {}
1202
+ const impl = roles.implementation ?? {}, rev = roles.review ?? {}
1203
+ return [
1204
+ `implement (step 2) subagent_type ${impl.agent}, no model or effort on the call — ${impl.model} / ${impl.effort}, pinned in its agent file`,
1205
+ policy.tiers.review === 'roles'
1206
+ ? `${(rev.lenses ?? []).map(l => l.replace(/^wuwei-review-/, 'review:')).join(', ')} (step 5) model: ${rev.model} — effort from the agent file`
1207
+ : `${(rev.lenses ?? []).map(l => l.replace(/^wuwei-review-/, 'review:')).join(', ')} (step 5) no model — the session's model; effort from the agent file`,
1208
+ 'other lenses no model — the session\'s; wuwei-security-auditor stays fable / high (pinned)',
1209
+ 'inline (no subagents, Codex) the session\'s, whatever the policy — say so in the round record',
1210
+ codexLine(policy.codex),
1211
+ ]
1212
+ }
1213
+
1214
+ function cmdClaim(argv) {
1215
+ const flag = name => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1] }
1216
+ const positional = argv.filter((a, i) => !a.startsWith('--') && argv[i - 1] !== '--by')
1217
+ const dest = process.cwd()
1218
+ const streamsDir = join(dest, '.wuwei', 'streams')
1219
+ const streams = existsSync(streamsDir) ? readdirSync(streamsDir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name) : []
1220
+ let [stream, phaseArg] = positional
1221
+ if (stream && !streams.includes(stream) && streams.length === 1 && phaseNo(stream)) [stream, phaseArg] = [streams[0], stream]
1222
+ stream ??= streams.length === 1 ? streams[0] : null
1223
+ if (!stream || !streams.includes(stream)) {
1224
+ console.error(`✗ name a stream: ${streams.join(', ') || 'none under .wuwei/streams/'}`)
1225
+ process.exit(1)
1226
+ }
1227
+ const r = claimPhases(dest, stream)
1228
+ if (r.error) { console.error(`✗ ${r.error}`); process.exit(1) }
1229
+ const sync = !r.integration ? 'no integration branch under ## Sign-off — cannot tell if the stream branch is current'
1230
+ : r.behind === 0 ? `current with origin/${r.integration}`
1231
+ : `${r.behind} commit(s) behind origin/${r.integration}`
1232
+ console.log(`stream ${stream} · ${r.remote} · ${sync}\n`)
1233
+ const w = Math.max(...r.phases.map(p => p.name.length))
1234
+ for (const p of r.phases)
1235
+ console.log(` ${p.name.padEnd(w)} ${p.state}${p.review === 'human' ? ' ← review: human' : ''}`)
1236
+
1237
+ const policy = modelPolicy(existsSync(join(streamsDir, stream, 'SPEC.md')) ? readFileSync(join(streamsDir, stream, 'SPEC.md'), 'utf8') : '')
1238
+ if (policy.error) { console.error(`\n✗ ${policy.error} — fix ## Sign-off (wuwei plan-check)`); process.exit(1) }
1239
+ console.log(`\ndispatch · model policy: ${policy.raw}\n${dispatchLines(policy).map(l => ` ${l}`).join('\n')}`)
1240
+
1241
+ if (!phaseArg) {
1242
+ const ready = r.phases.filter(p => p.state === 'ready')
1243
+ console.log(ready.length ? `\nclaim one: wuwei claim ${stream} ${ready[0].no}` : '\nnothing ready to claim')
1244
+ return
1245
+ }
1246
+ const p = r.phases.find(x => x.no === phaseNo(phaseArg) || x.name === phaseArg)
1247
+ if (!p) { console.error(`✗ ${phaseArg} is not a phase of ${stream}`); process.exit(1) }
1248
+ const takeOver = argv.includes('--take-over')
1249
+ const refuse = msg => { console.error(`\n✗ ${p.name}: ${msg}`); process.exit(1) }
1250
+ if (p.merged) refuse('already merged')
1251
+ if (p.state.startsWith('waiting') || p.state.startsWith('blocked')) refuse(p.state)
1252
+ if ((p.claimedBy || p.onRemote) && !takeOver)
1253
+ refuse(`${p.state} — taking it over is a decision: re-run with --take-over, which records it in the phase file`)
1254
+ // never cut a phase branch off a stream branch that has not caught up — the
1255
+ // skill syncs it and re-runs its green e2e first (execute step 1)
1256
+ if (r.behind !== 0) refuse(r.integration
1257
+ ? `the stream branch is ${r.behind} commit(s) behind origin/${r.integration} — sync it and re-run its green e2e first`
1258
+ : 'no integration branch recorded, so the stream branch cannot be checked current — record it under ## Sign-off')
1259
+ if (quiet('git', ['status', '--porcelain', '--untracked-files=no']).stdout.trim())
1260
+ refuse('uncommitted changes in this checkout — commit or stash first')
1261
+
1262
+ const by = flag('--by') ?? `${process.env.USER || process.env.USERNAME || 'unknown'}@${(quiet('hostname', []).stdout || 'host').trim()}`
1263
+ const at = new Date().toISOString().replace(/\.\d+Z$/, 'Z')
1264
+ const dry = argv.includes('--dry')
1265
+ const steps = [
1266
+ p.onRemote ? `check out ${p.branch} from origin (taking over)` : `cut ${p.branch} from ${r.remote}`,
1267
+ `claimed-by: ${by} · claimed-at: ${at}${takeOver ? ' · takeover recorded' : ''}`,
1268
+ `commit and push ${p.branch}`,
1269
+ ]
1270
+ console.log(`\n${dry ? '[dry] ' : ''}claim ${p.name}:\n${steps.map(s => ` ${s}`).join('\n')}`)
1271
+ if (dry) return
1272
+ const run = (args, what) => { const x = quiet('git', args); if (!ok(x)) refuse(`${what} failed: ${firstLine(x.stderr)}`) }
1273
+ run(['checkout', '-B', p.branch, p.onRemote ? `origin/${p.branch}` : r.remote], 'checkout')
1274
+ const file = join(dest, p.file)
1275
+ let text = readFileSync(file, 'utf8')
1276
+ text = setFrontmatter(text, { 'claimed-by': by, 'claimed-at': at })
1277
+ if (takeOver) text = text.trimEnd() + `\n\n> Claim taken over ${at} by ${by} from ${p.claimedBy ?? 'an unrecorded claim'}${p.claimedAt ? ` (claimed ${p.claimedAt})` : ''}.\n`
1278
+ writeFileSync(file, text)
1279
+ run(['add', p.file], 'git add')
1280
+ run(['commit', '-q', '-m', `claim(${stream}): ${p.name}${takeOver ? ' — taken over' : ''}`], 'commit')
1281
+ run(['push', '-q', '-u', 'origin', p.branch], 'push')
1282
+ console.log(`\n✓ claimed ${p.name} on ${p.branch}${p.tracker ? ` · tracker ${p.tracker}: move it under step 1's write rule` : ''}`)
1283
+ }
1284
+
1285
+ // ── plan-check: wuwei-plan-phases step 11's checkable half ───────────
1286
+ // read-only. What needs judgment — the phases matching the delivery
1287
+ // strategy, each design reconciling with SPEC's target — stays in the skill
1288
+ function cycleIn(edges) {
1289
+ // edges: Map node → [deps]; returns one cycle as a path, or null
1290
+ const state = new Map()
1291
+ const visit = (n, path) => {
1292
+ if (state.get(n) === 1) return [...path.slice(path.indexOf(n)), n]
1293
+ if (state.get(n) === 2) return null
1294
+ state.set(n, 1)
1295
+ for (const d of edges.get(n) ?? []) { const c = visit(d, [...path, n]); if (c) return c }
1296
+ state.set(n, 2)
1297
+ return null
1298
+ }
1299
+ for (const n of edges.keys()) { const c = visit(n, []); if (c) return c }
1300
+ return null
1301
+ }
1302
+
1303
+ const TASK_LINE = /^\s*[-*]\s*\[[ xX]\]\s*\**`?([A-Za-z]*\d+[a-z]?)`?\**[\s.:)—-]/
1304
+ const listOf = v => String(v ?? '').replace(/[[\]]/g, '').split(/[,\s]+/).map(x => x.replace(/[`*]/g, '')).filter(Boolean)
1305
+
1306
+ // ACCEPTANCE.md's ## Verify: the pages whose data states wuwei verify answers,
1307
+ // each bound to the phase that builds it — "- <page>: <route> · phase: NN".
1308
+ // null when the section is missing; none when it says `verify: none`
1309
+ function verifyItems(acceptance) {
1310
+ if (/^\s*verify:\s*none\b/im.test(acceptance)) return { none: true, items: [] }
1311
+ const sec = acceptance.split(/^## Verify\s*$/m)[1]?.split(/^## /m)[0]
1312
+ if (sec == null) return null
1313
+ const items = sec.split('\n').filter(l => /^\s*[-*]\s+\S/.test(l)).map(l => ({
1314
+ line: l.trim(), name: l.match(/^\s*[-*]\s+`?([\w.-]+)`?\s*:/)?.[1] ?? null, phase: l.match(/phase:?\s*`?(\d+)/i)?.[1] ?? null,
1315
+ }))
1316
+ return { none: false, items }
1317
+ }
1318
+
1319
+ function cmdPlanCheck(argv) {
1320
+ const dest = process.cwd()
1321
+ const streams = existsSync(join(dest, '.wuwei', 'streams'))
1322
+ ? readdirSync(join(dest, '.wuwei', 'streams'), { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name) : []
1323
+ const stream = argv.find(a => !a.startsWith('--')) ?? (streams.length === 1 ? streams[0] : null)
1324
+ if (!stream || !streams.includes(stream)) { console.error(`✗ name a stream: ${streams.join(', ') || 'none under .wuwei/streams/'}`); process.exit(1) }
1325
+ const dir = join(dest, '.wuwei', 'streams', stream)
1326
+ const errs = [], warns = []
1327
+ const read = f => existsSync(f) ? readFileSync(f, 'utf8') : null
1328
+ const spec = read(join(dir, 'SPEC.md'))
1329
+ if (!spec) errs.push('SPEC.md missing')
1330
+ const acceptance = read(join(dir, 'ACCEPTANCE.md'))
1331
+ if (!acceptance) errs.push('ACCEPTANCE.md missing')
1332
+
1333
+ // sign-off: the fields execute reads, and nothing open under auto
1334
+ const signOff = spec?.split(/^## Sign-off\s*$/m)[1]?.split(/^## /m)[0] ?? null
1335
+ if (spec && signOff == null) errs.push('SPEC.md has no ## Sign-off')
1336
+ const fields = [['approved', /approv/i], ['delivery strategy', /delivery|strategy|mvp-first|feature-complete/i], ['drive mode', /\b(drive )?mode\b/i],
1337
+ ['tracker', /tracker/i], ['stream branch', /stream branch/i], ['integration branch', /integration branch/i], ['starting ref', /cut from|starting ref|ref it was cut/i]]
1338
+ const signLines = (signOff ?? '').split('\n')
1339
+ for (const [name, re] of fields) if (signOff != null && !signLines.some(l => re.test(l))) errs.push(`## Sign-off: no ${name}`)
1340
+ const streamBranch = spec ? signOffField(spec, 'stream branch') : null
1341
+ if (streamBranch && !/\/base$/.test(streamBranch)) errs.push(`stream branch '${streamBranch}' must end in /base — git refuses phase branches under it (step 8)`)
1342
+ if (streamBranch && quiet('git', ['remote']).stdout.trim()) {
1343
+ const r = quiet('git', ['ls-remote', '--heads', 'origin', streamBranch], { timeout: 30000 })
1344
+ if (ok(r) && !r.stdout.trim()) errs.push(`stream branch ${streamBranch} is not on origin — push it, or the first phase PR has no base`)
1345
+ }
1346
+ const open = signLines.filter(l => /:\s*(\?|tbd|todo|pending|—|-|<[^>]+>)?\s*$/i.test(l) && /\w/.test(l.split(':')[0]) && /^\s*[-*|]/.test(l))
1347
+ const auto = signLines.some(l => /\bmode\b[^|\n]*\bauto\b/i.test(l))
1348
+ if (auto && open.length) errs.push(`drive mode is auto while ## Sign-off still has open fields: ${open.map(l => l.trim()).join(' / ')} — a partly answered sign-off stays manual`)
1349
+ const policy = spec ? modelPolicy(spec) : null
1350
+ if (policy?.error) errs.push(`## Sign-off: ${policy.error}`)
1351
+ const tracker = signLines.find(l => /tracker/i.test(l)) ?? ''
1352
+ const linked = tracker && !/\bnone\b/i.test(tracker)
1353
+
1354
+ // phases: depends-on resolves and is acyclic, at both levels
1355
+ const phasesDir = join(dir, 'phases')
1356
+ const files = existsSync(phasesDir) ? readdirSync(phasesDir).filter(f => f.endsWith('.md')).sort() : []
1357
+ if (!files.length) errs.push('no phases/*.md')
1358
+ const phaseEdges = new Map()
1359
+ for (const f of files) {
1360
+ const text = readFileSync(join(phasesDir, f), 'utf8')
1361
+ const { data, body } = parseFrontmatter(text)
1362
+ const no = phaseNo(f)
1363
+ const deps = listOf(data['depends-on']).map(phaseNo).filter(Boolean)
1364
+ phaseEdges.set(no, deps)
1365
+ for (const d of deps) if (!files.some(g => phaseNo(g) === d)) errs.push(`${f}: depends-on ${d} is not a phase`)
1366
+ if (linked && !data['tracker-issue']) errs.push(`${f}: no tracker-issue — the tracker is linked, so execute has nothing to transition`)
1367
+ if (!/^##\s+Intended design/m.test(body) && !/intended design/i.test(body))
1368
+ errs.push(`${f}: no ## Intended design, and no line saying why it needs none`)
1369
+
1370
+ // tasks
1371
+ const tasks = new Map()
1372
+ for (const line of body.split('\n')) {
1373
+ const m = line.match(TASK_LINE)
1374
+ if (!m) continue
1375
+ const dm = line.match(/depends-on:?\s*([^)\]]+)/i)
1376
+ tasks.set(m[1], dm ? listOf(dm[1]).filter(x => /\d/.test(x)) : [])
1377
+ }
1378
+ for (const [t, deps] of tasks) for (const d of deps)
1379
+ if (!tasks.has(d)) errs.push(`${f}: task ${t} depends-on ${d}, not a task in this phase`)
1380
+ const tc = cycleIn(tasks)
1381
+ if (tc) errs.push(`${f}: task dependency cycle ${tc.join(' → ')}`)
1382
+ // the diagram drawn from those fields
1383
+ const edges = [...tasks].flatMap(([t, deps]) => deps.map(d => `${d}>${t}`))
1384
+ const blocks = [...body.matchAll(/```mermaid\n([\s\S]*?)```/g)].map(m => m[1])
1385
+ const drawn = new Set(blocks.flatMap(b => [...b.matchAll(/\b([A-Za-z]*\d+[a-z]?)\b[^\n]*?-->(?:\|[^|]*\|)?\s*([A-Za-z]*\d+[a-z]?)\b/g)])
1386
+ .map(m => `${m[1]}>${m[2]}`).filter(e => e.split('>').every(x => tasks.has(x))))
1387
+ if (tasks.size > 1 && !blocks.length) warns.push(`${f}: ${tasks.size} tasks and no mermaid task diagram`)
1388
+ else if (blocks.length && edges.length) {
1389
+ const missing = edges.filter(e => !drawn.has(e)), extra = [...drawn].filter(e => !edges.includes(e))
1390
+ if (missing.length || extra.length)
1391
+ errs.push(`${f}: task diagram ≠ depends-on — ${missing.length ? `not drawn: ${missing.join(', ').replace(/>/g, '→')}` : ''}${missing.length && extra.length ? '; ' : ''}${extra.length ? `drawn but not in the fields: ${extra.join(', ').replace(/>/g, '→')}` : ''}`)
1392
+ }
1393
+ }
1394
+ const pc = cycleIn(phaseEdges)
1395
+ if (pc) errs.push(`phase dependency cycle ${pc.join(' → ')}`)
1396
+
1397
+ // ## Verify: every page's data states bound to a phase, or said to be none
1398
+ const vi = acceptance ? verifyItems(acceptance) : { none: true, items: [] }
1399
+ if (!vi) errs.push('ACCEPTANCE.md has no ## Verify, and no `verify: none` — say which pages wuwei verify answers for (step 6)')
1400
+ else {
1401
+ const seen = new Set()
1402
+ for (const it of vi.items) {
1403
+ if (!it.name) errs.push(`## Verify: "${it.line}" names no page — "- <page>: <route> · phase: NN"`)
1404
+ else if (seen.has(it.name)) errs.push(`## Verify: page ${it.name} listed twice`)
1405
+ else seen.add(it.name)
1406
+ if (!it.phase) errs.push(`## Verify: "${it.line}" is bound to no phase`)
1407
+ else if (!files.some(f => Number(phaseNo(f)) === Number(it.phase))) errs.push(`## Verify: ${it.name ?? it.line} is bound to phase ${it.phase}, which is not a phase`)
1408
+ }
1409
+ if (!vi.none && !vi.items.length) errs.push('## Verify is empty — list the pages, or write `verify: none`')
1410
+ }
1411
+ // no harness is not a reason for UAT (step 6): some phase has to add one
1412
+ const pkgDeps = (() => { try { const j = JSON.parse(readFileSync(join(dest, 'package.json'), 'utf8')); return Object.keys({ ...j.dependencies, ...j.devDependencies }) } catch { return [] } })()
1413
+ const harness = readdirSync(dest).some(f => /^(playwright|cypress)\.config\.|^wdio\.conf\./.test(f))
1414
+ || pkgDeps.some(d => /^(@playwright\/test|playwright|cypress|webdriverio|puppeteer|detox)$/.test(d))
1415
+ // mobile: Maestro flows, Flutter's patrol/integration_test, an XCUITest target, Espresso
1416
+ || existsSync(join(dest, '.maestro')) || /^\s*(patrol|integration_test):/m.test(read(join(dest, 'pubspec.yaml')) ?? '')
1417
+ || readdirSync(dest).some(f => /UITests$/.test(f)) || existsSync(join(dest, 'app', 'src', 'androidTest'))
1418
+ if (acceptance && /\be2e\b/i.test(acceptance) && !harness)
1419
+ warns.push('ACCEPTANCE.md binds e2e, and no harness was found (playwright/cypress/wdio config, one in package.json, .maestro/, patrol, *UITests, androidTest) — the first phase with a bound item adds it as its first task')
1420
+
1421
+ // STATE.md agrees with phases/
1422
+ const row = (readStateRows(join(dest, '.wuwei', 'STATE.md')) ?? []).find(r => r.stream === stream)
1423
+ if (!row) errs.push('STATE.md has no row for this stream')
1424
+ else {
1425
+ const total = row.done?.match(/\/\s*(\d+)/)?.[1]
1426
+ if (total && Number(total) !== files.length) errs.push(`STATE.md says ${row.done}, phases/ has ${files.length}`)
1427
+ }
1428
+
1429
+ console.log(`plan-check ${stream} · ${files.length} phase(s)`)
1430
+ for (const w of warns) console.log(`! ${w}`)
1431
+ for (const e of errs) console.log(`✗ ${e}`)
1432
+ console.log(errs.length ? `\n${errs.length} error(s)` : '\n✓ the checkable half of step 11 holds — the judgment half is still yours: phases match the strategy, designs slice the target, mermaid renders')
1433
+ process.exit(errs.length ? 1 : 0)
1434
+ }
1435
+
1436
+ // ── worktree: one throwaway tree per lens, named so no two can share it ─
1437
+ // a mutation another lens reverts under you reads as "green after mutation";
1438
+ // two lenses picking the same /tmp path is how that happened (#268 R2)
1439
+ function cmdWorktree(argv) {
1440
+ const positional = argv.filter(a => !a.startsWith('--'))
1441
+ const git = (args, opts) => quiet('git', args, opts)
1442
+ if (argv.includes('--prune')) {
1443
+ // every wuwei worktree, or only the reviewed sha's
1444
+ const sha = positional[0] ? git(['rev-parse', positional[0]]).stdout.trim().slice(0, 12) : ''
1445
+ const trees = git(['worktree', 'list', '--porcelain']).stdout.split('\n')
1446
+ .filter(l => l.startsWith('worktree ')).map(l => l.slice(9))
1447
+ .filter(p => p.split(/[\\/]/).pop().startsWith(`wuwei-${sha}`))
1448
+ for (const t of trees) {
1449
+ const r = git(['worktree', 'remove', '--force', t])
1450
+ console.log(`${ok(r) ? 'removed' : 'could not remove'} ${t}`)
1451
+ }
1452
+ if (!trees.length) console.log('no wuwei worktrees')
1453
+ return
1454
+ }
1455
+ const [lens, rev = 'HEAD'] = positional
1456
+ if (!lens || !/^[a-z0-9:_-]+$/i.test(lens)) {
1457
+ console.error('usage: wuwei worktree <lens> [<sha>] · wuwei worktree --prune [<sha>]')
1458
+ process.exit(1)
1459
+ }
1460
+ const sha = git(['rev-parse', '--verify', `${rev}^{commit}`]).stdout?.trim()
1461
+ if (!sha) { console.error(`✗ ${rev} is not a commit here`); process.exit(1) }
1462
+ const path = join(tmpdir(), `wuwei-${sha.slice(0, 12)}-${lens.replace(/:/g, '-')}-${Math.random().toString(36).slice(2, 8)}`)
1463
+ const r = git(['worktree', 'add', '--detach', '--quiet', path, sha])
1464
+ if (!ok(r)) { console.error(`✗ git worktree add failed: ${firstLine(r.stderr)}`); process.exit(1) }
1465
+ // stdout is the path alone, so `cd "$(wuwei worktree defects <sha>)"` works
1466
+ console.log(path)
1467
+ }
1468
+
1469
+ // ── mutate: one mutation, run, counted, restored — and recorded ──────
1470
+ // a green suite after a mutation has three causes and only one is a finding:
1471
+ // the test is blind, the mutation never landed, or the run never reached it.
1472
+ // This rules out the second (the diff must be non-empty), shows the third
1473
+ // (what ran, from the runner's own summary), and keeps the run on record
1474
+ const RUNNER_SUMMARY = [
1475
+ [/Tests?\s+(?:(\d+)\s+failed\s*\|\s*)?(\d+)\s+passed/i, m => [Number(m[1] ?? 0), Number(m[2])]], // vitest
1476
+ [/Tests:\s+(?:(\d+)\s+failed,\s*)?(?:\d+\s+\w+,\s*)*(\d+)\s+passed/i, m => [Number(m[1] ?? 0), Number(m[2])]], // jest
1477
+ [/=+\s*(?:(\d+)\s+failed,?\s*)?(\d+)\s+passed/i, m => [Number(m[1] ?? 0), Number(m[2])]], // pytest
1478
+ [/(\d+)\s+pass\b[\s\S]*?(\d+)\s+fail\b/i, m => [Number(m[2]), Number(m[1])]], // bun test
1479
+ [/ℹ pass (\d+)[\s\S]*?ℹ fail (\d+)/, m => [Number(m[2]), Number(m[1])]], // node --test
1480
+ [/^# pass (\d+)[\s\S]*?^# fail (\d+)/m, m => [Number(m[2]), Number(m[1])]], // TAP (node --test piped)
1481
+ ]
1482
+ const FAILING = [/^\s*(?:FAIL|×|✗|✖)\s+(.+?)(?:\s+\([\d.]+m?s\))?\s*$/gm, /^\s*●\s+(.+?)\s*$/gm, /^FAILED\s+(\S+)/gm, /^not ok \d+ - (.+)$/gm, /^\(fail\)\s+(.+?)(?:\s+\[[\d.]+m?s\])?\s*$/gm]
1483
+
1484
+ function runTests(cmd, cwd) {
1485
+ const r = spawnSync(cmd, { cwd, shell: true, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, timeout: 30 * 60 * 1000 })
1486
+ const out = `${r.stdout ?? ''}\n${r.stderr ?? ''}`.replace(/\x1b\[[0-9;]*m/g, '')
1487
+ let counts = null
1488
+ for (const [re, f] of RUNNER_SUMMARY) { const m = out.match(re); if (m) { counts = f(m); break } }
1489
+ const failing = [...new Set(FAILING.flatMap(re => [...out.matchAll(re)].map(m => m[1].trim())))].slice(0, 20)
1490
+ return { exit: r.status, failed: counts?.[0] ?? null, passed: counts?.[1] ?? null, failing, tail: out.trim().split('\n').slice(-5).join('\n') }
1491
+ }
1492
+
1493
+ function cmdMutate(argv) {
1494
+ const flag = name => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1] }
1495
+ const where = argv.find((a, i) => !a.startsWith('--') && !argv[i - 1]?.startsWith('--'))
1496
+ const [, file, line] = where?.match(/^(.+):(\d+)$/) ?? []
1497
+ const from = flag('--from'), to = flag('--to') ?? '', run = flag('--run')
1498
+ const wt = resolve(flag('--in') ?? process.cwd())
1499
+ if (!file || from == null || !run) {
1500
+ console.error(`usage: wuwei mutate <file>:<line> --from '<text>' [--to '<text>'] --run '<test command>' [--in <worktree>] [--no-baseline]
1501
+ runs only inside a tree made by \`wuwei worktree <lens> <sha>\`, never the dispatched one`)
1502
+ process.exit(1)
1503
+ }
1504
+ // the dispatched tree is never mutated: only a wuwei worktree qualifies
1505
+ const top = quiet('git', ['-C', wt, 'rev-parse', '--show-toplevel']).stdout?.trim()
1506
+ if (!top || !/^wuwei-[0-9a-f]{12}-/.test(top.split(/[\\/]/).pop()))
1507
+ { console.error(`✗ ${wt} is not a wuwei worktree — make one: wuwei worktree <lens> <sha>`); process.exit(1) }
1508
+ if (quiet('git', ['-C', top, 'status', '--porcelain', '--untracked-files=no']).stdout.trim())
1509
+ { console.error('✗ the worktree has uncommitted changes — a mutation on top of another reads as neither'); process.exit(1) }
1510
+ const path = join(top, file)
1511
+ if (!existsSync(path)) { console.error(`✗ ${file} is not in ${top}`); process.exit(1) }
1512
+ const original = readFileSync(path, 'utf8')
1513
+ const lines = original.split('\n')
1514
+ const n = Number(line) - 1
1515
+ const hits = lines[n]?.split(from).length - 1
1516
+ if (hits !== 1) { console.error(`✗ the mutation would not land: '${from}' occurs ${hits ?? 0} times on ${file}:${line} — it must occur once`); process.exit(1) }
1517
+
1518
+ const baseline = argv.includes('--no-baseline') ? null : runTests(run, top)
1519
+ lines[n] = lines[n].replace(from, to)
1520
+ writeFileSync(path, lines.join('\n'))
1521
+ const landed = quiet('git', ['-C', top, 'diff', '--numstat', '--', file]).stdout.trim()
1522
+ const result = landed ? runTests(run, top) : null
1523
+ writeFileSync(path, original)
1524
+ const restored = !quiet('git', ['-C', top, 'status', '--porcelain', '--untracked-files=no']).stdout.trim()
1525
+
1526
+ const fmt = r => r.failed == null ? `exit ${r.exit} (runner summary not recognised)` : `${r.failed} failed / ${r.passed} passed (exit ${r.exit})`
1527
+ const verdict = !landed ? 'did not land'
1528
+ : result.failed == null ? (result.exit === 0 ? 'green' : 'red') + ' by exit code'
1529
+ : result.failed > (baseline?.failed ?? 0) ? 'caught' : 'survived'
1530
+ const sha = quiet('git', ['-C', top, 'rev-parse', 'HEAD']).stdout.trim()
1531
+ const lens = top.split(/[\\/]/).pop().replace(/^wuwei-[0-9a-f]{12}-/, '').replace(/-[a-z0-9]{6}$/, '')
1532
+ const entry = [`- \`${file}:${line}\` \`${from}\` → \`${to}\` · **${verdict}** · ${landed ? 'landed' : 'not landed'}`,
1533
+ baseline ? `baseline ${fmt(baseline)}` : 'no baseline',
1534
+ result ? `mutated ${fmt(result)}` : '',
1535
+ result?.failing.length ? `failing: ${result.failing.join('; ')}` : '',
1536
+ restored ? 'restored' : '**NOT restored**', `\`${run}\``].filter(Boolean).join(' · ')
1537
+ console.log(entry)
1538
+ if (result && result.failed == null) console.log(result.tail)
1539
+ // the record: a mutation the digest cites is one this file shows was run
1540
+ const main = dirname(resolve(top, quiet('git', ['-C', top, 'rev-parse', '--git-common-dir']).stdout.trim()))
1541
+ const out = join(main, '.wuwei-cache', 'review', sha.slice(0, 12), 'mutations')
1542
+ mkdirSync(out, { recursive: true })
1543
+ writeFileSync(join(out, `${lens}.md`), (existsSync(join(out, `${lens}.md`)) ? readFileSync(join(out, `${lens}.md`), 'utf8') : `# mutations · ${lens} @ ${sha.slice(0, 12)}\n\n`) + entry + '\n')
1544
+ if (!restored) process.exit(2)
1545
+ }
1546
+
1547
+ // ── sites: a set derived from the tree, with its derivation ──────────
1548
+ // "four call sites", "every consumer", "the only reader": a count is the
1549
+ // output of a command, so print the command beside the count — and for a
1550
+ // symbol, ask the graph and a string grep both: each misses what the other finds
1551
+ function cmdSites(argv) {
1552
+ const flag = name => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1] }
1553
+ const dd = argv.indexOf('--')
1554
+ const pathspec = dd === -1 ? [] : argv.slice(dd + 1)
1555
+ const args = dd === -1 ? argv : argv.slice(0, dd)
1556
+ const pattern = args.find((a, i) => !a.startsWith('--') && !['--sha', '--symbol'].includes(args[i - 1]))
1557
+ const sha = flag('--sha') ?? 'HEAD', symbol = flag('--symbol'), regex = args.includes('--regex')
1558
+ if (!pattern && !symbol) { console.error("usage: wuwei sites <pattern> [--regex] [--sha <rev>] [--symbol <name>] [-- <pathspec>…]"); process.exit(1) }
1559
+ const term = pattern ?? symbol
1560
+ const grepArgs = ['grep', '-n', regex ? '-E' : '-F', '-e', term, sha, '--', ...pathspec]
1561
+ const g = quiet('git', grepArgs)
1562
+ if (g.status > 1) { console.error(`✗ git grep failed: ${firstLine(g.stderr)}`); process.exit(1) }
1563
+ const grepHits = g.stdout.split('\n').filter(Boolean).map(l => {
1564
+ const [, file, line, text] = l.replace(`${sha}:`, '').match(/^([^:]+):(\d+):(.*)$/) ?? []
1565
+ return { file, line: Number(line), text: (text ?? '').trim() }
1566
+ }).filter(h => h.file)
1567
+ const files = [...new Set(grepHits.map(h => h.file))]
1568
+ console.log(`${grepHits.length} site(s) in ${files.length} file(s) @ ${quiet('git', ['rev-parse', '--short', sha]).stdout.trim()}`)
1569
+ console.log(`derived: git ${grepArgs.map(a => /[\s|*()[\]+?$^{}\\]/.test(a) ? `'${a}'` : a).join(' ')}`)
1570
+ for (const f of files) {
1571
+ const hs = grepHits.filter(h => h.file === f)
1572
+ console.log(` ${f}:${hs.map(h => h.line).join(',')}`)
1573
+ }
1574
+
1575
+ if (symbol) {
1576
+ // the graph answers a different question: who references the symbol, not who spells it
1577
+ const st = quiet('codegraph', ['status', '--json'])
1578
+ let usable = false
1579
+ try { const j = JSON.parse(st.stdout); usable = j.initialized === true && !j.worktreeMismatch && !(j.pendingChanges?.length) } catch {}
1580
+ if (!usable) { console.log(`\ngraph: absent or stale — the grep above is the only answer, and it misses a name assembled at runtime`); return }
1581
+ const c = quiet('codegraph', ['callers', symbol, '--json'])
1582
+ // callers are entities (a function, or a file for top-level code) at their
1583
+ // start line, not call sites — so the two answers meet at the file
1584
+ const callers = []
1585
+ const walk = v => {
1586
+ if (Array.isArray(v)) return v.forEach(walk)
1587
+ if (v && typeof v === 'object') {
1588
+ const file = v.filePath ?? v.file ?? v.path
1589
+ if (typeof file === 'string') callers.push({ file, name: v.name ?? '?', kind: v.kind ?? '?', line: v.startLine ?? v.line })
1590
+ Object.values(v).forEach(walk)
1591
+ }
1592
+ }
1593
+ try { walk(JSON.parse(c.stdout)) } catch { console.log('\ngraph: callers returned no JSON'); return }
1594
+ const graphFiles = new Set(callers.map(c => c.file))
1595
+ console.log(`\ngraph: ${callers.length} caller(s) in ${graphFiles.size} file(s)`)
1596
+ for (const c of callers) console.log(` ${c.kind} ${c.name} ${c.file}:${c.line}`)
1597
+ const graphOnly = [...graphFiles].filter(f => !files.includes(f)), grepOnly = files.filter(f => !graphFiles.has(f))
1598
+ for (const f of graphOnly) console.log(` graph only ${f} (references ${term} without spelling it: alias, re-export, destructuring)`)
1599
+ for (const f of grepOnly) console.log(` grep only ${f} (spells ${term} without calling it — comment, string, declaration — or a call the graph missed)`)
1600
+ if (!graphOnly.length && !grepOnly.length) console.log(' graph and grep agree on the files')
1601
+ }
1602
+ }
1603
+
1604
+ // ── verify: hands and eyes — every data-bearing page through its states ──
1605
+ // The record: UI state bugs found one round at a time (#268 R1 → R2 → R3:
1606
+ // a refetch failure that replaces loaded rows, a 403 shown as a generic
1607
+ // error, a summary silently stale). One run walks every endpoint the page
1608
+ // calls through each state, so the matrix shows them at once. Playwright
1609
+ // comes from the project (or a global install); the app's own auth and
1610
+ // fixtures come from .wuwei/verify.json — the per-app half of this tool
1611
+ const VERIFY_STATES = ['500', '403', '404', 'empty', 'pending', 'refetch-500', 'refetch-403']
1612
+ // a loader as component libraries mark it (shadcn: data-slot="skeleton", animate-pulse)
1613
+ const BUSY = '[aria-busy="true"],[role="progressbar"],[data-slot*="skeleton"],[data-loading="true"],.skeleton,[data-skeleton],[class*="skeleton"],.animate-pulse,.animate-spin'
1614
+ const SAYS_ERROR = /error|failed|could not|couldn't|unable|try again|retry|錯誤|失敗|無法/i
1615
+ const TICKS = /\b\d+\s*(s|sec|secs|seconds?|min|mins|minutes?|h|hours?)\s+ago\b|\d+\s*(秒|分鐘|小時)前|just now|剛剛/i
1616
+ const SAYS_FORBIDDEN = /permission|forbidden|not allowed|denied|access|authori[sz]|權限|無權/i
1617
+
1618
+ function loadPlaywright(cwd) {
1619
+ for (const base of [join(cwd, 'package.json'), join(runNpm(['root', '-g'], { encoding: 'utf8' }).stdout?.trim() ?? '', 'noop.js')]) {
1620
+ try { return createRequire(base)('playwright') } catch {}
1621
+ }
1622
+ return null
1623
+ }
1624
+
1625
+ const globRe = g => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*') + '$')
1626
+
1627
+ function fixtureFor(fixtures, method, url) {
1628
+ const u = new URL(url)
1629
+ for (const [key, value] of Object.entries(fixtures ?? {})) {
1630
+ const [m, pattern] = key.includes(' ') ? key.split(/\s+/, 2) : ['GET', key]
1631
+ if (m.toUpperCase() !== method) continue
1632
+ const target = pattern.includes('?') ? u.pathname + u.search : u.pathname
1633
+ if (globRe(pattern).test(target)) {
1634
+ const v = value && typeof value === 'object' && ('status' in value || 'json' in value) ? value : { status: 200, json: value }
1635
+ return { key, status: v.status ?? 200, json: v.json ?? null }
1636
+ }
1637
+ }
1638
+ return null
1639
+ }
1640
+
1641
+ // the empty variant of a happy body: the list it carries, emptied
1642
+ // a total left behind ("Showing 1–3 of 3" over no rows) is the fixture lying, not the app
1643
+ const zeroTotals = o => Array.isArray(o) ? o : o && typeof o === 'object'
1644
+ ? Object.fromEntries(Object.entries(o).map(([k, v]) => [k, /^(total|totalCount|totalItems|count)$/.test(k) && typeof v === 'number' ? 0 : zeroTotals(v)]))
1645
+ : o
1646
+ function emptied(json) {
1647
+ if (Array.isArray(json)) return []
1648
+ if (json && typeof json === 'object') {
1649
+ for (const k of ['data', 'items', 'rows', 'list', 'results', 'records'])
1650
+ if (Array.isArray(json[k])) return zeroTotals({ ...json, [k]: [] })
1651
+ if (json.data && typeof json.data === 'object') { const inner = emptied(json.data); if (inner) return zeroTotals({ ...json, data: inner }) }
1652
+ }
1653
+ return null
1654
+ }
1655
+
1656
+ async function waitForUrl(url, ms) {
1657
+ const until = Date.now() + ms
1658
+ while (Date.now() < until) {
1659
+ try { const r = await fetch(url); if (r.status < 500) return true } catch {}
1660
+ await new Promise(r => setTimeout(r, 500))
1661
+ }
1662
+ return false
1663
+ }
1664
+
1665
+ async function cmdVerify(argv) {
1666
+ const dest = process.cwd()
1667
+ const cfgPath = join(dest, '.wuwei', 'verify.json')
1668
+ if (!existsSync(cfgPath)) {
1669
+ console.error(`✗ no .wuwei/verify.json — the per-app half of verify:
1670
+ {
1671
+ "url": "http://localhost:5173", // the running app (or "start" to launch it)
1672
+ "start": "npm run dev", // optional
1673
+ "api": ["**/api/**"], // requests to take over
1674
+ "auth": { "localStorage": { "token": "…" } },
1675
+ "fixtures": { "GET /api/me": { "role": "viewer" }, "GET /api/items": { "data": [ … ], "total": 2 } },
1676
+ "pages": [ { "name": "items", "path": "/items", "ready": "text=Items" } ],
1677
+ "refetch": "focus", "refetchAfterMs": 600000, "refetchWaitMs": 30000,
1678
+ "busy": "[data-slot=skeleton]", // optional: your loader, beyond the usual ones
1679
+ "errorWaitMs": 60000 // simulated time client retries may hold the loader
1680
+ }`)
1681
+ process.exit(1)
1682
+ }
1683
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'))
1684
+ const flag = name => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1] }
1685
+ const only = argv.filter((a, i) => !a.startsWith('--') && !['--phase', '--stream'].includes(argv[i - 1]))
1686
+ // --phase NN: the pages ACCEPTANCE.md binds to that phase — the contract, not memory
1687
+ if (flag('--phase')) {
1688
+ const sdir = join(dest, '.wuwei', 'streams')
1689
+ const streams = existsSync(sdir) ? readdirSync(sdir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name) : []
1690
+ const stream = flag('--stream') ?? (streams.length === 1 ? streams[0] : null)
1691
+ if (!stream || !streams.includes(stream)) { console.error(`✗ --stream: ${streams.join(', ') || 'none under .wuwei/streams/'}`); process.exit(1) }
1692
+ const accPath = join(sdir, stream, 'ACCEPTANCE.md')
1693
+ const vi = existsSync(accPath) ? verifyItems(readFileSync(accPath, 'utf8')) : null
1694
+ if (!vi) { console.error(`✗ ${stream}/ACCEPTANCE.md has no ## Verify — wuwei plan-check names what is missing`); process.exit(1) }
1695
+ const want = vi.items.filter(it => it.phase && Number(it.phase) === Number(flag('--phase'))).map(it => it.name)
1696
+ if (!want.length) { console.log(`phase ${flag('--phase')}: ACCEPTANCE.md binds no page to it — nothing to verify`); return }
1697
+ only.push(...want)
1698
+ }
1699
+ const missing = only.filter(n => !(cfg.pages ?? []).some(pg => pg.name === n))
1700
+ 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) }
1701
+ const pw = loadPlaywright(dest)
1702
+ if (!pw) { console.error('✗ Playwright not found in this project or globally — npm i -D playwright'); process.exit(1) }
1703
+ let server = null
1704
+ if (cfg.start) {
1705
+ server = spawn(cfg.start, { cwd: dest, shell: true, stdio: 'ignore', detached: true })
1706
+ 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) }
1707
+ } else if (!(await waitForUrl(cfg.url, 5000))) { console.error(`✗ ${cfg.url} is not reachable — start the app, or set "start"`); process.exit(1) }
1708
+
1709
+ const sha = quiet('git', ['rev-parse', 'HEAD']).stdout?.trim().slice(0, 12) || 'nogit'
1710
+ const outDir = join(dest, '.wuwei-cache', 'verify', sha)
1711
+ const browser = await pw.chromium.launch()
1712
+ const apiRes = [].concat(cfg.api ?? ['**/api/**']).map(globRe)
1713
+ const isApi = url => apiRes.some(re => re.test(url) || re.test(new URL(url).pathname))
1714
+ const report = []
1715
+
1716
+ // one visit: routes every API call to its fixture, except the endpoint under test
1717
+ const visit = async (page, { override, refetchFail, viewport } = {}) => {
1718
+ const ctx = await browser.newContext({ viewport: viewport ?? cfg.viewport ?? { width: 1280, height: 800 } })
1719
+ if (cfg.auth?.cookies) await ctx.addCookies(cfg.auth.cookies)
1720
+ if (cfg.auth?.localStorage || cfg.auth?.sessionStorage)
1721
+ await ctx.addInitScript(a => {
1722
+ for (const [k, v] of Object.entries(a.localStorage ?? {})) localStorage.setItem(k, typeof v === 'string' ? v : JSON.stringify(v))
1723
+ for (const [k, v] of Object.entries(a.sessionStorage ?? {})) sessionStorage.setItem(k, typeof v === 'string' ? v : JSON.stringify(v))
1724
+ }, cfg.auth)
1725
+ const p = await ctx.newPage()
1726
+ const seen = new Map(), unmocked = new Set(), errors = []
1727
+ let failing = false, refetched = 0
1728
+ p.on('pageerror', e => errors.push(String(e.message ?? e).split('\n')[0]))
1729
+ await p.route('**/*', async route => {
1730
+ const req = route.request(), url = req.url()
1731
+ if (!isApi(url)) return route.continue()
1732
+ const fx = fixtureFor(cfg.fixtures, req.method(), url)
1733
+ if (!fx) { unmocked.add(`${req.method()} ${new URL(url).pathname}`); return route.fulfill({ status: 501, body: 'unmocked by wuwei verify' }) }
1734
+ seen.set(fx.key, fx)
1735
+ const hit = override && fx.key === override.key
1736
+ if ((hit && override.state === 'pending')) return // never answered: the loading state
1737
+ if (hit && /^\d+$/.test(override.state)) return route.fulfill({ status: Number(override.state), contentType: 'application/json', body: JSON.stringify({ message: `wuwei verify ${override.state}` }) })
1738
+ if (hit && override.state === 'empty') return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(emptied(fx.json)) })
1739
+ 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}` }) })
1740
+ return route.fulfill({ status: fx.status, contentType: 'application/json', body: JSON.stringify(fx.json) })
1741
+ })
1742
+ // the page's clock is ours: a fresh cache never refetches on focus (React Query's
1743
+ // staleTime), and a client retrying 5xx (1s, 2s, 4s) is still loading when a real
1744
+ // wait gives up — both are moved through in simulated time, not slept through
1745
+ await p.clock.install()
1746
+ const busyNow = () => p.evaluate(sel => Boolean(document.querySelector(sel)) || /\bloading\b|載入中/i.test(document.body.innerText),
1747
+ [BUSY, cfg.busy].filter(Boolean).join(',')).catch(() => false)
1748
+ const settle = async (ms, until) => {
1749
+ for (let t = 0; t < ms; t += 1000) {
1750
+ if (until && !(await until())) return
1751
+ await p.clock.runFor(1000)
1752
+ // real time for the fetch a timer just fired: networkidle, once reached, never waits again
1753
+ await p.waitForTimeout(100)
1754
+ }
1755
+ }
1756
+ await p.goto(new URL(page.path, cfg.url).href, { waitUntil: 'domcontentloaded' })
1757
+ if (override?.state === 'pending') await p.waitForTimeout(cfg.pendingWaitMs ?? 1500)
1758
+ else {
1759
+ if (page.ready) await p.locator(page.ready).first().waitFor({ timeout: 15000 }).catch(() => {})
1760
+ await p.waitForLoadState('networkidle').catch(() => {})
1761
+ if (override) await settle(cfg.errorWaitMs ?? 60000, busyNow)
1762
+ }
1763
+ if (refetchFail) {
1764
+ failing = true
1765
+ await p.clock.fastForward(cfg.refetchAfterMs ?? 600000)
1766
+ // the app's refetch trigger: a window refocus by default
1767
+ if ((cfg.refetch ?? 'focus') === 'focus') await p.evaluate(() => { document.dispatchEvent(new Event('visibilitychange', { bubbles: true })); window.dispatchEvent(new Event('focus')) })
1768
+ else await p.locator(cfg.refetch).first().click().catch(() => {})
1769
+ await settle(cfg.refetchWaitMs ?? 30000)
1770
+ }
1771
+ const text = (await p.locator('body').innerText().catch(() => '')).replace(/[ \t]+/g, ' ').trim()
1772
+ const busy = await p.evaluate(sel => Boolean(document.querySelector(sel)), [BUSY, cfg.busy].filter(Boolean).join(','))
1773
+ const retry = await p.getByRole('button', { name: /retry|try again|重試/i }).count()
1774
+ return { p, ctx, text, busy, retry, seen, unmocked, errors, refetched }
1775
+ }
1776
+ const lines = t => new Set(t.split('\n').map(l => l.trim()).filter(l => l.length > 2))
1777
+
1778
+ for (const page of (cfg.pages ?? []).filter(pg => !only.length || only.includes(pg.name))) {
1779
+ const dir = join(outDir, page.name)
1780
+ mkdirSync(dir, { recursive: true })
1781
+ const happy = await visit(page)
1782
+ await happy.p.screenshot({ path: join(dir, 'happy.png'), fullPage: true })
1783
+ writeFileSync(join(dir, 'happy.txt'), happy.text)
1784
+ for (const vp of cfg.viewports ?? [{ width: 390, height: 844 }]) {
1785
+ const narrow = await visit(page, { viewport: vp })
1786
+ await narrow.p.screenshot({ path: join(dir, `happy-${vp.width}.png`), fullPage: true })
1787
+ await narrow.ctx.close()
1788
+ }
1789
+ const endpoints = [...happy.seen.keys()]
1790
+ const happyLines = lines(happy.text)
1791
+ 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 }]
1792
+ await happy.ctx.close()
1793
+ const served = new Map(happy.seen)
1794
+ for (const key of endpoints) {
1795
+ for (const state of VERIFY_STATES) {
1796
+ if (state === 'empty' && !emptied(served.get(key)?.json)) continue
1797
+ const refetch = state.match(/^refetch-(\d+)$/)
1798
+ const v = refetch ? await visit(page, { refetchFail: { key, status: Number(refetch[1]) } }) : await visit(page, { override: { key, state } })
1799
+ const slug = `${key.replace(/[^\w]+/g, '_').replace(/^_|_$/g, '')}--${state}`
1800
+ await v.p.screenshot({ path: join(dir, `${slug}.png`), fullPage: true })
1801
+ writeFileSync(join(dir, `${slug}.txt`), v.text)
1802
+ await v.ctx.close()
1803
+ const vLines = lines(v.text)
1804
+ // judged on what the state added to the screen: a nav item saying "Access" is not an answer
1805
+ const said = [...vLines].filter(l => !happyLines.has(l)).join('\n')
1806
+ const loading = v.busy || /loading|載入/i.test(said)
1807
+ const flags = v.errors.map(e => `page error: ${e}`)
1808
+ if (/^5/.test(state) && loading) flags.push(`still loading ${(cfg.errorWaitMs ?? 60000) / 1000}s on — client retries outlast the wait; raise "errorWaitMs"`)
1809
+ else if (/^5/.test(state) && !SAYS_ERROR.test(said)) flags.push('no error shown')
1810
+ if (state === '403' && !SAYS_FORBIDDEN.test(said)) flags.push('403 not told as a permission problem')
1811
+ if (state === '404' && v.retry) flags.push('Retry offered on a 404 — it can never succeed')
1812
+ if (state === '403' && v.retry) flags.push('Retry offered on a 403 — it can never succeed')
1813
+ if (state === 'pending' && !v.busy && !/loading|載入/i.test(v.text)) flags.push('no loading indicator')
1814
+ // a refetch that never happened says nothing about how a failed one looks;
1815
+ // what it did say is the lines it added — a relative time ticking is not an error
1816
+ const gone = [...happyLines].filter(l => !vLines.has(l) && !TICKS.test(l))
1817
+ const told = SAYS_ERROR.test(said) || (state === 'refetch-403' && SAYS_FORBIDDEN.test(said))
1818
+ 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"`)
1819
+ else if (refetch && !told) flags.push('silent: the failed refetch shows nothing')
1820
+ 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)})`)
1821
+ if (state === 'refetch-403' && v.refetched && told && !SAYS_FORBIDDEN.test(said)) flags.push('403 on refetch not told as a permission problem')
1822
+ rows.push({ endpoint: key, state, flags, shot: `${slug}.png`, text: v.text, said })
1823
+ }
1824
+ }
1825
+ // a failure that reads exactly like no data: the user is told there is nothing, not that it broke
1826
+ for (const e of rows.filter(r => r.state === 'empty' && r.said)) for (const r of rows)
1827
+ 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)}")`)
1828
+ const md = [`# verify ${page.name} @ ${sha}`, '', `page: \`${page.path}\` · endpoints: ${endpoints.length} · states: ${VERIFY_STATES.join(', ')}`, '',
1829
+ '| endpoint | state | flags | on screen, beyond happy | shot |', '|---|---|---|---|---|',
1830
+ ...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} |`)]
1831
+ writeFileSync(join(dir, 'matrix.md'), md.join('\n') + '\n')
1832
+ report.push({ page: page.name, rows, dir })
1833
+ }
1834
+ await browser.close()
1835
+ if (server) try { process.kill(-server.pid) } catch {}
1836
+
1837
+ for (const { page, rows, dir } of report) {
1838
+ const flagged = rows.filter(r => r.flags.length)
1839
+ console.log(`${page}: ${rows.length} state(s), ${flagged.length} flagged — ${join(dir, 'matrix.md')}`)
1840
+ for (const r of flagged) console.log(` ${r.endpoint} · ${r.state}: ${r.flags.join('; ')}`)
1841
+ }
1842
+ console.log('\nflags are leads, not findings: open the shot, and confirm against the spec what the page should say')
1843
+ }
1844
+
1845
+ // ── digest-check: wuwei-review §7's arithmetic, before publishing ─────
1846
+ // what the collator re-derives by hand: the header count against the table,
1847
+ // reporter credits, every file:line against the reviewed tree, every
1848
+ // identifier an Action names, HIGH × PLAUSIBLE, redaction, and HEAD moving
1849
+ const LENS_WORDS = /\b(opus|design|defects|security|code|claims|structure|drift|contract|coverage|composition|collator|dispatcher)\b/gi
1850
+ const SECRET = [
1851
+ [/\b[\w.+-]+@[\w-]+\.iam\.gserviceaccount\.com\b/, 'service account'],
1852
+ [/\b(ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{20,}/, 'GitHub token'],
1853
+ [/\bAKIA[0-9A-Z]{16}\b/, 'AWS key'],
1854
+ [/\bxox[abpr]-[A-Za-z0-9-]{10,}/, 'Slack token'],
1855
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, 'private key'],
1856
+ [/\b[\w.+-]+@(?!users\.noreply\.github\.com|anthropic\.com|example\.(?:com|org))[\w-]+\.[\w.]+\b/, 'email'],
1857
+ ]
1858
+
1859
+ function digestRows(text) {
1860
+ // finding rows: a table row whose first cell is a number, outside the
1861
+ // sections that hold other tables
1862
+ const skip = /^#+\s*(key decisions|threat|plan deviations|verdict|.*推導|.*raw\s*→)/i
1863
+ const rows = []
1864
+ let header = null, skipping = false, inDetails = false
1865
+ for (const line of text.split('\n')) {
1866
+ if (/^#+\s/.test(line)) { skipping = skip.test(line); header = null; continue }
1867
+ if (/<details>/.test(line)) inDetails = true
1868
+ if (/<\/details>/.test(line)) { inDetails = false; continue }
1869
+ if (skipping || inDetails || !/^\s*\|/.test(line)) { if (!/^\s*\|/.test(line)) header = null; continue }
1870
+ const cells = line.trim().replace(/^\||\|$/g, '').split(/(?<!\\)\|/).map(c => c.trim())
1871
+ if (/^[\s:|-]+$/.test(line.replace(/\|/g, ''))) continue
1872
+ if (!header) { header = cells.map(c => c.replace(/`/g, '').toLowerCase()); continue }
1873
+ const id = cells[0].replace(/[*`]/g, '').match(/^([A-Z]{1,3}-?)?(\d+)$/)
1874
+ if (!id) continue
1875
+ // exact header first: 'ev' is inside 'sev'
1876
+ const col = name => { let i = header.indexOf(name); if (i === -1) i = header.findIndex(h => h.includes(name)); return i === -1 ? '' : cells[i] ?? '' }
1877
+ rows.push({ n: Number(id[2]), key: (id[1] ?? '') + id[2], prefix: id[1] ?? '', line, sev: col('sev'), ev: col('ev'), reporter: col('reporter'),
1878
+ where: col('file:line') || col('位置') || col('file'), action: col('action') || col('建議') || col('怎麼改'), cells })
1879
+ }
1880
+ return rows
1881
+ }
1882
+
1883
+ function cmdDigestCheck(argv) {
1884
+ const file = argv.find(a => !a.startsWith('--'))
1885
+ if (!file || !existsSync(file)) { console.error('usage: wuwei digest-check <digest.md>'); process.exit(1) }
1886
+ const text = readFileSync(file, 'utf8')
1887
+ const errs = [], warns = [], oks = []
1888
+ const rows = digestRows(text)
1889
+
1890
+ // 1. the count line — template form or the prose form real digests drift into
1891
+ // the count lives on the line that says raw N (and the next): numbers
1892
+ // elsewhere — mutation counts, row ids — are not it
1893
+ const lines = text.split('\n')
1894
+ const at = lines.findIndex(l => /\braw\s*\d+/i.test(l))
1895
+ const countText = at === -1 ? text : lines.slice(at, at + 2).join('\n')
1896
+ const num = re => { const m = countText.match(re); return m ? Number(m[1]) : null }
1897
+ const N = num(/\braw\s*(\d+)/i)
1898
+ const K = num(/(\d+)\s*(?:rows?|列)\b/i) ?? num(/(\d+)\s*列/)
1899
+ const X = num(/\((\d+)\s*merged\)/i) ?? num(/(\d+)\s*(?:條|筆|列)?\s*merged/i) ?? num(/(\d+)\s*(?:條|筆|列)?\s*(?:合併|併入)/) ?? num(/(?:合併|併入)\s*(\d+)/)
1900
+ const D = num(/(\d+)\s*(?:條|筆|列)?\s*drop(?:ped)?\b/i) ?? num(/dropped\s*(\d+)/i) ?? num(/(\d+)\s*(?:條|筆|列)?\s*(?:丟棄|捨棄|撤回)/) ?? num(/(?:丟棄|捨棄|撤回)\s*(\d+)/)
1901
+ ?? (/(?:沒有|無)[^。\n]{0,12}(?:丟棄|捨棄|撤回)|no(?:thing)?\s+dropped/i.test(countText) ? 0 : null)
1902
+ if ([N, K, X, D].some(v => v == null))
1903
+ errs.push(`count line: could not read ${[['raw', N], ['rows', K], ['merged', X], ['dropped', D]].filter(([, v]) => v == null).map(([k]) => k).join(', ')} — use the template's \`raw N · M groups (X merged) · K rows (C closed) · D dropped\``)
1904
+ else if (N !== K + X + D) errs.push(`count: raw ${N} ≠ ${K} rows + ${X} merged + ${D} dropped (= ${K + X + D})`)
1905
+ else oks.push(`count: raw ${N} = ${K} rows + ${X} merged + ${D} dropped`)
1906
+
1907
+ // 2. the table against the header
1908
+ if (K != null && rows.length !== K) errs.push(`rows: the header says ${K}, the tables hold ${rows.length} — a closed row stays in the table, not a paragraph`)
1909
+ else if (K != null) oks.push(`rows: ${rows.length}, matches the header`)
1910
+ // numbering per prefix: 1…n, L1…Ln
1911
+ for (const prefix of new Set(rows.map(r => r.prefix))) {
1912
+ const nums = rows.filter(r => r.prefix === prefix).map(r => r.n)
1913
+ const dup = nums.filter((n, i) => nums.indexOf(n) !== i)
1914
+ const start = Math.min(...nums)
1915
+ const gaps = [...Array(Math.max(...nums) - start + 1).keys()].map(i => i + start).filter(n => !nums.includes(n))
1916
+ if (dup.length) errs.push(`rows: duplicate ${[...new Set(dup)].map(n => `#${prefix}${n}`).join(', ')}`)
1917
+ if (gaps.length) errs.push(`rows: missing ${gaps.map(n => `#${prefix}${n}`).join(', ')}`)
1918
+ }
1919
+
1920
+ // 3. reporter credits sum to raw — one credit per lens named in a row
1921
+ // `claims ×3` — one lens raising several that collation merged into one row
1922
+ const creditsOf = cell => {
1923
+ const seen = new Map()
1924
+ const raised = cell.replace(new RegExp(`${LENS_WORDS.source}\\s*(?:[—–]|\\(\\s*[—–]|(\\s*[—–])[^·、,;|]*`, 'gi'), '')
1925
+ for (const m of raised.matchAll(new RegExp(`${LENS_WORDS.source}(?:\\s*[×x]\\s*(\\d+))?`, 'gi')))
1926
+ seen.set(m[1].toLowerCase(), Math.max(seen.get(m[1].toLowerCase()) ?? 1, Number(m[2] ?? 1)))
1927
+ return [...seen.values()].reduce((a, b) => a + b, 0)
1928
+ }
1929
+ const credits = rows.reduce((sum, r) => sum + creditsOf(r.reporter), 0)
1930
+ if (rows.some(r => r.reporter)) {
1931
+ if (N != null && credits !== N) warns.push(`reporter credits: ${credits} lens names across the rows, raw is ${N} — a lens that raised two of what one row merges is written \`lens ×2\`; a concurrence at collation is not a credit`)
1932
+ else if (N != null) oks.push(`reporter credits: ${credits} = raw`)
1933
+ } else warns.push('reporter credits: no Reporter column found — not checked')
1934
+
1935
+ // 4. HIGH is never PLAUSIBLE
1936
+ for (const r of rows)
1937
+ if (/HIGH/.test(r.sev) && /PLAUSIBLE/.test(r.ev || r.line) && !/challenged/.test(r.line))
1938
+ errs.push(`#${r.key}: HIGH and PLAUSIBLE — nothing PLAUSIBLE is rated HIGH (wuwei-review §2)`)
1939
+
1940
+ // 5. the reviewed tree: every file:line resolves, every Action identifier exists
1941
+ const sha = (text.match(/(?:Reviewed at|審查版本)[::]?\**\s*`?([0-9a-f]{7,40})`?/i) ?? text.match(/^#+ .*@\s*`([0-9a-f]{7,40})`/m))?.[1]
1942
+ const tree = sha && ok(quiet('git', ['cat-file', '-e', `${sha}^{commit}`]))
1943
+ ? quiet('git', ['ls-tree', '-r', '--name-only', sha]).stdout.split('\n').filter(Boolean) : null
1944
+ if (!sha) warns.push('Reviewed at: no sha found — file:line and identifiers not checked')
1945
+ else if (!tree) warns.push(`Reviewed at ${sha}: not in this repository — run inside the reviewed repo, fetched`)
1946
+ else {
1947
+ const lines = new Map()
1948
+ const lengthOf = path => lines.get(path) ?? lines.set(path, quiet('git', ['show', `${sha}:${path}`]).stdout.replace(/\n$/, '').split('\n').length).get(path)
1949
+ let refs = 0
1950
+ for (const r of rows) {
1951
+ for (const m of r.where.matchAll(/`?([\w./@-]+\.[A-Za-z0-9]+):(\d+)(?:[-–](\d+))?`?/g)) {
1952
+ refs++
1953
+ const [, path, a, b] = m
1954
+ const hits = tree.filter(t => t === path || t.endsWith('/' + path))
1955
+ if (!hits.length) errs.push(`#${r.n}: ${path} is not in the tree at ${sha.slice(0, 8)}`)
1956
+ else if (hits.length > 1) warns.push(`#${r.n}: ${path} matches ${hits.length} files — give the full path`)
1957
+ else if (Number(b ?? a) > lengthOf(hits[0])) errs.push(`#${r.n}: ${hits[0]}:${b ?? a} is past the end (${lengthOf(hits[0])} lines)`)
1958
+ }
1959
+ // a deferral names its artifact, and the artifact exists
1960
+ for (const m of r.action.matchAll(/`?([\w./-]*todos?\/[\w./-]+\.md)`?/gi))
1961
+ if (!ok(quiet('git', ['cat-file', '-e', `HEAD:${m[1]}`]))) errs.push(`#${r.key}: deferral artifact ${m[1]} is not in the tree at HEAD`)
1962
+ if (/延後|defer|later PR|follow-?up|另開票|開票/i.test(r.action) && !/todos?\/|#\d+|\b[A-Z][A-Z0-9]+-\d+\b|\.md/.test(r.action))
1963
+ warns.push(`#${r.key}: deferred with no artifact named — a ticket or a todo path (wuwei-review §7)`)
1964
+ for (const m of r.action.matchAll(/`([A-Za-z_$][\w$.]*[A-Za-z0-9_$])`/g)) {
1965
+ const id = m[1].split('.').pop()
1966
+ if (id.length < 3 || /^(true|false|null|undefined)$/.test(id)) continue
1967
+ if (!ok(quiet('git', ['grep', '-q', '-F', id, sha]))) warns.push(`#${r.n}: Action names \`${id}\`, not in the tree at ${sha.slice(0, 8)} — say it is a name the fix creates, or correct it`)
1968
+ }
1969
+ }
1970
+ oks.push(`file:line: ${refs} reference(s) checked against ${sha.slice(0, 8)}`)
1971
+ // re-resolve HEAD before publishing
1972
+ const head = quiet('git', ['rev-parse', 'HEAD']).stdout.trim()
1973
+ if (!head.startsWith(sha)) {
1974
+ const since = quiet('git', ['log', '--oneline', `${sha}..HEAD`]).stdout.trim()
1975
+ warns.push(`HEAD moved: ${head.slice(0, 8)} is not the reviewed ${sha.slice(0, 8)}${since ? ` — since: ${since.split('\n').join('; ')}` : ''} — re-check every finding, or say the digest is against ${sha.slice(0, 8)}`)
1976
+ }
1977
+ }
1978
+
1979
+ // 6. the digest leaves the tree
1980
+ text.split('\n').forEach((l, i) => {
1981
+ for (const [re, kind] of SECRET) if (re.test(l)) errs.push(`line ${i + 1}: looks like ${/^[aeiou]/i.test(kind) ? 'an' : 'a'} ${kind} — redact as <redacted:${kind.replace(/ /g, '-')}>`)
1982
+ })
1983
+
1984
+ // 7. the verdict's two axes
1985
+ if (!/\b(approach|architecture|contract):\s*\**`?\s*(holds|redo|met|diverged)/i.test(text) || !/\b(merge|document|main):\s*\**`?\s*(ready|not yet)/i.test(text))
1986
+ errs.push('verdict: two axes not found — e.g. `approach: holds` · `merge: ready` (wuwei-review §7)')
1987
+
1988
+ for (const o of oks) console.log(`✓ ${o}`)
1989
+ for (const w of warns) console.log(`! ${w}`)
1990
+ for (const e of errs) console.log(`✗ ${e}`)
1991
+ console.log(errs.length ? `\n${errs.length} error(s) — fix before publishing` : '\nno errors')
1992
+ process.exit(errs.length ? 1 : 0)
1993
+ }
1994
+
1995
+ // ── review-prep ──────────────────────────────────────────────────────
1996
+ // wuwei-review §1's deterministic half as a program: the dispatcher checks a
1997
+ // command can answer, and the scan bundle. Judgment stays with the dispatcher —
1998
+ // reclassifying by content, platform state, what the thread says.
1999
+ const LOCKFILE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|go\.sum|poetry\.lock|uv\.lock|Gemfile\.lock|composer\.lock)$/
2000
+ const STATE_FILE = /(^|\/)(STATE|ROADMAP)\.md$/
2001
+ const AUTHOR_PATH = /\/Users\/|\/home\/|\/root\/|~\/|\$HOME|\$\{HOME\}|[A-Za-z]:\\Users|\/opt\/homebrew\//
2002
+ // a first pass by path; §1 reclassifies by content (a .md of shell commands is code)
2003
+ const kindOf = path => LOCKFILE.test(path) ? 'lock'
2004
+ : /(^|\/)(migrations?|drizzle)\/.*\.sql$|(^|\/)migrations?\//.test(path) ? 'migration'
2005
+ : /\.(md|mdx|rst|txt|adoc)$/i.test(path) ? 'prose'
2006
+ : /\.(json|jsonc|ya?ml|toml|ini|cfg|conf)$|(^|\/)(Dockerfile|\.env[^/]*|\.[^/]+rc)$|\.config\.[cm]?[jt]s$/.test(path) ? 'config'
2007
+ : 'code'
2008
+
2009
+ // optional tools: absent is an answer, not an error worth printing
2010
+ const quiet = (bin, args, opts = {}) =>
2011
+ spawnSync(bin, args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, shell: process.platform === 'win32', ...opts })
2012
+ const ok = r => !r.error && r.status === 0
2013
+ const firstLine = t => (t ?? '').trim().split('\n')[0]
2014
+
2015
+ function ghRepo() {
2016
+ const url = quiet('git', ['remote', 'get-url', 'origin']).stdout?.trim() ?? ''
2017
+ return url.match(/github\.com[:/]([^/]+)\/(.+?)(\.git)?$/)?.slice(1, 3).join('/') ?? null
2018
+ }
2019
+ // every page: one JSON array per page under --paginate, concatenated
2020
+ function ghAll(endpoint) {
2021
+ const r = quiet('gh', ['api', '--paginate', `${endpoint}${endpoint.includes('?') ? '&' : '?'}per_page=100`])
2022
+ if (!ok(r)) return null
2023
+ return JSON.parse(`[${r.stdout.trim().replace(/\]\s*\[/g, '],[')}]`).flat()
2024
+ }
2025
+
2026
+ function cmdReviewPrep(argv) {
2027
+ const flag = name => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1] }
2028
+ const pr = flag('--pr')?.replace(/^#/, '').match(/(\d+)$/)?.[1] ?? null
2029
+ const pathTarget = flag('--path')
2030
+ const noScan = argv.includes('--no-scan')
2031
+ let base = argv.filter((a, i) => !a.startsWith('--') && !['--pr', '--path'].includes(argv[i - 1]))[0] ?? null
2032
+ if (quiet('git', ['rev-parse', '--git-dir']).status !== 0) { console.error('✗ not a git repository'); process.exit(1) }
2033
+ const head = quiet('git', ['rev-parse', 'HEAD']).stdout.trim()
2034
+ const facts = [] // [check, result] — what the panel receives as facts
2035
+ const left = [] // what only the dispatcher can do
2036
+
2037
+ // the target and its base
2038
+ const repo = pr ? ghRepo() : null
2039
+ if (pr) {
2040
+ const r = quiet('gh', ['api', `repos/${repo}/pulls/${pr}`])
2041
+ if (!repo || !ok(r)) { console.error(`✗ cannot read PR #${pr} — needs \`gh api\` and a GitHub origin; or pass the base: wuwei review-prep <base>`); process.exit(1) }
2042
+ const p = JSON.parse(r.stdout)
2043
+ base ??= p.base.ref
2044
+ facts.push(['target', `PR #${pr} ${p.head.ref} → ${p.base.ref}`])
2045
+ if (p.head.sha !== head) facts.push(['HEAD', `**${head.slice(0, 8)} is not the PR head ${p.head.sha.slice(0, 8)}** — check out the PR head and re-run`])
2046
+ }
2047
+ if (!pathTarget && !base) {
2048
+ base = quiet('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).stdout?.trim()
2049
+ || quiet('git', ['ls-remote', '--symref', 'origin', 'HEAD'], { timeout: 30000 }).stdout?.match(/^ref: refs\/heads\/(\S+)/m)?.[1]
2050
+ || null
2051
+ if (!base) { console.error('✗ no base: pass one (wuwei review-prep main), --pr <n>, or --path <p>'); process.exit(1) }
2052
+ }
2053
+ // never a local copy of the base (§1): fetch it, then diff against origin's
2054
+ if (base) {
2055
+ const branch = base.replace(/^origin\//, '')
2056
+ const isSha = /^[0-9a-f]{7,40}$/.test(base)
2057
+ if (!isSha) {
2058
+ const f = quiet('git', ['fetch', '--quiet', 'origin', branch], { timeout: 60000 })
2059
+ base = ok(f) ? `origin/${branch}` : base
2060
+ facts.push(['base', ok(f) ? `origin/${branch} @ ${quiet('git', ['rev-parse', '--short', base]).stdout.trim()} (fetched)` : `${base} — **fetch failed, a local copy may be stale**`])
2061
+ } else facts.push(['base', base])
2062
+ }
2063
+
2064
+ const dir = join(process.cwd(), '.wuwei-cache', 'review', head.slice(0, 12))
2065
+ rmSync(dir, { recursive: true, force: true })
2066
+ mkdirSync(join(dir, 'reports'), { recursive: true })
2067
+ mkdirSync(join(dir, 'tools'), { recursive: true })
2068
+ if (quiet('git', ['check-ignore', '-q', '.wuwei-cache']).status !== 0)
2069
+ facts.push(['bundle', '**`.wuwei-cache/` is not gitignored** — add it, or the bundle shows up as untracked files'])
2070
+
2071
+ // INDEX.md — generated, never hand-written
2072
+ let rows
2073
+ if (pathTarget) {
2074
+ rows = quiet('git', ['ls-files', '--', pathTarget]).stdout.split('\n').filter(Boolean)
2075
+ .map(path => ({ path, status: '·', add: '', del: '' }))
2076
+ writeFileSync(join(dir, 'diff.patch'), 'none — a path target has no base\n')
2077
+ facts.push(['target', `path ${pathTarget} — no base: merges-cleanly and tool-maintained checks skipped`])
2078
+ } else {
2079
+ const range = `${base}...HEAD`
2080
+ const nums = new Map(quiet('git', ['diff', '--numstat', '-M', range]).stdout.split('\n').filter(Boolean)
2081
+ .map(l => { const [a, d, ...p] = l.split('\t'); return [p.join('\t').replace(/.*\{.* => (.*)\}.*|.* => /, (m, x) => x ?? ''), [a, d]] }))
2082
+ rows = quiet('git', ['diff', '--name-status', '-M', range]).stdout.split('\n').filter(Boolean).map(l => {
2083
+ const [st, ...paths] = l.split('\t')
2084
+ const path = paths.at(-1)
2085
+ const [add, del] = nums.get(path) ?? ['', '']
2086
+ return { path, status: st[0], add, del, from: paths.length > 1 ? paths[0] : null }
2087
+ })
2088
+ writeFileSync(join(dir, 'diff.patch'), quiet('git', ['diff', range]).stdout)
2089
+
2090
+ // merges cleanly
2091
+ const mt = quiet('git', ['merge-tree', '--write-tree', '--name-only', '--no-messages', base, 'HEAD'])
2092
+ facts.push(['merges cleanly', mt.status === 0 ? 'yes' : mt.status === 1
2093
+ ? `**no** — conflicts: ${mt.stdout.trim().split('\n').slice(1).filter(Boolean).join(', ')}`
2094
+ : `unknown — git merge-tree --write-tree needs git ≥ 2.38 (${firstLine(mt.stderr)})`])
2095
+ if (pr) {
2096
+ const files = ghAll(`repos/${repo}/pulls/${pr}/files`)
2097
+ facts.push(['PR file count', files == null ? 'not fetched'
2098
+ : files.length === rows.length ? `${rows.length}, matches INDEX`
2099
+ : `**platform ${files.length}, INDEX ${rows.length} — the base is stale or the PR moved; resync before dispatch**`])
2100
+ }
2101
+ // tool-maintained files: deletions the change must explain
2102
+ const tm = rows.filter(r => LOCKFILE.test(r.path) || STATE_FILE.test(r.path)).filter(r => Number(r.del) > 0)
2103
+ facts.push(['tool-maintained deletions', tm.length ? tm.map(r => `${r.path} −${r.del}`).join(', ') + ' — check each is explained' : 'none'])
2104
+ // author paths on added lines
2105
+ const hits = []
2106
+ let file = null, line = 0
2107
+ for (const l of readFileSync(join(dir, 'diff.patch'), 'utf8').split('\n')) {
2108
+ if (l.startsWith('+++ ')) { file = l.slice(6); continue }
2109
+ const h = l.match(/^@@ -\d+(?:,\d+)? \+(\d+)/)
2110
+ if (h) { line = Number(h[1]); continue }
2111
+ if (l.startsWith('+')) { if (AUTHOR_PATH.test(l)) hits.push(`${file}:${line}`); line++ }
2112
+ else if (!l.startsWith('-')) line++
2113
+ }
2114
+ facts.push(['author paths (added lines)', hits.length ? hits.join(', ') : 'none'])
2115
+ }
2116
+ const index = ['# INDEX', '', `reviewed: ${head} · base: ${base ?? 'none (path target)'} · ${rows.length} file(s)`,
2117
+ 'kind is a first pass by path — reclassify by content (wuwei-review §1)', '',
2118
+ '| path | st | + | − | kind | panel |', '|---|---|---|---|---|---|',
2119
+ ...rows.map(r => { const k = kindOf(r.path)
2120
+ return `| ${r.from ? `${r.from} → ` : ''}${r.path} | ${r.status} | ${r.add} | ${r.del} | ${k} | ${k === 'prose' ? 'document' : 'code'} |` })]
2121
+ writeFileSync(join(dir, 'INDEX.md'), index.join('\n') + '\n')
2122
+
2123
+ // CI at the reviewed sha — the verdict names the last green e2e, and this is where it starts
2124
+ const ciRepo = repo ?? ghRepo()
2125
+ if (ciRepo && !pathTarget) {
2126
+ const runs = ghAll(`repos/${ciRepo}/commits/${head}/check-runs`)
2127
+ const list = (runs ?? []).flatMap(x => x.check_runs ?? [x]).filter(x => x?.name)
2128
+ if (runs == null) facts.push(['CI @ HEAD', 'not fetched'])
2129
+ else if (!list.length) facts.push(['CI @ HEAD', 'no check runs at this sha'])
2130
+ else {
2131
+ const by = c => list.filter(x => (x.conclusion ?? x.status) === c).map(x => x.name)
2132
+ const failed = [...by('failure'), ...by('timed_out'), ...by('cancelled')], pending = list.filter(x => x.status !== 'completed').map(x => x.name)
2133
+ const e2e = list.filter(x => /e2e/i.test(x.name)).map(x => `${x.name}: ${x.conclusion ?? x.status}`)
2134
+ facts.push(['CI @ HEAD', `${by('success').length} success${failed.length ? ` · **failed: ${failed.join(', ')}**` : ''}${pending.length ? ` · pending: ${pending.join(', ')}` : ''}${e2e.length ? ` · ${e2e.join(', ')}` : ' · no e2e job'}`])
2135
+ }
2136
+ }
2137
+
2138
+ // the thread since the last round
2139
+ if (pr) {
2140
+ const parts = [['comment', `repos/${repo}/issues/${pr}/comments`], ['review', `repos/${repo}/pulls/${pr}/reviews`], ['inline', `repos/${repo}/pulls/${pr}/comments`]]
2141
+ .map(([type, ep]) => [type, ghAll(ep)])
2142
+ const all = parts.flatMap(([type, list]) => (list ?? []).map(c => ({ type, user: c.user?.login, at: c.submitted_at ?? c.created_at, state: c.state, url: c.html_url, body: c.body })))
2143
+ .sort((a, b) => (a.at ?? '').localeCompare(b.at ?? ''))
2144
+ writeFileSync(join(dir, 'thread.json'), JSON.stringify(all, null, 2))
2145
+ facts.push(['thread', parts.some(([, l]) => l == null) ? 'partly not fetched'
2146
+ : `${all.length} entries (${parts.map(([t, l]) => `${l.length} ${t}`).join(', ')}), latest ${all.at(-1)?.at ?? 'none'} — thread.json`])
2147
+ left.push('read thread.json for another review line in flight, or a rewrite that voids this round')
2148
+ }
2149
+
2150
+ // tools — detect once, record versions; shared read-only output runs once
2151
+ const tools = []
2152
+ const cg = quiet('codegraph', ['status', '--json'])
2153
+ if (cg.error) tools.push('codegraph: absent')
2154
+ else {
2155
+ let st = {}; try { st = JSON.parse(cg.stdout) } catch {}
2156
+ tools.push(st.initialized !== true ? 'codegraph: absent — not initialized'
2157
+ : st.worktreeMismatch ? `codegraph: absent — the index is of another tree (${JSON.stringify(st.worktreeMismatch)})`
2158
+ : st.pendingChanges?.length ? `codegraph: absent — ${st.pendingChanges.length} pending change(s); sync before dispatch`
2159
+ : 'codegraph: usable')
2160
+ }
2161
+ const sg = quiet('semgrep', ['--version'])
2162
+ if (sg.error) tools.push('semgrep: absent')
2163
+ else if (noScan || !base) tools.push(`semgrep ${firstLine(sg.stdout)}: not run${noScan ? ' (--no-scan)' : ' (no base)'}`)
2164
+ else {
2165
+ const mb = quiet('git', ['merge-base', 'HEAD', base]).stdout.trim()
2166
+ const r = quiet('semgrep', ['scan', '--config', 'p/owasp-top-ten', '--metrics', 'off', '--json', '--baseline-commit', mb], { timeout: 600000 })
2167
+ writeFileSync(join(dir, 'tools', 'semgrep.json'), r.stdout || '')
2168
+ let n = null; try { n = JSON.parse(r.stdout).results.length } catch {}
2169
+ tools.push(`semgrep ${firstLine(sg.stdout)}: ${n == null ? `**failed** (exit ${r.status}) — not "scanned clean"` : `${n} result(s) — tools/semgrep.json`}`)
2170
+ }
2171
+ const locks = rows.filter(r => LOCKFILE.test(r.path) && r.status !== 'D')
2172
+ const osv = quiet('osv-scanner', ['--version'])
2173
+ if (osv.error) tools.push(`osv-scanner: absent${locks.length ? ` — ${locks.length} lockfile(s) changed` : ''}`)
2174
+ else if (!locks.length) tools.push('osv-scanner: n/a — no lockfile changed')
2175
+ else if (noScan) tools.push('osv-scanner: not run (--no-scan)')
2176
+ else for (const l of locks) {
2177
+ // judge by exit code: 0 clean, 1 findings, anything else failed
2178
+ const r = quiet('osv-scanner', ['scan', 'source', '--format', 'json', '--lockfile', l.path], { timeout: 600000 })
2179
+ writeFileSync(join(dir, 'tools', `osv-${l.path.replace(/[/\\]/g, '_')}.json`), r.stdout || '')
2180
+ tools.push(`osv-scanner ${l.path}: ${r.status === 0 ? 'clean' : r.status === 1 ? 'findings' : `**failed** (exit ${r.status})`}`)
2181
+ }
2182
+ const pkg = existsSync('package.json') ? JSON.parse(readFileSync('package.json', 'utf8')) : null
2183
+ const linters = [
2184
+ ...Object.keys(pkg?.scripts ?? {}).filter(k => /lint|check|type|test|format/i.test(k)).map(k => `npm script \`${k}\``),
2185
+ ...['biome.json', 'biome.jsonc', 'tsconfig.json', '.eslintrc', '.eslintrc.json', 'eslint.config.js', 'eslint.config.mjs', 'ruff.toml', '.golangci.yml']
2186
+ .filter(f => existsSync(f)),
2187
+ ]
2188
+ tools.push(`repo linters: ${linters.length ? linters.join(', ') : 'none found'}`)
2189
+ // UI states: one verify run shows what a round of review finds one at a time
2190
+ const ui = rows.filter(r => r.status !== 'D' && /\.(tsx|jsx|vue|svelte)$/.test(r.path) && !/\.(test|spec|stories)\./.test(r.path))
2191
+ if (ui.length && existsSync(join('.wuwei', 'verify.json')))
2192
+ left.push(`${ui.length} UI file(s) changed — \`wuwei verify\` once; its flags go to review:defects as leads`)
2193
+ else if (ui.length) tools.push(`verify: ${ui.length} UI file(s) changed, no .wuwei/verify.json — UI states unchecked`)
2194
+
2195
+ left.push('reclassify INDEX rows by content where the path misleads (§1)',
2196
+ 'platform state the change depends on — env vars, deployed revisions (of what it calls too, when merging deploys it), protection, schedulers',
2197
+ "the change's new tests, once, in a fresh worktree with no .env",
2198
+ 'name the lenses, the bundle path and the digest template in the dispatch')
2199
+
2200
+ const prep = ['# PREP', '', `reviewed: \`${head}\` · bundle: \`${dir}\``, '',
2201
+ '## Facts (hand to every lens)', '', '| check | result |', '|---|---|', ...facts.map(([k, v]) => `| ${k} | ${v} |`), '',
2202
+ '## Tools', '', ...tools.map(t => `- ${t}`), '',
2203
+ '## Left for the dispatcher', '', ...left.map(l => `- [ ] ${l}`), '']
2204
+ writeFileSync(join(dir, 'PREP.md'), prep.join('\n'))
2205
+ console.log(prep.join('\n'))
2206
+ }
2207
+
2208
+ // ── GSD / superpowers detection + one-shot verbatim import ───────────
2209
+ // GSD stores everything under .planning/ (root docs + .planning/phases/<name>/
2210
+ // with *-CONTEXT.md, *-RESEARCH.md, *-<n>-PLAN.md (+ matching *-<n>-SUMMARY.md),
2211
+ // *-VERIFICATION.md). We copy content verbatim into the shape of .wuwei/ —
2212
+ // no parsing, no reinterpretation — and flag every imported file as unreviewed.
2213
+ // Original .planning/ is never touched or deleted. Every write below is also
2214
+ // skip-if-exists: a file this import already produced once is never
2215
+ // overwritten, so re-running init can never clobber edits made since —
2216
+ // the whole point of "review before sign-off" would collapse otherwise.
2217
+ const IMPORTED_HEADER = src =>
2218
+ `## Imported from GSD — ${src}\n\n_Verbatim copy, not restructured — review before sign-off._\n\n`
2219
+ const GSD_ROOT_FILES = ['PROJECT.md', 'STATE.md', 'ROADMAP.md', 'UAT.md']
2220
+
2221
+ function detectGSD(dest) {
2222
+ const planningDir = join(dest, '.planning')
2223
+ if (!existsSync(planningDir)) return null
2224
+ const hasRootDocs = GSD_ROOT_FILES.some(f => existsSync(join(planningDir, f)))
2225
+ const phasesDir = join(planningDir, 'phases')
2226
+ const phases = existsSync(phasesDir)
2227
+ ? readdirSync(phasesDir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name).sort()
2228
+ : []
2229
+ if (!hasRootDocs && !phases.length) return null // .planning/ exists but is empty
2230
+ return { planningDir, phasesDir, phases }
2231
+ }
2232
+
2233
+ function importGSDRoot(dest, planningDir, dry) {
2234
+ const rootFiles = GSD_ROOT_FILES.filter(f => existsSync(join(planningDir, f)))
2235
+ if (!rootFiles.length) return null
2236
+ const file = join(dest, '.wuwei', 'imported', 'GSD-PROJECT.md')
2237
+ if (existsSync(file)) return { file, skipped: true }
2238
+ const body = rootFiles
2239
+ .map(f => IMPORTED_HEADER(f) + readFileSync(join(planningDir, f), 'utf8'))
2240
+ .join('\n\n---\n\n')
2241
+ if (!dry) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, body) }
2242
+ return { file, skipped: false }
2243
+ }
2244
+
2245
+ // GSD's own plan-index (the number right before -PLAN.md, e.g. "01" in
2246
+ // 01-01-PLAN.md) becomes the imported filename's number, so it stays
2247
+ // traceable to the source even after renumbering across a phase's files
2248
+ function planIndex(planFile, fallback) {
2249
+ const m = planFile.match(/-(\d+)-PLAN\.md$/i)
2250
+ return (m ? m[1] : String(fallback)).padStart(2, '0')
2251
+ }
2252
+
2253
+ function importGSDPhase(dest, phasesDir, phaseName, dry) {
2254
+ const dir = join(phasesDir, phaseName)
2255
+ const files = readdirSync(dir)
2256
+ const read = f => readFileSync(join(dir, f), 'utf8')
2257
+ const streamDir = join(dest, '.wuwei', 'streams', phaseName)
2258
+ const written = []
2259
+ const skipped = []
2260
+
2261
+ const context = files.find(f => /-CONTEXT\.md$/i.test(f))
2262
+ const research = files.find(f => /-RESEARCH\.md$/i.test(f))
2263
+ const specParts = [context, research].filter(Boolean).map(f => IMPORTED_HEADER(f) + read(f))
2264
+ if (specParts.length) {
2265
+ const specFile = join(streamDir, 'SPEC.md')
2266
+ if (existsSync(specFile)) skipped.push(specFile)
2267
+ else {
2268
+ if (!dry) { mkdirSync(streamDir, { recursive: true }); writeFileSync(specFile, specParts.join('\n\n---\n\n')) }
2269
+ written.push(specFile)
2270
+ }
2271
+ }
2272
+
2273
+ const plans = files.filter(f => /-PLAN\.md$/i.test(f)).sort()
2274
+ const usedThisRun = new Set() // detects a same-run collision before it ever
2275
+ // reaches existsSync — two plans landing on the same index must never let
2276
+ // one silently mask the other under a misleading "already exists" skip
2277
+ plans.forEach((planFile, i) => {
2278
+ const base = planFile.replace(/-PLAN\.md$/i, '')
2279
+ const summaryRe = new RegExp(`^${escapeRe(base)}-SUMMARY\\.md$`, 'i')
2280
+ const summaryFile = files.find(f => summaryRe.test(f))
2281
+ let n = planIndex(planFile, i + 1)
2282
+ if (usedThisRun.has(n)) n = `${n}-${base}` // GSD's own numbering wasn't
2283
+ // unique here — disambiguate rather than pick a winner; both land, review
2284
+ // before sign-off is exactly where a human sorts out which is authoritative
2285
+ usedThisRun.add(n)
2286
+ const file = join(streamDir, 'phases', `${n}-imported.md`)
2287
+ if (existsSync(file)) { skipped.push(file); return }
2288
+ const parts = [IMPORTED_HEADER(planFile) + read(planFile)]
2289
+ if (summaryFile) parts.push(IMPORTED_HEADER(summaryFile) + read(summaryFile))
2290
+ if (!dry) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, parts.join('\n\n---\n\n')) }
2291
+ written.push(file)
2292
+ })
2293
+
2294
+ const verification = files.find(f => /-VERIFICATION\.md$/i.test(f))
2295
+ if (verification) {
2296
+ const accFile = join(streamDir, 'ACCEPTANCE.md')
2297
+ if (existsSync(accFile)) skipped.push(accFile)
2298
+ else {
2299
+ const body = IMPORTED_HEADER(verification) +
2300
+ '_Unstructured — split into critical paths / e2e / UAT / Verify / Threats before sign-off._\n\n' +
2301
+ read(verification)
2302
+ if (!dry) { mkdirSync(streamDir, { recursive: true }); writeFileSync(accFile, body) }
2303
+ written.push(accFile)
2304
+ }
2305
+ }
2306
+
2307
+ return { written, skipped, phaseCount: plans.length }
2308
+ }
2309
+
2310
+ // only inserts a fresh row — never overwrites one that exists. STATE.md is
2311
+ // human-editable (per docs/architecture.md); a stream that gained new plan
2312
+ // files since its first import will show a stale done/total here, and that's
2313
+ // the right trade: a slightly-stale count beats silently discarding whatever
2314
+ // the user has since changed on that row (mode, tracker, progress notes).
2315
+ const isTableSeparatorLine = l => /^[ \t]{0,3}\|[\s:-]+\|[\s:-]+\|/.test(l)
2316
+
2317
+ function appendImportedStateRow(stateFile, streamName, phaseCount, dry) {
2318
+ if (dry || !existsSync(stateFile)) return
2319
+ const content = readFileSync(stateFile, 'utf8')
2320
+ // anchored to the row's first cell (tolerating up to 3 leading spaces — a
2321
+ // legal indented row is still a row), and explicitly excluding the header
2322
+ // and separator lines — a stream literally named "stream"/"mode"/"tracker"/
2323
+ // "blocked" would otherwise match one of those instead of a real data row
2324
+ // and never get its own row inserted
2325
+ const rowRe = new RegExp(`^[ \\t]{0,3}\\|\\s*${escapeRe(streamName)}\\s*\\|`)
2326
+ const alreadyRecorded = content.split('\n').some(l =>
2327
+ l.trim() !== STATE_HEADER_LINE && !isTableSeparatorLine(l) && rowRe.test(l))
2328
+ if (alreadyRecorded) return
2329
+ const row = `| ${streamName} | manual | none | 0/${phaseCount} | unreviewed GSD import |\n`
2330
+ writeFileSync(stateFile, content.replace(/\n*$/, '\n') + row)
2331
+ }
2332
+
2333
+ // superpowers ships as .claude/skills/<name>/SKILL.md — behavior, not project
2334
+ // state, so there's nothing to migrate. We only flag the overlap so the user
2335
+ // can decide whether to keep both firing on the same triggers. Not an
2336
+ // exhaustive catalog of superpowers — just the skills wuwei itself credits
2337
+ // (docs/product.md), checked in both the target project and the user's own
2338
+ // ~/.claude/skills (where a personally-installed superpowers usually lives).
2339
+ const SUPERPOWERS_OVERLAP = [
2340
+ { name: 'brainstorming', wuwei: 'wuwei-brainstorm' },
2341
+ { name: 'subagent-driven-development', wuwei: 'wuwei-execute-phase (SDD mode)' },
2342
+ { name: 'test-driven-development', wuwei: 'wuwei-tdd' },
2343
+ { name: 'systematic-debugging', wuwei: 'wuwei-debug-root-cause' },
2344
+ ]
2345
+
2346
+ function detectSuperpowersOverlap(dest) {
2347
+ const userSkills = join(homedir(), '.claude', 'skills')
2348
+ const scopes = [{ dir: join(dest, '.claude', 'skills'), label: 'project' }, { dir: userSkills, label: 'user' }]
2349
+ const found = new Map() // dedupe by name if it exists in both scopes
2350
+ for (const { dir, label } of scopes) {
2351
+ if (!existsSync(dir)) continue
2352
+ for (const sp of SUPERPOWERS_OVERLAP)
2353
+ if (!found.has(sp.name) && existsSync(join(dir, sp.name, 'SKILL.md')))
2354
+ found.set(sp.name, { ...sp, scope: label })
2355
+ }
2356
+ return [...found.values()]
2357
+ }
2358
+
2359
+ // codegraph (github.com/colbymchenry/codegraph, npm @colbymchenry/codegraph)
2360
+ // is the local code-graph MCP server wuwei-map-codebase prefers as its
2361
+ // evidence source over static Glob/Grep scanning. wuwei does not hand-roll
2362
+ // per-tool MCP config for it — codegraph's own docs defer everything but a
2363
+ // manual Claude Code example to its own cross-client `codegraph install`,
2364
+ // and guessing at Cursor/Codex/Gemini/Antigravity's exact config shape here
2365
+ // would be the same mistake that produced .planning/ and CONCERNS.md: acting
2366
+ // on an unverified assumption instead of the tool's own authority. We only
2367
+ // detect and offer to run codegraph's own installer.
2368
+ function isCodegraphIndexed(dest) {
2369
+ return existsSync(join(dest, '.codegraph'))
2370
+ }
2371
+
2372
+ const CODEGRAPH_DOCS = 'https://colbymchenry.github.io/codegraph/reference/cli/'
2373
+
2374
+ // codegraph keeps a SQLite graph under .codegraph/ and manages a .gitignore
2375
+ // inside it; an older version's ignore did not cover the directory itself, so
2376
+ // repos indexed back then can have it committed. Once tracked, every branch
2377
+ // mutates one binary file and every merge conflicts with no way to resolve it
2378
+ // — which is exactly the state parallel phase branches produce. Cheap to
2379
+ // detect, so detect it rather than let a stream discover it at merge time.
2380
+ function codegraphTrackedFiles(dest) {
2381
+ const result = runGit(['ls-files', '--', '.codegraph'], { cwd: dest, encoding: 'utf8' })
2382
+ if (result.status !== 0) return [] // not a git repo, or no git — nothing to claim
2383
+ return (result.stdout ?? '').split('\n').map(l => l.trim()).filter(Boolean)
2384
+ }
2385
+
2386
+ // wuwei's uninstall removes exactly what wuwei stamped — that is the point of
2387
+ // the stamp, and exactly why it cannot itself delete codegraph's index or the
2388
+ // MCP config codegraph wrote into every client. But `init` offers to *run*
2389
+ // codegraph's installer, so leaving removal as a paragraph to read would make
2390
+ // getting in one keystroke and getting out a documentation exercise. Same
2391
+ // shape as the `npm uninstall -g` offer above: detect, offer, defer to the
2392
+ // tool's own command, and never do it unasked.
2393
+ async function offerCodegraphRemoval(scope, dest) {
2394
+ const repo = scope === 'repo'
2395
+ if (repo ? !isCodegraphIndexed(dest) : !isCodegraphGloballyInstalled()) return
2396
+
2397
+ const args = repo
2398
+ ? ['@colbymchenry/codegraph', 'uninit']
2399
+ : ['@colbymchenry/codegraph', 'uninstall']
2400
+ const manualHint = repo
2401
+ ? 'drop it yourself anytime:\n npx @colbymchenry/codegraph uninit\n' +
2402
+ ` ${CODEGRAPH_DOCS}`
2403
+ : 'remove it yourself anytime:\n' +
2404
+ ' npx @colbymchenry/codegraph uninstall # clients + CLI\n' +
2405
+ ' npx @colbymchenry/codegraph uninstall --keep-cli # client config only\n' +
2406
+ ` ${CODEGRAPH_DOCS}`
2407
+ const preamble = repo
2408
+ ? '\ncodegraph indexed this project (.codegraph/) — wuwei did not write it,' +
2409
+ ' so this\nis its own command, not ours.'
2410
+ : '\ncodegraph is still installed and still wired into your clients — wuwei' +
2411
+ ' never\nwrote that config, so this is its own command, not ours.'
2412
+ console.log(preamble)
2413
+
2414
+ if (!isInteractive()) {
2415
+ console.log(` ${manualHint}`)
2416
+ return
2417
+ }
2418
+ // default no: this deletes, and outside this project's scope in the user
2419
+ // case — other projects may still be using the graph we are about to remove
2420
+ const question = repo
2421
+ ? 'Run codegraph uninit to drop this project\'s index?'
2422
+ : 'Run codegraph\'s own uninstaller (removes it from every client AND' +
2423
+ ' uninstalls the CLI — affects your other projects too)?'
2424
+ if (!await promptYesNo(question)) {
2425
+ console.log(`kept — ${manualHint}`)
2426
+ return
2427
+ }
2428
+ const result = runNpx(args, repo ? { cwd: dest, stdio: 'inherit' } : { stdio: 'inherit' })
2429
+ console.log(result.status === 0
2430
+ ? (repo ? 'done — this project is no longer indexed.' : 'done — codegraph is removed.')
2431
+ : `\nthat failed${result.error ? '' : ` (exit ${result.status})`} — ${manualHint}`)
2432
+ }
2433
+
2434
+ // same ask-npm-directly discipline as isGloballyLinked — global bin locations
2435
+ // vary, so never infer one from a path guess
2436
+ function isCodegraphGloballyInstalled() {
2437
+ const result = runNpm(['root', '-g'], { encoding: 'utf8' })
2438
+ const globalRoot = result.stdout?.trim()
2439
+ return result.status === 0 && !!globalRoot
2440
+ && existsSync(join(globalRoot, '@colbymchenry', 'codegraph'))
2441
+ }
2442
+
2443
+ // A fresh scaffold and an inherited codebase need different first moves, and
2444
+ // the difference is decidable: what does the repo already track that wuwei did
2445
+ // not just write? Manifests are checked too, because a repo can hold real code
2446
+ // and no commits yet (first push pending), and because "has a manifest" is the
2447
+ // cheap answer where git is absent entirely.
2448
+ const MANIFESTS = [
2449
+ 'package.json', 'go.mod', 'pyproject.toml', 'requirements.txt', 'Cargo.toml',
2450
+ 'pom.xml', 'build.gradle', 'build.gradle.kts', 'Gemfile', 'composer.json',
2451
+ 'pubspec.yaml', 'mix.exs', '*.csproj',
2452
+ ]
2453
+ const WUWEI_OWNED = /^(\.wuwei\/|\.github\/PULL_REQUEST_TEMPLATE\.md$|\.claude\/|CLAUDE\.md$|AGENTS\.md$)/
2454
+
2455
+ function isExistingProject(dest) {
2456
+ if (MANIFESTS.some(m => !m.includes('*') && existsSync(join(dest, m)))) return true
2457
+ const r = runGit(['ls-files'], { cwd: dest, encoding: 'utf8' })
2458
+ if (r.status !== 0) return false // not a git repo and no manifest — treat as fresh
2459
+ return (r.stdout ?? '')
2460
+ .split('\n')
2461
+ .map(l => l.trim())
2462
+ .filter(Boolean)
2463
+ .some(f => !WUWEI_OWNED.test(f))
2464
+ }
2465
+
2466
+ async function offerCodegraphSetup(dest, dry, existing) {
2467
+ if (dry) return
2468
+ // nothing to index yet: offering a code graph for a repo with no code is a
2469
+ // question whose answer cannot matter until there is code
2470
+ if (!existing) return
2471
+ // an index already committed is worse than no index: flag it before the
2472
+ // "already indexed, nothing to do" early return swallows the whole check
2473
+ const tracked = codegraphTrackedFiles(dest)
2474
+ if (tracked.length) {
2475
+ console.log(`\n.codegraph/ is tracked by git (${tracked.length} file(s)). Parallel phase` +
2476
+ ' branches each\nrewrite that index, and the merge conflicts are binary — there is' +
2477
+ ' nothing to\nresolve. Untrack it:\n' +
2478
+ ' git rm -r --cached .codegraph && echo .codegraph/ >> .gitignore')
2479
+ }
2480
+ if (isCodegraphIndexed(dest)) return
2481
+ const manualHint = 'set it up yourself anytime:\n' +
2482
+ ' npx @colbymchenry/codegraph install && npx @colbymchenry/codegraph init'
2483
+ console.log('\nno codegraph index found for this project.' +
2484
+ ' wuwei queries a code graph in four places:\n' +
2485
+ ' wuwei-map-codebase architecture edges from real imports/calls\n' +
2486
+ ' wuwei-plan-phases research, so a phase\'s depends-on is not guessed\n' +
2487
+ ' review:design/defects blast radius, and call sites left behind\n' +
2488
+ ' audit:security reachability of a new sink\n' +
2489
+ '\n' +
2490
+ ' All four still run on Grep without it, and each says which path it took.\n' +
2491
+ ' The one hard consequence: reachability sets security severity, so with\n' +
2492
+ ' no graph a finding cannot be rated HIGH — only MED, reachability\n' +
2493
+ ' unproven. Worth knowing before skipping this.')
2494
+ if (!isInteractive()) {
2495
+ console.log(` ${manualHint}`)
2496
+ return
2497
+ }
2498
+ const ask = 'Set up codegraph now (its own installer wires every detected' +
2499
+ ' client, then indexes this project)?'
2500
+ // recommended yes: it is what every graph-reading step wants, and codegraph
2501
+ // ships its own uninstall/uninit, so this is reversible by the tool that did it
2502
+ let setUp = await promptYesNo(ask, true)
2503
+ if (!setUp) {
2504
+ // one re-ask, not a nag: name the concrete loss, then default to the answer
2505
+ // already given, so a bare Enter confirms the refusal instead of reversing it
2506
+ console.log('\n concretely, that turns this off: audit:security can no' +
2507
+ ' longer prove a\n sink is reachable, so no security finding in this' +
2508
+ ' project will be rated\n HIGH — every one caps at MED, "reachability' +
2509
+ ' unproven".')
2510
+ const stillSkip = await promptYesNo(' Still skip codegraph?', true)
2511
+ setUp = !stillSkip
2512
+ }
2513
+ if (!setUp) {
2514
+ console.log('skipped — this is a third-party tool wuwei defers to, never' +
2515
+ ' re-implements, so its installer stays its own call. ' + manualHint)
2516
+ return
2517
+ }
2518
+ const install = runNpx(['@colbymchenry/codegraph', 'install'], { stdio: 'inherit' })
2519
+ if (install.status !== 0) {
2520
+ console.log(`\ninstall step failed${install.error ? '' : ` (exit ${install.status})`} — ${manualHint}`)
2521
+ return
2522
+ }
2523
+ const init = runNpx(['@colbymchenry/codegraph', 'init'], { cwd: dest, stdio: 'inherit' })
2524
+ console.log(init.status === 0
2525
+ ? 'done — codegraph is indexed; every graph-reading step will use it next run.'
2526
+ : `\ninit step failed${init.error ? '' : ` (exit ${init.status})`} — ${manualHint}`)
2527
+ }
2528
+
2529
+ // `def` is what a bare Enter means. It stays false for anything that removes,
2530
+ // installs globally, or writes outside this repo — a mis-hit Enter must never
2531
+ // be the thing that does one of those. It is true only where the recommended
2532
+ // answer is yes and the action is reversible by the tool that performs it.
2533
+ // ── security scanners ────────────────────────────────────────────────
2534
+ // The security lens reads a diff and reasons about it. Two tools answer parts
2535
+ // of that deterministically — "does this line concatenate SQL" is a rule
2536
+ // match, "does this version have a known CVE" is a database lookup — and the
2537
+ // agent then judges reachability and exploitability, which is the half it is
2538
+ // actually better at. Absent, the audit still runs and says it was inference.
2539
+ // wuwei never bundles or reimplements either; it offers the vendor's own
2540
+ // documented install and gets out of the way.
2541
+ const SCANNERS = [
2542
+ {
2543
+ bin: 'semgrep',
2544
+ buys: 'OWASP Top 10 pattern scan of the diff, instead of the auditor eyeballing for injection and hard-coded secrets',
2545
+ note: 'LGPL-2.1, no account. The OSS engine is single-file; cross-file analysis is a paid tier, so reachability still comes from the code graph either way.',
2546
+ docs: 'https://semgrep.dev/docs/getting-started/quickstart',
2547
+ },
2548
+ {
2549
+ bin: 'osv-scanner',
2550
+ buys: 'known CVEs in new dependencies, looked up in the OSV database instead of guessed from the manifest',
2551
+ note: 'Apache-2.0, Google, single static binary. Works fully offline with --offline --download-offline-databases.',
2552
+ docs: 'https://google.github.io/osv-scanner/installation/',
2553
+ },
2554
+ ]
2555
+
2556
+ const onPath = bin => {
2557
+ const probe = process.platform === 'win32' ? 'where' : 'which'
2558
+ const r = spawnSync(probe, [bin], { stdio: 'ignore', shell: process.platform === 'win32' })
2559
+ return r.status === 0
2560
+ }
2561
+
2562
+ // Only ever return a command we have grounds to believe in: a package manager
2563
+ // that is actually present, or — for osv-scanner — the release asset naming
2564
+ // that was checked against every platform GitHub publishes. Anything else
2565
+ // returns null and the user gets the vendor's docs instead of a guess.
2566
+ function installPlan(bin) {
2567
+ if (bin === 'semgrep') {
2568
+ if (onPath('pipx')) return { label: 'pipx install semgrep', run: ['pipx', ['install', 'semgrep']] }
2569
+ if (onPath('brew')) return { label: 'brew install semgrep', run: ['brew', ['install', 'semgrep']] }
2570
+ if (onPath('python3')) return {
2571
+ label: 'python3 -m pip install --user semgrep',
2572
+ run: ['python3', ['-m', 'pip', 'install', '--user', 'semgrep']],
2573
+ }
2574
+ return null
2575
+ }
2576
+ if (onPath('brew')) return { label: 'brew install osv-scanner', run: ['brew', ['install', 'osv-scanner']] }
2577
+ const asset = OSV_ASSETS[`${process.platform}-${process.arch}`]
2578
+ if (!asset) return null
2579
+ const dest = join(homedir(), '.local', 'bin', process.platform === 'win32' ? 'osv-scanner.exe' : 'osv-scanner')
2580
+ return {
2581
+ label: `download ${asset} → ${dest}`,
2582
+ note: 'ensure ~/.local/bin is on your PATH',
2583
+ download: { url: `https://github.com/google/osv-scanner/releases/latest/download/${asset}`, dest },
2584
+ }
2585
+ }
2586
+
2587
+ const OSV_ASSETS = {
2588
+ 'linux-x64': 'osv-scanner_linux_amd64',
2589
+ 'linux-arm64': 'osv-scanner_linux_arm64',
2590
+ 'darwin-x64': 'osv-scanner_darwin_amd64',
2591
+ 'darwin-arm64': 'osv-scanner_darwin_arm64',
2592
+ 'win32-x64': 'osv-scanner_windows_amd64.exe',
2593
+ }
2594
+
2595
+ async function offerScannerSetup(dry) {
2596
+ if (dry) return
2597
+ const missing = SCANNERS.filter(s => !onPath(s.bin))
2598
+ if (!missing.length) return
2599
+
2600
+ console.log('\nthe security lens in `wuwei-execute-phase` reads the diff and reasons' +
2601
+ ' about it.\nthese answer part of that from evidence instead:')
2602
+ for (const s of missing) console.log(` ${s.bin.padEnd(13)} ${s.buys}`)
2603
+ console.log(` ${missing.length > 1 ? 'Neither is' : 'It is not'} required — without` +
2604
+ ' them the audit still runs and opens by saying\n it is inference, not a rule' +
2605
+ ' engine. wuwei installs nothing unasked.')
2606
+
2607
+ for (const s of missing) {
2608
+ const plan = installPlan(s.bin)
2609
+ console.log(`\n ${s.bin} — ${s.note}`)
2610
+ if (!plan) {
2611
+ console.log(` no install channel detected on this machine — see ${s.docs}`)
2612
+ continue
2613
+ }
2614
+ if (!isInteractive()) { console.log(` ${plan.label}\n ${s.docs}`); continue }
2615
+ // recommended yes, and every path here is undone by `wuwei uninstall`
2616
+ if (!await promptYesNo(` Install ${s.bin}? (${plan.label})`, true)) {
2617
+ console.log(` skipped — ${plan.label} · ${s.docs}`)
2618
+ continue
2619
+ }
2620
+ const ok = plan.download ? downloadBinary(plan.download) : runCmd(plan.run[0], plan.run[1], { stdio: 'inherit' }).status === 0
2621
+ console.log(ok
2622
+ ? ` done — ${s.bin} installed.${plan.note ? ` ${plan.note}` : ''}`
2623
+ : ` that failed — ${plan.label} · ${s.docs}`)
2624
+ }
2625
+ }
2626
+
2627
+ function downloadBinary({ url, dest }) {
2628
+ mkdirSync(dirname(dest), { recursive: true })
2629
+ const r = runCmd('curl', ['-sSL', '--fail', '-o', dest, url])
2630
+ if (r.status !== 0) return false
2631
+ try { chmodSync(dest, 0o755) } catch { return }
2632
+ return true
2633
+ }
2634
+
2635
+ // Mirror of the offer: wuwei suggested these, so it says how they come back
2636
+ // out. It never removes them unasked, and never guesses a removal it cannot
2637
+ // justify from where the binary actually sits.
2638
+ async function offerScannerRemoval() {
2639
+ const present = SCANNERS.filter(s => onPath(s.bin))
2640
+ if (!present.length) return
2641
+ console.log('\nsecurity scanners wuwei suggested are still installed. They are' +
2642
+ ' machine-wide\nand other projects may use them, so nothing here is removed unasked.')
2643
+ for (const s of present) {
2644
+ const plan = removalPlan(s.bin)
2645
+ if (!plan) { console.log(` ${s.bin} — installed outside anything wuwei can identify; remove it with whatever installed it`); continue }
2646
+ if (!isInteractive()) { console.log(` ${s.bin} — ${plan.label}`); continue }
2647
+ // default no: this deletes, and something else on this machine may want it
2648
+ if (!await promptYesNo(` Remove ${s.bin}? (${plan.label})`, false)) {
2649
+ console.log(` kept — ${plan.label}`)
2650
+ continue
2651
+ }
2652
+ const ok = plan.file
2653
+ ? (() => { try { rmSync(plan.file); return true } catch { return } })()
2654
+ : runCmd(plan.run[0], plan.run[1], { stdio: 'inherit' }).status === 0
2655
+ console.log(ok ? ` done — ${s.bin} removed.` : ` that failed — ${plan.label}`)
2656
+ }
2657
+ }
2658
+
2659
+ function removalPlan(bin) {
2660
+ const probe = process.platform === 'win32' ? 'where' : 'which'
2661
+ const where = spawnSync(probe, [bin], { encoding: 'utf8', shell: process.platform === 'win32' })
2662
+ const path = (where.stdout ?? '').split('\n')[0].trim()
2663
+ if (bin === 'osv-scanner' && path.startsWith(join(homedir(), '.local', 'bin')))
2664
+ return { label: `delete ${path}`, file: path }
2665
+ if (path.includes('/Cellar/') || path.includes('/homebrew/'))
2666
+ return { label: `brew uninstall ${bin}`, run: ['brew', ['uninstall', bin]] }
2667
+ if (bin === 'semgrep' && onPath('pipx') && path.includes('pipx'))
2668
+ return { label: 'pipx uninstall semgrep', run: ['pipx', ['uninstall', 'semgrep']] }
2669
+ if (bin === 'semgrep' && onPath('python3'))
2670
+ return { label: 'python3 -m pip uninstall -y semgrep', run: ['python3', ['-m', 'pip', 'uninstall', '-y', 'semgrep']] }
2671
+ return null
2672
+ }
2673
+
2674
+ async function promptYesNo(question, def = false) {
2675
+ if (!isInteractive()) return false // non-interactive: never hang, default to no
2676
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
2677
+ try {
2678
+ const answer = (await rl.question(`${question} ${def ? '[Y/n]' : '[y/N]'} `)).trim()
2679
+ if (!answer) return def
2680
+ return /^y(es)?$/i.test(answer)
2681
+ } catch { // Ctrl-D (EOF) rejects the pending question with an AbortError — treat as the default
2682
+ return def
2683
+ } finally {
2684
+ rl.close()
2685
+ }
2686
+ }
2687
+
2688
+ async function promptText(question, def = '') {
2689
+ if (!isInteractive()) return def
2690
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
2691
+ try { return (await rl.question(`${question}${def ? ` [${def}]` : ''} `)).trim() || def } catch { return def } finally { rl.close() }
2692
+ }
2693
+
2694
+ // ── Codex: a skill cannot set its model — the session is started with one.
2695
+ // So execute's tier on Codex is a profile in $CODEX_HOME/config.toml that the
2696
+ // session is launched under (codex --profile wuwei-execute); init offers to
2697
+ // write it, claim prints how to start. Effort values: codex-rs ReasoningEffort
2698
+ const CODEX_PROFILE = 'wuwei-execute'
2699
+ const CODEX_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra', 'persistent']
2700
+ const CODEX_PROFILE_MARK = '# added by wuwei init — wuwei-execute-phase runs under this profile; `wuwei uninstall` removes it'
2701
+ const codexConfigPath = () => join(codexHome(), 'config.toml')
2702
+ // [profiles.wuwei-execute]'s model and effort, or null; plus the top-level model
2703
+ function codexProfile() {
2704
+ const text = existsSync(codexConfigPath()) ? readFileSync(codexConfigPath(), 'utf8') : ''
2705
+ const sec = text.split(/^(?=\[)/m).find(s => new RegExp(`^\\[profiles\\.(?:"${CODEX_PROFILE}"|${CODEX_PROFILE})\\]`).test(s))
2706
+ const val = (s, k) => s?.match(new RegExp(`^${k}\\s*=\\s*"([^"]*)"`, 'm'))?.[1] ?? null
2707
+ const top = text.split(/^\[/m)[0]
2708
+ return { profile: sec ? { model: val(sec, 'model'), effort: val(sec, 'model_reasoning_effort') } : null, topModel: val(top, 'model') }
2709
+ }
2710
+ const codexLaunch = (model, effort) => `codex -m ${model} -c model_reasoning_effort=${effort}`
2711
+
2712
+ async function offerCodexProfile(dry) {
2713
+ if (!existsSync(codexHome())) return
2714
+ const { profile, topModel } = codexProfile()
2715
+ if (profile) { console.log(`
2716
+ codex: profile ${CODEX_PROFILE} — ${profile.model ?? '(model unset)'} / ${profile.effort ?? '(effort unset)'} · start execute with \`codex --profile ${CODEX_PROFILE}\``); return }
2717
+ const snippet = m => [CODEX_PROFILE_MARK, `[profiles.${CODEX_PROFILE}]`, `model = "${m.model}"`, `model_reasoning_effort = "${m.effort}"`].join('\n')
2718
+ console.log(`
2719
+ codex: a skill can't choose Codex's model — the session is started with one. wuwei-execute-phase runs under a profile you launch it with.`)
2720
+ if (!isInteractive()) { console.log(` add to ${codexConfigPath()}:\n${snippet({ model: '<model>', effort: 'xhigh' }).replace(/^/gm, ' ')}`); return }
2721
+ if (!await promptYesNo(` Add [profiles.${CODEX_PROFILE}] to ${codexConfigPath()}?`, true)) { console.log(' skipped — re-run `wuwei init` anytime'); return }
2722
+ const model = await promptText(' model for execute:', topModel ?? '')
2723
+ let effort = await promptText(` reasoning effort (${CODEX_EFFORTS.join('|')}):`, 'xhigh')
2724
+ if (!model || !CODEX_EFFORTS.includes(effort)) { console.log(` ✗ ${!model ? 'no model given' : `effort must be one of ${CODEX_EFFORTS.join('|')}`} — nothing written`); return }
2725
+ if (dry) { console.log(` [dry] would append:\n${snippet({ model, effort }).replace(/^/gm, ' ')}`); return }
2726
+ const prev = existsSync(codexConfigPath()) ? readFileSync(codexConfigPath(), 'utf8') : ''
2727
+ writeFileSync(codexConfigPath(), (prev ? prev.replace(/\n*$/, '\n\n') : '') + snippet({ model, effort }) + '\n')
2728
+ console.log(` ✓ written · start execute with \`codex --profile ${CODEX_PROFILE}\`, and write \`codex: ${model} / ${effort}\` under the stream's ## Sign-off`)
2729
+ }
2730
+
2731
+ // only the section init wrote (its marker line above it), never one you made
2732
+ function removeCodexProfile() {
2733
+ if (!existsSync(codexConfigPath())) return false
2734
+ const text = readFileSync(codexConfigPath(), 'utf8')
2735
+ const re = new RegExp(`\\n*${escapeRe(CODEX_PROFILE_MARK)}\\n\\[profiles\\.${CODEX_PROFILE}\\][^\\[]*`)
2736
+ if (!re.test(text)) return false
2737
+ writeFileSync(codexConfigPath(), text.replace(re, '\n').replace(/^\n+/, '').replace(/\n*$/, '\n'))
2738
+ return true
2739
+ }
2740
+
2741
+ async function cmdInit(target, dry) {
2742
+ // bootstrap a project: repo-scope install into the target (default: cwd) —
2743
+ // the entrypoint `npx <pkg> init` runs from anywhere, package files as ROOT
2744
+ const dest = resolve(target ?? process.cwd())
2745
+ await cmdInstall(dest, dry)
2746
+
2747
+ const gsd = detectGSD(dest)
2748
+ if (gsd) {
2749
+ const signal = gsd.phases.length
2750
+ ? `${gsd.phases.length} phase(s) under .planning/phases/`
2751
+ : 'project docs under .planning/ (no phases yet)'
2752
+ console.log(`\ndetected GSD planning state: ${signal}`)
2753
+ const doImport = await promptYesNo(
2754
+ 'Import into .wuwei/ as streams? (verbatim copy; .planning/ and any already-imported files are left untouched)')
2755
+ if (doImport) {
2756
+ const pfx = dry ? '[dry] ' : ''
2757
+ const root = importGSDRoot(dest, gsd.planningDir, dry)
2758
+ if (root) console.log(` ${pfx}${root.file}${root.skipped ? ' (already exists, skipped)' : ''}`)
2759
+ let anySkipped = root?.skipped ?? false
2760
+ for (const phase of gsd.phases) {
2761
+ const { written, skipped, phaseCount } = importGSDPhase(dest, gsd.phasesDir, phase, dry)
2762
+ for (const w of written) console.log(` ${pfx}${w}`)
2763
+ for (const s of skipped) console.log(` ${s} (already exists, skipped)`)
2764
+ if (skipped.length) anySkipped = true
2765
+ appendImportedStateRow(join(dest, '.wuwei', 'STATE.md'), phase, phaseCount, dry)
2766
+ }
2767
+ if (gsd.phases.length) console.log(`${pfx}imported ${gsd.phases.length} phase(s) as stream(s) — mode: manual, flagged unreviewed`)
2768
+ else if (root) console.log(`${pfx}imported GSD project docs — no phases to import yet`)
2769
+ if (anySkipped) console.log(' some files already existed and were left untouched — delete one yourself and re-run to pick up newer GSD content (safe to leave alone if you already reviewed/edited it)')
2770
+ } else {
2771
+ console.log('skipped — re-run `wuwei init` anytime to import later')
2772
+ }
2773
+ }
2774
+
2775
+ const existing = isExistingProject(dest)
2776
+ const overlaps = detectSuperpowersOverlap(dest)
2777
+ if (overlaps.length) {
2778
+ console.log('\ndetected superpowers skills that overlap wuwei\'s own (not an exhaustive check) — nothing changed, just flagging:')
2779
+ for (const o of overlaps) console.log(` ${o.name} (superpowers, ${o.scope} scope) ↔ ${o.wuwei}`)
2780
+ console.log(' consider disabling one side so the same situation doesn\'t trigger twice.')
2781
+ }
2782
+
2783
+ await offerCodegraphSetup(dest, dry, existing)
2784
+ await offerScannerSetup(dry)
2785
+ await offerCodexProfile(dry)
2786
+
2787
+ const installHint = isSkillsInstalled()
2788
+ ? ''
2789
+ : '\n wuwei install # once per machine — disciplines into ~/.claude'
2790
+ console.log(`
2791
+ next steps — the cycle is grill → brainstorm → plan → execute:${installHint}${existing
2792
+ ? `
2793
+ 1. wuwei-map-codebase # orient first → .wuwei/codebase/MAP.md. Planning
2794
+ # slices phases from this; a guessed dependency
2795
+ # graph makes \`depends-on\` wrong, and the whole
2796
+ # execution order rests on that one field
2797
+ 2. wuwei-grill # then interrogate the requirement — no solutions yet
2798
+ 3. wuwei-brainstorm # approaches, converged with recorded reasons
2799
+ 4. wuwei-plan-phases # phases + acceptance contract → sign-off`
2800
+ : `
2801
+ 1. wuwei-grill # interrogate the requirement first — no solutions yet
2802
+ 2. wuwei-brainstorm # approaches, converged with recorded reasons
2803
+ 3. wuwei-plan-phases # phases + acceptance contract → sign-off`}`)
2804
+ }
2805
+
2806
+ const [, , cmd = 'build', ...rest] = process.argv
2807
+ const dry = rest.includes('--dry')
2808
+ const arg = rest.filter(a => !a.startsWith('--'))[0]
2809
+ noticeUpdate(cmd)
2810
+ switch (cmd) {
2811
+ case '-v': case '--version': case 'version': console.log(PKG.version); break
2812
+ case '__update-check': cmdUpdateCheck(); break
2813
+ case 'build': cmdBuild(dry); break
2814
+ case 'check': cmdCheck(); break
2815
+ case 'list': cmdList(); break
2816
+ case 'init': await cmdInit(arg, dry); break
2817
+ case 'install': await cmdInstall(arg, dry); break
2818
+ case 'update': await cmdUpdate(dry, rest.includes('--no-fetch')); break
2819
+ case 'status': cmdStatus(arg); break
2820
+ case 'review-prep': cmdReviewPrep(rest); break
2821
+ case 'verify': await cmdVerify(rest); break
2822
+ case 'claim': cmdClaim(rest); break
2823
+ case 'digest-check': cmdDigestCheck(rest); break
2824
+ case 'worktree': cmdWorktree(rest); break
2825
+ case 'mutate': cmdMutate(rest); break
2826
+ case 'sites': cmdSites(rest); break
2827
+ case 'plan-check': cmdPlanCheck(rest); break
2828
+ case 'uninstall': await cmdUninstall(arg); break
2829
+ default:
2830
+ console.error(`unknown command '${cmd}'\nusage: wuwei [build|check|list|init [path]|install [path]|update|status [path]|review-prep [base] [--pr n] [--path p]|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]`)
2831
+ process.exit(1)
2832
+ }