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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/agents/_template/agent.md +28 -0
- package/agents/wuwei-audit-composition/agent.md +85 -0
- package/agents/wuwei-audit-contract/agent.md +97 -0
- package/agents/wuwei-audit-coverage/agent.md +93 -0
- package/agents/wuwei-implementer/agent.md +34 -0
- package/agents/wuwei-pre-implementation-doubt/agent.md +158 -0
- package/agents/wuwei-review-claims/agent.md +187 -0
- package/agents/wuwei-review-code/agent.md +219 -0
- package/agents/wuwei-review-defects/agent.md +180 -0
- package/agents/wuwei-review-design/agent.md +177 -0
- package/agents/wuwei-review-drift/agent.md +184 -0
- package/agents/wuwei-review-structure/agent.md +86 -0
- package/agents/wuwei-security-auditor/agent.md +229 -0
- package/bin/wuwei.js +2832 -0
- package/package.json +44 -4
- package/skills/_template/skill.md +34 -0
- package/skills/wuwei-brainstorm/skill.md +69 -0
- package/skills/wuwei-commit-hygiene/skill.md +38 -0
- package/skills/wuwei-debug-root-cause/skill.md +48 -0
- package/skills/wuwei-execute-phase/skill.md +624 -0
- package/skills/wuwei-grill/skill.md +67 -0
- package/skills/wuwei-legible-output/skill.md +27 -0
- package/skills/wuwei-map-codebase/skill.md +171 -0
- package/skills/wuwei-plan-phases/skill.md +326 -0
- package/skills/wuwei-review/references/lens-rules.md +41 -0
- package/skills/wuwei-review/references/prep.md +35 -0
- package/skills/wuwei-review/references/scopes.md +56 -0
- package/skills/wuwei-review/references/stream.md +94 -0
- package/skills/wuwei-review/skill.md +495 -0
- package/skills/wuwei-tdd/skill.md +47 -0
- package/templates/code-review.md +137 -0
- package/templates/pr.md +35 -0
- 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
|
+
}
|