wei-wuwei 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ One section per published version, newest first. `wuwei update` prints the
4
4
  sections between the installed version and the new one before it asks;
5
5
  `wuwei check` fails when package.json's version has no section here.
6
6
 
7
+ ## 0.2.1
8
+
9
+ - `wuwei review-collect`: finds the latest Claude Code session that
10
+ dispatched a wuwei lens, copies each lens's transcript and report into
11
+ the review bundle's `reports/`, and prints what each one spent — turns,
12
+ context built, cached re-reads, and where the reading went. wuwei-review
13
+ §7 uses it instead of the collator re-typing each report.
14
+
15
+ ## 0.2.0
16
+
17
+ Execute runs at a fixed tier; the model policy now covers review only.
18
+
19
+ - `wuwei-execute-phase` runs at `sonnet` / `xhigh` under every policy: its
20
+ own frontmatter sets the tier for the turn it runs in, and step 2 always
21
+ dispatches the new `wuwei-implementer` agent (pinned `sonnet` / `xhigh`)
22
+ with no model or effort on the call. A stop names `/wuwei-execute-phase`,
23
+ since a reply in chat continues on the session's model.
24
+ - **Changed:** `model policy` under `## Sign-off` is `inherit` | `roles` for
25
+ the review tier (`roles` sends `review:design` and `review:defects` on
26
+ opus). `implementation=…` is refused by `plan-check` and `claim`.
27
+ - `wuwei claim` prints how steps 2 and 5 dispatch, and for Codex how to
28
+ start the session; `wuwei check` ties the skill's and the agent's tier to
29
+ `modelRoles` in `wuwei.config.json`.
30
+ - Codex: `## Sign-off` takes `codex: <model> / <effort>`; `wuwei init`
31
+ offers a `[profiles.wuwei-execute]` in `$CODEX_HOME/config.toml` (removed
32
+ by `wuwei uninstall`); `claim` prints `codex --profile wuwei-execute`.
33
+
7
34
  ## 0.1.0
8
35
 
9
36
  First release.
package/README.md CHANGED
@@ -59,13 +59,15 @@ cd wuwei
59
59
  npm run check # lint frontmatter
60
60
  npm run build # emit repo-local artifacts (.claude/, .agents/, AGENTS.md, CLAUDE.md)
61
61
  node bin/wuwei.js init <project> # repo scope: templates + .wuwei/ skeleton, then
62
- # offers the tools below and names the next step
62
+ # offers the tools below (and, with Codex, an execute
63
+ # profile in $CODEX_HOME/config.toml) and names the next step
63
64
  node bin/wuwei.js install # user scope: skills/agents → ~/.claude, rules → ~/.claude/CLAUDE.md;
64
65
  # Codex: skills → ~/.agents/skills, rules → $CODEX_HOME/AGENTS.md
65
66
  node bin/wuwei.js status <project> # read-only: streams/claims from .wuwei, drift vs STATE.md flagged
66
67
  node bin/wuwei.js update # git pull if behind, diff against what install wrote, apply on yes
67
68
  node bin/wuwei.js review-prep <base> # wuwei-review's prep: dispatcher checks + scan bundle (or --pr <n>)
68
69
  node bin/wuwei.js claim [stream] [NN] # list eligible phases, or claim one: cut, claim, push
70
+ node bin/wuwei.js review-collect # each lens's transcript + report into the bundle, and what each one spent
69
71
  node bin/wuwei.js digest-check <f> # a review digest's arithmetic before it is published
70
72
  node bin/wuwei.js plan-check [stream] # plan-phases step 11's checkable half, before sign-off
71
73
  node bin/wuwei.js worktree <lens> <sha> # a throwaway worktree no other lens shares (--prune)
@@ -91,8 +93,16 @@ npm in the background, and when a newer version is out it says so on stderr
91
93
  (at most hourly; never from a clone or in CI; `WUWEI_NO_UPDATE_CHECK=1` turns
92
94
  it off).
93
95
 
94
- Releasing: bump `package.json`, add its `## <version>` section to
95
- `CHANGELOG.md` (`wuwei check` fails without one), push a `v<version>` tag.
96
+ Releasing is merging. Every merge to main that changes what the package ships
97
+ (`bin`, `skills`, `agents`, `templates`, `wuwei.config.json`) is released by
98
+ `.github/workflows/publish.yml`: the version is bumped by the merged commits'
99
+ Conventional Commit types — `feat` minor, `type!:` or a `BREAKING CHANGE:`
100
+ footer major (minor while under 1.0), anything else patch — `CHANGELOG.md`
101
+ gains a section from the merged PR titles, and the release commit, the
102
+ `v<version>` tag, the npm publish and the GitHub Release follow. A docs- or
103
+ tests-only merge releases nothing; *Run workflow* (`workflow_dispatch`) with a
104
+ bump releases now. A version already set in `package.json` that npm has not
105
+ seen goes out as is.
96
106
 
97
107
  ## Daily use
98
108
 
@@ -188,7 +198,7 @@ wuwei/
188
198
  ├── agents/ # canonical subagent definitions
189
199
  │ └── _template/
190
200
  ├── templates/ # PR / review templates, installed per-repo
191
- ├── bin/wuwei.js # CLI: build · check · list · init · install · update · status · review-prep · claim · digest-check · plan-check · worktree · mutate · sites · verify · uninstall
201
+ ├── bin/wuwei.js # CLI: build · check · list · init · install · update · status · review-prep · review-collect · claim · digest-check · plan-check · worktree · mutate · sites · verify · uninstall
192
202
  │ # the per-tool emitters live here too, not in adapters/
193
203
  ├── adapters/ # notes only — see bin/wuwei.js for the actual emitters
194
204
  ├── docs/
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: wuwei-implementer
3
+ description: >-
4
+ The implementation subagent of wuwei-execute-phase step 2, under every model
5
+ policy: one task of a phase, dispatched fresh with SPEC, the phase context,
6
+ the ladder and step 4's floor. Exists so implementation runs at sonnet /
7
+ xhigh — a dispatch alone can set a model but not an effort.
8
+ model: sonnet
9
+ effort: xhigh
10
+ version: 0.1.0
11
+ targets: [claude-code]
12
+ ---
13
+
14
+ # wuwei-implementer
15
+
16
+ ## Mandate
17
+
18
+ Implement the one task you were dispatched with, inside the phase you were
19
+ given, and nothing beyond it. What arrives with the dispatch governs: SPEC,
20
+ the phase context, the ladder (climb it before writing), and step 4's floor.
21
+ `wuwei-tdd` governs the task; commit as `wuwei-commit-hygiene` says — one
22
+ concern per commit, the why in the body.
23
+
24
+ ## Out of scope
25
+
26
+ - Another task, or the phase's next step — hand back instead.
27
+ - A planning defect (a missing `depends-on`, an interface not there yet):
28
+ report it; do not work around it.
29
+ - Review of your own work — a fresh subagent does that.
30
+
31
+ ## Output contract
32
+
33
+ What landed (commit shas, one line each), the tests that ran and their
34
+ result, and anything you stopped short of — with why.
package/bin/wuwei.js CHANGED
@@ -22,6 +22,8 @@
22
22
  * wuwei claim [<stream>] [<phase>] [--take-over] [--by <id>] [--dry]
23
23
  * wuwei-execute-phase step 1: list what is eligible,
24
24
  * or claim one — cut, claim, push
25
+ * wuwei review-collect [--session <id>] each subagent's transcript and report into
26
+ * the bundle's reports/, and what each one spent
25
27
  * wuwei digest-check <file> wuwei-review §7's arithmetic before publishing: count,
26
28
  * rows, credits, file:line, Action names, redaction
27
29
  * wuwei worktree <lens> [<sha>] a throwaway worktree no other lens shares; --prune
@@ -187,10 +189,8 @@ function validate(items) {
187
189
  errs.push(`${it.rel}: description too long (${it.data.description.length} > 400)`)
188
190
  if (it.data.mode && !MODES.includes(it.data.mode))
189
191
  errs.push(`${it.rel}: mode must be one of ${MODES.join('|')}`)
190
- // a skill is not a subagent: these fields would be emitted into SKILL.md
191
- // and ignored, so say it at check time instead of shipping dead frontmatter
192
- if ((it.data.model || it.data.effort) && it.kind !== 'agents')
193
- errs.push(`${it.rel}: model/effort apply to agents only — a skill is not a subagent`)
192
+ // on a skill, Claude Code applies model/effort to the turn the skill runs
193
+ // in (and only that turn — the session's resume on the next prompt)
194
194
  if (it.data.model && !MODEL_ALIASES.includes(it.data.model)
195
195
  && !/^claude-[a-z0-9.-]+$/.test(it.data.model))
196
196
  errs.push(`${it.rel}: model must be ${MODEL_ALIASES.join('|')} or a full id (claude-…)`)
@@ -536,6 +536,21 @@ function lint(items) {
536
536
  for (const rel of Object.keys(budgets))
537
537
  if (!items.some(it => it.rel === rel || (it.refs ?? []).some(r => r.rel === rel)))
538
538
  errs.push(`wuwei.config.json: wordBudgets names ${rel}, which is not a definition or reference`)
539
+ // modelRoles names the tier; the agent file is what dispatch actually reads
540
+ const impl = CONFIG.modelRoles?.implementation
541
+ if (impl) {
542
+ const ag = items.find(it => it.kind === 'agents' && it.data.name === impl.agent)
543
+ if (!ag) errs.push(`wuwei.config.json: modelRoles.implementation.agent ${impl.agent} is not an agent`)
544
+ else for (const f of ['model', 'effort']) if (ag.data[f] !== impl[f])
545
+ errs.push(`${ag.rel}: ${f} ${ag.data[f] ?? '(unset)'} ≠ modelRoles.implementation.${f} ${impl[f]} — the roles tier is what this file pins`)
546
+ }
547
+ // the skill that runs implementation runs its own turn at the same tier
548
+ const sk = impl?.skill && items.find(it => it.kind === 'skills' && it.data.name === impl.skill)
549
+ if (impl?.skill && !sk) errs.push(`wuwei.config.json: modelRoles.implementation.skill ${impl.skill} is not a skill`)
550
+ else if (sk) for (const f of ['model', 'effort']) if (sk.data[f] !== impl[f])
551
+ errs.push(`${sk.rel}: ${f} ${sk.data[f] ?? '(unset)'} ≠ modelRoles.implementation.${f} ${impl[f]} — execute runs at the implementer's tier`)
552
+ for (const l of CONFIG.modelRoles?.review?.lenses ?? [])
553
+ 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`)
539
554
  // update prints this file's sections — a version without one updates in silence
540
555
  const changelog = existsSync(join(ROOT, 'CHANGELOG.md')) ? readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8') : ''
541
556
  if (!new RegExp(`^## v?${escapeRe(PKG.version)}\\b`, 'm').test(changelog))
@@ -692,6 +707,7 @@ async function cmdUninstall(target) {
692
707
  }
693
708
  if (stripManagedBlock(join(codexHome(), 'AGENTS.md')))
694
709
  removed.push(join(codexHome(), 'AGENTS.md') + ' (managed block)')
710
+ if (removeCodexProfile()) removed.push(`${codexConfigPath()} ([profiles.${CODEX_PROFILE}])`)
695
711
  if (existsSync(manifestPath())) { rmSync(manifestPath()); removed.push(manifestPath()) }
696
712
  console.log(`user scope ← ${base}`)
697
713
  for (const r of removed) console.log(` removed ${r}`)
@@ -1152,6 +1168,51 @@ function claimPhases(dest, stream) {
1152
1168
  return { streamBranch, integration, remote, behind, phases }
1153
1169
  }
1154
1170
 
1171
+ // SPEC ## Sign-off's model policy: `inherit` (default, also when the line is
1172
+ // absent) or `roles`, for the review tier. Implementation is not on the menu:
1173
+ // it is always wuwei-implementer at modelRoles.implementation's tier
1174
+ function modelPolicy(spec) {
1175
+ const raw = (signOffField(spec ?? '', 'model policy') ?? 'inherit').trim()
1176
+ const v = (raw.match(/\breview\s*[=:]\s*(\w+)/i)?.[1] ?? raw).toLowerCase()
1177
+ const error = /\bimplementation\b/i.test(raw)
1178
+ ? `model policy '${raw}': implementation is fixed (wuwei-implementer, ${CONFIG.modelRoles?.implementation?.model} / ${CONFIG.modelRoles?.implementation?.effort}) — the policy is inherit or roles, for review`
1179
+ : !['inherit', 'roles'].includes(v) ? `model policy '${raw}': inherit or roles` : null
1180
+ // Codex, when it runs execute: `codex: <model> / <effort>` — the session it is started as
1181
+ const cx = (spec ?? '').split(/^## Sign-off\s*$/m)[1]?.split(/^## /m)[0]?.split('\n').find(l => /^[-*|\s]*codex\s*[:|]/i.test(l))
1182
+ const cv = cx ? (cx.match(/`([^`]+)`/)?.[1] ?? cx.split(/[:|]/).map(x => x.trim()).filter(Boolean).at(-1)) : null
1183
+ const cm = cv?.match(/^(\S+)\s*\/\s*(\S+)$/)
1184
+ const codex = cm ? { model: cm[1], effort: cm[2] } : null
1185
+ const codexError = cx && !cm ? `codex '${cv}': write it as <model> / <effort>`
1186
+ : codex && !CODEX_EFFORTS.includes(codex.effort) ? `codex effort '${codex.effort}': one of ${CODEX_EFFORTS.join('|')}` : null
1187
+ return { raw, tiers: { review: v }, error: error ?? codexError, codex }
1188
+ }
1189
+
1190
+ function codexLine(want) {
1191
+ const { profile } = codexProfile()
1192
+ if (!want) return profile
1193
+ ? `codex start it with \`codex --profile ${CODEX_PROFILE}\` (${profile.model} / ${profile.effort}) — none recorded under ## Sign-off`
1194
+ : 'codex the model Codex was started with — no `codex:` under ## Sign-off'
1195
+ return profile?.model === want.model && profile?.effort === want.effort
1196
+ ? `codex start it with \`codex --profile ${CODEX_PROFILE}\` (${want.model} / ${want.effort})`
1197
+ : `codex start it with \`${codexLaunch(want.model, want.effort)}\`${profile ? ` — profile ${CODEX_PROFILE} is ${profile.model} / ${profile.effort}, not what ## Sign-off says` : ''}`
1198
+ }
1199
+
1200
+ // what step 2 and step 5 dispatch, from the policy and wuwei.config.json's
1201
+ // modelRoles — printed, so the tier is read off the screen, not remembered
1202
+ function dispatchLines(policy) {
1203
+ const roles = CONFIG.modelRoles ?? {}
1204
+ const impl = roles.implementation ?? {}, rev = roles.review ?? {}
1205
+ return [
1206
+ `implement (step 2) subagent_type ${impl.agent}, no model or effort on the call — ${impl.model} / ${impl.effort}, pinned in its agent file`,
1207
+ policy.tiers.review === 'roles'
1208
+ ? `${(rev.lenses ?? []).map(l => l.replace(/^wuwei-review-/, 'review:')).join(', ')} (step 5) model: ${rev.model} — effort from the agent file`
1209
+ : `${(rev.lenses ?? []).map(l => l.replace(/^wuwei-review-/, 'review:')).join(', ')} (step 5) no model — the session's model; effort from the agent file`,
1210
+ 'other lenses no model — the session\'s; wuwei-security-auditor stays fable / high (pinned)',
1211
+ 'inline (no subagents, Codex) the session\'s, whatever the policy — say so in the round record',
1212
+ codexLine(policy.codex),
1213
+ ]
1214
+ }
1215
+
1155
1216
  function cmdClaim(argv) {
1156
1217
  const flag = name => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1] }
1157
1218
  const positional = argv.filter((a, i) => !a.startsWith('--') && argv[i - 1] !== '--by')
@@ -1175,6 +1236,10 @@ function cmdClaim(argv) {
1175
1236
  for (const p of r.phases)
1176
1237
  console.log(` ${p.name.padEnd(w)} ${p.state}${p.review === 'human' ? ' ← review: human' : ''}`)
1177
1238
 
1239
+ const policy = modelPolicy(existsSync(join(streamsDir, stream, 'SPEC.md')) ? readFileSync(join(streamsDir, stream, 'SPEC.md'), 'utf8') : '')
1240
+ if (policy.error) { console.error(`\n✗ ${policy.error} — fix ## Sign-off (wuwei plan-check)`); process.exit(1) }
1241
+ console.log(`\ndispatch · model policy: ${policy.raw}\n${dispatchLines(policy).map(l => ` ${l}`).join('\n')}`)
1242
+
1178
1243
  if (!phaseArg) {
1179
1244
  const ready = r.phases.filter(p => p.state === 'ready')
1180
1245
  console.log(ready.length ? `\nclaim one: wuwei claim ${stream} ${ready[0].no}` : '\nnothing ready to claim')
@@ -1283,6 +1348,8 @@ function cmdPlanCheck(argv) {
1283
1348
  const open = signLines.filter(l => /:\s*(\?|tbd|todo|pending|—|-|<[^>]+>)?\s*$/i.test(l) && /\w/.test(l.split(':')[0]) && /^\s*[-*|]/.test(l))
1284
1349
  const auto = signLines.some(l => /\bmode\b[^|\n]*\bauto\b/i.test(l))
1285
1350
  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`)
1351
+ const policy = spec ? modelPolicy(spec) : null
1352
+ if (policy?.error) errs.push(`## Sign-off: ${policy.error}`)
1286
1353
  const tracker = signLines.find(l => /tracker/i.test(l)) ?? ''
1287
1354
  const linked = tracker && !/\bnone\b/i.test(tracker)
1288
1355
 
@@ -1777,6 +1844,90 @@ async function cmdVerify(argv) {
1777
1844
  console.log('\nflags are leads, not findings: open the shot, and confirm against the spec what the page should say')
1778
1845
  }
1779
1846
 
1847
+ // ── review-collect: the lenses' own record, from Claude Code's transcripts ──
1848
+ // Claude Code keeps every subagent's transcript under ~/.claude/projects/<cwd,
1849
+ // non-alphanumerics as '-'>/<session>/subagents/agent-<id>.jsonl, its type in
1850
+ // the .meta.json beside it. §7 used to have the collator re-type each report
1851
+ // into the bundle — skipped in practice, and paid for in tokens when not. This
1852
+ // copies them, and reads what each one spent
1853
+ function transcriptStats(file) {
1854
+ const usage = new Map(), uses = new Map(), reads = new Map()
1855
+ const t = { turns: 0, built: 0, reread: 0, input: 0, output: 0, first: null, last: null, report: '' }
1856
+ const kind = (name, inp = {}) => name === 'Read' ? (/\.wuwei-cache/.test(inp.file_path ?? '') ? 'Read:bundle' : 'Read:tree')
1857
+ : name === 'Bash' ? (/codegraph/.test(inp.command ?? '') ? 'Bash:codegraph' : /\b(git )?grep\b|\brg\b/.test(inp.command ?? '') ? 'Bash:grep' : 'Bash:other') : name
1858
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
1859
+ let d; try { d = JSON.parse(line) } catch { continue }
1860
+ if (d.timestamp) { t.first ??= d.timestamp; t.last = d.timestamp }
1861
+ const m = d.message
1862
+ if (!m || typeof m !== 'object') continue
1863
+ // one message arrives as several lines while it streams: its last usage is its total
1864
+ if (d.type === 'assistant' && m.usage) usage.set(m.id, m.usage)
1865
+ for (const x of Array.isArray(m.content) ? m.content : []) {
1866
+ if (x.type === 'tool_use') {
1867
+ uses.set(x.id, kind(x.name, x.input))
1868
+ if (x.name === 'SubagentHandback' && x.input?.message) t.report = x.input.message
1869
+ } else if (x.type === 'tool_result') {
1870
+ const k = uses.get(x.tool_use_id) ?? '?', r = reads.get(k) ?? { n: 0, tokens: 0 }
1871
+ r.n++; r.tokens += Math.round(JSON.stringify(x.content ?? '').length / 4); reads.set(k, r)
1872
+ } else if (x.type === 'text' && d.type === 'assistant' && x.text?.trim()) t.lastText = x.text
1873
+ }
1874
+ }
1875
+ for (const u of usage.values()) {
1876
+ t.turns++; t.built += u.cache_creation_input_tokens ?? 0; t.reread += u.cache_read_input_tokens ?? 0
1877
+ t.input += u.input_tokens ?? 0; t.output += u.output_tokens ?? 0
1878
+ }
1879
+ t.report ||= t.lastText ?? ''
1880
+ t.reads = [...reads].sort((a, b) => b[1].tokens - a[1].tokens)
1881
+ return t
1882
+ }
1883
+
1884
+ function cmdReviewCollect(argv) {
1885
+ const flag = name => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1] }
1886
+ const projects = join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'projects')
1887
+ const projDir = join(projects, process.cwd().replace(/[^A-Za-z0-9]/g, '-'))
1888
+ if (!existsSync(projDir)) { console.error(`✗ no Claude Code sessions recorded for ${process.cwd()} (${projDir}) — run it where the review ran (Codex has no subagent transcripts)`); process.exit(1) }
1889
+ const sessions = readdirSync(projDir, { withFileTypes: true }).filter(d => d.isDirectory() && existsSync(join(projDir, d.name, 'subagents')))
1890
+ .map(d => {
1891
+ const dir = join(projDir, d.name, 'subagents')
1892
+ const agents = readdirSync(dir).filter(f => f.endsWith('.meta.json')).map(f => {
1893
+ let meta = {}; try { meta = JSON.parse(readFileSync(join(dir, f), 'utf8')) } catch {}
1894
+ const jsonl = join(dir, f.replace(/\.meta\.json$/, '.jsonl'))
1895
+ return { meta, jsonl, mtime: existsSync(jsonl) ? statSync(jsonl).mtimeMs : 0 }
1896
+ }).filter(a => a.mtime)
1897
+ return { id: d.name, agents, wuwei: agents.some(a => /^wuwei-/.test(a.meta.agentType ?? '')), latest: Math.max(0, ...agents.map(a => a.mtime)) }
1898
+ })
1899
+ const session = flag('--session') ? sessions.find(s => s.id.startsWith(flag('--session')))
1900
+ : sessions.filter(s => s.wuwei).sort((a, b) => b.latest - a.latest)[0]
1901
+ if (!session) { console.error(`✗ ${flag('--session') ? `no session ${flag('--session')}` : 'no session here dispatched a wuwei lens'} under ${projDir}`); process.exit(1) }
1902
+
1903
+ const sha = flag('--sha') ?? quiet('git', ['rev-parse', '--verify', '--quiet', 'HEAD']).stdout?.trim().slice(0, 12) ?? 'nogit'
1904
+ const out = join(process.cwd(), '.wuwei-cache', 'review', sha, 'reports')
1905
+ mkdirSync(out, { recursive: true })
1906
+ const rows = [], used = new Map()
1907
+ for (const a of session.agents.map(a => ({ ...a, s: transcriptStats(a.jsonl) })).sort((x, y) => (x.s.first ?? '').localeCompare(y.s.first ?? ''))) {
1908
+ const type = a.meta.agentType ?? 'agent'
1909
+ const base = (type === 'general-purpose' && a.meta.description ? a.meta.description.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 30) : type).replace(/^wuwei-/, '')
1910
+ const n = (used.get(base) ?? 0) + 1; used.set(base, n)
1911
+ const name = n === 1 ? base : `${base}-${n}`
1912
+ writeFileSync(join(out, `${name}.jsonl`), readFileSync(a.jsonl))
1913
+ if (a.s.report) writeFileSync(join(out, `${name}.md`), a.s.report.trimEnd() + '\n')
1914
+ rows.push({ name, ...a.s })
1915
+ }
1916
+ const k = x => x >= 1e6 ? `${(x / 1e6).toFixed(1)}M` : x >= 1e3 ? `${Math.round(x / 1e3)}k` : String(x)
1917
+ const total = rows.reduce((t, r) => ({ built: t.built + r.built, reread: t.reread + r.reread }), { built: 0, reread: 0 })
1918
+ console.log(`session ${session.id} · ${rows.length} subagent(s) → ${out}\n`)
1919
+ // output tokens are left out: the transcript records a streaming chunk's count, not the message's
1920
+ console.log('| agent | turns | context built | re-read (cached) | where the reading went |\n|---|---|---|---|---|')
1921
+ for (const r of [...rows].sort((a, b) => (b.built + b.reread / 10) - (a.built + a.reread / 10)))
1922
+ console.log(`| ${r.name} | ${r.turns} | ${k(r.built)} | ${k(r.reread)} | ${r.reads.slice(0, 4).map(([kk, v]) => `${kk} ${v.n}× ≈${k(v.tokens)}`).join(' · ') || '—'} |`)
1923
+ console.log(`| **total** | | ${k(total.built)} | ${k(total.reread)} | |`)
1924
+ const byKind = new Map()
1925
+ for (const r of rows) for (const [kk, v] of r.reads) byKind.set(kk, (byKind.get(kk) ?? 0) + v.tokens)
1926
+ const top = [...byKind].sort((a, b) => b[1] - a[1]).slice(0, 3)
1927
+ if (top.length) console.log(`\nmost read, across the lenses: ${top.map(([kk, v]) => `${kk} ≈${k(v)}`).join(' · ')}`)
1928
+ console.log('re-read is billed at about a tenth of context built; a lens with many turns pays its context again on each')
1929
+ }
1930
+
1780
1931
  // ── digest-check: wuwei-review §7's arithmetic, before publishing ─────
1781
1932
  // what the collator re-derives by hand: the header count against the table,
1782
1933
  // reporter credits, every file:line against the reviewed tree, every
@@ -2620,6 +2771,59 @@ async function promptYesNo(question, def = false) {
2620
2771
  }
2621
2772
  }
2622
2773
 
2774
+ async function promptText(question, def = '') {
2775
+ if (!isInteractive()) return def
2776
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
2777
+ try { return (await rl.question(`${question}${def ? ` [${def}]` : ''} `)).trim() || def } catch { return def } finally { rl.close() }
2778
+ }
2779
+
2780
+ // ── Codex: a skill cannot set its model — the session is started with one.
2781
+ // So execute's tier on Codex is a profile in $CODEX_HOME/config.toml that the
2782
+ // session is launched under (codex --profile wuwei-execute); init offers to
2783
+ // write it, claim prints how to start. Effort values: codex-rs ReasoningEffort
2784
+ const CODEX_PROFILE = 'wuwei-execute'
2785
+ const CODEX_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra', 'persistent']
2786
+ const CODEX_PROFILE_MARK = '# added by wuwei init — wuwei-execute-phase runs under this profile; `wuwei uninstall` removes it'
2787
+ const codexConfigPath = () => join(codexHome(), 'config.toml')
2788
+ // [profiles.wuwei-execute]'s model and effort, or null; plus the top-level model
2789
+ function codexProfile() {
2790
+ const text = existsSync(codexConfigPath()) ? readFileSync(codexConfigPath(), 'utf8') : ''
2791
+ const sec = text.split(/^(?=\[)/m).find(s => new RegExp(`^\\[profiles\\.(?:"${CODEX_PROFILE}"|${CODEX_PROFILE})\\]`).test(s))
2792
+ const val = (s, k) => s?.match(new RegExp(`^${k}\\s*=\\s*"([^"]*)"`, 'm'))?.[1] ?? null
2793
+ const top = text.split(/^\[/m)[0]
2794
+ return { profile: sec ? { model: val(sec, 'model'), effort: val(sec, 'model_reasoning_effort') } : null, topModel: val(top, 'model') }
2795
+ }
2796
+ const codexLaunch = (model, effort) => `codex -m ${model} -c model_reasoning_effort=${effort}`
2797
+
2798
+ async function offerCodexProfile(dry) {
2799
+ if (!existsSync(codexHome())) return
2800
+ const { profile, topModel } = codexProfile()
2801
+ if (profile) { console.log(`
2802
+ codex: profile ${CODEX_PROFILE} — ${profile.model ?? '(model unset)'} / ${profile.effort ?? '(effort unset)'} · start execute with \`codex --profile ${CODEX_PROFILE}\``); return }
2803
+ const snippet = m => [CODEX_PROFILE_MARK, `[profiles.${CODEX_PROFILE}]`, `model = "${m.model}"`, `model_reasoning_effort = "${m.effort}"`].join('\n')
2804
+ console.log(`
2805
+ 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.`)
2806
+ if (!isInteractive()) { console.log(` add to ${codexConfigPath()}:\n${snippet({ model: '<model>', effort: 'xhigh' }).replace(/^/gm, ' ')}`); return }
2807
+ if (!await promptYesNo(` Add [profiles.${CODEX_PROFILE}] to ${codexConfigPath()}?`, true)) { console.log(' skipped — re-run `wuwei init` anytime'); return }
2808
+ const model = await promptText(' model for execute:', topModel ?? '')
2809
+ let effort = await promptText(` reasoning effort (${CODEX_EFFORTS.join('|')}):`, 'xhigh')
2810
+ if (!model || !CODEX_EFFORTS.includes(effort)) { console.log(` ✗ ${!model ? 'no model given' : `effort must be one of ${CODEX_EFFORTS.join('|')}`} — nothing written`); return }
2811
+ if (dry) { console.log(` [dry] would append:\n${snippet({ model, effort }).replace(/^/gm, ' ')}`); return }
2812
+ const prev = existsSync(codexConfigPath()) ? readFileSync(codexConfigPath(), 'utf8') : ''
2813
+ writeFileSync(codexConfigPath(), (prev ? prev.replace(/\n*$/, '\n\n') : '') + snippet({ model, effort }) + '\n')
2814
+ console.log(` ✓ written · start execute with \`codex --profile ${CODEX_PROFILE}\`, and write \`codex: ${model} / ${effort}\` under the stream's ## Sign-off`)
2815
+ }
2816
+
2817
+ // only the section init wrote (its marker line above it), never one you made
2818
+ function removeCodexProfile() {
2819
+ if (!existsSync(codexConfigPath())) return false
2820
+ const text = readFileSync(codexConfigPath(), 'utf8')
2821
+ const re = new RegExp(`\\n*${escapeRe(CODEX_PROFILE_MARK)}\\n\\[profiles\\.${CODEX_PROFILE}\\][^\\[]*`)
2822
+ if (!re.test(text)) return false
2823
+ writeFileSync(codexConfigPath(), text.replace(re, '\n').replace(/^\n+/, '').replace(/\n*$/, '\n'))
2824
+ return true
2825
+ }
2826
+
2623
2827
  async function cmdInit(target, dry) {
2624
2828
  // bootstrap a project: repo-scope install into the target (default: cwd) —
2625
2829
  // the entrypoint `npx <pkg> init` runs from anywhere, package files as ROOT
@@ -2664,6 +2868,7 @@ async function cmdInit(target, dry) {
2664
2868
 
2665
2869
  await offerCodegraphSetup(dest, dry, existing)
2666
2870
  await offerScannerSetup(dry)
2871
+ await offerCodexProfile(dry)
2667
2872
 
2668
2873
  const installHint = isSkillsInstalled()
2669
2874
  ? ''
@@ -2699,6 +2904,7 @@ switch (cmd) {
2699
2904
  case 'update': await cmdUpdate(dry, rest.includes('--no-fetch')); break
2700
2905
  case 'status': cmdStatus(arg); break
2701
2906
  case 'review-prep': cmdReviewPrep(rest); break
2907
+ case 'review-collect': cmdReviewCollect(rest); break
2702
2908
  case 'verify': await cmdVerify(rest); break
2703
2909
  case 'claim': cmdClaim(rest); break
2704
2910
  case 'digest-check': cmdDigestCheck(rest); break
@@ -2708,6 +2914,6 @@ switch (cmd) {
2708
2914
  case 'plan-check': cmdPlanCheck(rest); break
2709
2915
  case 'uninstall': await cmdUninstall(arg); break
2710
2916
  default:
2711
- 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]`)
2917
+ console.error(`unknown command '${cmd}'\nusage: wuwei [build|check|list|init [path]|install [path]|update|status [path]|review-prep [base] [--pr n] [--path p]|review-collect|verify [page] [--phase NN]|claim [stream] [phase]|digest-check <digest.md>|worktree <lens> [sha]|mutate <file>:<line>|sites <pattern>|plan-check [stream]|uninstall [path]|--version] [--dry]`)
2712
2918
  process.exit(1)
2713
2919
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wei-wuwei",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "為無為,則無不治 — a source-of-truth skills & agents framework that compiles to Claude Code and Codex (Cursor and Gemini CLI planned). Write once, run in every agent, govern without forcing.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,8 @@ description: >-
9
9
  simplify/review/fix → PR — one phase, or a range on request.
10
10
  version: 0.2.0
11
11
  mode: triggered
12
+ model: sonnet
13
+ effort: xhigh
12
14
  targets: [claude-code, codex]
13
15
  tags: [execution, workflow]
14
16
  ---
@@ -98,9 +100,12 @@ Stopping early is normal — say where, why, and what resumes:
98
100
  ```
99
101
  stopped at 03-appcheck-wiring · bound e2e red (auth/refresh.spec.ts)
100
102
  02 merged. 03 claimed, branch pushed, not merged.
101
- next invocation resumes at 03.
103
+ next invocation resumes at 03: /wuwei-execute-phase
102
104
  ```
103
105
 
106
+ Name the invocation: this skill's sonnet / xhigh holds for the turn it runs
107
+ in, and a reply in chat continues on the session's model.
108
+
104
109
  **Scope is distance, never permission.** `mode` is the stream's standing
105
110
  authorisation from sign-off; scope is only how far one invocation tries to
106
111
  get.
@@ -152,8 +157,9 @@ get.
152
157
  context, **the ladder below**, and **step 4's floor** — and nothing else.
153
158
  The ladder is a question the subagent writing the helper has to ask; an
154
159
  orchestrator holding it while the subagent writes is the same as not having
155
- it. Dispatch at the tier in `## Sign-off` (`inherit` by
156
- default; `sonnet` / `xhigh` under `roles`). Inline instead → watch for
160
+ it. Dispatch `wuwei-implementer` with **no `model` or `effort` on the
161
+ call** — its file pins sonnet / xhigh, and a value on the call beats it.
162
+ Inline instead → watch for
157
163
  drift, keep the phase small, and note that the model policy has nothing to
158
164
  attach to.
159
165
 
@@ -333,8 +339,7 @@ get.
333
339
  `ACCEPTANCE.md`, the **base ref just synced to**, the **tool facts**
334
340
  (`wuwei-review` §1, §5), and **the scope** (`wuwei-review` §6) — plus, for the
335
341
  document panel, **the intended reader and the facts this phase
336
- establishes** (`wuwei-review` §3). Models: the security auditor's pin, the
337
- `## Sign-off` policy for design and defects, the session's for the rest
342
+ establishes** (`wuwei-review` §3). Models: as `wuwei claim` printed them
338
343
  (`wuwei-review` §2).
339
344
 
340
345
  **Round 1 is full scope; every round after it is delta** — dispatched with
@@ -202,15 +202,18 @@ tags: [planning, workflow]
202
202
  - **merge method**: unsquashed by default — a merge commit — so ask only
203
203
  whether this stream squashes instead, plus which methods the repo allows
204
204
  (table below);
205
- - **model policy**: `inherit` (default) | `roles` | a mix. Ask only
205
+ - **model policy** for review: `inherit` (default) | `roles`. Ask only
206
206
  whether to change the default;
207
+ - **Codex**, when it runs execute: the model and effort it is started
208
+ with — `codex: <model> / <effort>`. A skill cannot set Codex's model;
209
+ `wuwei claim` prints the launch (`wuwei init` offers the profile);
207
210
  - whether the plan goes through **its own PR** first — if so, steps 8–9
208
211
  wait for that PR to merge.
209
212
 
210
- Two tiers are configurable, and both are honoured because both dispatch as
211
- subagents: **implementation** (execute step 2, and step 5's
212
- `review:defects` lens) and **review** (step 5's `review:design` lens) —
213
- confirm both, not one. The panel's other lenses inherit the session's
213
+ Implementation is not on the menu: execute runs, and dispatches its
214
+ implementer, at sonnet / xhigh (`wuwei.config.json` `modelRoles`). The
215
+ policy sets **review** — step 5's `review:design` and `review:defects`,
216
+ which dispatch as subagents. The panel's other lenses inherit the session's
214
217
  model, **except `wuwei-security-auditor`**, which pins `fable` / `high` in
215
218
  frontmatter and holds under every policy including `inherit`. Say so,
216
219
  rather than letting a `roles` answer imply it was chosen.
@@ -31,5 +31,5 @@ slices they need:
31
31
  diff.patch git diff <base>...HEAD, three dots, verbatim
32
32
  symbols.md definitions the diff references, from `codegraph node` — or "graph absent"
33
33
  tools/<tool>.json each detected tool's read-only output over the change (§5), run once
34
- reports/<lens>.md each lens's raw report, as returned — never the session scratchpad
34
+ reports/<lens>.md each lens's raw report — `wuwei review-collect`, or written as returned
35
35
  ```
@@ -102,7 +102,7 @@ disk**; lenses pull the slices they need:
102
102
  ```
103
103
  .wuwei-cache/review/<reviewed-sha>/ # gitignored — never inside the reviewed tree
104
104
  PREP.md · INDEX.md (kind is a first pass by path — reclassify by content) · diff.patch
105
- thread.json · tools/<tool>.json · reports/<lens>.md (each lens's raw report, as returned)
105
+ thread.json · tools/<tool>.json · reports/<lens>.md + .jsonl (`wuwei review-collect`)
106
106
  ```
107
107
 
108
108
  **No copies of the changed files** — each lens's context holds what it reads
@@ -346,9 +346,9 @@ finding closes.
346
346
 
347
347
  ## 7. Collate
348
348
 
349
- The collator is the dispatching session. It first writes each lens's report
350
- into the bundle's `reports/<lens>.md` as returned — the harness's output file,
351
- copied, not re-typed. Then one digest
349
+ The collator is the dispatching session. **`wuwei review-collect`** first
350
+ copies each lens's transcript and report into the bundle's `reports/` —
351
+ never re-typed (no subagents, as on Codex: write them yourself). Then one digest
352
352
  against `.wuwei/templates/code-review.md`, opening with which
353
353
  panels ran and why. **Whichever panels ran share one table** — code and
354
354
  document on a mixed target, or the stream panel's five reporters plus §2 on
package/wuwei.config.json CHANGED
@@ -16,6 +16,10 @@
16
16
  "failOnDrift": true,
17
17
  "stampGeneratedHeader": true
18
18
  },
19
+ "modelRoles": {
20
+ "implementation": { "skill": "wuwei-execute-phase", "agent": "wuwei-implementer", "model": "sonnet", "effort": "xhigh" },
21
+ "review": { "lenses": ["wuwei-review-design", "wuwei-review-defects"], "model": "opus" }
22
+ },
19
23
  "wordBudgets": {
20
24
  "skills/wuwei-brainstorm/skill.md": 500,
21
25
  "skills/wuwei-commit-hygiene/skill.md": 200,